디버깅(Debugging)

디버깅(Debugging)

Elixir에서 코드를 디버깅하는 방법은 여럿이에요. 이번 장에서는 그중 비교적 자주 쓰는 방법 몇 가지를 살펴볼게요.

출처: Debugging

본문

IO.inspect/2

IO.inspect(item, opts \\\\ [])가 디버깅에서 정말 유용한 이유는, 원래 코드의 동작에 영향을 주지 않으면서 전달받은 item 인자를 그대로 반환하기 때문이에요. 예를 하나 볼게요.

(1..10)
|> IO.inspect()
|> Enum.map(fn x -> x * 2 end)
|> IO.inspect()
|> Enum.sum()
|> IO.inspect()

출력:

1..10
[2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
110

보시다시피 IO.inspect/2는 결과를 바꾸지 않고 코드의 거의 어느 곳에서든 값을 "엿볼" 수 있게 해 줘요. 그래서 위 예시처럼 파이프라인 중간에 끼워 넣기에 아주 좋죠.

IO.inspect/2label 옵션으로 출력에 장식도 할 수 있어요. 라벨은 inspect된 item 앞에 출력됩니다.

[1, 2, 3]
|> IO.inspect(label: "before")
|> Enum.map(&(&1 * 2))
|> IO.inspect(label: "after")
|> Enum.sum

출력:

before: [1, 2, 3]
after: [2, 4, 6]

IO.inspect/2binding/0과 함께 쓰는 것도 매우 흔해요. binding/0은 모든 변수 이름과 값을 반환하거든요.

def some_function(a, b, c) do
  IO.inspect(binding())
  ...
end

some_function/3:foo, "bar", :baz로 호출하면 이렇게 출력돼요.

[a: :foo, b: "bar", c: :baz]

이 함수와 지원되는 모든 옵션에 대해 더 자세히 알고 싶다면 각각 IO.inspect/2Inspect.Opts를 참고하세요.

dbg/2

Elixir v1.14에서 dbg/2가 도입됐어요. dbgIO.inspect/2와 비슷하지만 디버깅에 특화되어 있어요. 전달된 값을 출력하고 반환한다는 점(IO.inspect/2와 같음)은 동일하지만, 코드와 위치도 함께 출력합니다.

# In my_file.exs
feature = %{name: :dbg, inspiration: "Rust"}
dbg(feature)
dbg(Map.put(feature, :in_version, "1.14.0"))

위 코드는 이렇게 출력해요.

[my_file.exs:2: (file)]
feature #=> %{inspiration: "Rust", name: :dbg}
[my_file.exs:3: (file)]
Map.put(feature, :in_version, "1.14.0") #=> %{in_version: "1.14.0", inspiration: "Rust", name: :dbg}

IO.inspect/2를 이야기할 때 |> 파이프라인의 단계 사이에 놓으면 유용하다고 했었죠. dbg는 더 잘해요. Elixir 코드를 이해하므로 파이프라인의 모든 단계에서 값을 출력해 줍니다.

# In dbg_pipes.exs
__ENV__.file
|> String.split("/", trim: true)
|> List.last()
|> File.exists?()
|> dbg()

이 코드는 이렇게 출력해요.

[dbg_pipes.exs:5: (file)]
__ENV__.file #=> "/home/myuser/dbg_pipes.exs"
|> String.split("/", trim: true) #=> ["home", "myuser", "dbg_pipes.exs"]
|> List.last() #=> "dbg_pipes.exs"
|> File.exists?() #=> true

dbg는 Elixir 구조에 대한 편의를 제공하지만, 디버깅하면서 코드를 실행하고 중단점(breakpoint)을 설정하려면 IEx가 필요해요.

Pry

IEx를 쓸 때 --dbg pry 옵션을 넘기면 dbg 호출이 있는 지점에서 코드 실행을 "멈출" 수 있어요.

$ iex --dbg pry

프로젝트 안에서 디버깅하려면 이렇게 합니다.

$ iex --dbg pry -S mix

이제 dbg를 호출할 때마다 기존 코드를 pry할지 물어봐요. 수락하면 해당 코드의 모든 변수, import, alias에 IEx에서 직접 접근할 수 있어요. 이걸 "prying"이라고 해요. pry 세션이 실행되는 동안에는 continue(또는 c)나 next(또는 n)를 호출할 때까지 코드 실행이 멈춰 있어요. iex -S mix TASK로 프로젝트 맥락에서 iex를 실행할 수 있다는 점도 항상 기억하세요.

중단점(Breakpoints)

dbg 호출은 디버깅하려는 코드를 수정해야 하고 단계별 실행(stepping) 기능도 제한적이에요. 다행히 IEx는 IEx.break!/2 함수를 제공해서, 소스를 수정하지 않고도 어떤 Elixir 코드든 중단점을 설정하고 관리할 수 있어요.

dbg와 비슷하게, 일단 중단점에 도달하면 continue(또는 c)나 next(또는 n)를 호출할 때까지 코드 실행이 멈춰요. 중단점은 기본적으로 줄 단위로 이동할 수 있지만, 컴파일된 모듈에 중단점을 설정하면 alias와 import에는 접근할 수 없어요.

mix test 태스크는 -b/--breakpoints 플래그로 중단점과 직접 통합됩니다. 이 플래그를 쓰면 실행될 모든 테스트의 시작 부분에 중단점이 설정돼요.

실제로 쓸 수 있는 명령 몇 가지를 보여드릴게요.

# Debug all failed tests
$ iex -S mix test --breakpoints --failed
# Debug the test at the given file:line
$ iex -S mix test -b path/to/file:line

Observer

복잡한 시스템을 디버깅할 때는 코드 단위로 뛰어다니는 것만으로는 부족해요. 전체 가상 머신, 프로세스, 애플리케이션을 이해하고 추적(tracing) 메커니즘을 설정하는 게 필요해요. 다행히 Erlang의 :observer로 이걸 할 수 있습니다. 애플리케이션에서:

$ iex
iex> :observer.start()

누락된 의존성

iex -S mix로 프로젝트 안에서 iex를 실행하면 observer가 의존성으로 제공되지 않아요. 그러려면 그 전에 다음 함수들을 호출해야 합니다.

iex> Mix.ensure_application!(:observer)
iex> :observer.start()

위 호출 중 하나라도 실패하면 다음과 같은 경우일 수 있어요. 일부 패키지 매니저는 GUI 지원을 위한 WX 바인딩 없이 최소화된 Erlang을 기본 설치해요. 어떤 패키지 매니저에서는 headless Erlang을 더 완전한 패키지로 교체할 수 있어요(Debian/Ubuntu/Arch에서 erlang vs erlang-nox라는 패키지를 찾아보세요). 다른 매니저에서는 별도의 erlang-wx(또는 비슷한 이름) 패키지를 설치해야 할 수도 있어요.

위 작업을 하면 런타임과 프로젝트를 완전히 이해하고 탐색할 수 있는 여러 창(pane)을 제공하는 또 하나의 그래픽 사용자 인터페이스가 열립니다.

Observer를 실제 프로젝트 맥락에서 살펴보는 건 Mix & OTP 가이드의 Dynamic Supervisor 챕터에서 해요. 이건 Phoenix 프레임워크가 단일 머신에서 2백만 연결을 달성할 때 사용한 디버깅 기법이기도 합니다.

Phoenix 웹 프레임워크를 쓰고 있다면, 생산 노드를 위한 웹 대시보드인 Phoenix LiveDashboard가 딸려 와서 Observer와 비슷한 기능을 제공해요.

마지막으로, IEx에서 runtime_info/0을 직접 호출하면 런타임 정보의 미니 오버뷰를 얻을 수 있다는 점도 기억하세요.

다른 도구와 커뮤니티(Other tools and community)

우리는 Erlang VM이 제공하는 것의 표면만 살짝 만져봤어요. 예를 들어:

즐거운 디버깅 되세요!

더 알아보기