Published on

Elixir로 파이프라인·패턴매칭 리팩토링 7법

Authors

서버 사이드 Elixir를 쓰다 보면 처음에는 함수형 스타일이 깔끔해 보이는데, 기능이 늘수록 case 중첩, if 분기, with 남발, 그리고 어딘가에서 튀어나오는 nil 때문에 코드가 다시 복잡해지는 순간이 옵니다. 이때 가장 강력한 무기는 파이프라인(|>)과 패턴 매칭입니다.

이 글은 “파이프라인·패턴매칭으로 리팩토링”을 실무 관점에서 7가지 규칙으로 정리합니다. 각 규칙은 냄새 코드와 개선 예제를 같이 보여주며, 단순 문법 소개가 아니라 유지보수성과 오류 처리 일관성에 초점을 맞춥니다.

참고로, 파이프라인이라는 단어가 DB나 MongoDB Aggregation의 pipeline을 떠올리게도 하는데, 접근 방식은 유사합니다. 단계들을 잘게 쪼개고, 각 단계의 입력과 출력을 명확히 만들어 성능과 안정성을 확보합니다. MongoDB 쪽 최적화가 궁금하다면 MongoDB $lookup 느림? 인덱스·pipeline 튜닝도 함께 보면 사고방식이 연결됩니다.

1) case 중첩을 “단계별 파이프 + 태그 튜플”로 바꾸기

냄새 코드

여러 검증과 변환이 섞이면 case가 중첩되고, 실패 경로가 흐릿해집니다.

case parse(body) do
  {:ok, params} ->
    case validate(params) do
      :ok ->
        case insert(params) do
          {:ok, user} -> {:ok, user}
          {:error, reason} -> {:error, reason}
        end

      {:error, e} ->
        {:error, e}
    end

  {:error, e} ->
    {:error, e}
end

리팩토링

각 단계가 {:ok, value} 혹은 {:error, reason}을 반환하도록 통일한 뒤, with 또는 파이프 기반의 체이닝으로 평평하게 만듭니다.

with {:ok, params} <- parse(body),
     :ok <- validate(params),
     {:ok, user} <- insert(params) do
  {:ok, user}
end

여기서 한 단계 더 나아가면, 모든 단계가 같은 형태를 반환하도록 맞추는 게 핵심입니다. 예를 들어 validate/1:ok 대신 {:ok, params}를 반환하면 파이프가 더 자연스러워집니다.

body
|> parse()
|> validate()
|> insert()

이 스타일이 잘 먹히려면 “태그 튜플의 규약”이 중요합니다. 팀 차원에서 {:ok, value} / {:error, reason}를 기본 계약으로 두면 이후 리팩토링 비용이 크게 줄어듭니다.

2) “불필요한 변수 바인딩”을 패턴으로 없애기

Elixir 초보 코드에서 흔한 패턴은 값을 꺼내기 위해 변수를 계속 만들고, 그 변수를 다시 넘기는 방식입니다.

냄새 코드

{:ok, user} = Accounts.get_user(id)
email = user.email
name = user.profile.name
send_welcome(email, name)

리팩토링

필요한 형태만 바로 매칭해서 의도를 드러냅니다.

with {:ok, %{email: email, profile: %{name: name}}} <- Accounts.get_user(id) do
  send_welcome(email, name)
end

이 방식의 장점은 “필요한 데이터가 없으면 즉시 실패”한다는 점입니다. 즉, profilenil일 수 있는 모델이라면 이 매칭이 실패하면서 자연스럽게 예외 혹은 에러 흐름으로 이동합니다. 중요한 건, 이런 실패를 예외로 둘지 {:error, ...}로 다룰지 정책을 정하는 것입니다.

3) 파이프라인에서 “한 단계는 한 책임”만 갖게 쪼개기

파이프라인은 길어질수록 읽기 좋아 보이지만, 각 단계가 너무 많은 일을 하면 오히려 디버깅이 어려워집니다.

냄새 코드

params
|> normalize()
|> Map.put("role", "user")
|> validate_and_transform_and_log_and_sanitize()
|> Repo.insert()

리팩토링

단계를 분리하고, 각 단계의 입력과 출력을 명확히 합니다.

params
|> normalize_params()
|> put_default_role()
|> sanitize_params()
|> validate_params()
|> build_changeset()
|> Repo.insert()

그리고 각 함수는 가능하면 “순수 함수”로 유지합니다. 로그 같은 부수효과는 별도 단계로 분리하고, 실패 가능성이 있으면 반환 타입을 통일합니다.

def validate_params(params) do
  case MyValidator.validate(params) do
    :ok -> {:ok, params}
    {:error, reason} -> {:error, reason}
  end
end

4) if / cond를 “함수 헤드 패턴 매칭 + 가드”로 바꾸기

조건 분기가 늘어날수록, 함수 내부에 if가 쌓이고 “정책 로직”이 “절차 로직”으로 변합니다.

냄새 코드

def price(amount, user) do
  if user.vip do
    amount * 0.8
  else
    if amount > 100_000 do
      amount * 0.9
    else
      amount
    end
  end
end

리팩토링

함수 헤드로 정책을 선언적으로 분리합니다.

def price(amount, %{vip: true}) when is_number(amount), do: amount * 0.8

def price(amount, _user) when amount > 100_000, do: amount * 0.9

def price(amount, _user) when is_number(amount), do: amount

이렇게 하면 “우선순위”가 함수 선언 순서로 고정되고, 테스트도 케이스별로 쪼개기 쉬워집니다.

5) nil 전파를 “명시적 태그”로 바꾸기

Elixir에서 nil은 편하지만, 파이프라인에서 nil이 섞이면 어디서 깨졌는지 추적이 어렵습니다. 특히 Map.get/2를 연속으로 쓰면 nil이 조용히 전파됩니다.

냄새 코드

name =
  user
  |> Map.get(:profile)
  |> Map.get(:name)
  |> String.trim()

profilenil이면 Map.get(nil, :name)에서 터집니다.

리팩토링 A: 패턴 매칭으로 “있을 때만 처리”

def profile_name(%{profile: %{name: name}}) when is_binary(name), do: {:ok, String.trim(name)}
def profile_name(_), do: {:error, :missing_profile_name}

리팩토링 B: get_in/2then/2로 안전하게 연결

case get_in(user, [:profile, :name]) do
  name when is_binary(name) -> {:ok, name |> String.trim()}
  _ -> {:error, :missing_profile_name}
end

핵심은 nil을 “값”으로 취급하지 말고, 실패를 도메인 에러로 승격시키는 것입니다. 그래야 호출자가 실패를 처리할 책임이 명확해집니다.

6) with는 “성공 경로를 직선”으로, 실패는 “한 곳”에서 처리

with는 잘 쓰면 매우 강력하지만, 남용하면 오히려 읽기 어려워집니다. 요령은 두 가지입니다.

  • with 블록 안에서는 성공 경로만 직선으로 유지
  • 실패 처리는 else에서 한 번에

예제

with {:ok, token} <- Auth.extract_token(conn),
     {:ok, claims} <- Auth.verify(token),
     {:ok, user} <- Accounts.get_user_by_sub(claims["sub"]) do
  {:ok, user}
else
  {:error, :missing_token} -> {:error, :unauthorized}
  {:error, :invalid_token} -> {:error, :unauthorized}
  {:error, :not_found} -> {:error, :unauthorized}
  other -> other
end

여기서 더 리팩토링하면, else에서 여러 케이스를 한 의미로 합치기 위해 “에러 정규화 함수”를 둘 수 있습니다.

def normalize_auth_error({:error, _}), do: {:error, :unauthorized}

def authenticate(conn) do
  with {:ok, token} <- Auth.extract_token(conn),
       {:ok, claims} <- Auth.verify(token),
       {:ok, user} <- Accounts.get_user_by_sub(claims["sub"]) do
    {:ok, user}
  else
    err -> normalize_auth_error(err)
  end
end

이 패턴은 MSA에서 타임아웃이나 데드라인 같은 오류를 계층적으로 정리할 때도 유사하게 적용됩니다. 관심 있다면 gRPC MSA에서 DEADLINE_EXCEEDED 원인 9가지처럼 “원인 분류 후 표준 응답으로 정규화”하는 접근이 도움이 됩니다.

7) 파이프라인 디버깅을 위한 “탭 함수”를 습관화하기

파이프라인은 중간 상태를 보기 어렵다는 단점이 있습니다. 이때 IO.inspect/2를 중간에 박아넣는 방식은 흔하지만, 운영 코드에 남기기 부담스럽습니다.

추천 패턴: tap/2 혹은 then/2

Elixir에는 tap/2가 있어 “값은 그대로 흘려보내고 부수효과만 수행”할 수 있습니다.

params
|> normalize_params()
|> tap(fn p -> Logger.debug("normalized=#{inspect(p)}") end)
|> sanitize_params()
|> validate_params()

then/2는 값을 받아 다음 계산으로 넘기는 데 쓰이며, 파이프라인에서 “여기서만 형태를 바꾸고 싶다” 같은 지점에 유용합니다.

user
|> Accounts.enrich()
|> then(fn u -> %{id: u.id, email: u.email} end)

이런 “관찰 가능성”을 확보하는 습관은 CI에서 캐시 미스나 워크플로우 분기 디버깅을 할 때와도 결이 비슷합니다. 원인을 찾기 위해 중간 산출물을 남기는 전략은 GitHub Actions 캐시가 안 먹을 때 key 전략과 디버깅에서도 그대로 통합니다.

마무리: 7법을 팀 규약으로 만들면 리팩토링이 빨라진다

정리하면, 파이프라인·패턴 매칭 리팩토링의 본질은 “데이터 흐름을 직선화하고, 실패를 명시화하며, 각 단계의 계약을 통일”하는 것입니다.

  • 중첩 case는 단계별 태그 튜플로 평탄화
  • 필요한 값은 변수로 꺼내기보다 패턴으로 바로 매칭
  • 파이프라인 단계는 한 책임만 갖게 쪼개기
  • if는 함수 헤드 + 가드로 정책을 선언
  • nil은 조용히 흘려보내지 말고 도메인 에러로 승격
  • with는 성공 경로를 직선으로, 실패는 한 곳에서 정규화
  • tap / then으로 파이프라인의 관찰 가능성 확보

이 7가지를 코드 리뷰 체크리스트로 만들면, Elixir 코드베이스가 커질수록 “새 기능 추가”보다 “이해 비용”이 커지는 문제를 상당히 완화할 수 있습니다.