Published on

Elixir with/|> 파이프라인 오류 디버깅 실전

Authors

서버 사이드 Elixir 코드를 짜다 보면 |>로 이어진 파이프라인과 with로 묶인 단계적 검증이 가장 읽기 좋은 형태가 됩니다. 문제는 에러가 났을 때입니다. 파이프라인은 “어디서부터 값이 이상해졌는지”가 한 번에 보이지 않고, with는 매칭 실패가 곧바로 탈출되기 때문에 “실패 이유가 무엇인지”가 흐릿해지기 쉽습니다.

이 글은 다음 상황을 빠르게 해결하는 데 초점을 둡니다.

  • |> 중간 단계에서 타입이 바뀌거나 nil이 들어와 크래시가 나는 경우
  • with에서 어느 패턴 매칭이 실패했는지, 실패 값이 무엇인지 추적해야 하는 경우
  • 성공/실패 경로를 명확히 나누면서도 로그와 관측 가능성을 확보하는 방법

파이프라인 디버깅은 본질적으로 “관측 가능성(Observability)” 문제이기도 합니다. 운영 환경에서 원인 추적이 어려운 문제를 줄이는 접근은 레이트리밋/트래픽 제어처럼 시스템적 관점과도 맞닿아 있습니다. 필요하다면 OpenAI Responses API 429 레이트리밋 토큰버킷으로 끝내기처럼 ‘문제가 터졌을 때 재현과 측정이 가능한 구조’로 만드는 글도 함께 참고하면 좋습니다.

|> 파이프라인이 깨지는 대표 패턴

1) 중간 함수가 기대한 타입을 반환하지 않는다

예를 들어 문자열을 파싱해 정수로 만들고, 그 정수로 DB 조회를 하는 흐름을 생각해봅시다.

id =
  params["id"]
  |> String.trim()
  |> String.to_integer()

Repo.get(User, id)

여기서 params["id"]nil이면 String.trim/1에서 바로 예외가 납니다. 혹은 "abc"String.to_integer/1에서 예외가 납니다. 파이프라인 자체는 읽기 좋지만, 실패 지점과 입력값이 로그로 남지 않으면 운영에서 곤란합니다.

2) |>는 “에러를 전달”하지 않는다

Elixir의 파이프는 단순히 “왼쪽 값을 오른쪽 함수의 첫 번째 인자로 넣는” 문법 설탕입니다. {:ok, value} 같은 튜플을 자동으로 풀어주지 않습니다.

{:ok, user} = Accounts.fetch_user(id)

user
|> Accounts.ensure_active()
|> Accounts.issue_token()

여기서 fetch_user/1{:error, :not_found}를 반환하면 매칭에서 터집니다. 즉, 파이프라인 이전에서 이미 크래시가 날 수 있고, 파이프라인은 그 사실을 가려주지 않습니다.

파이프라인 디버깅 도구: tap/2, then/2, dbg/2

Elixir 1.12 이후에는 파이프라인 중간을 깨지 않고 관측하는 도구가 좋아졌습니다.

tap/2: 값을 그대로 흘리면서 부수효과만 수행

params["id"]
|> tap(fn v -> Logger.debug("raw id=#{inspect(v)}") end)
|> String.trim()
|> tap(fn v -> Logger.debug("trimmed id=#{inspect(v)}") end)
|> String.to_integer()
|> tap(fn v -> Logger.debug("parsed id=#{inspect(v)}") end)
  • tap/2는 입력값을 그대로 반환합니다.
  • 중간 값을 로그로 남기거나 메트릭을 찍을 때 파이프라인을 망치지 않습니다.

then/2: 다음 단계가 복잡할 때 “파이프를 끊지 않고” 블록 처리

params
|> Map.get("filters", %{})
|> then(fn filters ->
  # 여러 값을 조합하거나 조건 분기가 필요할 때
  if Map.get(filters, "active") do
    Map.put(filters, "status", "active")
  else
    filters
  end
end)
|> RepoQuery.apply_filters()
  • then/2는 “값을 받아서 새로운 값으로 변환”에 적합합니다.
  • 파이프라인에서 갑자기 case가 튀어나오는 가독성 저하를 줄입니다.

dbg/2: 개발 중 즉시 값 찍기

params["id"]
|> dbg(label: "raw id")
|> String.trim()
|> dbg(label: "trimmed")
|> String.to_integer()
  • dbg/2는 개발 환경에서 매우 편합니다.
  • 다만 운영 로그에 섞이면 노이즈가 되므로, 릴리즈 전에 제거하거나 조건부로 쓰는 습관이 좋습니다.

with에서 오류 추적이 어려운 이유

with는 여러 단계의 {:ok, value} 흐름을 깔끔하게 만들지만, 실패하면 “첫 실패 값”이 그대로 else로 떨어집니다. 이때 실패 값이 설계되지 않으면 원인을 알기 어렵습니다.

나쁜 예: 실패 값이 제각각이라 else에서 처리 불가

with {:ok, id} <- parse_id(params),
     {:ok, user} <- Accounts.fetch_user(id),
     true <- Accounts.active?(user),
     {:ok, token} <- Accounts.issue_token(user) do
  {:ok, token}
else
  _ -> {:error, :bad_request}
end
  • true <- ... 매칭이 실패하면 false가 떨어집니다.
  • parse_id/1{:error, :invalid}를 줄 수 있고, fetch_user/1{:error, :not_found}를 줄 수 있습니다.
  • 그런데 else_로 뭉개면, 디버깅 정보가 사라집니다.

with 디버깅을 위한 설계 패턴

핵심은 “실패 형태를 표준화”하고, “실패 지점을 식별 가능한 태그로 감싸는 것”입니다.

패턴 1) 실패를 {:error, {step, reason}}로 통일

def login(params) do
  with {:ok, id} <- tag_error(:parse_id, parse_id(params)),
       {:ok, user} <- tag_error(:fetch_user, Accounts.fetch_user(id)),
       :ok <- tag_error(:ensure_active, ensure_active(user)),
       {:ok, token} <- tag_error(:issue_token, Accounts.issue_token(user)) do
    {:ok, token}
  else
    {:error, {step, reason}} ->
      Logger.warning("login failed step=#{step} reason=#{inspect(reason)}")
      {:error, {step, reason}}
  end
end

defp tag_error(step, {:ok, v}), do: {:ok, v}

defp tag_error(step, :ok), do: :ok

defp tag_error(step, {:error, reason}), do: {:error, {step, reason}}

defp tag_error(step, other), do: {:error, {step, {:unexpected, other}}}


defp ensure_active(user) do
  if Accounts.active?(user), do: :ok, else: {:error, :inactive}
end

defp parse_id(params) do
  case Map.get(params, "id") do
    nil -> {:error, :missing_id}
    id when is_binary(id) ->
      id
      |> String.trim()
      |> Integer.parse()
      |> case do
        {int, ""} -> {:ok, int}
        _ -> {:error, :invalid_id}
      end
    _ ->
      {:error, :invalid_type}
  end
end

이렇게 하면:

  • 실패가 나면 항상 {:error, {step, reason}}로 떨어집니다.
  • 운영 로그에서 “어느 단계(step)에서” 실패했는지 즉시 필터링할 수 있습니다.
  • with의 장점(성공 경로의 간결함)을 유지하면서도, 실패 경로의 정보 손실을 막습니다.

패턴 2) with 안에 tap/2로 단계별 값 관측

성공 경로에서 값이 이상해지는 문제라면 with 내부에서도 tap/2를 쓸 수 있습니다.

with {:ok, id} <- parse_id(params) |> tap(fn r -> Logger.debug("parse_id=#{inspect(r)}") end),
     {:ok, user} <- Accounts.fetch_user(id) |> tap(fn r -> Logger.debug("fetch_user=#{inspect(r)}") end),
     :ok <- ensure_active(user) |> tap(fn r -> Logger.debug("ensure_active=#{inspect(r)}") end),
     {:ok, token} <- Accounts.issue_token(user) |> tap(fn r -> Logger.debug("issue_token=#{inspect(r)}") end) do
  {:ok, token}
else
  err ->
    Logger.warning("login failed err=#{inspect(err)}")
    {:error, :login_failed}
end
  • tap/2는 반환값을 바꾸지 않으므로 with 매칭에 영향이 없습니다.
  • 다만 로그가 과도해지기 쉬우니, 디버깅 기간에만 활성화하거나 로그 레벨을 조절하세요.

패턴 3) else는 “매칭 실패 값”을 구체적으로 분기

withelse는 실패 케이스를 패턴 매칭으로 분기할 수 있습니다.

with {:ok, id} <- parse_id(params),
     {:ok, user} <- Accounts.fetch_user(id),
     :ok <- ensure_active(user) do
  {:ok, user}
else
  {:error, :missing_id} -> {:error, :bad_request}
  {:error, :invalid_id} -> {:error, :bad_request}
  {:error, :not_found} -> {:error, :not_found}
  {:error, :inactive} -> {:error, :forbidden}
  other ->
    Logger.error("unexpected login error=#{inspect(other)}")
    {:error, :internal_error}
end
  • “사용자에게 노출할 에러”와 “내부에서 관측할 에러”를 분리할 수 있습니다.
  • 단점은 케이스가 늘어나면 else가 커집니다. 이때는 앞에서 소개한 {:error, {step, reason}} 태깅이 더 관리하기 좋습니다.

|>with를 섞을 때 자주 나는 실수

실수 1) with 안에서 파이프를 과도하게 길게 만든다

with는 단계가 이미 보이는데, 각 단계 내부에서 또 긴 파이프가 생기면 “어디가 단계 경계인지”가 흐려집니다. 긴 파이프는 별도 함수로 추출하고, 그 함수의 입출력을 명확히 하는 게 디버깅에 유리합니다.

# 좋음: 파싱 로직을 함수로 격리
with {:ok, id} <- parse_id(params),
     {:ok, user} <- Accounts.fetch_user(id) do
  {:ok, user}
end

실수 2) Integer.parse/1 결과를 제대로 확인하지 않는다

Integer.parse/1{"123", ""}처럼 “잔여 문자열”을 돌려줍니다. 잔여가 비어있지 않으면 사실상 파싱 실패로 보는 게 안전합니다.

case Integer.parse("12x") do
  {int, ""} -> {:ok, int}
  _ -> {:error, :invalid}
end

실수 3) 예외를 with로 처리하려고 한다

with는 예외를 잡지 않습니다. 예외를 다루려면 try/rescue 또는 아예 예외가 아닌 반환값 기반으로 함수들을 설계해야 합니다.

try do
  params["id"]
  |> String.trim()
  |> String.to_integer()
rescue
  e in ArgumentError ->
    {:error, {:invalid_id, e.message}}
end

운영 코드에서는 예외 기반보다 {:ok, _} | {:error, _} 기반이 관측과 테스트에 유리한 경우가 많습니다.

운영에서의 “재현 가능한 디버깅”을 위한 로그/메트릭 패턴

로컬에서 dbg/2로 끝나는 문제가 아니라면, 결국 운영에서 “어떤 입력이 어떤 단계에서 실패했는지”를 남겨야 합니다. 이때 중요한 건 로그를 무작정 늘리는 게 아니라, 구조화된 형태로 남기는 것입니다.

구조화 로그 필드 추천

  • request_id 또는 트레이스 ID
  • step (예: :parse_id, :fetch_user)
  • reason (예: :missing_id, :not_found)
  • 민감정보가 아닌 범위의 입력 요약(예: id의 타입, 길이)
Logger.warning(fn ->
  "login failed request_id=#{Logger.metadata()[:request_id]} step=#{step} reason=#{inspect(reason)}"
end)

:telemetry로 단계별 실패 카운팅

로그는 샘플링/필터링에서 누락될 수 있습니다. 실패율을 추적하려면 카운터 메트릭이 더 안정적입니다.

:telemetry.execute(
  [:my_app, :login, :failed],
  %{count: 1},
  %{step: step, reason: reason}
)

이런 식으로 “스텝별 실패율”을 대시보드에서 보면, 배포 직후 어떤 단계가 급증했는지 바로 감지할 수 있습니다. 파이프라인 디버깅을 ‘관측 가능성’ 관점에서 접근하면, 예를 들어 JVM 메모리 문제를 진단하듯 원인 범위를 빠르게 좁히는 데 도움이 됩니다. 비슷한 방식의 진단 사고법은 Spring Boot Metaspace OOM 원인과 해결 가이드 같은 글과도 결이 같습니다.

테스트로 디버깅 비용 줄이기: 단계별 단위 테스트

파이프라인은 “한 번에 끝까지” 테스트하기 쉽지만, 디버깅을 줄이려면 단계 함수들을 쪼개 테스트하는 게 효과적입니다.

defmodule MyApp.AuthTest do
  use ExUnit.Case, async: true

  test "parse_id returns error when missing" do
    assert {:error, :missing_id} = MyApp.Auth.parse_id(%{})
  end

  test "parse_id rejects trailing characters" do
    assert {:error, :invalid_id} = MyApp.Auth.parse_id(%{"id" => "12x"})
  end

  test "parse_id accepts trimmed integer" do
    assert {:ok, 12} = MyApp.Auth.parse_id(%{"id" => " 12 "})
  end
end
  • 파이프라인 전체가 실패할 때 “어느 단계가 원인인지”를 테스트가 먼저 알려줍니다.
  • 특히 문자열 파싱/인코딩/디코딩 계열은 엣지 케이스가 많아, 테스트가 곧 디버깅 비용 절감입니다. 이 관점은 Python UnicodeDecodeError 완벽 해결 가이드에서 다루는 ‘입력의 다양성을 전제로 한 방어적 처리’와도 유사합니다.

정리: with|> 오류 추적 체크리스트

  • 파이프라인 중간 값 관측은 tap/2를 기본으로, 개발 중 빠른 확인은 dbg/2를 사용한다.
  • with는 실패를 “표준화된 에러 형태”로 설계하고, 가능하면 {:error, {step, reason}}로 태깅한다.
  • true <- ... 같은 불리언 매칭은 디버깅을 어렵게 하므로 :ok | {:error, reason}로 바꾼다.
  • 예외는 with로 잡을 수 없으니, 예외를 반환값 기반으로 바꾸거나 try/rescue로 경계를 명확히 한다.
  • 운영에서는 로그보다 “스텝별 실패 메트릭”이 더 빠르게 이상징후를 알려준다.

|>with는 Elixir의 생산성을 폭발시키는 도구지만, 디버깅 전략까지 함께 가져가야 팀 코드베이스가 안정적으로 커집니다. 위 패턴들을 템플릿처럼 정리해두면, 다음에 비슷한 오류가 났을 때 ‘감’이 아니라 ‘절차’로 원인을 추적할 수 있습니다.