try, catch 그리고 rescue

try, catch 그리고 rescue

Elixir에는 오류(error), throw, exit이라는 세 가지 오류 메커니즘이 있어요. 이번 장에서는 각각을 살펴보면서 언제 무엇을 써야 하는지도 함께 정리해 볼게요.

출처: Elixir 공식 가이드

오류 (Errors)

오류(또는 예외)는 코드에서 예외적인 일이 발생했을 때 사용돼요. 아톰에 숫자를 더하려고 하면 샘플 오류를 얻을 수 있어요.

iex> :foo + 1
** (ArithmeticError) bad argument in arithmetic expression
     :erlang.+(:foo, 1)

raise/1을 쓰면 언제든 런타임 오류를 발생시킬 수 있어요.

iex> raise "oops"
** (RuntimeError) oops

다른 오류는 raise/2에 오류 이름과 키워드 인자 목록을 넘겨서 발생시킬 수 있어요.

iex> raise ArgumentError, message: "invalid argument foo"
** (ArgumentError) invalid argument foo

모듈을 만들고 그 안에서 defexception/1 구조를 사용하면 나만의 오류도 정의할 수 있어요. 그러면 모듈과 같은 이름을 가진 오류가 만들어져요. 가장 흔한 경우는 message 필드를 가진 커스텀 예외를 정의하는 거예요.

iex> defmodule MyError do
iex>   defexception message: "default message"
iex> end
iex> raise MyError
** (MyError) default message
iex> raise MyError, message: "custom message"
** (MyError) custom message

오류는 try/rescue 구조로 잡을 수 있어요.

iex> try do
...>   raise "oops"
...> rescue
...>   e in RuntimeError -> e
...> end
%RuntimeError{message: "oops"}

위 예제는 런타임 오류를 잡아서 예외 자체를 돌려주고, 그 결과가 iex 세션에 출력돼요.

예외를 사용할 일이 없다면 rescue에 변수를 넘길 필요가 없어요.

iex> try do
...>   raise "oops"
...> rescue
...>   RuntimeError -> "Error!"
...> end
"Error!"

실제로 Elixir 개발자들은 try/rescue 구조를 거의 사용하지 않아요. 예를 들어 많은 언어가 파일을 성공적으로 열지 못했을 때 오류를 강제로 잡게 하죠. 하지만 Elixir는 대신 파일이 성공적으로 열렸는지에 대한 정보를 담은 튜플을 돌려주는 File.read/1 함수를 제공해요.

iex> File.read("hello")
{:error, :enoent}
iex> File.write("hello", "world")
:ok
iex> File.read("hello")
{:ok, "world"}

여기엔 try/rescue가 없어요. 파일을 여는 여러 결과를 처리하고 싶다면 case 구조를 사용한 패턴 매칭으로 처리할 수 있어요.

iex> case File.read("hello") do
...>   {:ok, body} -> IO.puts("Success: #{body}")
...>   {:error, reason} -> IO.puts("Error: #{reason}")
...> end

파일이 존재할 것이라고 기대하는(그리고 파일이 없다는 게 진짜 오류인) 경우에는 File.read!/1을 사용하면 돼요.

iex> File.read!("unknown")
** (File.Error) could not read file "unknown": no such file or directory
    (elixir) lib/file.ex:272: File.read!/1

결국 파일을 여는 중 발생한 오류가 예외적인지 아닌지는 애플리케이션이 결정할 문제예요. 그래서 Elixir는 File.read/1을 비롯한 많은 함수에 예외를 강요하지 않아요. 대신 진행 방식을 결정하는 건 개발자에게 맡기죠.

표준 라이브러리의 많은 함수는 매치할 튜플을 돌려주는 대신 예외를 발생시키는 대응 함수를 두는 패턴을 따르죠. 관례는 {:ok, result}{:error, reason} 튜플을 돌려주는 함수(foo)와, foo와 같은 인자를 받지만 오류 시 예외를 발생시키는 함수(foo!, 같은 이름에 !가 붙은 것)를 만드는 거예요. 모든 게 잘 진행되면 foo!은 (튜플로 감싸지 않은) 결과를 돌려줘야 해요. File 모듈이 이 관례의 좋은 예시예요.

Fail fast / Let it crash

Erlang 커뮤니티와 Elixir 커뮤니티에서 흔한 말이 있어요. 바로 "fail fast" / "let it crash"예요. let it crash의 핵심은, 예상치 못한 일이 발생하면 그 예외를 잡지 않고 그냥 일어나게 두는 게 가장 좋다는 거예요.

여기서 "예상치 못한(unexpected)"이라는 단어를 강조하는 게 중요해요. 예를 들어 파일을 처리하는 스크립트를 만든다고 생각해 볼게요. 스크립트는 파일 이름을 입력으로 받아요. 사용자가 실수로 알 수 없는 파일 이름을 줄 거라고 예상하는 건 당연해요. 이런 시나리오에서 File.read!/1로 파일을 읽고 잘못된 파일 이름이면 크래시하게 둘 수도 있지만, File.read/1을 사용해서 스크립트 사용자에게 무엇이 잘못됐는지 명확하고 정확한 피드백을 주는 게 더 합리적일 거예요.

반대로 특정 파일이 반드시 존재할 것이라고 충분히 기대하는 경우도 있어요. 그런데 그 파일이 없다면, 다른 어딘가에서 심각한 잘못이 발생했다는 뜻이에요. 이런 경우엔 File.read!/1만으로 충분해요.

두 번째 접근 방식이 잘 동작하는 이유는, Processes 장에서 논의했듯이 모든 Elixir 코드가 격리된 프로세스 안에서 실행되고 기본적으로 아무것도 공유하지 않기 때문이에요. 따라서 한 프로세스에서 처리되지 않은 예외가 다른 프로세스의 상태를 크래시시키거나 손상시키지 않아요. 이를 통해 우리는 슈퍼바이저(supervisor) 프로세스를 정의할 수 있는데, 슈퍼바이저는 프로세스가 예상치 못하게 종료되는 것을 관찰하다가 그 자리에 새 프로세스를 시작하는 역할을 해요.

결국 "fail fast" / "let it crash"는, 예상치 못한 일이 발생했을 때 언제 어떻게 발생할 수 있는지 완전한 맥락 없이 가능한 모든 오류를 맹목적으로 잡으려 하기보다, 슈퍼바이저가 갓 시작한 새 프로세스 안에서 처음부터 다시 시작하는 것이 가장 좋다는 말이에요.

재발생 (Reraise)

일반적으로 Elixir에서 try/rescue를 피하지만, 관측성/모니터링을 위해 사용하고 싶은 상황이 하나 있어요. 무언가 잘못됐다는 걸 로깅하고 싶다고 상상해 볼게요. 이렇게 할 수 있어요.

try do
  ... some code ...
rescue
  e ->
    Logger.error(Exception.format(:error, e, __STACKTRACE__))
    reraise e, __STACKTRACE__
end

위 예제에서는 예외를 잡아서 로깅한 다음 다시 발생시켰어요. 예외를 포맷할 때와 재발생할 때 모두 __STACKTRACE__ 구조를 사용했어요. 이렇게 하면 예외의 값이나 출처를 바꾸지 않고 그대로 재발생시킬 수 있어요.

일반적으로 Elixir에서는 오류를 문자 그대로 받아들여요. 오류는 예상치 못하거나 예외적인 상황을 위해 예약된 것이지, 절대 코드 흐름을 제어하는 데 쓰지 않아요. 실제로 흐름 제어 구조가 필요하다면 throw를 써야 해요. 다음에 그걸 볼게요.

throw

Elixir에서는 값을 throw해서 나중에 catch할 수 있어요. throwcatch는 throw와 catch를 쓰지 않고는 값을 가져오는 것이 불가능한 상황을 위해 예약되어 있어요.

이런 상황은 적절한 API를 제공하지 않는 라이브러리와 연동할 때를 제외하면 실제로는 꽤 드물어요. 예를 들어 Enum 모듈이 값을 찾는 API를 제공하지 않고, 숫자 목록에서 13의 첫 번째 배수를 찾아야 한다고 상상해 볼게요.

iex> try do
...>   Enum.each(-50..50, fn x ->
...>     if rem(x, 13) == 0, do: throw(x)
...>   end)
...>   "Got nothing"
...> catch
...>   x -> "Got #{x}"
...> end
"Got -39"

Enum 모듈은 적절한 API를 제공하므로, 실제로는 Enum.find/2를 쓰는 게 정석이에요.

iex> Enum.find(-50..50, &(rem(&1, 13) == 0))
-39

exit

모든 Elixir 코드는 서로 통신하는 프로세스 안에서 실행돼요. 프로세스가 "자연적인 원인"(예: 처리되지 않은 예외)으로 죽으면 exit 신호를 보내요. 프로세스는 명시적으로 exit 신호를 보내서 죽을 수도 있어요.

iex> spawn_link(fn -> exit(1) end)
** (EXIT from #PID<0.56.0>) shell process exited with reason: 1

위 예제에서 링크된 프로세스는 값 1을 가진 exit 신호를 보내며 죽었어요. Elixir 셸은 그런 메시지를 자동으로 처리해 터미널에 출력해요.

exittry/catch로 "잡을" 수도 있어요.

iex> try do
...>   exit("I am exiting")
...> catch
...>   :exit, _ -> "not really"
...> end
"not really"

catch는 매칭되는 try 없이 함수 본문 안에서도 사용할 수 있어요.

defmodule Example do
  def matched_catch do
    exit(:timeout)
  catch
    :exit, :timeout ->
      {:error, :timeout}
  end

  def mismatched_catch do
    exit(:timeout)
  catch
    # Since no clause matches, this catch will have no effect
    :exit, :explosion ->
      {:error, :explosion}
  end
end

하지만 try/catch를 쓰는 것 자체가 이미 흔하지 않고, exit를 잡는 데 쓰는 건 더 드물어요.

exit 신호는 Erlang VM이 제공하는 장애 허용(fault tolerant) 시스템의 중요한 부분이에요. 프로세스는 보통 감독 트리(supervision tree) 아래에서 실행되는데, 이 트리는 감독되는 프로세스들의 exit 신호를 듣는 프로세스들이에요. exit 신호를 받으면 감독 전략이 발동해서 감독되는 프로세스를 다시 시작해요.

바로 이 감독 시스템 덕분에 Elixir에서 try/catchtry/rescue 같은 구조가 드물게 쓰이는 거예요. 오류를 잡기보다는 "fail fast"를 선호하는데, 감독 트리가 오류 후에 애플리케이션이 알려진 초기 상태로 돌아가도록 보장해 주기 때문이에요.

After

때로는 오류를 발생시킬 수 있는 어떤 동작 이후에 자원을 정리하는 것이 필요해요. try/after 구조가 그걸 가능하게 해 줘요. 예를 들어 파일을 열고 after 절로 그 파일을 닫을 수 있어요. 무언가 잘못되더라도요.

iex> {:ok, file} = File.open("sample", [:utf8, :write])
iex> try do
...>   IO.write(file, "olá")
...>   raise "oops, something went wrong"
...> after
...>   File.close(file)
...> end
** (RuntimeError) oops, something went wrong

after 절은 try 블록이 성공하든 실패하든 관계없이 실행돼요. 다만 주의할 점이 있는데, 링크된 프로세스가 exit하면 이 프로세스도 exit하고 after 절은 실행되지 않아요. 즉 after는 부드러운(soft) 보장만 해 줘요. 다행히 Elixir에서 파일은 현재 프로세스에도 링크되어 있어서, after 절과는 무관하게 현재 프로세스가 크래시하면 파일은 항상 닫혀요. ETS 테이블, 소켓, 포트 같은 다른 자원도 마찬가지예요.

때로는 함수 본문 전체를 try 구조로 감싸고 싶을 수 있어요. 종종 그 뒤에 실행될 코드를 보장하기 위해서죠. 이런 경우 Elixir는 try 줄을 생략할 수 있게 해 줘요.

iex> defmodule RunAfter do
...>   def without_even_trying do
...>     raise "oops"
...>   after
...>     IO.puts("cleaning up!")
...>   end
...> end
iex> RunAfter.without_even_trying
cleaning up!
** (RuntimeError) oops

after, rescue, catch 중 하나가 지정되면 Elixir는 자동으로 함수 본문을 try로 감싸요. after 블록은 부수 효과(side effect)를 처리하며, 그 위 절들의 반환값은 바꾸지 않아요.

Else

else 블록이 있으면, try 블록이 throw나 오류 없이 끝났을 때 그 결과를 매치해요.

iex> x = 2
2
iex> try do
...>   1 / x
...> rescue
...>   ArithmeticError ->
...>     :infinity
...> else
...>   y when y < 1 and y > -1 ->
...>     :small
...>   _ ->
...>     :large
...> end
:small

else 블록 안의 예외는 잡히지 않아요. else 블록 안에서 어떤 패턴도 매치되지 않으면 예외가 발생하는데, 이 예외는 현재의 try/catch/rescue/after 블록으로 잡히지 않아요.

변수 스코프 (Variables scope)

case, cond, if 같은 다른 구조와 마찬가지로, try/catch/rescue/after 블록 안에서 정의된 변수는 바깥 컨텍스트로 새지 않아요. 다시 말해 이 코드는 유효하지 않아요.

iex> try do
...>   raise "fail"
...>   what_happened = :did_not_raise
...> rescue
...>   _ -> what_happened = :rescued
...> end
iex> what_happened
** (CompileError) undefined variable "what_happened"

대신 try 표현식의 값을 돌려줘야 해요.

iex> what_happened =
...>   try do
...>     raise "fail"
...>     :did_not_raise
...>   rescue
...>     _ -> :rescued
...>   end
iex> what_happened
:rescued

게다가 try 블록(do 블록) 안에서 정의된 변수는 rescue/after/else 안에서도 사용할 수 없어요. try 블록은 언제든 실패할 수 있고, 그 경우 변수가 애초에 바인딩되지 않았을 수 있기 때문이에요. 그래서 이것도 유효하지 않아요.

iex> try do
...>   raise "fail"
...>   another_what_happened = :did_not_raise
...> rescue
...>   _ -> another_what_happened
...> end
** (CompileError) undefined variable "another_what_happened"

이상으로 try, catch, rescue에 대한 소개를 마칠게요. 다른 언어보다 Elixir에서는 이들이 덜 자주 쓰인다는 걸 알 수 있을 거예요.

다음에는 Elixir의 핵심 기능인 Processes를 볼 텐데, 이것으로 동시적(concurrent), 병렬적(parallel), 분산적(distributed) 프로그램을 쉽고 이해하기 좋게 작성할 수 있어요.

더 알아보기

  • Exception 모듈 문서 — 예외 포맷과 관련 함수를 확인할 수 있어요.
  • Logger 모듈 문서 — 예외를 로깅할 때 사용하는 로깅 모듈이에요.
  • Patterns and Guardscase를 활용한 오류 결과 처리의 기반이 되는 내용이에요.
  • Processes — 이 장에서 언급한 프로세스 격리와 감독 트리를 자세히 다뤄요.