- Published on
Elixir로 순수함수+파이프라인 리팩터링 7패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Elixir 코드는 기본적으로 함수형 스타일을 권장하지만, 실제 서비스 코드에서는 로깅, DB/HTTP 호출, 조건 분기, 복잡한 검증이 섞이면서 파이프라인이 금방 “읽기 어렵고 테스트하기 어려운” 형태로 굳어집니다. 특히 with 블록이 길어지거나, 파이프 중간에 case가 난무하거나, 상태를 암묵적으로 갱신하는 코드가 늘어나는 순간 유지보수 비용이 급증합니다.
이 글에서는 순수함수(pure function) 와 파이프라인(pipeline) 을 중심으로, 실무에서 바로 적용 가능한 리팩터링 7패턴을 정리합니다. 목표는 단순합니다.
- 계산은 순수하게: 입력이 같으면 출력이 같도록
- 부수효과는 경계로: DB/HTTP/로그는 가장자리로 밀어내기
- 파이프라인은 문장처럼: 위에서 아래로 읽히게
참고로 멱등성/중복 처리 같은 주제는 파이프라인 설계와도 맞닿아 있습니다. 결제나 요청 처리에서 “같은 입력은 같은 결과”를 보장하고 싶다면 아래 글도 함께 보면 좋습니다.
1) “계산”과 “효과”를 분리하는 경계 만들기
가장 큰 리팩터링 포인트는 순수 계산과 I/O(부수효과) 를 분리하는 것입니다. 예를 들어 주문 생성 로직에서 가격 계산/검증은 순수함수로, DB 저장은 효과로 둡니다.
리팩터링 전: 모든 게 한 함수에 섞임
def create_order(params) do
user = Repo.get!(User, params["user_id"])
items = parse_items(params["items"])
total = Enum.reduce(items, 0, fn i, acc -> acc + i.price * i.qty end)
if total <= 0 do
{:error, :invalid_total}
else
order = %Order{user_id: user.id, total: total}
Repo.insert(order)
end
end
리팩터링 후: 순수 계산은 build_*로, 효과는 별도
def create_order(params) do
with {:ok, input} <- normalize_input(params),
{:ok, order_changeset} <- build_order_changeset(input),
{:ok, order} <- persist_order(order_changeset) do
{:ok, order}
end
end
defp normalize_input(params) do
# 순수하게 파싱/정규화
{:ok, %{user_id: params["user_id"], items: parse_items(params["items"])}}
end
defp build_order_changeset(%{user_id: user_id, items: items}) do
total = calc_total(items)
if total > 0 do
{:ok, Order.changeset(%Order{}, %{user_id: user_id, total: total})}
else
{:error, :invalid_total}
end
end
defp calc_total(items) do
Enum.reduce(items, 0, fn i, acc -> acc + i.price * i.qty end)
end
defp persist_order(changeset) do
Repo.insert(changeset)
end
핵심은 calc_total/1, build_order_changeset/1 같은 함수가 DB나 프로세스 상태에 의존하지 않도록 만드는 것입니다. 이렇게 하면 테스트는 입력/출력만으로 끝나고, 실패 케이스도 명확해집니다.
2) 파이프라인에서 “단일 책임 단계”로 쪼개기
파이프라인은 “문장”처럼 읽히는 게 이상적입니다. 이를 위해 각 단계는 한 가지 일만 하도록 쪼갭니다.
나쁜 예: 단계 하나가 너무 많은 일을 함
params
|> validate_and_transform_and_enrich()
|> Repo.insert()
좋은 예: 단계가 의도를 드러냄
params
|> normalize_params()
|> validate_params()
|> enrich_context()
|> build_changeset()
|> persist()
각 함수는 작아지지만, 그 대신 파이프라인 자체가 “설계 문서”가 됩니다. 리뷰어는 구현을 열지 않아도 흐름을 파악할 수 있습니다.
3) with로 {:ok, _} 체인을 표준화하기
Elixir에서 실무 파이프라인은 대부분 {:ok, value} / {:error, reason} 형태를 사용합니다. 이때 with는 성공 경로를 직선으로 만들고, 실패는 자동으로 빠져나가게 합니다.
def register_user(params) do
with {:ok, normalized} <- normalize(params),
{:ok, validated} <- validate(normalized),
{:ok, user} <- insert_user(validated),
{:ok, _} <- publish_event(user) do
{:ok, user}
end
end
여기서 중요한 리팩터링 포인트는 각 단계가 같은 계약(contract) 을 갖도록 맞추는 것입니다.
- 성공:
{:ok, value} - 실패:
{:error, reason}
이 규칙이 지켜지면 중간에 case를 끼워 넣을 일이 크게 줄어듭니다.
4) then/2로 “파이프 중간의 표현식” 깔끔하게 넣기
Elixir 1.12+의 then/2는 파이프 중간에 익명 함수를 자연스럽게 끼워 넣는 도구입니다. 특히 “중간 계산 결과를 바꿔 끼우고 싶을 때” 유용합니다.
items
|> Enum.group_by(& &1.category)
|> then(fn grouped -> Map.update(grouped, :etc, [], & &1) end)
|> Map.put_new(:meta, %{generated_at: DateTime.utc_now()})
리팩터링 관점에서 then/2는 다음 상황에서 좋습니다.
- 임시로
case/if를 넣고 싶지만 함수로 빼기 애매할 때 - 파이프의 “자료형 변환 지점”을 명확히 표시하고 싶을 때
다만 남용하면 익명 함수가 길어져 오히려 가독성이 떨어지므로, 5~10줄 이상이면 defp로 승격시키는 편이 좋습니다.
5) 조건 분기는 “파이프라인 밖”이 아니라 “단계 함수”로 숨기기
파이프라인에서 if/case가 튀어나오면 흐름이 끊깁니다. 조건 분기는 보통 단계 함수 내부로 넣어 “항상 같은 타입을 반환”하게 만드는 게 좋습니다.
리팩터링 전: 파이프라인이 중간에서 끊김
user
|> enrich_profile()
|> (fn u -> if u.admin, do: add_admin_flags(u), else: u end).()
|> serialize()
리팩터링 후: maybe_* 단계로 흡수
user
|> enrich_profile()
|> maybe_add_admin_flags()
|> serialize()
defp maybe_add_admin_flags(%{admin: true} = u), do: add_admin_flags(u)
defp maybe_add_admin_flags(u), do: u
이 패턴은 특히 “옵션 기능”이 많은 도메인(권한, 할인, 실험 플래그)에서 큰 효과가 있습니다.
6) 컨텍스트(Map/Struct)로 파이프라인 상태를 명시적으로 전달하기
파이프라인이 길어질수록 중간 산출물이 많아집니다. 이때 튜플로 왔다갔다 하거나, 변수를 여러 개 바인딩하면 코드가 지저분해집니다. 해결책은 컨텍스트 맵을 만들어 단계별로 채워 넣는 것입니다.
def run_checkout(params) do
%{params: params}
|> load_user()
|> load_cart()
|> calc_totals()
|> build_order()
|> persist_order()
end
defp load_user(%{params: %{"user_id" => id}} = ctx) do
user = Repo.get(User, id)
Map.put(ctx, :user, user)
end
defp calc_totals(%{cart: cart} = ctx) do
totals = %{subtotal: calc_subtotal(cart), tax: 0}
Map.put(ctx, :totals, totals)
end
장점:
- 파이프라인 각 단계가 “무엇을 읽고/무엇을 쓰는지” 선명해짐
- 테스트에서 특정 단계만 호출하기 쉬움
- 로깅/트레이싱 시 컨텍스트를 그대로 활용 가능
주의점:
- 컨텍스트가 비대해지면 “만능 객체”가 되기 쉬움
- 단계별로 필요한 키만 유지하거나, 도메인별 서브맵을 두는 식으로 관리하는 게 좋음
7) 에러를 “의미 있는 도메인 에러”로 승격하고, 가장자리에서만 문자열화하기
실무에서 파이프라인이 망가지는 가장 흔한 이유는 에러 형태가 제각각이기 때문입니다. 어떤 함수는 {:error, "msg"}를, 어떤 함수는 {:error, :not_found}를, 또 어떤 함수는 예외를 던집니다.
리팩터링의 목표는 다음입니다.
- 내부: 구조화된 도메인 에러(원인/필드/컨텍스트)
- 외부(API 응답, 로그): 문자열/코드로 변환
도메인 에러 구조체 정의
defmodule MyApp.DomainError do
defexception [:code, :message, :meta]
@type t :: %__MODULE__{code: atom(), message: String.t(), meta: map()}
end
파이프라인 내부에서는 구조화된 에러로 통일
defp validate_email(%{email: email} = input) do
if String.contains?(email, "@") do
{:ok, input}
else
{:error, %MyApp.DomainError{code: :invalid_email, message: "invalid email", meta: %{email: email}}}
end
end
가장자리(Controller/Boundary)에서만 응답 형태로 변환
def to_http({:ok, data}), do: %{status: 200, body: data}
def to_http({:error, %MyApp.DomainError{code: code, message: msg}}) do
%{status: 400, body: %{error: Atom.to_string(code), message: msg}}
end
이렇게 해두면 장애 대응 시 “왜 실패했는지”를 메타로 남길 수 있고, 재시도/보상 같은 설계에서도 에러 분류가 쉬워집니다. 분산 트랜잭션에서 실패 복구를 설계할 때도 결국 에러를 잘 분류해야 하므로, 관심 있다면 아래 글을 같이 보면 맥락이 이어집니다.
예제: 7패턴을 한 번에 적용한 “읽히는” 파이프라인
아래는 사용자 프로필 업데이트를 예로 든 통합 예시입니다.
def update_profile(params) do
with {:ok, input} <- normalize(params),
{:ok, input} <- validate(input),
{:ok, ctx} <- build_context(input),
{:ok, changeset} <- build_changeset(ctx),
{:ok, user} <- persist(changeset),
:ok <- audit_log(user) do
{:ok, user}
end
end
defp normalize(params) do
{:ok,
%{
user_id: params["user_id"],
email: String.trim(params["email"] || ""),
nickname: String.trim(params["nickname"] || "")
}}
end
defp validate(%{email: email} = input) do
if String.contains?(email, "@") do
{:ok, input}
else
{:error, %MyApp.DomainError{code: :invalid_email, message: "invalid email", meta: %{email: email}}}
end
end
defp build_context(input) do
# 효과(Repo) 단계는 경계에 가깝게 두되, 컨텍스트로 결과를 명시
user = Repo.get(User, input.user_id)
if user do
{:ok, %{input: input, user: user}}
else
{:error, %MyApp.DomainError{code: :user_not_found, message: "user not found", meta: %{id: input.user_id}}}
end
end
defp build_changeset(%{user: user, input: input}) do
attrs = %{email: input.email, nickname: input.nickname}
{:ok, User.changeset(user, attrs)}
end
defp persist(changeset), do: Repo.update(changeset)
defp audit_log(user) do
# 부수효과는 마지막으로
Logger.info("profile_updated", user_id: user.id)
:ok
end
이 예제는 다음을 만족합니다.
- 순수 계산(정규화/검증)과 효과(Repo/Logger)를 분리
- 모든 단계의 반환 타입을 정렬
- 파이프라인(또는
with)이 읽히는 순서로 설계 - 에러는 도메인 에러로 승격
마무리 체크리스트
리팩터링을 시작할 때 아래 체크리스트로 “파이프라인이 왜 읽기 어려운지”를 빠르게 진단할 수 있습니다.
- 단계 함수가 한 번에 두 가지 이상 일을 하고 있나
- 어떤 단계는 예외를 던지고, 어떤 단계는
{:error, _}를 반환하나 - 파이프 중간에
case/if가 튀어나와 흐름이 끊기나 - 중간 산출물을 변수로 여러 개 들고 다니느라 문맥이 사라지나
- 에러가 문자열로 흩어져 있어 분류/재시도/관측이 어려운가
이 7패턴은 “함수형스럽게 보이게 만드는 테크닉”이 아니라, 테스트 가능성, 장애 대응, 변경 용이성을 올리는 실전 패턴입니다. 특히 Elixir처럼 동시성과 장애 허용을 전제로 하는 생태계에서는, 순수함수와 파이프라인의 품질이 곧 시스템의 품질로 이어집니다.