- Published on
Elixir 파이프라인·Enum으로 순수함수 합성하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 개발을 하다 보면 “데이터를 읽고, 정제하고, 검증하고, 변환하고, 집계해서 결과를 만든다” 같은 흐름이 반복됩니다. 이때 코드가 금방 복잡해지는 지점은 두 가지입니다.
- 중간 상태(임시 변수)가 늘어나면서 흐름이 끊김
- I/O(HTTP, DB, 파일)와 도메인 로직이 섞여 테스트가 어려워짐
Elixir는 |> 파이프라인과 Enum/Stream 조합으로 이 문제를 상당히 우아하게 해결합니다. 핵심은 순수함수 합성입니다. 즉, 입력이 같으면 출력이 같은 함수들을 작은 단위로 쪼개고, 파이프로 이어 “데이터가 흘러가는 구조”로 만들면 읽기/테스트/확장이 쉬워집니다.
아래에서는 실전에서 자주 쓰는 패턴을 중심으로, 파이프라인과 Enum으로 순수함수 중심의 합성 구조를 만드는 방법을 정리합니다.
1) 파이프라인은 “데이터 흐름”을 고정한다
Elixir의 |>는 왼쪽 값을 오른쪽 함수의 첫 번째 인자로 넣습니다. 즉, “이 값이 다음 단계로 넘어간다”를 코드 구조로 고정합니다.
다음 두 코드는 동치입니다.
result = String.trim(input)
result = String.downcase(result)
result = String.replace(result, ~r/\s+/, " ")
result =
input
|> String.trim()
|> String.downcase()
|> String.replace(~r/\s+/, " ")
두 번째가 좋은 이유는 단순히 “예쁘다”가 아니라, 중간 상태가 이름을 갖고 퍼지지 않으며, 단계별로 함수 경계가 명확해져 테스트가 쉬워진다는 점입니다.
파이프라인 설계 규칙(현업 기준)
- 한 단계는 가능한 한 “한 가지 일”만 한다
- 각 단계는 입력과 출력 타입(자료형)을 안정적으로 유지한다
- I/O는 파이프라인 바깥(또는 끝단)으로 밀어낸다
이 규칙만 지켜도, 코드가 “데이터 변환 파이프”처럼 읽히기 시작합니다.
2) Enum은 “리스트 변환 DSL”이다
Enum은 컬렉션(주로 리스트)을 다루는 함수 집합입니다. 순수함수 합성의 핵심은 map, filter, reduce를 조합해 중간 자료구조를 최소화하고 의도를 드러내는 것입니다.
예를 들어 주문 목록에서 유효한 주문만 골라 총액을 계산한다고 해봅시다.
defmodule Order do
defstruct [:id, :amount, :status]
end
defmodule Billing do
def total_paid_amount(orders) do
orders
|> Enum.filter(&paid?/1)
|> Enum.map(& &1.amount)
|> Enum.sum()
end
defp paid?(%Order{status: :paid}), do: true
defp paid?(_), do: false
end
filter는 “통과 조건”을map은 “필요한 형태로 변환”을sum은 “집계”를 담당합니다.
이 구조는 테스트가 매우 쉽습니다. total_paid_amount/1은 I/O가 없고 입력만 주면 결과가 결정되기 때문입니다.
3) “검증 + 변환”은 map/filter보다 reduce가 깔끔할 때가 많다
map과 filter를 여러 번 쓰면 중간 리스트가 계속 만들어집니다(Elixir는 불변 자료구조이므로). 데이터가 크거나, 검증 로직이 복잡할수록 reduce로 한 번에 끝내는 편이 명확한 경우가 많습니다.
예: 문자열 숫자 리스트를 정수로 파싱하되, 음수는 버리고, 파싱 실패는 스킵하면서 합산.
defmodule Parse do
def sum_positive_ints(strings) do
strings
|> Enum.reduce(0, fn s, acc ->
case Integer.parse(s) do
{n, ""} when n > 0 -> acc + n
_ -> acc
end
end)
end
end
여기서 중요한 포인트는 reduce의 누산기 acc가 상태처럼 보이지만 불변이며, 함수는 여전히 순수하다는 점입니다.
4) 오류 처리는 with로, 성공 경로를 직선으로 만든다
파이프라인은 “첫 번째 인자” 규칙 때문에 {:ok, value}/{:error, reason} 같은 튜플 기반 흐름을 그대로 연결하기가 애매합니다. 이때 with가 강력합니다. with는 성공 경로를 직선으로 만들고, 실패는 즉시 빠져나오게 합니다.
예: 사용자 입력을 정규화하고 검증한 뒤 도메인 구조체로 만든다고 가정.
defmodule Signup do
def normalize_email(email) do
email
|> String.trim()
|> String.downcase()
end
def validate_email(email) do
if String.contains?(email, "@"), do: {:ok, email}, else: {:error, :invalid_email}
end
def validate_password(pw) do
if String.length(pw) >= 12, do: {:ok, pw}, else: {:error, :weak_password}
end
def build_user(email, pw) do
with {:ok, email} <- email |> normalize_email() |> validate_email(),
{:ok, pw} <- validate_password(pw) do
{:ok, %{email: email, password: pw}}
end
end
end
normalize_email/1은 순수함수validate_*는 순수함수(결과를 튜플로 표현)build_user/2는 “합성 오케스트레이션”
이렇게 하면 실패 케이스 테스트도 단순해집니다.
5) 부수효과(I/O)는 “끝단”으로 모으고, 나머지는 순수하게 유지한다
실무에서 가장 큰 차이는 여기서 납니다. DB 조회, 외부 API 호출, 로깅 같은 부수효과가 파이프라인 중간중간 섞이면 합성이 깨지고 테스트가 어려워집니다.
권장 패턴은 다음입니다.
- I/O로 데이터를 가져온다
- 순수 파이프라인으로 변환/검증/집계한다
- I/O로 저장하거나 응답한다
예를 들어 “API 호출 결과를 정제해서 재시도 정책에 맞게 분류” 같은 작업을 한다면, 재시도 자체는 I/O/시간과 엮이지만 분류 로직은 순수함수로 뽑을 수 있습니다. 이런 관점은 재시도 설계 글에서도 그대로 통합니다: OpenAI 429·Rate Limit 에러 재시도 설계
6) Stream으로 “게으른 파이프라인”을 만들고 마지막에만 Enum으로 닫기
Enum은 즉시(eager) 평가합니다. 데이터가 크면 중간 리스트가 부담이 됩니다. Stream은 지연(lazy) 평가로, 최종적으로 결과를 필요로 할 때만 계산합니다.
예: 큰 로그 파일 라인을 읽어 특정 조건의 라인 수를 센다.
count =
File.stream!("app.log")
|> Stream.map(&String.trim/1)
|> Stream.filter(&String.contains?(&1, "ERROR"))
|> Enum.count()
Stream.map/filter는 파이프를 “지연 변환”으로 유지Enum.count에서 실제로 순회가 발생
다만 Stream은 디버깅 시점에 “어디서 계산이 일어나는지”가 덜 직관적일 수 있으니, 작은 데이터에서는 Enum이 더 단순합니다.
7) 합성 가능한 함수 시그니처를 의식하라
파이프라인 합성이 잘 되려면 함수 시그니처가 파이프 친화적이어야 합니다.
- 파이프 입력이 첫 번째 인자에 오도록 설계
- 옵션은 키워드 리스트로 뒤에 배치
예:
defmodule Text do
def normalize(s, opts \\ []) do
s
|> maybe_downcase(opts)
|> String.trim()
end
defp maybe_downcase(s, opts) do
if Keyword.get(opts, :downcase, true), do: String.downcase(s), else: s
end
end
" Hello "
|> Text.normalize(downcase: false)
이런 식으로 만들면 호출부가 자연스럽게 파이프 형태를 띱니다.
8) Enum 조합의 흔한 함정: “여러 번 순회”와 “불필요한 중간 리스트”
다음 코드는 읽기 쉽지만, 컬렉션을 여러 번 순회합니다.
items
|> Enum.filter(&predicate/1)
|> Enum.map(&transform/1)
|> Enum.take(10)
데이터가 크고 take로 일부만 필요하다면 Stream으로 바꾸는 게 보통 이득입니다.
items
|> Stream.filter(&predicate/1)
|> Stream.map(&transform/1)
|> Enum.take(10)
또는 Enum.reduce_while/3로 “10개 모이면 중단” 같은 제어도 가능합니다.
result =
items
|> Enum.reduce_while([], fn item, acc ->
if predicate(item) do
acc = [transform(item) | acc]
if length(acc) >= 10, do: {:halt, Enum.reverse(acc)}, else: {:cont, acc}
else
{:cont, acc}
end
end)
이 패턴은 성능 최적화 포인트가 분명합니다. 다만 length/1는 리스트 길이를 매번 세므로, 실제로는 카운터를 함께 누산하는 형태가 더 좋습니다.
9) “도메인 파이프라인”을 모듈로 고정하면 리팩터링 비용이 줄어든다
파이프라인을 함수 내부에 길게 늘어놓기만 하면 재사용이 어렵습니다. 실전에서는 “도메인 단계”를 함수로 끊고, 공개 API는 얇게 유지하는 편이 좋습니다.
예:
defmodule Analytics do
def report(raw_events) do
raw_events
|> normalize_events()
|> drop_invalid()
|> aggregate()
end
defp normalize_events(events) do
Enum.map(events, &normalize_event/1)
end
defp drop_invalid(events) do
Enum.filter(events, &valid?/1)
end
defp aggregate(events) do
Enum.frequencies_by(events, & &1.type)
end
defp normalize_event(event), do: event
defp valid?(_event), do: true
end
이 구조는 “로직을 읽는 사람”에게도 좋지만, “성능/장애 대응” 관점에서도 유리합니다. 특정 단계가 병목이면 그 단계만 Stream으로 바꾸거나, 입력을 바꾸거나, 캐시를 붙이는 식으로 국소 최적화가 가능합니다. 운영 환경에서 병목을 좁혀가는 접근은 예를 들어 p99 지연을 다루는 글과도 결이 같습니다: Spring Boot 3.x JFR로 p99 지연 30% 줄이기
10) 테스트 전략: 순수함수는 단위 테스트, I/O는 얇은 통합 테스트
순수함수 합성 패턴의 보상은 테스트에서 크게 옵니다.
normalize_*,validate_*,aggregate같은 함수는 입력/출력만 검증- API 호출/DB 저장은 “경계”만 얇게 테스트
예를 들어 Signup.build_user/2는 다음처럼 간단히 테스트할 수 있습니다.
# ExUnit 예시
defmodule SignupTest do
use ExUnit.Case
test "build_user/2 rejects weak password" do
assert {:error, :weak_password} = Signup.build_user("a@b.com", "short")
end
test "build_user/2 normalizes email" do
assert {:ok, user} = Signup.build_user(" A@B.COM ", "veryverystrongpw")
assert user.email == "a@b.com"
end
end
순수함수로 구성했기 때문에 목(mock)이 거의 필요 없습니다.
11) 정리: Elixir 합성의 핵심 체크리스트
|>로 데이터 흐름을 고정하고, 단계는 작게 쪼갠다- 리스트 변환은
Enum.map/filter/reduce로 의도를 드러낸다 - 큰 데이터/부분 결과는
Stream으로 지연 평가 후Enum으로 닫는다 - 성공/실패가 섞인 흐름은
with로 직선화한다 - I/O는 끝단으로 모으고, 로직은 순수함수로 유지한다
이 패턴을 팀 코드베이스에 적용하면, “기능 추가”와 “성능 개선”이 서로 발목 잡지 않게 됩니다. 특히 장애 상황에서 원인 구간을 좁히거나, 재시도/백오프 같은 정책을 도입할 때도 순수함수로 분리된 코드는 변경 반경이 작아집니다. 관련해서 재시도 구현 관점은 OpenAI 429 RateLimit 재시도·백오프 구현 가이드도 함께 참고하면 좋습니다.