- Published on
Elixir 파이프라인·패턴매칭 함수 설계 7팁
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Elixir 코드를 오래 유지보수하다 보면, 결국 생산성을 좌우하는 건 문법 지식이 아니라 함수 인터페이스와 실패 모델을 얼마나 일관되게 설계했는가입니다. Elixir의 강점인 파이프라인(|>)과 패턴 매칭은 “코드를 읽는 흐름”과 “실패를 다루는 규칙”을 동시에 정리해 주지만, 반대로 잘못 쓰면 파이프라인이 끊기고 예외가 새어나오며 분기 로직이 흩어집니다.
아래 7가지 팁은 실무에서 자주 겪는 문제(파이프라인 중간 실패, 입력 검증 난립, case 중첩, 반환값 불일치)를 줄이기 위한 함수 설계 규칙에 가깝습니다.
1) 파이프라인의 첫 인자는 “데이터”로 고정하라
Elixir 파이프라인은 “왼쪽 값이 다음 함수의 첫 번째 인자”로 들어간다는 규칙이 핵심입니다. 따라서 파이프라인을 길게 쓰고 싶다면, 각 단계 함수의 첫 인자는 항상 변환 대상 데이터가 되도록 맞추는 편이 좋습니다.
나쁜 예는 옵션이나 컨텍스트가 첫 인자로 와서 파이프라인이 매번 깨지는 형태입니다.
# 좋지 않음: ctx가 첫 인자라 파이프라인이 망가짐
MyApp.Auth.verify(ctx, token)
|> MyApp.Auth.load_user(ctx)
아래처럼 “데이터를 첫 인자”로 두면 파이프라인이 자연스럽게 이어집니다.
# 권장: token/user 같은 데이터가 첫 인자
token
|> MyApp.Auth.verify(ctx)
|> MyApp.Auth.load_user(ctx)
이 작은 규칙 하나로, 함수들이 “파이프라인 친화적”이 되고 호출부가 깔끔해집니다.
2) 실패를 예외로 흘리지 말고 {:ok, v} / {:error, r}로 표준화하라
파이프라인은 기본적으로 값 변환에 강합니다. 반면 실패가 섞이면, 중간에 raise가 터지거나 nil이 흘러가서 후속 단계가 터지는 일이 흔합니다.
실무에서는 아래 두 가지 중 하나로 표준화하는 편이 안정적입니다.
- 변환 파이프라인: 실패가 거의 없고, 실패 시 예외가 자연스러운 순수 변환
- 검증/IO 파이프라인: 실패가 정상 흐름이므로
{:ok, v}/{:error, reason}
특히 IO나 외부 의존(HTTP, DB, 캐시)이 끼면 {:ok, _} 규약이 유지보수성을 크게 올립니다. 재시도/백오프 같은 실패 제어 패턴도 이 규약 위에서 깔끔해집니다. 실패 제어 관점은 OpenAI 429·Rate Limit - 백오프·큐잉 실전 글의 접근과도 유사합니다.
간단한 예시입니다.
def fetch_user(id) do
with {:ok, id} <- validate_id(id),
{:ok, user} <- repo_get_user(id) do
{:ok, user}
end
end
defp validate_id(id) when is_integer(id) and id > 0, do: {:ok, id}
defp validate_id(_), do: {:error, :invalid_id}
defp repo_get_user(id) do
case MyApp.Repo.get(MyApp.User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
핵심은 “실패가 정상인 경로”를 예외로 처리하지 않고, 타입(튜플)로 표현해 호출부가 예측 가능하게 만드는 것입니다.
3) with는 “선형 파이프라인 + 실패 탈출”로 써라
with는 파이프라인을 대체하기보다, 단계별 검증/조회가 직렬로 이어지고 중간 실패 시 즉시 탈출해야 할 때 가장 빛납니다. case 중첩을 줄이고, 실패 원인을 위로 올리기 쉬워집니다.
def create_session(params) do
with {:ok, email} <- fetch_email(params),
{:ok, user} <- find_user_by_email(email),
:ok <- ensure_active(user),
{:ok, token} <- issue_token(user) do
{:ok, %{token: token}}
else
{:error, reason} -> {:error, reason}
:error -> {:error, :unknown}
end
end
defp fetch_email(%{"email" => email}) when is_binary(email), do: {:ok, email}
defp fetch_email(_), do: {:error, :missing_email}
defp ensure_active(%{active: true}), do: :ok
defp ensure_active(_), do: {:error, :inactive}
여기서 중요한 설계 포인트는 다음입니다.
- 각 단계가 같은 실패 규약을 공유한다
with안에서는 “다음 단계로 넘길 값”만 바인딩한다else는 “예상 가능한 실패들”만 처리하고 과도한 분기 로직을 넣지 않는다
4) 다중 함수 헤드로 “입력 검증”을 호출부에서 숨겨라
Elixir의 패턴 매칭은 데이터 구조 검증을 자연스럽게 코드에 녹입니다. 특히 맵/리스트/튜플 구조가 정해진 입력이라면, case로 내부에서 검사하기보다 함수 헤드에서 매칭하는 게 읽기 좋습니다.
# 컨트롤러/핸들러에서 자주 쓰는 패턴
def handle_event(%{"type" => "signup", "email" => email} = payload) when is_binary(email) do
payload
|> normalize_payload()
|> process_signup()
end
def handle_event(%{"type" => "password_reset", "user_id" => user_id}) when is_integer(user_id) do
process_password_reset(user_id)
end
def handle_event(_), do: {:error, :unsupported_event}
이 방식의 장점은 “검증 로직”이 함수 본문을 더럽히지 않고, 지원하는 입력 스펙이 함수 시그니처 자체로 문서화된다는 점입니다.
5) 가드(guard)로 도메인 제약을 “가볍게” 표현하라
가드는 패턴 매칭만으로 부족한 제약(범위, 타입, 간단한 조건)을 표현할 때 유용합니다. 다만 가드는 제한된 함수만 쓸 수 있으니, 복잡한 검증은 별도 함수로 빼고 가드는 “빠른 필터”로 쓰는 편이 좋습니다.
def discount_price(price, rate)
when is_integer(price) and price >= 0 and is_float(rate) and rate >= 0.0 and rate <= 1.0 do
trunc(price * (1.0 - rate))
end
def discount_price(_, _), do: {:error, :invalid_discount_args}
이렇게 하면 호출부는 실패 가능성을 명확히 인지하고, 함수 내부는 방어 코드가 줄어듭니다.
6) 파이프라인 중간에 “형태가 바뀌는 값”을 넣지 마라
파이프라인이 읽기 좋은 이유는 “값이 같은 종류로 계속 변환된다”는 전제가 있기 때문입니다. 중간에 값의 형태가 급변하면(예: 맵에서 튜플로, 리스트에서 nil로) 다음 단계가 의미를 잃습니다.
예를 들어 “검증 단계는 {:ok, v}를 반환하고, 다음 단계는 맵을 기대”하면 파이프라인이 즉시 어색해집니다.
# 좋지 않음: validate는 {:ok, map}인데 다음 단계는 map을 기대
params
|> validate_params()
|> normalize_params()
이 경우는 두 가지 중 하나로 정리하세요.
- 전 구간을
{:ok, v}파이프라인으로 맞추기 with로 묶어서 값 언래핑을 한 번만 하기
전 구간을 튜플 규약으로 맞추는 예시는 아래처럼 작성할 수 있습니다.
def normalize_and_save(params) do
{:ok, params}
|> validate_params()
|> normalize_params()
|> persist()
end
defp validate_params({:ok, %{"email" => email} = params}) when is_binary(email), do: {:ok, params}
defp validate_params({:ok, _}), do: {:error, :invalid_params}
defp normalize_params({:ok, params}), do: {:ok, Map.update(params, "email", nil, &String.downcase/1)}
defp persist({:ok, params}), do: MyApp.Users.create(params)
defp persist({:error, reason}), do: {:error, reason}
이 패턴은 “중간 어디서 실패해도 마지막까지 안전하게 흘러가며” 호출부가 예측 가능해집니다.
7) 에러 이유는 “기계가 처리할 수 있게” 작게 유지하라
{:error, reason}의 reason을 문자열로 길게 만들기 시작하면, 로깅/국제화/UI 매핑에서 다시 난장판이 됩니다. 실무에서는 다음 중 하나로 통일하는 편이 좋습니다.
- 원자(atom):
:not_found,:invalid_email - 구조체/맵:
%{code: :invalid_email, field: :email}
예시:
defp validate_email(email) when is_binary(email) do
if String.contains?(email, "@") do
{:ok, email}
else
{:error, %{code: :invalid_format, field: :email}}
end
end
defp validate_email(_), do: {:error, %{code: :required, field: :email}}
이렇게 만들어두면 API 레이어에서 다음처럼 일관되게 매핑할 수 있습니다.
def to_http_error(%{code: :required, field: field}), do: {400, %{error: "required", field: field}}
def to_http_error(%{code: :invalid_format, field: field}), do: {400, %{error: "invalid_format", field: field}}
분산 환경에서 실패가 “정상 흐름”이 되는 경우(예: 보상 트랜잭션, 재처리)에는 에러 코드의 안정성이 더 중요해집니다. 관련 설계 관점은 MSA에서 Saga 보상 트랜잭션 설계 체크리스트와 Saga 보상 트랜잭션 실패 재처리 설계 가이드도 함께 참고하면 좋습니다.
마무리: “읽기 흐름”과 “실패 모델”을 같이 설계하라
Elixir에서 파이프라인과 패턴 매칭을 잘 쓴다는 건, 단순히 |>를 많이 쓰는 게 아니라 다음을 동시에 만족시키는 것입니다.
- 파이프라인이 자연스럽게 이어지도록 함수 인자 순서를 맞춘다
- 실패를 예외가 아닌 반환값 규약으로 표준화한다
with로 선형 흐름과 실패 탈출을 정리한다- 다중 헤드와 가드로 입력 스펙을 함수 시그니처로 끌어올린다
- 에러 이유를 작고 안정적인 코드로 유지한다
이 7가지 팁을 팀 규칙으로 합의해두면, 코드 리뷰에서 “취향 싸움”이 줄고, 신규 합류자가 코드를 읽는 속도가 눈에 띄게 빨라집니다.