- Published on
Elixir 불변데이터·파이프라인 실무 패턴 7
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드에서 Elixir를 쓰기 시작하면 가장 먼저 체감하는 장점이 불변데이터(immutable data)와 파이프라인 연산자 |> 입니다. 다만 실무에서는 단순히 map |> filter |> reduce 수준을 넘어, 검증과 에러 전파, DB 트랜잭션, 외부 API 호출, 성능(불필요한 복사/재계산), 관측 가능성(로그/메트릭) 까지 한 번에 묶어야 합니다.
이 글은 “불변”과 “파이프라인”을 전제로, 팀 코드베이스에서 반복적으로 등장하는 패턴 7가지를 예제로 정리합니다. 특히 {:ok, value} / {:error, reason} 흐름과 with/then/tap/Ecto.Multi를 어떻게 조합하면 유지보수가 쉬워지는지에 초점을 둡니다.
또한 분산 환경에서 재시도와 실패 처리가 중요하다면, 설계 관점에서 Anthropic Claude 429 레이트리밋 재시도 설계법, 트랜잭션/보상 흐름이 필요하다면 MSA Saga 보상 트랜잭션 실패 재처리 설계도 함께 참고하면 좋습니다.
1) 파이프라인은 “데이터 변환”과 “효과”를 분리한다
Elixir 파이프라인은 읽기 좋지만, 네트워크 호출/DB 저장 같은 효과(effect) 가 섞이면 테스트와 재사용성이 떨어집니다. 실무에서는 다음 규칙이 유용합니다.
- 파이프라인 앞부분: 순수 변환(pure transformation)
- 파이프라인 끝부분: 효과 수행(저장/발행/호출)
defmodule Accounts.Normalize do
def normalize_params(params) do
params
|> stringify_keys()
|> trim_strings()
|> downcase_email()
end
defp stringify_keys(map) when is_map(map) do
for {k, v} <- map, into: %{}, do: {to_string(k), v}
end
defp trim_strings(map) do
for {k, v} <- map, into: %{} do
{k, if(is_binary(v), do: String.trim(v), else: v)}
end
end
defp downcase_email(%{"email" => email} = map) when is_binary(email) do
%{map | "email" => String.downcase(email)}
end
defp downcase_email(map), do: map
end
defmodule Accounts do
alias Accounts.Normalize
def register(params) do
params
|> Normalize.normalize_params()
|> create_user_in_db()
end
defp create_user_in_db(attrs) do
# 여기서부터 효과(Effect)
# Repo.insert(...)
{:ok, attrs}
end
end
핵심은 “정규화 로직”이 DB 없이도 단독 테스트 가능해지고, 파이프라인이 길어져도 책임이 명확해진다는 점입니다.
2) {:ok, _} / {:error, _} 파이프라인은 then과 with로 정리한다
실무에서 가장 많이 망가지는 부분이 {:ok, v}를 파이프로 넘기다가 중간에 형태가 깨지는 경우입니다. 권장 패턴은 두 가지입니다.
2-1) with로 “단계적 바인딩”을 명시
def create_order(params) do
with {:ok, attrs} <- validate(params),
{:ok, priced} <- price(attrs),
{:ok, saved} <- persist(priced) do
{:ok, saved}
end
end
with는 실패 시 즉시 빠져나가므로, 에러 전파가 자동으로 됩니다.
2-2) then으로 “값 변환”을 명확히
then은 파이프라인 중간에서 “이 시점의 값을 fn으로 감싸서 처리”할 때 유용합니다.
attrs =
params
|> Accounts.Normalize.normalize_params()
|> then(fn normalized -> Map.put_new(normalized, "source", "web") end)
then을 쓰면 “파이프라인이 값 변환만 한다”는 의도가 유지됩니다.
3) 검증은 Ecto.Changeset을 “파이프라인형 도메인 규칙”으로 쌓는다
Elixir 실무에서 검증은 대부분 Ecto.Changeset으로 귀결됩니다. 중요한 건 검증을 한 번에 다 쓰기보다, 작은 규칙을 함수로 분리하고 파이프로 조립하는 방식입니다.
def changeset(user, attrs) do
user
|> Ecto.Changeset.cast(attrs, [:email, :name, :age])
|> Ecto.Changeset.validate_required([:email])
|> validate_email_format()
|> validate_adult()
end
defp validate_email_format(changeset) do
Ecto.Changeset.validate_format(changeset, :email, ~r/@/)
end
defp validate_adult(changeset) do
Ecto.Changeset.validate_number(changeset, :age, greater_than_or_equal_to: 18)
end
이 방식은 “불변 데이터”의 장점(이전 상태를 보존)을 그대로 활용합니다. 각 단계는 새로운 changeset을 반환하고, 어디서 오류가 생겼는지 추적하기도 쉽습니다.
4) 사이드이펙트는 tap으로 관측 가능성을 올린다
실무에서는 디버깅/관측이 중요합니다. 하지만 파이프라인에 IO.inspect를 섞기 시작하면 코드가 지저분해집니다. 이때 tap이 깔끔합니다.
result =
params
|> Accounts.Normalize.normalize_params()
|> tap(fn v -> Logger.debug("normalized=`#{inspect(v)}`") end)
|> validate()
|> tap(fn v -> Logger.debug("validated=`#{inspect(v)}`") end)
tap은 값을 변경하지 않고 “보고만” 합니다. 즉, 파이프라인의 의미를 해치지 않으면서 로그/메트릭을 끼워 넣을 수 있습니다.
관측 관점의 체계적인 접근은 프런트 성능이든 백엔드든 유사합니다. 예를 들어 원인 추적을 구조화하는 방식은 Chrome INP 급락 원인 찾기 - Long Task 추적 같은 글의 접근과도 통합니다.
5) 트랜잭션 파이프라인은 Ecto.Multi로 “불변 단계”를 조립한다
DB 작업이 2단계 이상이면 Repo.transaction에 여러 호출을 넣기보다 Ecto.Multi를 권합니다. Multi는 불변 구조로 단계들을 누적하고, 마지막에 한 번에 실행합니다.
def create_order_with_items(order_attrs, item_attrs_list) do
Ecto.Multi.new()
|> Ecto.Multi.insert(:order, Order.changeset(%Order{}, order_attrs))
|> Ecto.Multi.run(:items, fn repo, %{order: order} ->
items =
item_attrs_list
|> Enum.map(fn attrs -> Map.put(attrs, :order_id, order.id) end)
# 예시: 단순 insert_many 대신 개별 검증이 필요하다고 가정
items
|> Enum.reduce_while({:ok, []}, fn attrs, {:ok, acc} ->
case repo.insert(OrderItem.changeset(%OrderItem{}, attrs)) do
{:ok, item} -> {:cont, {:ok, [item | acc]}}
{:error, cs} -> {:halt, {:error, cs}}
end
end)
|> case do
{:ok, items} -> {:ok, Enum.reverse(items)}
{:error, reason} -> {:error, reason}
end
end)
|> Repo.transaction()
end
이 패턴의 장점은 다음과 같습니다.
- 단계별 결과가 맵으로 전달되어 의존성이 명확
- 실패 시 전체 롤백이 자동
- “어떤 단계에서 실패했는지”가 트랜잭션 결과에 남음
분산 트랜잭션이나 보상 트랜잭션으로 확장해야 한다면, 위 Multi 흐름을 경계로 “외부 시스템 호출”을 분리하고 재시도/보상을 설계하는 편이 안전합니다. 이 주제는 MSA Saga 보상 트랜잭션 실패 재처리 설계와 연결됩니다.
6) 에러 모델을 “태그드 튜플”로 통일하고, 경계에서만 예외를 쓴다
Elixir는 예외도 있지만, 실무에서는 다음처럼 계층을 나누는 게 유지보수에 좋습니다.
- 도메인/애플리케이션 로직:
{:ok, v}/{:error, reason} - 경계(HTTP 핸들러, 워커 런타임): 예외를 HTTP 응답/재시도로 매핑
예를 들어 에러 reason을 구조화합니다.
defmodule Errors do
def validation(changeset), do: {:validation, changeset}
def not_found(resource), do: {:not_found, resource}
def conflict(msg), do: {:conflict, msg}
def external(service, detail), do: {:external, service, detail}
end
def get_user(id) do
case Repo.get(User, id) do
nil -> {:error, Errors.not_found(:user)}
user -> {:ok, user}
end
end
이렇게 해두면 Phoenix 컨트롤러에서 매핑이 단순해집니다.
def render_error(conn, {:not_found, _}) do
send_resp(conn, 404, "not found")
end
def render_error(conn, {:validation, changeset}) do
send_resp(conn, 422, inspect(changeset.errors))
end
핵심은 “예외는 정말 예외적인 상황(버그/불변식 위반)”에만 쓰고, 정상적인 실패는 데이터로 다루는 것입니다.
7) 불변데이터 성능: “큰 구조를 자주 갱신”하지 말고 경로를 최소화한다
불변은 안전하지만, 큰 맵/리스트를 반복 갱신하면 비용이 생깁니다. BEAM은 구조 공유를 하지만, 갱신 경로가 길면 그만큼 새 노드가 생깁니다. 실무 팁은 다음과 같습니다.
- 깊은 중첩 업데이트는
put_in/update_in을 쓰되, 업데이트 횟수를 줄인다 - 반복 루프에서 누적은
Enum.reduce로 한 번에 만든다 - 리스트는 앞에 붙이고 마지막에
Enum.reverse하는 패턴을 선호
# 나쁜 예: 매 반복마다 깊은 업데이트
state = %{stats: %{ok: 0, error: 0}}
state2 =
Enum.reduce(results, state, fn r, acc ->
if r == :ok do
update_in(acc, [:stats, :ok], &(&1 + 1))
else
update_in(acc, [:stats, :error], &(&1 + 1))
end
end)
# 더 나은 예: 카운팅은 지역 변수(튜플)로 누적 후 마지막에 한 번만 반영
{ok_cnt, err_cnt} =
Enum.reduce(results, {0, 0}, fn r, {ok, err} ->
if r == :ok, do: {ok + 1, err}, else: {ok, err + 1}
end)
state3 = %{stats: %{ok: ok_cnt, error: err_cnt}}
또 다른 실전 포인트는 “큰 바이너리”입니다. JSON 원문, 로그 페이로드 같은 큰 바이너리를 여러 구조에 중복으로 넣으면 메모리 압력이 올라갑니다. 가능하면 원문은 한 번만 들고, 파싱 결과만 전달하거나 필요한 필드만 추출해 전달하세요.
마무리: 파이프라인을 “읽기 쉬운 데이터 플로우”로 유지하라
정리하면, Elixir의 불변데이터와 파이프라인을 실무에서 잘 쓰는 핵심은 다음입니다.
- 파이프라인 앞은 순수 변환, 끝에서 효과 수행
with로 에러 전파를 구조화하고,then으로 변환 의도를 드러내기- 검증은
Changeset규칙을 작은 함수로 쪼개 파이프로 조립 - 관측은
tap으로 값은 건드리지 않고 삽입 - 다단계 DB 작업은
Ecto.Multi로 불변 단계 조립 - 에러는 태그드 튜플로 통일하고 경계에서만 예외로 변환
- 불변 구조 갱신 경로를 줄여 성능/메모리를 관리
이 7가지 패턴을 팀 규칙으로 합의해두면, 코드 리뷰에서 “파이프라인이 왜 깨졌는지”, “에러가 어디서 새는지”, “트랜잭션 경계가 어디인지”를 훨씬 빠르게 판단할 수 있습니다.