Published on

Elixir 불변데이터·파이프라인 실무 패턴 7

Authors

서버 사이드에서 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, _} 파이프라인은 thenwith로 정리한다

실무에서 가장 많이 망가지는 부분이 {: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의 불변데이터와 파이프라인을 실무에서 잘 쓰는 핵심은 다음입니다.

  1. 파이프라인 앞은 순수 변환, 끝에서 효과 수행
  2. with로 에러 전파를 구조화하고, then으로 변환 의도를 드러내기
  3. 검증은 Changeset 규칙을 작은 함수로 쪼개 파이프로 조립
  4. 관측은 tap으로 값은 건드리지 않고 삽입
  5. 다단계 DB 작업은 Ecto.Multi로 불변 단계 조립
  6. 에러는 태그드 튜플로 통일하고 경계에서만 예외로 변환
  7. 불변 구조 갱신 경로를 줄여 성능/메모리를 관리

이 7가지 패턴을 팀 규칙으로 합의해두면, 코드 리뷰에서 “파이프라인이 왜 깨졌는지”, “에러가 어디서 새는지”, “트랜잭션 경계가 어디인지”를 훨씬 빠르게 판단할 수 있습니다.