- Published on
Elixir 파이프라인·with로 에러 흐름 합치기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드를 읽기 어렵게 만드는 대표 원인은 “성공 경로는 직선인데 실패 경로는 지그재그”라는 점입니다. Elixir는 파이프라인 연산자 |> 덕분에 성공 경로를 함수 조합 형태로 깔끔하게 만들 수 있지만, 중간 단계에서 에러가 발생할 때 case 중첩이 늘어나면서 흐름이 쉽게 깨집니다.
이 글에서는 파이프라인과 with를 조합해 성공 경로의 가독성과 실패 경로의 일관성을 동시에 확보하는 방법을 정리합니다. 핵심은 다음 두 가지입니다.
- 파이프라인은 “값을 변환하는 순수한 단계”에 집중한다
with는 “단계별로 실패할 수 있는 작업을 직렬로 묶고, 실패를 한 군데로 모은다”
실무에서 오류 흐름을 설계할 때는 HTTP 500, 502, 504 같은 장애 원인 분석처럼 “어디서 실패했는지”가 중요합니다. 비슷한 맥락으로, Elixir에서도 에러를 한 형태로 모아두면 로깅과 관측성이 좋아집니다. 참고로 운영 장애를 빠르게 진단하는 관점은 Cloudflare 520·521, Nginx·ALB 로그로 30분 진단 글의 접근과도 닮아 있습니다.
파이프라인 |> 의 강점과 한계
파이프라인은 “데이터를 한 방향으로 흘려보내며 변환”할 때 최고입니다.
input
|> String.trim()
|> String.downcase()
|> String.replace(" ", "-")
하지만 중간 함수가 {:ok, value} 또는 {:error, reason} 같은 튜플을 반환하기 시작하면, 파이프라인은 그대로 두기 어렵습니다.
# 안티패턴 예시: 파이프라인 중간에 결과 형태가 바뀌기 시작
email
|> Accounts.normalize_email()
|> Accounts.ensure_not_blocked()
|> Accounts.find_user_by_email()
위 코드가 자연스럽게 보이려면 모든 함수가 “그냥 값”을 받거나 “그냥 값”을 반환해야 합니다. 그런데 실제로는
- 정규화는 성공만 한다
- 차단 여부 확인은 실패할 수 있다
- DB 조회는 실패할 수 있다
처럼 단계별 실패 가능성이 다릅니다. 이때 파이프라인만 고집하면 결국 case가 끼어들고 중첩됩니다.
with 로 실패 가능한 단계를 직렬화하기
with는 패턴 매칭이 실패하면 즉시 중단하고 else로 빠집니다. 그래서 단계별로 {:ok, value}를 반환하는 함수들을 “한 줄로” 연결할 수 있습니다.
with {:ok, email} <- Accounts.normalize_email(raw_email),
{:ok, _} <- Accounts.ensure_not_blocked(email),
{:ok, user} <- Accounts.fetch_user_by_email(email) do
{:ok, user}
else
{:error, reason} -> {:error, reason}
end
여기서 중요한 점은 with가 성공 경로를 깔끔하게 만들 뿐 아니라, 실패 경로를 한 곳으로 모아준다는 점입니다.
with 를 쓸 때 함수 반환 규격을 맞추는 이유
with는 각 단계가 같은 모양의 결과를 반환할수록 강력합니다. 실무에서는 다음 규칙을 추천합니다.
- 성공은
{:ok, value} - 실패는
{:error, reason}
이 규격이 맞으면, 실패 처리는 else에서 한 번만 하면 됩니다.
파이프라인과 with 를 함께 쓰는 기본 패턴
가장 흔한 패턴은 이렇습니다.
- 파이프라인으로 “실패하지 않는 정리 작업”을 먼저 수행
with로 “실패 가능한 단계”를 직렬로 묶기- 마지막에 다시 파이프라인으로 “성공 결과의 후처리”를 수행
예제로 “회원 가입 요청을 받아 사용자 생성” 흐름을 만들어 보겠습니다.
예제: 입력 정리 |> + 검증 및 저장 with
defmodule MyApp.Signup do
alias MyApp.{Accounts, Repo}
def signup(params) when is_map(params) do
params =
params
|> normalize_params()
with {:ok, attrs} <- validate_params(params),
{:ok, _} <- Accounts.ensure_not_blocked(attrs.email),
{:ok, user} <- Accounts.create_user(attrs) do
user
|> Accounts.to_public_view()
|> then(&{:ok, &1})
else
{:error, reason} ->
{:error, reason}
end
end
defp normalize_params(params) do
params
|> Map.update("email", nil, &normalize_email/1)
|> Map.update("name", nil, &normalize_name/1)
end
defp normalize_email(nil), do: nil
defp normalize_email(email), do: email |> String.trim() |> String.downcase()
defp normalize_name(nil), do: nil
defp normalize_name(name), do: name |> String.trim()
defp validate_params(%{"email" => email, "name" => name})
when is_binary(email) and is_binary(name) do
{:ok, %{email: email, name: name}}
end
defp validate_params(_), do: {:error, :invalid_params}
end
포인트는 다음과 같습니다.
normalize_params/1는 실패하지 않는 변환이므로 파이프라인으로 처리validate_params/1,ensure_not_blocked/1,create_user/1는 실패 가능하므로with에 배치- 성공 결과에 대한 후처리
to_public_view/1는 다시 파이프라인으로 처리
이 구조를 잡아두면, 컨트롤러나 GraphQL 리졸버에서는 case 한 번으로 끝납니다.
case MyApp.Signup.signup(params) do
{:ok, user} ->
json(conn, user)
{:error, :invalid_params} ->
conn |> put_status(400) |> json(%{error: "invalid_params"})
{:error, :blocked} ->
conn |> put_status(403) |> json(%{error: "blocked"})
{:error, _} ->
conn |> put_status(500) |> json(%{error: "internal"})
end
이런 “에러를 한 형태로 모으는” 설계는 마이크로서비스에서 데드라인 초과나 503을 추적할 때처럼, 원인을 분류하고 관측하기 쉬워집니다. 관련 디버깅 관점은 gRPC 마이크로서비스 503·데드라인 초과 디버깅 글도 참고할 만합니다.
with 의 else 를 제대로 쓰는 법
else는 단순히 {:error, reason}만 받는 곳이 아닙니다. with의 각 <-는 “패턴 매칭”이기 때문에, 매칭이 실패하면 그 실패한 값이 그대로 else로 넘어옵니다.
예를 들어 어떤 단계가 :ok만 반환한다고 가정해봅시다.
with :ok <- MyApp.RateLimit.check(key),
{:ok, user} <- Accounts.fetch_user(id) do
{:ok, user}
else
:rate_limited -> {:error, :rate_limited}
{:error, reason} -> {:error, reason}
end
이때 RateLimit.check/1이 :rate_limited를 반환하면 else에서 해당 값을 정확히 매칭해 처리할 수 있습니다.
실무 팁: else 는 “에러 매핑 계층”으로 써라
else에서 모든 에러를 그대로 반환하면 호출부가 복잡해질 수 있습니다. 오히려 else는 내부 에러를 API 친화적인 에러로 바꾸는 “매핑 계층”으로 쓰는 편이 좋습니다.
with {:ok, user} <- Accounts.fetch_user(id),
{:ok, token} <- Accounts.issue_token(user) do
{:ok, token}
else
{:error, :not_found} -> {:error, :user_not_found}
{:error, :db_timeout} -> {:error, :temporary_unavailable}
other -> {:error, {:unknown, other}}
end
이렇게 하면 외부로 노출되는 에러 타입이 안정적으로 유지되고, 내부 구현을 바꿔도 계약이 덜 흔들립니다.
파이프라인으로 {:ok, _} 흐름을 잇고 싶을 때
가끔은 “with는 좋은데, 성공 경로를 파이프라인처럼 계속 흘리고 싶다”는 요구가 생깁니다. 이때 선택지는 두 가지입니다.
with블록 내부에서 파이프라인을 사용한다{:ok, value}를 다루는 헬퍼를 만들어 파이프라인을 유지한다
1) with 내부에서 파이프라인 사용
with {:ok, user} <- Accounts.fetch_user(id),
{:ok, profile} <- Accounts.fetch_profile(user) do
profile
|> Accounts.decorate()
|> Accounts.to_public_view()
|> then(&{:ok, &1})
end
가장 단순하고, 대부분 이걸로 충분합니다.
2) {:ok, _} 전용 파이프 헬퍼 만들기
표준 라이브러리에 “Result monad” 같은 것은 없지만, 간단한 헬퍼는 쉽게 만들 수 있습니다.
defmodule MyApp.Result do
def map({:ok, value}, fun) when is_function(fun, 1), do: {:ok, fun.(value)}
def map({:error, _} = err, _fun), do: err
def bind({:ok, value}, fun) when is_function(fun, 1), do: fun.(value)
def bind({:error, _} = err, _fun), do: err
end
사용 예시는 다음과 같습니다.
alias MyApp.Result
raw_email
|> Accounts.normalize_email()
|> Result.bind(&Accounts.ensure_not_blocked/1)
|> Result.bind(&Accounts.fetch_user_by_email/1)
|> Result.map(&Accounts.to_public_view/1)
이 방식은 파이프라인 감각을 유지하면서도 에러를 자동 전파합니다. 다만 팀 내 합의 없이 도입하면 “우리만의 규약”이 늘어날 수 있으니, 코드베이스 성격에 맞춰 선택하세요.
with 와 트랜잭션을 결합할 때의 주의점
Ecto 트랜잭션에서는 Repo.transaction/1이 {:ok, value} 또는 {:error, reason} 형태를 반환합니다. 이때도 with가 잘 맞지만, 트랜잭션 내부에서 Repo.rollback/1이 던지는 흐름을 이해해야 합니다.
def create_order(attrs) do
Repo.transaction(fn ->
with {:ok, order} <- Orders.insert_order(attrs),
{:ok, _} <- Orders.reserve_stock(order),
{:ok, _} <- Orders.charge_payment(order) do
order
else
{:error, reason} ->
Repo.rollback(reason)
end
end)
end
이 함수의 최종 반환은 다음 중 하나가 됩니다.
- 성공:
{:ok, order} - 실패:
{:error, reason}
즉, 트랜잭션 외부 호출자는 일관된 형태로 처리할 수 있습니다.
흔한 실수 5가지
1) 성공은 값, 실패는 튜플로 섞어 반환
with를 쓰려면 단계별 반환 형태를 맞추는 게 중요합니다. 성공은 값, 실패는 {:error, reason}으로 섞이면 패턴이 깨지고 else가 복잡해집니다.
2) with 안에서 너무 많은 일을 한 번에 수행
with는 “실패 가능한 단계의 직렬화”에 집중하고, 변환 로직은 별도 함수로 분리하는 편이 테스트가 쉬워집니다.
3) else에서 모든 에러를 :unknown으로 뭉개기
관측성과 디버깅이 어려워집니다. 최소한 “사용자 입력 문제”와 “일시적 인프라 문제” 정도는 분리하세요.
4) case 중첩을 줄이려다 오히려 with 중첩을 만드는 경우
with가 중첩되기 시작하면, 상위 레벨에서 단계를 재구성하거나 Result.bind/2 같은 헬퍼를 고려하는 게 낫습니다.
5) 에러 이유에 원인 컨텍스트가 없음
예를 들어 {:error, :db_error}만 던지면 나중에 어디서 났는지 추적이 어렵습니다. {:error, {:db_error, :fetch_user}}처럼 단계 정보를 포함하거나, 로깅에서 메타데이터를 남기세요.
정리: “변환은 파이프, 실패는 with”로 규칙을 세워라
Elixir에서 파이프라인과 with를 함께 쓰면 다음 효과가 큽니다.
- 성공 경로는 파이프라인처럼 읽기 쉬워진다
- 실패 경로는
with ... else로 한 곳에 모여 일관성이 생긴다 - 반환 규격을
{:ok, _}와{:error, _}로 통일하면 호출부가 단순해진다
마지막으로, 운영 환경에서 오류를 빨리 잡는 시스템은 대체로 “오류를 분류하고 흐름을 표준화”합니다. 애플리케이션 코드도 마찬가지입니다. 파이프라인으로 의도를 드러내고, with로 실패를 한데 모으는 규칙을 팀 컨벤션으로 정해두면, 기능이 늘어도 코드의 복잡도가 덜 폭발합니다.