- Published on
Elixir 파이프라인에 모나드 적용 - 실패 전파 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Elixir 코드를 오래 운영하다 보면, 결국 가장 많이 마주치는 문제는 “정상 흐름은 간단한데, 실패 처리 때문에 코드가 지저분해진다”입니다. 특히 |> 파이프라인을 쓰면 성공 경로는 우아해지지만, 중간 단계에서 실패가 발생했을 때 실패를 자연스럽게 전파하는 구조를 만들지 않으면 곧바로 case 중첩이 늘어납니다.
이 글에서는 Elixir에서 흔히 쓰는 {:ok, value} / {:error, reason} 튜플을 Result(Either) 모나드처럼 다루는 방식으로 파이프라인에 “실패 전파 패턴”을 적용하는 방법을 정리합니다. 용어는 모나드라고 부르지만, 핵심은 단순합니다.
- 모든 단계 함수가 동일한 컨벤션(예:
{:ok, _}또는{:error, _})을 반환한다 - 성공이면 다음 단계로 값을 넘기고, 실패면 즉시 실패를 반환한다
- 파이프라인 문법을 최대한 유지하면서 분기 코드를 최소화한다
왜 파이프라인은 실패 처리에서 무너질까
Elixir의 |>는 “왼쪽 값을 오른쪽 함수의 첫 번째 인자로 넣는” 문법 설탕입니다. 그런데 각 단계가 단순 값이 아니라 {:ok, value} 같은 컨테이너가 되면, 다음 함수는 value가 아니라 {:ok, value}를 받게 됩니다.
즉 다음과 같은 코드가 자연스럽게 실패합니다.
user_id
|> fetch_user()
|> validate_user()
|> charge_card()
fetch_user/1가 {:ok, user}를 반환하면 validate_user/1는 user가 아니라 {:ok, user}를 받습니다. 그래서 결국 아래처럼 case로 풀어 쓰기 시작합니다.
case fetch_user(user_id) do
{:ok, user} ->
case validate_user(user) do
{:ok, valid_user} ->
charge_card(valid_user)
{:error, reason} ->
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
이 중첩을 없애는 것이 곧 “실패 전파 패턴”의 목적입니다.
Elixir에서의 모나드: with는 사실상 Result 체이닝
Elixir 표준 문법 중 실패 전파에 가장 가까운 도구는 with입니다. with는 패턴 매칭이 실패하면 즉시 else로 이동하고, 성공하면 다음 줄로 값을 바인딩합니다.
def checkout(user_id, card, amount) do
with {:ok, user} <- fetch_user(user_id),
:ok <- validate_user(user),
{:ok, receipt} <- charge_card(card, amount) do
{:ok, receipt}
else
{:error, reason} -> {:error, reason}
:error -> {:error, :unknown}
end
end
장점은 강력합니다.
- 실패 전파가 자동
- 중첩 제거
- 각 단계가 명시적으로 “성공 패턴”을 선언
단점도 있습니다.
- 파이프라인
|>의 “위에서 아래로 흐르는 느낌”이 약해짐 - 단계가 많아질수록
with헤더가 길어짐 else에서 실패 타입을 정리하지 않으면 에러가 다시 복잡해짐
그래서 실무에서는 with를 기본으로 하되, 파이프라인 스타일을 유지하고 싶을 때 “Result 바인드(bind)” 유틸을 만들어 쓰는 경우가 많습니다.
핵심 패턴 1: Result bind로 파이프라인 유지하기
모나드에서 bind는 “컨테이너 안의 값을 꺼내 다음 함수에 넣고, 실패면 그대로 실패를 반환”입니다. Elixir에서는 다음처럼 구현할 수 있습니다.
defmodule Result do
@type t(a) :: {:ok, a} | {:error, any()}
def ok(value), do: {:ok, value}
def error(reason), do: {:error, reason}
def bind({:ok, value}, fun) when is_function(fun, 1), do: fun.(value)
def bind({:error, _} = err, _fun), do: err
end
이제 파이프라인은 이렇게 됩니다.
import Result
def checkout(user_id, card, amount) do
ok(user_id)
|> bind(&fetch_user/1)
|> bind(&validate_user/1)
|> bind(fn user -> charge_card(card, amount, user) end)
end
여기서 중요한 규칙은 하나입니다.
fetch_user/1,validate_user/1,charge_card/3는 반드시{:ok, _}또는{:error, _}를 반환
예를 들어 validate_user/1가 :ok를 반환하면 체인이 끊깁니다. 따라서 성공도 {:ok, user}처럼 “다음 단계로 넘길 값”을 포함하도록 설계하는 편이 파이프라인에 더 잘 맞습니다.
validate_user/1를 파이프라인 친화적으로 만들기
def validate_user(%{status: :active} = user), do: {:ok, user}
def validate_user(_), do: {:error, :inactive_user}
이렇게 하면 “검증을 통과하면 원래 값을 그대로 넘긴다”라는 관용구가 됩니다.
핵심 패턴 2: map과 tap으로 부수효과 분리
실패 전파 체인을 쓰다 보면, 어떤 단계는 값을 변환하고(map), 어떤 단계는 로깅/메트릭 같은 부수효과만 수행하고 싶습니다(tap).
defmodule Result do
def map({:ok, value}, fun) when is_function(fun, 1), do: {:ok, fun.(value)}
def map({:error, _} = err, _fun), do: err
def tap({:ok, value} = ok, fun) when is_function(fun, 1) do
fun.(value)
ok
end
def tap({:error, _} = err, _fun), do: err
end
사용 예시는 다음과 같습니다.
import Result
def checkout(user_id, card, amount) do
ok(user_id)
|> bind(&fetch_user/1)
|> tap(fn user -> Logger.metadata(user_id: user.id) end)
|> bind(&validate_user/1)
|> bind(fn user -> charge_card(card, amount, user) end)
|> map(fn receipt -> %{receipt | processed_at: DateTime.utc_now()} end)
end
tap은 성공일 때만 실행되고 값을 바꾸지 않습니다.map은 성공일 때만 값을 변환합니다.
이 조합이 갖춰지면, 파이프라인이 “성공 흐름을 읽는 문장”처럼 유지됩니다.
핵심 패턴 3: 에러 타입을 표준화해 else 지옥을 막기
실패 전파를 도입해도, 각 단계가 제각각의 에러 형태를 반환하면 상위 레이어에서 다시 분기해야 합니다.
- 어떤 함수는
{:error, :not_found} - 어떤 함수는
{:error, %{code: 123, msg: "..."}} - 어떤 함수는
:error
이러면 결국 with ... else에서 케이스가 늘어납니다. 해결책은 “에러를 도메인 타입으로 표준화”하는 것입니다.
예를 들어 결제 도메인이라면 다음처럼 정리합니다.
defmodule CheckoutError do
defstruct [:type, :message, :meta]
def new(type, message, meta \\ %{}) do
%__MODULE__{type: type, message: message, meta: meta}
end
end
그리고 모든 실패는 {:error, %CheckoutError{...}}로 통일합니다.
def fetch_user(user_id) do
case Repo.get(User, user_id) do
nil -> {:error, CheckoutError.new(:user_not_found, "user not found", %{user_id: user_id})}
user -> {:ok, user}
end
end
이렇게 해두면 상위 레이어는 에러 형태를 예측할 수 있어, HTTP 변환이나 로깅 정책이 단순해집니다.
이 패턴은 분산 트랜잭션/사가에서도 특히 중요합니다. 결제 같은 플로우에서 멱등성과 실패 복구를 다룰 때는 에러 타입이 곧 “보상 트랜잭션의 분기 조건”이 되기 때문입니다. 관련해서는 Saga+Outbox로 중복결제 막는 멱등키·Kafka 패턴 글과 함께 보면 실패 전파를 넘어 “실패 후 처리”까지 설계 관점이 이어집니다.
핵심 패턴 4: 예외를 {:error, _}로 내리기(경계에서만)
Elixir/Erlang 생태계에서는 예외를 완전히 금지하지 않습니다. 다만 “도메인 실패”는 예외가 아니라 값으로 표현하고, 예외는 정말 예측 불가능한 상황에 쓰는 편이 운영에 유리합니다.
외부 라이브러리 호출이 예외를 던진다면, 경계에서만 잡아서 Result로 변환합니다.
def safe(fun) when is_function(fun, 0) do
try do
{:ok, fun.()}
rescue
e -> {:error, CheckoutError.new(:exception, Exception.message(e), %{module: e.__struct__})}
catch
kind, reason -> {:error, CheckoutError.new(:throw, "caught", %{kind: kind, reason: reason})}
end
end
사용 예:
import Result
def charge_card(card, amount, user) do
safe(fn -> PaymentGateway.charge(card, amount, user.external_id) end)
|> bind(fn gateway_resp -> normalize_gateway_response(gateway_resp) end)
end
이렇게 하면 파이프라인의 실패 전파 모델을 깨지 않으면서도, 예외를 상위로 폭발시키지 않고 관측 가능한 에러로 바꿀 수 있습니다.
실전 예제: 주문 생성 파이프라인에 실패 전파 적용
요구사항을 가정해봅시다.
- 입력 검증
- 사용자 조회
- 재고 확인
- 주문 생성
- 결제 승인
- 이벤트 발행
각 단계는 실패할 수 있고, 실패하면 즉시 멈춰야 합니다.
defmodule OrderFlow do
import Result
def create_order(params) do
ok(params)
|> bind(&validate_params/1)
|> bind(&fetch_user/1)
|> bind(&check_stock/1)
|> bind(&insert_order/1)
|> bind(&authorize_payment/1)
|> tap(&publish_order_created_event/1)
end
def validate_params(%{"user_id" => user_id, "items" => items} = params)
when is_list(items) and length(items) > 0 do
{:ok, Map.put(params, "user_id", user_id)}
end
def validate_params(_), do: {:error, CheckoutError.new(:bad_request, "invalid params")}
def fetch_user(%{"user_id" => user_id} = params) do
case Repo.get(User, user_id) do
nil -> {:error, CheckoutError.new(:user_not_found, "user not found", %{user_id: user_id})}
user -> {:ok, Map.put(params, "user", user)}
end
end
def check_stock(%{"items" => items} = params) do
case Inventory.reserve(items) do
{:ok, reservation} -> {:ok, Map.put(params, "reservation", reservation)}
{:error, reason} -> {:error, CheckoutError.new(:out_of_stock, "out of stock", %{reason: reason})}
end
end
def insert_order(params) do
Repo.transaction(fn ->
{:ok, order} = Orders.insert_from_params(params)
order
end)
|> case do
{:ok, order} -> {:ok, Map.put(params, "order", order)}
{:error, reason} -> {:error, CheckoutError.new(:db_error, "failed to insert order", %{reason: reason})}
end
end
def authorize_payment(%{"order" => order} = params) do
case Payment.authorize(order) do
{:ok, auth} -> {:ok, Map.put(params, "payment_auth", auth)}
{:error, reason} -> {:error, CheckoutError.new(:payment_failed, "payment failed", %{reason: reason})}
end
end
def publish_order_created_event(%{"order" => order}) do
Events.publish("order.created", %{order_id: order.id})
end
end
이 예제의 포인트는 다음과 같습니다.
- 각 단계가 입력과 출력의 형태를 일관되게 유지합니다
- 실패 시 즉시
{:error, ...}로 단락 평가됩니다 tap으로 이벤트 발행을 분리해도 성공 흐름이 유지됩니다
이 구조는 이벤트 기반 아키텍처나 DDD 경계에서도 잘 맞습니다. 특히 바운디드 컨텍스트를 나눌 때 “유스케이스 단위의 실패 전파”가 깔끔하면, 애플리케이션 서비스 계층이 얇아지고 테스트도 쉬워집니다. 참고로 컨텍스트 분리 관점은 EventStorming 1일 워크숍으로 DDD 바운디드 컨텍스트 나누기 글이 도움이 됩니다.
언제 with를 쓰고 언제 bind 파이프라인을 쓰나
둘 다 정답이 될 수 있고, 팀의 취향/코드베이스에 따라 섞어 쓰는 경우가 많습니다.
with- 장점: 표준 문법, 가독성 좋음, 패턴 매칭 강력
- 추천: 단계 수가 적고, 각 단계의 결과 바인딩이 중요할 때
bind기반 파이프라인- 장점: 파이프라인 스타일 유지,
tap/map조합으로 선언적 - 추천: 단계가 많고 성공 흐름을 “문장처럼” 읽히게 하고 싶을 때
- 장점: 파이프라인 스타일 유지,
개인적으로는 다음 기준이 실무에서 잘 맞았습니다.
- “데이터를 계속 누적하며 컨텍스트 맵을 키워가는 플로우”는
bind파이프라인 - “몇 개의 값 바인딩으로 끝나는 짧은 플로우”는
with
테스트 전략: 실패 전파는 테이블 기반 테스트와 궁합이 좋다
실패 전파 패턴을 도입하면, 각 단계가 {:ok, _} / {:error, _}로 정규화되어 테스트가 단순해집니다.
defmodule OrderFlowTest do
use ExUnit.Case
test "invalid params" do
assert {:error, err} = OrderFlow.create_order(%{"user_id" => 1, "items" => []})
assert err.type == :bad_request
end
test "user not found" do
assert {:error, err} = OrderFlow.create_order(%{"user_id" => -1, "items" => [%{"sku" => "A", "qty" => 1}]})
assert err.type == :user_not_found
end
end
이 방식은 운영 장애 대응에도 간접적으로 도움이 됩니다. 에러가 표준화되어 있으면 로그/메트릭에서 집계가 쉬워지고, 원인 분석 시간이 줄어듭니다. 장애 진단 체크리스트를 운영 관점에서 확장하고 싶다면 K8s CrashLoopBackOff 원인별 진단 체크리스트 같은 글의 “원인 분류” 접근을 애플리케이션 에러에도 적용할 수 있습니다.
흔한 함정 3가지
1) 어떤 단계는 :ok, 어떤 단계는 {:ok, _}를 반환
파이프라인 체이닝이 목표라면 성공 형태를 통일해야 합니다. :ok를 반환하는 함수는 다음 단계에 넘길 값이 없으므로 체인이 끊깁니다. 가장 쉬운 해결은 “검증 성공 시 원본 값을 그대로 {:ok, value}로 반환”하는 규칙입니다.
2) 실패를 nil로 표현
nil은 파이프라인에서 조용히 흘러가며 더 큰 버그를 만들기 쉽습니다. 실패는 반드시 {:error, reason}로 표현해 상위에서 관측 가능하게 만드세요.
3) 에러를 문자열로만 남김
문자열 에러는 로깅에는 편하지만, 분기/집계가 어렵습니다. type(원인 분류)과 meta(관측 정보)를 포함한 구조체나 맵으로 표준화하는 편이 운영에 훨씬 유리합니다.
정리
Elixir 파이프라인에 모나드적 실패 전파를 적용하는 요령은 복잡하지 않습니다.
- 성공/실패 반환 형태를
{:ok, value}/{:error, reason}로 통일 with로 표준적인 실패 전파를 쓰거나bind/map/tap유틸로 파이프라인 스타일을 유지- 에러 타입을 구조화해 상위 레이어의 분기와 관측 비용을 줄임
이 패턴은 단순히 코드 예쁘게 만드는 수준을 넘어, “실패가 기본인 분산 시스템”에서 운영 가능성을 높이는 토대가 됩니다. 결제, 주문, 외부 API 연동처럼 실패가 잦은 도메인일수록 효과가 더 크게 체감됩니다.