- Published on
Elixir 패턴매칭과 파이프라인 함수조합 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Elixir를 실무에 적용하다 보면, 결국 두 가지 무기가 생산성을 좌우합니다. 하나는 패턴매칭으로 분기와 검증을 함수 시그니처로 끌어올리는 것, 다른 하나는 파이프라인으로 변환 단계를 조합해 읽기 쉬운 데이터 플로우를 만드는 것입니다.
이 글에서는 "그럴듯한 예제"가 아니라, API/배치/도메인 로직에서 반복되는 문제(입력 검증, 에러 전파, 조건 분기, 로깅/메트릭, 트랜잭션)를 패턴매칭 + 파이프라인으로 어떻게 깔끔하게 구성하는지 실전 관점으로 정리합니다.
또한 재시도·백오프 같은 운영 이슈는 언어를 막론하고 중요합니다. 필요하면 OpenAI 429·5xx 재시도, Idempotency 키로 중복 결제 막기 같은 글과 함께 “실패를 다루는 코드”까지 확장해보면 좋습니다.
1) 패턴매칭은 "if"가 아니라 "계약"이다
Elixir에서 패턴매칭을 잘 쓰면, 함수는 단순히 값을 받는 곳이 아니라 입력 형태에 대한 계약(Contract) 이 됩니다.
함수 헤드로 검증을 올리기
예를 들어 주문 생성에서 자주 보는 입력 형태를 생각해봅시다.
user_id는 정수items는 비어있지 않은 리스트coupon은 optional
이를 if로 검증하면 함수 본문이 금방 지저분해집니다. 대신 함수 헤드/가드로 끌어올리면 “들어올 수 있는 입력”이 명확해집니다.
defmodule Shop.Orders do
def create_order(%{"user_id" => user_id, "items" => items} = params)
when is_integer(user_id) and is_list(items) and length(items) > 0 do
params
|> normalize_params()
|> validate_items()
|> persist_order()
end
def create_order(_params) do
{:error, :invalid_params}
end
defp normalize_params(params) do
params
|> Map.update("coupon", nil, & &1)
end
defp validate_items(%{"items" => items} = params) do
if Enum.all?(items, &valid_item?/1), do: {:ok, params}, else: {:error, :invalid_items}
end
defp persist_order({:ok, params}) do
# DB insert...
{:ok, %{id: 123, params: params}}
end
defp persist_order({:error, _} = err), do: err
defp valid_item?(%{"sku" => sku, "qty" => qty}) when is_binary(sku) and is_integer(qty) and qty > 0,
do: true
defp valid_item?(_), do: false
end
핵심은 create_order/1의 첫 번째 정의가 “정상 입력”을 선언하고, 두 번째 정의가 “그 외”를 받아주는 폴백(fallback) 이라는 점입니다.
다만 위 코드는 {:ok, ...} / {:error, ...} 튜플이 파이프라인 중간에 섞이면서, 각 단계가 {:ok, ...}를 처리하도록 보일러플레이트가 생깁니다. 이 문제를 해결하는 대표 패턴이 다음 섹션의 with 조합입니다.
2) 파이프라인은 "단계"를 보여주고, with는 "에러 흐름"을 정리한다
파이프라인은 기본적으로 단일 값을 다음 함수로 넘기는 구조입니다. 하지만 실무에서는 대부분 {:ok, value} / {:error, reason} 형태로 에러를 전파합니다.
이때 파이프라인만 고집하면 각 단계마다 case를 쓰거나, 위 예제처럼 persist_order/1에서 튜플을 받아 분기하는 식의 코드가 늘어납니다.
with로 성공 경로를 직선으로 만들기
with는 성공 패턴만 연결하고, 실패는 자동으로 빠져나오게 만들어 줍니다.
defmodule Shop.Orders do
def create_order(params) do
with {:ok, normalized} <- normalize(params),
{:ok, user_id} <- fetch_user_id(normalized),
{:ok, items} <- fetch_items(normalized),
:ok <- validate_items(items),
{:ok, order} <- insert_order(user_id, items, normalized) do
{:ok, order}
end
end
defp normalize(%{} = params), do: {:ok, Map.update(params, "coupon", nil, & &1)}
defp normalize(_), do: {:error, :invalid_params}
defp fetch_user_id(%{"user_id" => id}) when is_integer(id), do: {:ok, id}
defp fetch_user_id(_), do: {:error, :missing_user_id}
defp fetch_items(%{"items" => items}) when is_list(items) and length(items) > 0, do: {:ok, items}
defp fetch_items(_), do: {:error, :missing_items}
defp validate_items(items) do
if Enum.all?(items, &valid_item?/1), do: :ok, else: {:error, :invalid_items}
end
defp insert_order(user_id, items, params) do
# DB insert...
{:ok, %{id: 123, user_id: user_id, items: items, coupon: Map.get(params, "coupon")}}
end
defp valid_item?(%{"sku" => sku, "qty" => qty}) when is_binary(sku) and is_integer(qty) and qty > 0,
do: true
defp valid_item?(_), do: false
end
이 구조의 장점:
- 성공 경로가 위에서 아래로 “직선”이라 읽기 쉽습니다.
- 실패는
{:error, reason}또는 원하는 값으로 즉시 반환됩니다. - 각 함수는 한 가지 역할만 하도록 쪼개기 쉬워집니다.
파이프라인과 with를 섞는 실전형 스타일
실무에서는 “전처리/변환은 파이프라인”, “검증/의존 단계는 with”로 섞으면 균형이 좋습니다.
def create_order(params) do
params =
params
|> coerce_types()
|> trim_strings()
|> default_coupon()
with {:ok, user_id} <- fetch_user_id(params),
{:ok, items} <- fetch_items(params),
:ok <- validate_items(items),
{:ok, order} <- insert_order(user_id, items, params) do
{:ok, order}
end
end
defp coerce_types(params), do: params
defp trim_strings(params), do: params
defp default_coupon(params), do: Map.put_new(params, "coupon", nil)
3) 패턴매칭으로 "도메인 이벤트"를 깔끔하게 분기하기
상태 머신이나 이벤트 처리 로직에서 case가 길어지기 쉬운데, 이때 패턴매칭은 분기를 깔끔하게 정리해줍니다.
예를 들어 결제 상태 업데이트를 이벤트로 처리한다고 해봅시다.
defmodule Billing.Payments do
def apply_event(payment, %{type: :authorized, amount: amount}) when amount > 0 do
{:ok, %{payment | status: :authorized, authorized_amount: amount}}
end
def apply_event(payment, %{type: :captured, capture_id: id}) when is_binary(id) do
{:ok, %{payment | status: :captured, capture_id: id}}
end
def apply_event(payment, %{type: :failed, reason: reason}) do
{:ok, %{payment | status: :failed, failure_reason: reason}}
end
def apply_event(_payment, _event) do
{:error, :unknown_event}
end
end
이 방식은 “이벤트 타입별로 허용되는 필드”가 함수 정의 자체에 드러납니다. 이후 이벤트가 늘어나도 apply_event/2만 확장하면 되고, 잘못된 이벤트는 자동으로 폴백으로 떨어집니다.
4) 파이프라인에서 자주 하는 실수: 튜플을 값처럼 흘려보내기
파이프라인은 기본적으로 다음과 같이 동작합니다.
value |> f(a)는f(value, a)
즉, {:ok, value} 같은 튜플을 파이프라인에 그대로 흘려보내면, 다음 함수가 그 튜플을 입력으로 받게 됩니다. 의도한 것이 아니라면 버그가 됩니다.
해결책 A: with로 에러 흐름을 분리
앞서 본 방식이 가장 흔한 정답입니다.
해결책 B: then/2로 중간 변환을 명시
Elixir에는 then/2가 있어 파이프라인 중간에서 “여기서 한 번 꺼내서 처리”를 표현할 수 있습니다.
params
|> normalize()
|> then(fn
{:ok, p} -> enrich(p)
{:error, _} = err -> err
end)
다만 이 패턴이 많아지면 with가 더 읽기 쉬운 경우가 많습니다.
5) case와 패턴매칭을 "짧게" 유지하는 요령
case는 나쁘지 않지만, 길어지는 순간 유지보수 비용이 급증합니다. 다음 요령이 효과적입니다.
요령 1: case는 "1단계 분기"까지만
깊어지는 분기는 함수로 뽑고, 함수 헤드 패턴매칭으로 나눕니다.
def handle_response(resp) do
case resp do
%{status: 200, body: body} -> parse_ok(body)
%{status: status} when status in [400, 422] -> {:error, :bad_request}
_ -> {:error, :unexpected}
end
end
defp parse_ok(%{"data" => data}), do: {:ok, data}
defp parse_ok(_), do: {:error, :invalid_payload}
요령 2: "데이터 형태"가 곧 분기 조건이면 함수 헤드로
if Map.has_key? 같은 체크가 반복되면 거의 항상 함수 헤드로 옮길 수 있습니다.
6) 파이프라인 함수조합을 "재사용 가능한 단계"로 만들기
실무에서 파이프라인의 진짜 힘은 “한 번 만든 단계를 다른 흐름에 재사용”하는 데 있습니다. 핵심은 각 단계를 다음 조건을 만족하게 설계하는 것입니다.
- 입력/출력이 명확하다
- 부작용이 있으면 이름에 드러난다
- 실패 가능성이 있으면 반환 타입을 통일한다
예시: 로그/메트릭을 단계로 끼워넣기
defmodule Util.Pipeline do
require Logger
def tap(value, fun) when is_function(fun, 1) do
fun.(value)
value
end
def tap_ok({:ok, value} = ok, fun) when is_function(fun, 1) do
fun.(value)
ok
end
def tap_ok(other, _fun), do: other
end
# 사용
alias Util.Pipeline
result =
params
|> normalize()
|> Pipeline.tap(fn p -> Logger.info("normalized=#{inspect(p)}") end)
|> do_work()
|> Pipeline.tap_ok(fn v -> Logger.info("ok=#{inspect(v)}") end)
이렇게 하면 파이프라인의 “데이터 흐름”을 깨지 않고 관측 가능성을 끼워넣을 수 있습니다.
운영에서 실패를 다루는 관점은 재시도/백오프/서킷브레이커로 확장됩니다. 언어는 다르지만 개념은 동일하므로 Python 데코레이터로 재시도·백오프 구현 5패턴도 함께 참고하면 설계 감각을 넓히는 데 도움이 됩니다.
7) Ecto 트랜잭션에서 패턴매칭 + 파이프라인을 쓰는 방식
DB 트랜잭션은 성공/실패 흐름이 명확하므로 with와 잘 맞습니다. 또한 Ecto의 Multi는 단계 조합이라는 측면에서 파이프라인적 사고와도 궁합이 좋습니다.
defmodule Shop.Checkout do
import Ecto.Query
alias Ecto.Multi
alias Shop.Repo
def checkout(%{"user_id" => user_id, "items" => items} = params)
when is_integer(user_id) and is_list(items) do
Multi.new()
|> Multi.run(:user, fn _repo, _changes -> fetch_user(user_id) end)
|> Multi.run(:validated_items, fn _repo, _changes -> validate_and_price(items) end)
|> Multi.insert(:order, fn changes -> build_order_changeset(changes.user, changes.validated_items, params) end)
|> Repo.transaction()
|> case do
{:ok, %{order: order}} -> {:ok, order}
{:error, _step, reason, _changes} -> {:error, reason}
end
end
def checkout(_), do: {:error, :invalid_params}
defp fetch_user(user_id) do
# Repo.get(User, user_id)
{:ok, %{id: user_id}}
end
defp validate_and_price(items) do
if Enum.all?(items, &valid_item?/1), do: {:ok, items}, else: {:error, :invalid_items}
end
defp build_order_changeset(user, items, _params) do
# Ecto.Changeset...
%{user_id: user.id, items: items}
end
defp valid_item?(%{"sku" => sku, "qty" => qty}) when is_binary(sku) and is_integer(qty) and qty > 0, do: true
defp valid_item?(_), do: false
end
여기서도 포인트는 같습니다.
- 입력 검증은 함수 헤드/가드로
- 단계 조합은
Multi로 - 트랜잭션 결과 분기는 최종
case한 번으로
8) 실무 체크리스트: 읽히는 Elixir 조합을 만드는 규칙
마지막으로, 패턴매칭 + 파이프라인을 “멋있게”가 아니라 “팀이 유지보수 가능하게” 만드는 규칙을 정리합니다.
- 성공 경로는 직선으로 보이게 하라: 가능하면
with로 성공 패턴만 나열한다. - 검증은 함수 헤드로 올려라:
Map.has_key?/nil체크가 반복되면 시그니처를 바꿀 신호다. - 단계 함수는 반환 타입을 통일하라: 실패 가능성이 있으면
{:ok, v}/{:error, r}로 맞추고,with로 묶는다. - 파이프라인 중간에 튜플이 섞이면 경계하라: 의도적으로 다루지 않으면
with로 전환한다. - 부작용은 이름으로 드러내라:
fetch_/insert_/publish_같은 접두어로 파이프라인의 성격을 명확히 한다.
마무리
Elixir의 패턴매칭은 분기문을 줄이는 문법 설탕이 아니라, 입력 계약과 도메인 규칙을 코드 구조로 고정하는 도구입니다. 파이프라인은 단순히 “왼쪽에서 오른쪽으로 흘리는” 문법이 아니라, 단계 조합을 통한 복잡도 관리 방식입니다.
둘을 함께 쓰면 다음이 가능해집니다.
- 함수 시그니처만 봐도 어떤 입력이 허용되는지 알 수 있고
- 성공 경로는 파이프라인/
with로 읽기 쉬우며 - 실패 경로는 일관된 형태로 빠르게 탈출하고
- 단계는 재사용 가능한 조각으로 남습니다
이 글의 예제들을 본인 서비스의 “주문/결제/정산/동기화” 같은 실제 유스케이스에 맞춰, 입력 계약(패턴매칭)과 단계 조합(파이프라인/with/Multi)을 분리해보면 코드가 눈에 띄게 단단해질 겁니다.