- Published on
Elixir OTP에서 Either로 에러 체이닝하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Elixir OTP를 쓰다 보면 거의 모든 함수가 {:ok, value} 또는 {:error, reason} 형태로 결과를 반환합니다. 이 패턴은 Erlang/OTP의 전통이고, 실패를 값으로 다룬다는 점에서 매우 강력합니다. 문제는 비즈니스 로직이 길어질수록 with 블록이 비대해지거나, 단계별 에러에 컨텍스트를 덧붙이기 위해 보일러플레이트가 늘어난다는 점입니다.
여기서 말하는 Either 모나드는 Haskell/Scala의 Either처럼 Right는 성공, Left는 실패를 담고, map/flat_map으로 연쇄(체이닝)할 수 있는 추상화입니다. Elixir에는 표준 Either 타입이 없지만, {:ok, _}/{:error, _} 자체가 사실상 Either로 동작합니다. 핵심은 이를 "모나딕한 연산(체이닝)"으로 다루는 도구를 만들고, OTP 경계(GenServer 콜백, Supervisor 재시작, Task 등)에서 어떻게 안전하게 적용할지입니다.
참고로 함수형 언어에서 Either 조합 패턴은 다른 생태계에서도 자주 다룹니다. 예를 들어 Scala의 실전 패턴은 Scala 3 Option·Either 조합 실전 패턴 7 글이 도움이 됩니다. 이 글에서는 그 아이디어를 Elixir OTP 스타일로 옮겨봅니다.
OTP에서 에러 체이닝이 어려워지는 지점
OTP 애플리케이션에서 에러는 크게 두 층으로 나뉩니다.
- 값으로서의 에러:
{:error, reason}로 반환하고 호출자가 분기 처리 - 프로세스 실패(크래시): 예외를 던져 프로세스를 종료시키고 Supervisor가 재시작
값으로서의 에러는 제어 가능하고 테스트하기 쉽지만, 긴 파이프라인에서 컨텍스트를 잃기 쉽습니다.
- 어떤 단계에서 실패했는지
- 입력 파라미터의 일부(예:
user_id,order_id)를 함께 남기고 싶은지 - 외부 시스템(HTTP/DB) 호출 실패인지, 도메인 검증 실패인지
반대로 크래시는 OTP가 가장 잘하는 방식이지만, 모든 실패를 크래시로 처리하면 "예상 가능한 실패"까지 재시작으로 이어져 비용이 커질 수 있습니다.
따라서 실무에서는 보통 다음처럼 나눕니다.
- 예상 가능한 실패(검증, 권한, 재고 부족):
{:error, reason}로 반환 - 예상 불가능/버그/불변식 위반: 예외로 크래시
Either 체이닝은 첫 번째 범주를 깔끔하게 만들고, 두 번째 범주는 명확히 크래시로 남겨 OTP 철학과도 충돌하지 않습니다.
{:ok, _}/{:error, _}를 Either처럼 다루는 최소 도구
Elixir에서는 새로운 자료형을 만들기보다, 기존 튜플을 그대로 두고 연산만 제공하는 편이 자연스럽습니다.
아래는 map/flat_map/map_error/tap/context 정도를 제공하는 작은 모듈입니다.
defmodule MyApp.Either do
@type t(a) :: {:ok, a} | {:error, any()}
def ok(v), do: {:ok, v}
def error(e), do: {:error, e}
# 성공 값 변환
def map({:ok, v}, fun), do: {:ok, fun.(v)}
def map({:error, _} = err, _fun), do: err
# 다음 단계로 체이닝 (fun은 {:ok, _} | {:error, _} 반환)
def flat_map({:ok, v}, fun), do: fun.(v)
def flat_map({:error, _} = err, _fun), do: err
# 에러를 다른 형태로 변환
def map_error({:ok, _} = ok, _fun), do: ok
def map_error({:error, e}, fun), do: {:error, fun.(e)}
# 성공/실패 모두에서 부수효과 실행(로깅, 메트릭)
def tap({:ok, v} = ok, fun) do
fun.({:ok, v})
ok
end
def tap({:error, e} = err, fun) do
fun.({:error, e})
err
end
# 컨텍스트를 에러에 덧붙이기(실패 지점 추적)
def context({:ok, _} = ok, _ctx), do: ok
def context({:error, e}, ctx) when is_map(ctx) do
{:error, %{error: e, ctx: ctx}}
end
end
이 정도만 있어도 with를 대체하려는 것이 아니라, 에러를 꾸미고(컨텍스트), 단계적으로 조합하고, 로깅/관측을 한곳에서 일관되게 하기가 쉬워집니다.
with vs Either 체이닝: 무엇이 더 좋은가
Elixir의 with는 이미 "모나딕"합니다. 예를 들어 다음은 전형적인 파이프라인입니다.
with {:ok, user} <- Accounts.fetch_user(user_id),
:ok <- Policy.ensure_can_order(user),
{:ok, cart} <- Carts.get_open_cart(user),
{:ok, order} <- Orders.create_from_cart(cart) do
{:ok, order}
else
{:error, reason} -> {:error, reason}
other -> {:error, {:unexpected, other}}
end
그런데 다음 요구사항이 들어오면 with는 점점 무거워집니다.
- 각 단계 실패에 공통 컨텍스트(예:
user_id)를 붙이기 - 실패 타입을 도메인 에러로 정규화하기
- 성공/실패 모두 텔레메트리 이벤트를 남기기
Either 체이닝은 이런 "횡단 관심사"를 작은 연산자로 흡수할 수 있습니다.
실전 예제: 주문 생성 파이프라인을 Either로 정리
아래는 동일한 로직을 Either.flat_map/2로 체이닝한 예시입니다.
defmodule MyApp.Checkout do
alias MyApp.Either
def place_order(user_id) do
Either.ok(user_id)
|> Either.flat_map(&fetch_user/1)
|> Either.flat_map(&ensure_can_order/1)
|> Either.flat_map(&get_open_cart/1)
|> Either.flat_map(&create_order/1)
|> Either.map_error(&normalize_error/1)
|> Either.context(%{user_id: user_id, flow: :place_order})
end
defp fetch_user(user_id), do: MyApp.Accounts.fetch_user(user_id)
defp ensure_can_order(user), do: MyApp.Policy.ensure_can_order(user)
defp get_open_cart(user), do: MyApp.Carts.get_open_cart(user)
defp create_order(cart), do: MyApp.Orders.create_from_cart(cart)
defp normalize_error(%{error: _} = e), do: e
defp normalize_error({:db, reason}), do: %{kind: :db, reason: reason}
defp normalize_error(:forbidden), do: %{kind: :policy, reason: :forbidden}
defp normalize_error(other), do: %{kind: :unknown, reason: other}
end
포인트는 다음입니다.
- 성공 경로는 "단계 함수"만 읽으면 됩니다.
normalize_error/1에서 에러 형태를 통일해 API 응답, 로깅, 메트릭을 단순화합니다.context/2로 실패에 공통 메타데이터를 붙여 "어디서 무엇이" 실패했는지 추적하기 쉬워집니다.
OTP 경계에서의 적용: GenServer 콜백과 Either
GenServer 콜백은 반환 형태가 엄격합니다. 예를 들어 handle_call/3은 {:reply, reply, new_state} 같은 형태를 반환해야 합니다. 이때 Either 체이닝은 "도메인 로직" 내부에서만 쓰고, 경계에서 튜플을 맞춰주는 것이 좋습니다.
defmodule MyApp.OrderServer do
use GenServer
alias MyApp.Either
def handle_call({:place_order, user_id}, _from, state) do
result =
MyApp.Checkout.place_order(user_id)
|> Either.tap(&emit_telemetry/1)
case result do
{:ok, order} ->
{:reply, {:ok, order}, state}
{:error, err} ->
# 예상 가능한 실패는 reply로 반환
{:reply, {:error, err}, state}
end
end
defp emit_telemetry({:ok, _order}) do
:telemetry.execute([:my_app, :checkout], %{count: 1}, %{result: :ok})
end
defp emit_telemetry({:error, err}) do
:telemetry.execute([:my_app, :checkout], %{count: 1}, %{result: :error, error: err})
end
end
여기서 중요한 운영 포인트는 "예상 가능한 실패"를 {:reply, {:error, ...}, state}로 돌려주되, 불변식 위반이나 버그로 판단되는 경우는 과감히 크래시시키는 것입니다.
예를 들어 create_order/1에서 "절대 nil이면 안 되는 값"이 nil인 경우는 raise로 처리하고 Supervisor에 맡기는 편이 낫습니다.
에러 컨텍스트 설계: 문자열 대신 구조화된 맵
Elixir에서 흔히 하는 실수는 에러를 문자열로 만들어 버리는 것입니다. 문자열은 사용자 메시지로는 좋지만, 운영/관측/분기에는 약합니다.
Either 체이닝을 쓴다면 에러를 다음처럼 구조화하는 것을 권합니다.
kind: 에러 분류(예::validation,:policy,:db,:external)reason: 원인(원본 reason 유지)ctx: 흐름 컨텍스트(요청 식별자, 핵심 파라미터)stack또는source: 실패 단계(함수명/모듈명/태그)
위에서 만든 context/2는 최소 형태이므로, 실무에서는 "단계 태그"를 자동으로 쌓는 방식도 고려할 수 있습니다.
defmodule MyApp.Either do
def tag({:ok, _} = ok, _tag), do: ok
def tag({:error, e}, tag) do
e2 =
case e do
%{tags: tags} -> %{e | tags: [tag | tags]}
_ -> %{error: e, tags: [tag]}
end
{:error, e2}
end
end
이렇게 하면 체이닝 중간에 다음처럼 넣을 수 있습니다.
Either.ok(user_id)
|> Either.flat_map(&fetch_user/1)
|> Either.tag(:fetch_user)
|> Either.flat_map(&get_open_cart/1)
|> Either.tag(:get_open_cart)
MDX로 렌더링되는 문서에서는 부등호 문자가 빌드 에러를 낼 수 있으니, 위처럼 코드 블록 안에서만 비교 연산자나 제네릭 같은 표기를 쓰는 습관이 안전합니다.
Task/외부 호출과 Either: 실패를 값으로, 타임아웃은 명시적으로
OTP에서 외부 호출은 실패가 정상입니다. HTTP, DB, 메시지 브로커는 언제든 흔들립니다. Either 체이닝을 적용할 때는 다음을 권합니다.
- 외부 호출 모듈은 가능한 한
{:ok, _}/{:error, _}로 반환 - 타임아웃, 회로차단, 재시도는 호출부에서 정책적으로 감싸기
- 실패를 숨기지 말고
kind: :external같은 분류로 올리기
예시로 Task를 써서 비동기 호출 결과를 Either로 정규화할 수 있습니다.
defmodule MyApp.External do
def fetch_price(product_id) do
task = Task.async(fn -> do_fetch_price(product_id) end)
case Task.yield(task, 1_000) || Task.shutdown(task) do
{:ok, {:ok, price}} -> {:ok, price}
{:ok, {:error, reason}} -> {:error, %{kind: :external, reason: reason}}
nil -> {:error, %{kind: :timeout, reason: :price_service_timeout}}
end
end
defp do_fetch_price(product_id) do
# HTTP 호출 가정
{:ok, %{product_id: product_id, price: 1000}}
end
end
이런 식으로 "타임아웃"을 명시적인 에러 값으로 만들면, 상위 파이프라인에서 동일한 방식으로 체이닝할 수 있습니다.
언제 Either 체이닝을 쓰고, 언제 with를 유지할까
Either 유틸리티는 만능이 아닙니다. 다음 기준이 현실적입니다.
with가 더 좋은 경우- 단계가 3개 이하로 짧고, 에러 가공이 거의 없는 경우
- 패턴 매칭이 복잡하고, 중간 값 바인딩이 많은 경우
Either 체이닝이 더 좋은 경우
- 에러에 공통 컨텍스트를 붙이고 싶을 때
- 에러 타입을 정규화해 API/로깅/메트릭을 단순화하고 싶을 때
- 동일한 체이닝 패턴이 여러 유스케이스에 반복될 때
특히 OTP에서는 "도메인 로직"을 순수 함수에 가깝게 유지하고, 프로세스 경계에서만 side effect를 모으는 구성이 유지보수에 유리합니다. Either 체이닝은 이 분리에 잘 맞습니다.
운영 관점: 에러 체이닝은 관측 가능성까지 포함한다
에러를 깔끔하게 체이닝하는 목적은 단지 코드가 예뻐지는 것이 아니라, 운영에서 다음이 가능해지는 것입니다.
- 에러 분류별 알람 라우팅
- 실패 단계(tag)별 빈도 분석
- 특정 컨텍스트(예:
user_id,order_id) 기반 트레이싱
이는 LLM/RAG에서 "출처 검증"으로 환각을 줄이듯, 시스템에서도 "근거(컨텍스트)"를 남겨 원인을 빠르게 좁히는 접근과 닮아 있습니다. 관측/정합성을 다루는 관점이 궁금하다면 LangChain RAG 환각 줄이기 - 출처검증·재랭킹도 함께 읽어볼 만합니다.
정리
- Elixir의
{:ok, _}/{:error, _}는 이미 Either처럼 쓸 수 있는 기반입니다. - 작은
Either유틸(map,flat_map,map_error,context,tap)만으로도 에러 체이닝과 컨텍스트 보존이 크게 쉬워집니다. - OTP에서는 모든 실패를 크래시로 몰지 말고, 예상 가능한 실패는 값으로 처리하되, 버그/불변식 위반은 크래시로 남기는 경계를 명확히 하세요.
- 최종 목표는 "체이닝" 자체가 아니라, 에러 구조화와 관측 가능성(telemetry, 로깅, 분류)을 일관되게 만드는 것입니다.