Published on

Elixir 파이프라인·패턴매칭으로 에러 흐름 제어

Authors

Elixir에서 에러 처리는 보통 try/rescue 같은 예외 기반보다는 {:ok, value} / {:error, reason} 튜플을 데이터로 흘려보내는 방식이 중심입니다. 이 접근은 “에러가 발생할 수 있음”을 타입(형태)로 표현하고, 패턴매칭으로 성공/실패 분기를 명시적으로 만들 수 있게 해줍니다.

이번 글에서는 파이프라인(|>)과 패턴매칭을 조합해 에러 흐름을 깔끔하게 제어하는 실전 패턴을 다룹니다. 특히 with를 이용한 단락 평가(중간 실패 시 즉시 종료), case/함수 헤드로 분기 고도화, 그리고 에러 컨텍스트를 풍부하게 만드는 방법을 예제로 정리합니다.

관련해서 “쉘 파이프라인에서 에러가 어디서 무시되는지”를 다루는 글도 함께 보면 사고방식이 연결됩니다. 예를 들어 Bash의 pipefail 개념은 Elixir의 “실패를 값으로 흘려보내고 중간에서 멈추기”와 유사한 면이 있습니다: Bash set -e가 무시될 때 - pipefail·trap

Elixir 에러 흐름의 기본: 예외 대신 결과 튜플

Elixir/Erlang 생태계에서 권장되는 기본 형태는 다음입니다.

  • 성공: {:ok, value}
  • 실패: {:error, reason}

이렇게 하면 호출자는 반드시 패턴매칭으로 결과를 다뤄야 하고, 실패를 “숨기지” 않게 됩니다.

@spec fetch_user(integer()) :: {:ok, map()} | {:error, :not_found}
def fetch_user(id) do
  case Repo.get(User, id) do
    nil -> {:error, :not_found}
    user -> {:ok, user}
  end
end

여기서 중요한 포인트는 reason을 단순한 원자(atom)로 끝내지 않고, 상황에 따라 구조화할 수 있다는 점입니다.

파이프라인은 만능이 아니다: |>는 성공/실패를 자동 전파하지 않는다

처음 Elixir를 배우면 다음처럼 “파이프라인으로 쭉 연결하면 에러도 흘러가겠지”라고 기대하기 쉽습니다.

# 안 좋은 예: 중간부터 형태가 달라져서 깨지기 쉬움
result =
  input
  |> parse()
  |> validate()
  |> persist()

하지만 parse/1{:ok, value}를 반환한다면, 그 다음 validate/1{:ok, value}를 입력으로 받게 됩니다. 즉, 각 함수가 같은 형태의 입력/출력을 합의하지 않으면 파이프라인은 금방 무너집니다.

그래서 Elixir에서는 “파이프라인은 순수 변환(단일 값)”에 강하고, “에러가 낄 수 있는 단계적 작업”은 보통 with로 묶어 단락 평가를 적용합니다.

with로 에러 단락 평가 만들기

with는 여러 패턴매칭을 순차로 시도하다가 하나라도 매칭이 실패하면 그 값을 그대로 반환합니다. 즉, {:error, reason}을 자연스럽게 전파하는 데 매우 적합합니다.

@spec register_user(map()) :: {:ok, map()} | {:error, term()}
def register_user(params) do
  with {:ok, attrs} <- parse_params(params),
       :ok <- validate_attrs(attrs),
       {:ok, user} <- insert_user(attrs),
       :ok <- send_welcome_email(user) do
    {:ok, user}
  end
end

여기서 핵심은 각 단계의 반환 형태를 일관되게 만드는 것입니다.

  • 실패 가능 단계는 {:ok, v} / {:error, r}
  • 단순 검증은 :ok / {:error, r}

이렇게 합의하면 with는 “실패 지점에서 즉시 종료”하며, 호출자는 최종 결과만 보면 됩니다.

else로 실패를 정규화하기

with는 실패 값을 그대로 반환하므로, 실패 형태가 제각각이면 호출자 입장에서 다루기 어려워집니다. 이때 else로 실패를 정규화합니다.

def register_user(params) do
  with {:ok, attrs} <- parse_params(params),
       :ok <- validate_attrs(attrs),
       {:ok, user} <- insert_user(attrs) do
    {:ok, user}
  else
    {:error, :invalid_email} -> {:error, %{code: :invalid_email, field: :email}}
    {:error, :duplicate} -> {:error, %{code: :duplicate_user}}
    :error -> {:error, %{code: :unknown}}
    other -> {:error, %{code: :unexpected, detail: other}}
  end
end

else를 과하게 키우면 또 다른 복잡도가 생기므로, “정규화가 필요한 경계(예: 외부 입력, 외부 API, DB)”에만 적용하는 게 좋습니다.

함수 헤드 패턴매칭으로 성공/실패 분기 숨기지 않기

패턴매칭은 case뿐 아니라 함수 정의(헤드)에서도 강력합니다. 특히 “입력 형태에 따라 다른 로직”을 깔끔하게 표현할 수 있습니다.

def normalize_result({:ok, value}), do: {:ok, value}
def normalize_result({:error, reason}), do: {:error, reason}
def normalize_result(nil), do: {:error, :not_found}

이런 작은 어댑터 함수를 두면, 라이브러리마다 다른 반환 형태를 우리 서비스의 표준 형태로 맞추기 쉬워집니다.

case는 로컬 분기, with는 선형 플로우에 최적

case는 특정 지점에서 분기하는 데 좋고, with는 여러 단계의 선형 플로우에 좋습니다. 둘을 섞을 때는 “에러 흐름의 일관성”을 우선하세요.

def ensure_admin(user_id) do
  with {:ok, user} <- fetch_user(user_id) do
    case user.role do
      :admin -> {:ok, user}
      _ -> {:error, :forbidden}
    end
  end
end

여기서 fetch_user/1의 실패는 with가 처리하고, 성공 후 권한 분기는 case가 처리합니다.

파이프라인을 살리고 싶다면: then/2와 “명시적 언래핑”

Elixir에는 then/2(또는 Kernel.then/2)를 이용해 파이프라인 중간에서 형태를 바꿀 수 있습니다. 성공/실패 튜플을 파이프라인으로 억지로 이어붙이기보다는, 중간에 한 번 “언래핑”하는 지점을 두는 방식이 안전합니다.

def register_user(params) do
  params
  |> parse_params()
  |> then(fn
    {:ok, attrs} ->
      with :ok <- validate_attrs(attrs),
           {:ok, user} <- insert_user(attrs) do
        {:ok, user}
      end

    {:error, reason} ->
      {:error, reason}
  end)
end

이 패턴은 “앞부분은 파이프라인(변환 중심), 뒷부분은 with(실패 전파 중심)”처럼 성격이 다른 단계를 분리할 때 유용합니다.

에러 컨텍스트를 잃지 않는 법: reason을 구조화하라

단순히 {:error, :invalid}만 반환하면 디버깅과 관측(로그/트레이싱)에서 정보가 부족해집니다. 다음처럼 구조화된 맵을 쓰면 호출자도, 로깅도, API 응답 변환도 쉬워집니다.

{:error, %{code: :validation_failed, field: :email, message: "invalid format"}}

또는 외부 시스템 오류는 원인을 중첩해서 들고 가는 것도 좋습니다.

{:error, %{code: :upstream_failed, service: :payments, detail: upstream_reason}}

이렇게 하면 상위 계층(예: Phoenix 컨트롤러, GraphQL 리졸버)에서 code 중심으로 HTTP 상태/에러 메시지를 일관되게 매핑할 수 있습니다.

“실패는 값”을 테스트하기: 순수 함수로 경계 분리

에러 흐름 제어가 잘 된 코드는 테스트가 쉬워집니다. 특히 DB/프로세스(GenServer) 같은 경계를 순수 함수로 분리하면, 성공/실패 케이스를 값 기반으로 빠르게 검증할 수 있습니다.

GenServer 로직을 순수 함수로 떼어내 테스트하는 접근은 아래 글과도 연결됩니다: Elixir GenServer를 순수 함수로 테스트하는 법

예를 들어 “상태 전이 + 에러”를 순수 함수로 표현하면 다음처럼 됩니다.

@spec apply_debit(%{balance: integer()}, integer()) ::
        {:ok, %{balance: integer()}} | {:error, %{code: atom()}}
def apply_debit(%{balance: bal} = account, amount) when amount > 0 do
  if bal >= amount do
    {:ok, %{account | balance: bal - amount}}
  else
    {:error, %{code: :insufficient_funds}}
  end
end

def apply_debit(_account, _amount), do: {:error, %{code: :invalid_amount}}

이 함수는 예외를 던지지 않고 결과를 반환하므로, 테스트는 “반환값 패턴”만 보면 됩니다.

test "debit fails when insufficient" do
  assert {:error, %{code: :insufficient_funds}} = apply_debit(%{balance: 10}, 20)
end

흔한 안티패턴 3가지

1) 실패를 nil로만 표현

nil은 정보가 없습니다. “왜 실패했는지”를 잃습니다. nil을 쓸 거라면 즉시 {:error, :not_found} 같은 형태로 변환하는 어댑터를 두세요.

2) 깊은 중첩 case 지옥

여러 단계가 이어질 때 case를 중첩하면 가독성이 급락합니다. 이때 with로 선형화하세요.

3) 예외를 제어 흐름으로 사용

예외는 “정말 예외적인 상황(불변식 위반, 버그, 라이브러리 오용)”에 두고, 예상 가능한 실패(검증 실패, 중복, 권한 없음)는 결과 튜플로 모델링하는 편이 유지보수에 유리합니다.

실전 예제: 컨트롤러/서비스 계층에서 에러 매핑

Phoenix 같은 웹 계층에서는 서비스 함수가 {:ok, v} / {:error, r}를 반환하면, 컨트롤러는 이를 HTTP 응답으로 매핑하기만 하면 됩니다.

def create(conn, params) do
  case Accounts.register_user(params) do
    {:ok, user} ->
      conn
      |> put_status(201)
      |> json(%{id: user.id})

    {:error, %{code: :invalid_email} = err} ->
      conn
      |> put_status(422)
      |> json(%{error: err})

    {:error, %{code: :duplicate_user}} ->
      conn
      |> put_status(409)
      |> json(%{error: %{code: :duplicate_user}})

    {:error, other} ->
      conn
      |> put_status(500)
      |> json(%{error: %{code: :internal, detail: other}})
  end
end

여기서 중요한 점은 “서비스 계층에서 에러를 구조화해두면, 웹 계층은 거의 기계적으로 매핑”할 수 있다는 것입니다.

정리: 파이프라인은 변환, 패턴매칭은 계약, with는 에러 전파

  • 파이프라인(|>)은 값 변환을 읽기 좋게 만든다.
  • 패턴매칭은 성공/실패 계약을 코드 표면으로 끌어올린다.
  • with는 여러 단계 작업에서 실패를 자연스럽게 단락 평가로 전파한다.
  • 에러를 구조화하면(예: %{code: ..., detail: ...}) 관측/테스트/응답 매핑이 쉬워진다.

이 조합에 익숙해지면 Elixir 코드가 “정상 흐름은 직선, 실패 흐름은 명시적”으로 정돈되고, 장애 대응과 리팩터링 비용이 크게 줄어듭니다.