- Published on
Elixir에서 모나드 흉내내기 - with·Result 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 단계의 연산을 순차로 엮다 보면, 매 단계마다 실패 가능성이 끼어들어 코드가 금방 지저분해집니다. Elixir에서는 예외를 남발하기보다 {:ok, value} / {:error, reason} 튜플로 실패를 표현하는 관용이 강하고, 이 관용을 with로 묶으면 “성공이면 다음 단계로, 실패면 즉시 중단”이라는 흐름을 자연스럽게 만들 수 있습니다.
이 글에서는 함수형 언어에서 말하는 모나드의 핵심 효용(실패 전파와 조합성)을 Elixir에서 with + Result 튜플 컨벤션으로 흉내내는 방법을 다룹니다. 완전한 모나드 구현을 목표로 하기보다, 실무에서 유지보수 가능한 형태로 표준화하는 데 초점을 맞춥니다.
또한 비슷한 문제를 다른 생태계에서 어떻게 다루는지 감각을 잡고 싶다면, 스트림 조합의 함정을 다룬 글인 Kotlin Sequence vs Flow - 스트림 함정 디버깅도 함께 읽어보면 좋습니다. “조합은 쉬워 보이지만, 실패/중단/지연 평가가 섞이면 복잡해진다”는 공통점이 있습니다.
Elixir에서 말하는 Result 패턴
Elixir 표준 라이브러리와 커뮤니티 관례에서 Result는 대개 아래 형태로 표현합니다.
- 성공:
{:ok, value} - 실패:
{:error, reason}
이 구조의 장점은 단순합니다.
- 패턴 매칭으로 분기 비용이 낮음
- 실패를 값으로 다루므로 테스트가 쉬움
- 예외와 달리 “실패가 가능한 경로”가 타입(튜플 모양)으로 드러남
단점도 있습니다.
- 단계가 늘수록
case중첩이 늘어남 - 실패 reason의 형태가 제각각이면 호출부에서 처리하기 어려움
with는 첫 번째 단점을 크게 줄여주고, Result 표준화는 두 번째 단점을 줄여줍니다.
case 중첩이 만드는 실패 전파 지옥
예를 들어, 이메일로 사용자를 찾고, 토큰을 검증하고, 권한을 확인한 뒤 결과를 반환하는 흐름을 생각해봅시다.
def authenticate(email, token) do
case Users.get_by_email(email) do
{:ok, user} ->
case Tokens.verify(token) do
{:ok, claims} ->
case Authz.allowed?(user, claims) do
true -> {:ok, user}
false -> {:error, :forbidden}
end
{:error, reason} ->
{:error, {:invalid_token, reason}}
end
{:error, :not_found} ->
{:error, :user_not_found}
end
end
이 코드는 동작하지만, 단계가 더 늘어나면 가독성이 급격히 떨어집니다. 또한 각 단계의 실패 reason이 제각각이면(:not_found, {:invalid, _}, false) 호출부에서 일관되게 처리하기 어렵습니다.
with로 실패 전파를 직선화하기
with는 “패턴 매칭이 성공한 경우에만 다음 줄로 진행”합니다. 중간에 매칭이 실패하면 즉시 else로 빠집니다.
def authenticate(email, token) do
with {:ok, user} <- Users.get_by_email(email),
{:ok, claims} <- Tokens.verify(token),
:ok <- Authz.ensure_allowed(user, claims) do
{:ok, user}
else
{:error, reason} ->
{:error, reason}
end
end
여기서 중요한 포인트는 Authz.ensure_allowed/2가 true/false가 아니라 **:ok 또는 {:error, reason}**을 반환하도록 바꿨다는 점입니다. 즉, 각 단계의 결과 형태를 Result로 통일하면 with가 가장 빛납니다.
with의 핵심 규칙
with pattern <- expr에서expr결과가pattern과 매칭되면 다음으로 진행- 매칭 실패 시, 그 “실패한 값”이
else로 전달됨 else는 패턴 매칭이므로, 실패 값의 모양이 일관적일수록 처리하기 쉬움
Result를 표준화하는 실무형 규칙
프로젝트가 커질수록 “에러 reason을 어떤 모양으로 할 것인가”가 중요해집니다. 추천하는 실무 규칙은 아래 중 하나입니다.
규칙 A: {:error, atom()} 중심으로 단순화
- 장점: 호출부가 단순, 로깅/메트릭 태깅 쉬움
- 단점: 디버깅 정보가 부족할 수 있음
예:
{:error, :user_not_found}
{:error, :invalid_token}
{:error, :forbidden}
규칙 B: {:error, {atom(), meta}}로 컨텍스트 포함
- 장점: 원인과 컨텍스트를 함께 운반
- 단점: reason이 복잡해져 패턴이 늘어날 수 있음
예:
{:error, {:invalid_token, %{provider: :jwt, detail: reason}}}
{:error, {:validation_failed, %{field: :email}}}
어느 쪽이든 중요한 건 팀 내에서 일관되게 쓰는 것입니다.
작은 Result 헬퍼로 조합성을 올리기
with는 강력하지만, 때로는 “값을 변환(map)하고 싶다”거나 “에러를 다른 에러로 바꾸고 싶다” 같은 요구가 생깁니다. 이때 모나드의 map / bind(flatMap)에 해당하는 헬퍼를 최소한으로 제공하면 코드가 더 단정해집니다.
아래는 외부 라이브러리 없이 쓸 수 있는 간단한 모듈 예시입니다.
defmodule Result do
@type t(a) :: {:ok, a} | {:error, any()}
def ok(value), do: {:ok, value}
def error(reason), do: {:error, reason}
# map: 성공 값만 변환
def map({:ok, v}, fun) when is_function(fun, 1), do: {:ok, fun.(v)}
def map({:error, _} = err, _fun), do: err
# bind: 성공이면 다음 Result-producing 함수로 연결
def bind({:ok, v}, fun) when is_function(fun, 1), do: fun.(v)
def bind({:error, _} = err, _fun), do: err
# map_error: 에러만 변환
def map_error({:error, r}, fun) when is_function(fun, 1), do: {:error, fun.(r)}
def map_error({:ok, _} = ok, _fun), do: ok
end
이 정도만 있어도, with 바깥에서 작은 변환을 깔끔하게 처리할 수 있습니다.
with + Result로 API 핸들러 구성하기
Phoenix 컨트롤러나 JSON API 핸들러에서 흔한 패턴은 다음입니다.
- 입력 검증
- 도메인 로직 호출
- 응답 형태로 변환
이때 with를 쓰면 실패 시 early return이 자연스럽습니다.
def create(conn, params) do
with {:ok, input} <- Validators.cast_create_user(params),
{:ok, user} <- Users.create(input),
{:ok, dto} <- Presenters.user(user) do
json(conn, %{data: dto})
else
{:error, {:validation_failed, meta}} ->
conn
|> put_status(422)
|> json(%{error: "validation_failed", meta: meta})
{:error, :email_taken} ->
conn
|> put_status(409)
|> json(%{error: "email_taken"})
{:error, reason} ->
conn
|> put_status(500)
|> json(%{error: "internal_error", reason: inspect(reason)})
end
end
여기서 포인트는 세 가지입니다.
else는 “실패 값의 패턴 매칭 테이블”이다- 자주 발생하는 도메인 에러는 명시적으로 매핑해 HTTP 상태코드를 안정화한다
- 나머지는 마지막에 포괄 처리하되, 운영 환경에서는
inspect(reason)를 그대로 노출하지 않도록 주의한다
with가 모나드를 완전히 대체하지 못하는 지점
with는 실패 전파(단락 평가)에 강하지만, 모나드가 제공하는 모든 조합성을 제공하진 않습니다.
- 리스트/비동기/스트림 같은 다른 컨텍스트를 일반화해 다루는 추상화는 약함
- 여러 실패를 누적(Validation applicative 스타일)하는 데는 부적합
with는 첫 실패에서 멈추는 구조
입력 검증에서 “에러를 한 번에 모아서 보여주기”가 필요하다면, Result 단락 평가보다는 검증 전용 구조(예: 에러 리스트 누적)를 쓰는 편이 낫습니다.
with 사용 시 자주 겪는 함정 5가지
1) 한 단계가 {:ok, _}를 반환하지 않음
with 체인의 모든 단계가 같은 모양의 Result를 반환해야 깔끔합니다. 중간에 boolean, nil, :ok만 반환하면 체인이 깨집니다.
해결: ensure_* 계열 함수를 만들어 :ok 또는 {:error, reason}로 통일하세요.
def ensure_present(nil), do: {:error, :missing}
def ensure_present(v), do: {:ok, v}
2) else에서 원치 않는 값이 떨어짐
매칭 실패 시 else로 떨어지는 값은 “실패한 값 그대로”입니다. 예를 들어 {:ok, v}를 기대했는데 :error 같은 값이 오면 else에서 패턴이 안 맞아 WithClauseError가 날 수 있습니다.
해결: 실패 값의 형태를 강제하거나, else에 포괄 패턴을 추가합니다.
else
{:error, reason} -> {:error, reason}
other -> {:error, {:unexpected_failure, other}}
end
3) 디버깅이 어려움
with는 직선적이지만 “어느 단계에서 실패했는지”가 reason에 담겨 있지 않으면 추적이 어렵습니다.
해결: map_error로 태깅하거나, 단계별로 에러를 감싸세요.
with {:ok, user} <- Users.get_by_email(email) |> Result.map_error(&{:user_lookup_failed, &1}),
{:ok, claims} <- Tokens.verify(token) |> Result.map_error(&{:token_verify_failed, &1}) do
{:ok, {user, claims}}
else
{:error, tagged} -> {:error, tagged}
end
4) 예외를 Result로 섞어 쓰기
외부 라이브러리가 예외를 던지는데 이를 그대로 두면, with의 장점(명시적 실패 전파)이 사라집니다.
해결: 경계에서 예외를 잡아 Result로 변환하되, 정말 복구 불가능한 경우만 예외로 둡니다.
def safe_parse_json!(bin) do
Jason.decode(bin)
rescue
e in Jason.DecodeError -> {:error, {:invalid_json, e.data}}
end
함수 이름에 !를 붙였다면 일반적으로 예외를 던진다는 관례가 있으니, 위처럼 Result를 반환한다면 safe_parse_json/1 같은 이름이 더 적절합니다.
5) 트랜잭션과 섞일 때 롤백 기준이 모호해짐
Ecto 트랜잭션에서는 {:error, reason} 반환을 어떻게 롤백으로 연결할지 규칙을 명확히 해야 합니다. Ecto.Multi를 쓰면 단계별로 {:ok, _} / {:error, _}를 자연스럽게 연결할 수 있습니다.
현실적인 결론: “모나드”보다 “컨벤션”이 이긴다
Elixir에서 모나드를 정교하게 구현하는 것보다, 팀이 합의한 Result 컨벤션을 만들고 with로 실패 전파를 직선화하는 편이 실무적으로 더 큰 효과를 냅니다.
- 모든 도메인 함수는 가능한 한
{:ok, value}/{:error, reason}을 반환한다 ensure_*류 함수로 boolean/nil을 Result로 정규화한다else는 HTTP/CLI/배치 등 “출력 계층”에서만 풍성하게 처리하고, 도메인 계층은 reason을 일관되게 유지한다
운영 환경에서 이런 패턴이 특히 빛나는 이유는 “실패가 값으로 남기 때문”입니다. 예외 스택트레이스만 믿고 가기보다, 실패 reason을 구조화해 로깅/메트릭으로 연결하면 장애 대응이 빨라집니다. 재시도와 백오프처럼 실패를 제어하는 기법은 OpenAI 429 Rate Limit 재시도·백오프 구현 가이드처럼 다른 영역에서도 반복해서 등장하는데, 결국 핵심은 “실패를 다룰 수 있는 형태로 표준화”하는 것입니다.
부록: with를 함수형 파이프처럼 쓰고 싶을 때
with는 파이프(|>)와 결합해 쓰기 애매한 경우가 있습니다. 그럴 때는 중간 변환을 Result.map/2로 분리하면 읽기 좋아집니다.
with {:ok, user} <- Users.get_by_email(email),
{:ok, profile} <- Profiles.get(user.id) do
profile
|> normalize_profile()
|> Result.ok()
end
혹은 bind를 활용해 파이프라인을 구성할 수도 있습니다.
Users.get_by_email(email)
|> Result.bind(fn user -> Profiles.get(user.id) end)
|> Result.map(&normalize_profile/1)
이 두 방식 중 무엇이 더 낫다기보다, 팀이 읽기 쉬운 쪽으로 통일하는 것이 유지보수에 유리합니다.