- Published on
Elixir 함수형 파이프라인 디버깅 실전 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
함수형 스타일의 Elixir는 파이프라인(|>)으로 데이터 흐름을 읽기 좋게 만드는 대신, 중간 값이 예상과 달라졌을 때 “어디서부터 틀어졌는지” 찾는 과정이 낯설 수 있습니다. 특히 nil이 섞이거나, {:error, reason} 튜플이 중간에 끼어들거나, 패턴 매칭이 실패해 예외가 터지면 파이프라인은 한 줄로 예뻐 보여도 디버깅 난이도는 급격히 올라갑니다.
이 글은 “파이프라인을 깨지 않고” 혹은 “깨더라도 최소한의 비용으로” 원인을 추적하는 실전 패턴을 모아 설명합니다. 단순 출력부터 구조적 로깅, telemetry 계측까지 단계적으로 확장해보겠습니다.
파이프라인 디버깅이 어려운 이유
파이프라인은 기본적으로 다음 규칙을 따릅니다.
- 왼쪽 값을 오른쪽 함수의 첫 번째 인자로 넣는다
- 한 단계라도 리턴 타입이 예상과 달라지면 이후 단계에서 패턴 매칭 실패 또는 함수 절 정의 실패가 발생한다
즉, 파이프라인 디버깅의 핵심은 “각 단계의 입력과 출력 shape(타입/구조)를 확인”하는 것입니다.
아래는 흔한 예시입니다.
params
|> normalize_params()
|> validate()
|> Repo.insert()
|> notify()
여기서 validate/1이 {:error, changeset}를 반환하는데 Repo.insert/1은 Ecto.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로 감싸기보다 “어디서 터졌는지”를 먼저
파이프라인 도중 예외가 발생하면 스택트레이스에 단서가 있지만, 중간 값이 크거나 변환이 많으면 원인 파악이 느립니다.
권장 순서는 보통 이렇습니다.
- 예외를 억지로 삼키지 말고 그대로 터뜨려 스택트레이스를 확보
- 예외가 난 함수 바로 앞 단계에
then/2또는Debug.tap/2로 입력값을 관찰 - 입력값의 shape을 고정(가드, 패턴 매칭,
with)해 재발 방지
예를 들어 FunctionClauseError가 format_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/2나 tap 패턴으로 파이프라인을 깨지 않게 만들고, 실패가 섞이면 with로 흐름을 정리하세요. 여기에 Logger 메타데이터와 telemetry까지 얹으면, 로컬 디버깅을 넘어 운영 환경에서도 재현과 원인 추적이 훨씬 빨라집니다.