Published on

Elixir 함수형 파이프라인 디버깅 실전 가이드

Authors

함수형 스타일의 Elixir는 파이프라인(|>)으로 데이터 흐름을 읽기 좋게 만드는 대신, 중간 값이 예상과 달라졌을 때 “어디서부터 틀어졌는지” 찾는 과정이 낯설 수 있습니다. 특히 nil이 섞이거나, {:error, reason} 튜플이 중간에 끼어들거나, 패턴 매칭이 실패해 예외가 터지면 파이프라인은 한 줄로 예뻐 보여도 디버깅 난이도는 급격히 올라갑니다.

이 글은 “파이프라인을 깨지 않고” 혹은 “깨더라도 최소한의 비용으로” 원인을 추적하는 실전 패턴을 모아 설명합니다. 단순 출력부터 구조적 로깅, telemetry 계측까지 단계적으로 확장해보겠습니다.

파이프라인 디버깅이 어려운 이유

파이프라인은 기본적으로 다음 규칙을 따릅니다.

  • 왼쪽 값을 오른쪽 함수의 첫 번째 인자로 넣는다
  • 한 단계라도 리턴 타입이 예상과 달라지면 이후 단계에서 패턴 매칭 실패 또는 함수 절 정의 실패가 발생한다

즉, 파이프라인 디버깅의 핵심은 “각 단계의 입력과 출력 shape(타입/구조)를 확인”하는 것입니다.

아래는 흔한 예시입니다.

params
|> normalize_params()
|> validate()
|> Repo.insert()
|> notify()

여기서 validate/1{:error, changeset}를 반환하는데 Repo.insert/1Ecto.Changeset을 기대한다면, 다음 단계에서 바로 터집니다. 파이프라인을 잘 쓰려면 각 단계가 어떤 계약을 갖는지 명확해야 하고, 디버깅 시에도 그 계약을 확인해야 합니다.

1) 가장 빠른 방법: IO.inspect/2를 “태그”와 함께 넣기

가장 흔하고 즉효가 있는 방식입니다.

params
|> IO.inspect(label: "raw params")
|> normalize_params()
|> IO.inspect(label: "normalized")
|> validate()
|> IO.inspect(label: "validated")
|> Repo.insert()
|> IO.inspect(label: "insert result")

팁: 출력량을 줄이기

  • 큰 맵/리스트는 limit: 옵션을 사용
  • 구조를 보기 좋게 pretty: true 사용
value
|> IO.inspect(label: "debug", pretty: true, limit: 20)

팁: 프로덕션 코드에 남기지 않기

IO.inspect/2는 표준 출력으로 나가며, 배포 환경에서 로그 정책과 충돌할 수 있습니다. 로컬/개발에서만 쓰고 지우는 습관이 중요합니다.

2) 파이프라인을 유지하면서 “중간 훅” 넣기: then/2

then/2는 값을 받아서 익명 함수로 넘긴 뒤 결과를 반환합니다. 즉, 파이프라인을 끊지 않고도 “중간에서 뭔가를 하고 다시 흐름으로 복귀”할 수 있습니다.

params
|> normalize_params()
|> then(fn v ->
  IO.inspect(v, label: "after normalize")
  v
end)
|> validate()
|> then(fn v ->
  IO.inspect(v, label: "after validate")
  v
end)
|> Repo.insert()

이 패턴의 장점은 다음과 같습니다.

  • 출력 외에도 간단한 검증(assertion)이나 변환을 끼워 넣기 쉽다
  • 특정 조건에서만 로깅하도록 분기하기 좋다

예를 들어 nil이 나오면 바로 멈추고 싶다면:

value
|> then(fn v ->
  if is_nil(v), do: raise("unexpected nil"), else: v
end)

3) 함수 경계에서 계약을 고정하기: tap/2와 유사 패턴

Elixir에는 Ruby의 tap 같은 표준 함수가 없지만, 동일한 패턴을 직접 만들 수 있습니다.

defmodule Debug do
  def tap(value, fun) when is_function(fun, 1) do
    fun.(value)
    value
  end
end

사용:

params
|> normalize_params()
|> Debug.tap(&IO.inspect(&1, label: "normalized"))
|> validate()
|> Debug.tap(&IO.inspect(&1, label: "validated"))
|> Repo.insert()

이 방식은 then/2보다 의도가 더 명확합니다. “값은 그대로 흘려보내되, 옆에서 관찰만 한다”는 계약을 코드로 표현할 수 있습니다.

4) 실패가 섞이는 파이프라인: with로 에러 흐름을 구조화

실무 파이프라인은 보통 중간에 {:ok, v} 또는 {:error, reason} 같은 결과가 섞입니다. 이때 무리하게 파이프라인만 고집하면 디버깅이 더 어려워집니다.

아래처럼 with를 사용하면 실패 지점을 명확히 만들 수 있고, 디버깅 포인트도 자연스럽게 생깁니다.

with {:ok, normalized} <- normalize_params(params),
     {:ok, changeset} <- validate(normalized),
     {:ok, record} <- Repo.insert(changeset) do
  {:ok, record}
else
  {:error, reason} = err ->
    Logger.warning("pipeline failed", reason: inspect(reason))
    err
end

디버깅 포인트를 with 안에 넣기

with {:ok, normalized} <- normalize_params(params),
     _ <- Logger.debug("normalized", normalized: inspect(normalized)),
     {:ok, changeset} <- validate(normalized),
     _ <- Logger.debug("changeset", changeset: inspect(changeset)),
     {:ok, record} <- Repo.insert(changeset) do
  {:ok, record}
end

여기서 _ <- Logger.debug(...)는 값 흐름과 무관하게 “한 번 실행”되는 훅입니다. 다만 Logger.debug/2의 반환값에 의존하지 않도록 _ <- 형태로 버리는 게 포인트입니다.

5) 예외로 터질 때: rescue로 감싸기보다 “어디서 터졌는지”를 먼저

파이프라인 도중 예외가 발생하면 스택트레이스에 단서가 있지만, 중간 값이 크거나 변환이 많으면 원인 파악이 느립니다.

권장 순서는 보통 이렇습니다.

  1. 예외를 억지로 삼키지 말고 그대로 터뜨려 스택트레이스를 확보
  2. 예외가 난 함수 바로 앞 단계에 then/2 또는 Debug.tap/2로 입력값을 관찰
  3. 입력값의 shape을 고정(가드, 패턴 매칭, with)해 재발 방지

예를 들어 FunctionClauseErrorformat_phone/1에서 난다면:

user
|> then(fn u -> IO.inspect(u.phone, label: "phone before format"); u end)
|> Map.update!(:phone, &format_phone/1)

이렇게 “실패 함수의 직전 입력”을 찍는 것이 가장 빠릅니다.

6) Logger를 구조적으로 쓰기: metadata와 레벨 전략

IO.inspect/2는 빠르지만, 운영 환경에서는 Logger가 훨씬 유용합니다.

  • 요청 단위 상관관계: request_id 같은 메타데이터
  • 레벨별 필터링: debug는 개발에서만, info는 핵심 이벤트, warning은 이상징후

예시:

Logger.metadata(request_id: request_id)

params
|> normalize_params()
|> then(fn v ->
  Logger.debug("normalized params", normalized: inspect(v))
  v
end)
|> validate()

과도한 inspect/1 비용 주의

큰 구조를 inspect/1로 문자열화하면 CPU와 메모리를 잡아먹습니다. 이 문제는 런타임에서 다른 형태의 장애로 번질 수 있는데, 메모리 압박은 컨테이너 환경에서 OOMKilled로 이어지기도 합니다. 운영에서 “디버깅 로그를 추가했더니 더 불안정해졌다”는 상황을 피하려면, 출력량 제한과 레벨 관리가 필요합니다.

관련해서 메모리 압박이 장애로 이어지는 메커니즘은 쿠버네티스 환경에서도 자주 등장합니다. 필요하면 EKS CrashLoopBackOff - OOMKilled·Exit 137 원인과 해결도 함께 참고해보세요.

7) 파이프라인을 “테스트 가능한 조각”으로 쪼개서 디버깅 시간을 줄이기

디버깅이 반복되는 코드는 대개 함수 경계가 모호합니다. 파이프라인 각 단계를 “입력/출력 계약이 분명한 순수 함수”로 만들면, 문제를 REPL에서 재현하기 쉬워지고 테스트도 쉬워집니다.

예시: 단계별로 명시적인 반환 형태를 통일

def normalize_params(params) do
  {:ok,
   params
   |> Map.update("email", nil, &String.downcase/1)
   |> Map.update("name", nil, &String.trim/1)}
end

def validate(params) do
  changeset = MySchema.changeset(%MySchema{}, params)

  if changeset.valid? do
    {:ok, changeset}
  else
    {:error, changeset}
  end
end

이렇게 만들면 상위 흐름은 with로 안정적으로 묶을 수 있고, 실패 지점도 선명합니다.

8) telemetry로 “관찰 가능성”을 올리기

단순 디버깅을 넘어, 운영에서 “어느 단계가 느려졌는지” “어느 단계에서 실패가 늘었는지”를 알고 싶다면 telemetry가 유용합니다. 파이프라인 각 단계에 이벤트를 박아두면, 로그보다 구조적으로 집계할 수 있습니다.

간단한 예:

:telemetry.execute(
  [:my_app, :pipeline, :step],
  %{duration_ms: 12},
  %{step: :validate, request_id: request_id}
)

이벤트 핸들러를 붙여 로깅/메트릭으로 전송할 수 있습니다. 이렇게 계측을 해두면, 장애가 났을 때 “느려진 단계”를 먼저 좁히고 그 단계에만 디버깅 훅을 집중할 수 있습니다.

재시도/백오프 같은 운영 패턴과 결합할 때도 계측은 큰 도움이 됩니다. 예를 들어 외부 API 호출이 포함된 파이프라인이라면, 실패율 상승을 감지해 재시도 정책을 조정해야 합니다. 이 주제는 OpenAI API 429 RateLimit 재시도·백오프 실무 글의 설계 관점도 참고할 만합니다.

9) 실전 예제: 결제 요청 파이프라인을 디버깅하기

아래는 “입력 정규화 → 검증 → 외부 결제 API 호출 → DB 저장” 흐름이라고 가정한 예시입니다.

문제가 있는 버전

params
|> normalize_params()
|> validate!()
|> call_payment_gateway()
|> Repo.insert!()

문제점:

  • validate!/1이 예외를 던지면 어디서 왜 실패했는지 맥락이 빈약해질 수 있음
  • call_payment_gateway/1{:error, reason}을 반환하면 다음 단계에서 폭발

디버깅 친화적으로 개선

def run_payment(params, request_id) do
  Logger.metadata(request_id: request_id)

  with {:ok, normalized} <- normalize_params(params),
       _ <- Logger.debug("normalized", normalized: inspect(normalized, limit: 30)),
       {:ok, changeset} <- validate(normalized),
       {:ok, payment_req} <- build_payment_request(changeset),
       {:ok, payment_res} <- call_payment_gateway(payment_req),
       _ <- Logger.info("payment success", gateway_id: payment_res.gateway_id),
       {:ok, record} <- persist_payment(changeset, payment_res) do
    {:ok, record}
  else
    {:error, %Ecto.Changeset{} = cs} ->
      Logger.warning("validation failed", errors: inspect(cs.errors))
      {:error, :invalid_input}

    {:error, :timeout} ->
      Logger.error("gateway timeout")
      {:error, :gateway_timeout}

    {:error, reason} ->
      Logger.error("payment failed", reason: inspect(reason))
      {:error, :payment_failed}
  end
end

핵심은 다음입니다.

  • 외부 호출과 DB 저장 같은 “실패 가능성이 큰 단계”는 {:ok, v} {:error, r} 형태로 통일
  • 디버깅 로그는 단계별로 최소한만 남기되, request_id로 상관관계를 확보
  • 실패 케이스를 else에서 명시적으로 분기해 원인을 빠르게 좁힘

10) 체크리스트: 파이프라인 디버깅을 빠르게 만드는 습관

  • 각 단계의 반환 타입을 문서화하거나 이름으로 드러내기
    • 예: validate/1{:ok, changeset} 또는 {:error, changeset}
  • 중간 관찰은 then/2 또는 tap 유틸로 표준화하기
  • 예외 기반 흐름(bang 함수)은 경계에서만 사용하고, 내부는 가능한 한 결과 튜플로
  • 운영 로깅은 Logger로, 출력량 제한과 레벨 전략을 함께 적용
  • 반복 장애는 telemetry 계측으로 “어느 단계가 문제인지”를 숫자로 만들기

마무리

Elixir 파이프라인 디버깅은 결국 “값의 shape을 단계별로 확인하고, 실패 흐름을 구조화”하는 문제입니다. IO.inspect/2로 즉시 관찰하고, then/2tap 패턴으로 파이프라인을 깨지 않게 만들고, 실패가 섞이면 with로 흐름을 정리하세요. 여기에 Logger 메타데이터와 telemetry까지 얹으면, 로컬 디버깅을 넘어 운영 환경에서도 재현과 원인 추적이 훨씬 빨라집니다.