IO와 파일 시스템

IO와 파일 시스템

이번 장에서는 입출력 메커니즘과 파일 시스템 관련 작업, 그리고 관련 모듈인 IO, File, Path를 소개할게요. IO 시스템을 보면 Elixir와 Erlang VM의 철학과 재미있는 특성도 자연스럽게 드러나요.

출처: Elixir 공식 가이드

IO 모듈

IO 모듈은 표준 입력/출력(:stdio), 표준 에러(:stderr), 파일, 그리고 그 밖의 IO 장치에 읽고 쓰는 Elixir의 핵심 메커니즘이에요. 사용법은 꽤 직관적이에요.

iex> IO.puts("hello world")
hello world
:ok
iex> IO.gets("yes or no? ")
yes or no? yes
"yes\n"

기본적으로 IO 모듈의 함수들은 표준 입력에서 읽고 표준 출력에 써요. 예를 들어 표준 에러 장치에 쓰고 싶다면 :stderr를 인자로 넘기면 바꿀 수 있어요.

iex> IO.puts(:stderr, "hello world")
hello world
:ok

File 모듈

File 모듈에는 파일을 IO 장치로 열 수 있게 해 주는 함수들이 있어요. 기본적으로 파일은 바이너리 모드로 열리는데, 이 경우 IO 모듈의 특수한 함수인 IO.binread/2IO.binwrite/2를 사용해야 해요.

데이터 손실 경고

아래 코드는 파일을 쓰기용으로 열어요. 주어진 경로에 이미 파일이 있다면 그 내용은 삭제된다는 점을 주의하세요.

iex> {:ok, file} = File.open("path/to/file/hello", [:write])
{:ok, #PID<0.47.0>}
iex> IO.binwrite(file, "world")
:ok
iex> File.close(file)
:ok
iex> File.read("path/to/file/hello")
{:ok, "world"}

파일의 내용을 보존하고 싶다면 :write 대신 :append 옵션으로 열면 돼요. :utf8 옵션을 넘기면 파일에서 읽은 바이트를 UTF-8로 인코딩된 바이트로 해석하도록 File 모듈에 알려줘요.

파일을 열고, 읽고, 쓰는 함수 외에도 File 모듈은 파일 시스템을 다루는 함수를 많이 제공해요. 이 함수들은 UNIX에서 이름을 따왔어요. 예를 들어 File.rm/1은 파일을, File.mkdir/1은 디렉터리를, File.mkdir_p/1은 디렉터리와 그 상위 체인 전체를 만들어요. File.cp_r/2File.rm_rf/1은 파일과 디렉터리를 재귀적으로(즉, 디렉터리 안의 내용까지 포함해서) 각각 복사하고 삭제해요.

File 모듈의 함수에는 두 가지 변형이 있다는 것도 눈치챘을 거예요. 하나는 "일반" 버전이고, 다른 하나는 뒤에 뱅(bang, !)이 붙는 버전이에요. 예를 들어 위 예제에서 "hello" 파일을 읽을 때 File.read/1을 썼는데, File.read!/1도 쓸 수 있어요.

iex> File.read("path/to/file/hello")
{:ok, "world"}
iex> File.read!("path/to/file/hello")
"world"
iex> File.read("path/to/file/unknown")
{:error, :enoent}
iex> File.read!("path/to/file/unknown")
** (File.Error) could not read file "path/to/file/unknown": no such file or directory

!가 붙은 버전은 튜플 대신 파일의 내용을 그대로 돌려주고, 문제가 생기면 오류를 발생(raise)시켜요.

!가 없는 버전은 패턴 매칭으로 다양한 결과를 처리하고 싶을 때 유용해요.

case File.read("path/to/file/hello") do
  {:ok, body} -> # do something with the `body`
  {:error, reason} -> # handle the error caused by `reason`
end

하지만 파일이 반드시 존재할 것이라고 기대하는 상황이라면, 뱅 버전이 의미 있는 오류 메시지를 발생시키므로 더 유용해요. 다음과 같이 쓰는 것은 피하세요.

{:ok, body} = File.read("path/to/file/unknown")

왜냐하면 오류가 날 때 File.read/1{:error, reason}을 돌려주고 패턴 매칭이 실패하기 때문이에요. 결과적으로 오류는 발생하지만, 그 메시지가 실제로 무슨 문제인지 알기 어려운 "매치되지 않는 패턴" 얘기가 되어 버려요.

그러니 오류 결과를 처리하고 싶지 않다면, File.read!/1처럼 느낌표로 끝나는 함수를 쓰는 게 좋아요.

Path 모듈

File 모듈의 함수 대부분은 경로를 인자로 받아요. 그러한 경로는 보통 일반 바이너리(string)예요. Path 모듈은 그런 경로를 다루기 위한 기능을 제공해요.

iex> Path.join("foo", "bar")
"foo/bar"
iex> Path.expand("~/hello")
"/Users/jose/hello"

문자열을 직접 조작하는 것보다 Path 모듈의 함수를 쓰는 게 좋아요. Path 모듈이 운영체제의 차이를 투명하게 처리해 주기 때문이에요. 마지막으로, Elixir는 파일 작업을 수행할 때 Windows에서 슬래시(/)를 자동으로 백슬래시(\)로 변환한다는 점도 기억해 두세요.

이렇게 Elixir가 IO와 파일 시스템을 다루기 위해 제공하는 주요 모듈들을 살펴봤어요. 다음 절에서는 한발 더 들어가 IO 시스템이 VM 안에서 어떻게 구현되는지 살펴볼게요.

프로세스 (Processes)

File.open/2{:ok, pid} 같은 튜플을 돌려주는 걸 눈치챘을 거예요.

iex> {:ok, file} = File.open("hello")
{:ok, #PID<0.47.0>}

이게 가능한 이유는 IO 모듈이 실제로는 프로세스와 함께 동작하기 때문이에요. 파일은 프로세스예요. 그래서 이미 닫힌 파일에 쓰려고 하면, 사실은 종료된 프로세스에 메시지를 보내는 것과 같아요.

iex> File.close(file)
:ok
iex> IO.write(file, "is anybody out there")
** (ErlangError) Erlang error: :terminated:

  * 1st argument: the device has terminated

    (stdlib 5.0) io.erl:94: :io.put_chars(#PID<0.114.0>, "is anybody out there")
    iex:4: (file)

IO.write(pid, binary)를 요청하면 무슨 일이 일어나는지 더 자세히 볼게요. IO 모듈은 원하는 연산을 담아 pid로 식별되는 프로세스에 메시지를 보내요. 작은 임시 프로세스를 만들어 확인해 볼게요.

iex> pid = spawn(fn ->
...>   receive do
...>     msg -> IO.inspect(msg)
...>   end
...> end)
#PID<0.57.0>
iex> IO.write(pid, "hello")
{:io_request, #PID<0.41.0>, #Reference<0.0.8.91>,
 {:put_chars, :unicode, "hello"}}
** (ErlangError) erlang error: :terminated

IO.write/2 이후에 IO 모듈이 보낸 요청(네 개 요소로 된 튜플)이 출력되는 걸 볼 수 있어요. 곧바로 이어서, IO 모듈이 어떤 종류의 결과를 기대했는데 우리가 그 결과를 주지 않아서 실패하는 것도 확인할 수 있어요.

IO 장치를 프로세스로 모델링함으로써, Erlang VM은 노드 간에도 파일 읽기/쓰기를 가능하게 해 줘요. 꽤 멋지죠?

iodata와 chardata

지금까지 예제에서는 파일에 쓸 때 바이너리(string)를 사용했어요. 그런데 Elixir의 IO 함수 대부분은 "iodata"나 "chardata"라는 형태도 받아들여요.

"iodata"와 "chardata"를 쓰는 주요 이유 중 하나는 성능이에요. 예를 들어 애플리케이션에서 누군가에게 인사를 건네야 한다고 생각해 볼게요.

name = "Mary"
IO.puts("Hello " <> name <> "!")

Elixir에서 문자열은 대부분의 데이터 구조처럼 불변(immutable)이에요. 그래서 위 예제는 문자열 "Mary"를 새로운 "Hello Mary!" 문자열로 복사해요. 이렇게 짧은 문자열에서는 문제가 되지 않겠지만, 문자열이 커지면 복사 비용이 꽤 커질 수 있어요! 그래서 Elixir의 IO 함수는 대신 문자열 목록을 넘길 수 있게 해 줘요.

name = "Mary"
IO.puts(["Hello ", name, "!"])

위 예제에는 복사가 없어요. 대신 원본 name을 담은 목록을 만들어요. 우리는 이런 목록을 "iodata" 또는 "chardata"라고 부르며, 곧 이 둘의 정확한 차이를 배울 거예요.

이런 목록은 여러 시나리오에서 문자열 처리 를 단순화할 수 있어서 매우 유용해요. 예를 들어 ["apple", "banana", "lemon"] 같은 값 목록을 쉼표로 구분해서 디스크에 쓰고 싶다고 생각해 볼게요. 어떻게 할 수 있을까요?

하나의 방법은 Enum.join/2로 값을 문자열로 변환하는 거예요.

iex> Enum.join(["apple", "banana", "lemon"], ",")
"apple,banana,lemon"

위 코드는 각 값을 새 문자열로 복사해 새 문자열을 돌려줘요. 그런데 이번 절에서 배운 지식으로는, IO/File 함수에 문자열 목록을 넘길 수 있다는 걸 알죠. 그래서 대신 이렇게 할 수 있어요.

iex> Enum.intersperse(["apple", "banana", "lemon"], ",")
["apple", ",", "banana", ",", "lemon"]

"iodata"와 "chardata"는 문자열만 담는 게 아니라, 문자열의 중첩된 목록도 얼마든지 담을 수 있어요.

iex> IO.puts(["apple", [",", "banana", [",", "lemon"]]])

"iodata"와 "chardata"는 정수도 담을 수 있어요. 예를 들어 쉼표를 나타내는 정수(44)인 ?,를 구분자로 써서 쉼표로 구분된 목록을 출력할 수 있어요.

iex> IO.puts(["apple", ?,, "banana", ?,, "lemon"])

"iodata"와 "chardata"의 차이는 바로 위에서 말한 정수가 무엇을 나타내느냐에 있어요. iodata에서 정수는 바이트를 나타내고, chardata에서 정수는 Unicode 코드포인트를 나타내요. ASCII 문자에서는 바이트 표현과 코드포인트 표현이 같아서 두 분류 모두에 들어맞아요. 기본 IO 장치는 chardata를 사용하므로 다음과 같이 쓸 수 있어요.

iex> IO.puts([?O, ?l, ?á, ?\s, "Mary", ?!])

~c"hello world" 같은 Charlist는 정수의 목록이므로, 따라서 chardata예요.

이 짧은 절에 많은 내용을 담았으니 정리해 볼게요.

  • iodata와 chardata는 바이너리와 정수의 목록이에요. 이 바이너리와 정수는 목록 안에 얼마든지 중첩될 수 있어요. 그 목적은 IO 장치와 파일을 다룰 때 유연성과 성능을 주는 거예요.

  • iodata와 chardata 중 무엇을 쓸지는 IO 장치의 인코딩에 달려 있어요. 인코딩 없이 열린 파일은 iodata를 기대하며, 이 경우 IO 모듈에서 bin*으로 시작하는 함수를 사용해야 해요. 기본 IO 장치(:stdio)와 :utf8 인코딩으로 연 파일은 chardata를 기대하며 IO 모듈의 나머지 함수들로 처리해요.

이것으로 IO 장치와 IO 관련 기능에 대한 탐험이 끝났어요. IO, File, Path라는 세 Elixir 모듈과, VM이 기본 IO 메커니즘에 프로세스를 어떻게 사용하는지, 그리고 IO 연산에 chardata와 iodata를 어떻게 쓰는지 배웠어요.

다음에는 Elixir 개발자에게 아주 중요한 주제, 문서 작성에 대해 이야기할게요.

더 알아보기

  • File 모듈 문서 — 파일 시스템 관련 함수 전체 목록을 확인할 수 있어요.
  • IO 모듈 문서bin* 함수와 chardata를 다루는 함수들을 확인할 수 있어요.
  • Path 모듈 문서 — 경로 처리 함수들에 대한 자세한 내용을 확인할 수 있어요.
  • Processes — IO 장치가 프로세스로 구현된다는 이 장의 배경이 되는 내용을 다뤄요.