Published on

Elixir 함수형 파이프라인 최적화 - Stream vs Enum

Authors

Elixir에서 데이터 변환 로직은 보통 파이프라인(|>)으로 표현합니다. 문제는 파이프라인이 길어질수록 중간 리스트가 몇 번 생성되는지, 언제 실제 계산이 수행되는지, 얼마나 빨리 멈출 수 있는지에 따라 성능과 메모리 사용량이 크게 달라진다는 점입니다.

이 글은 EnumStream의 차이를 “게으른 평가(lazy) vs 즉시 평가(eager)” 같은 교과서적 문장으로 끝내지 않고, 파이프라인 최적화 관점에서 어떤 패턴이 병목을 만드는지, 그리고 어떤 조합이 가장 비용 대비 효과가 좋은지를 코드로 정리합니다.

Enum vs Stream: 핵심 차이 3가지

1) 실행 시점: 즉시 실행 vs 지연 실행

  • Enum은 호출 즉시 전체 컬렉션을 순회하며 결과를 만듭니다.
  • Stream은 “연산 계획”을 쌓아두고, 최종적으로 소비되는 순간에 한 원소씩 흘려보냅니다.

즉, Stream.map/2, Stream.filter/2를 여러 번 이어도 중간 리스트를 만들지 않고, 최종 소비(Enum.to_list/1, Enum.sum/1, Enum.take/2 등) 시점에만 실제로 계산합니다.

2) 메모리 모델: 중간 리스트 생성 vs 상수에 가까운 메모리

Enum 파이프라인은 각 단계가 새 리스트를 만들기 쉽습니다.

list = 1..1_000_000 |> Enum.to_list()

result =
  list
  |> Enum.map(&(&1 * 2))
  |> Enum.filter(&(rem(&1, 3) == 0))
  |> Enum.map(&(&1 + 1))

위 코드는 단계별로 큰 리스트가 여러 번 생길 수 있습니다(가비지 컬렉션 부담도 증가). 반면 Stream으로 바꾸면 소비 시점까지는 중간 리스트가 거의 생기지 않습니다.

list = 1..1_000_000 |> Enum.to_list()

result =
  list
  |> Stream.map(&(&1 * 2))
  |> Stream.filter(&(rem(&1, 3) == 0))
  |> Stream.map(&(&1 + 1))
  |> Enum.to_list()

중요한 현실 포인트는, 마지막에 Enum.to_list/1로 전부 물질화하면 결국 결과 리스트는 만들어집니다. 다만 중간 결과 리스트를 만들지 않는 것이 핵심 이득입니다.

3) 조기 종료: 얼마나 빨리 멈출 수 있나

Stream은 한 원소씩 처리하므로 Enum.take/2, Enum.find/2, Enum.reduce_while/3 같은 소비 연산과 결합하면 필요한 만큼만 계산하고 멈출 수 있습니다.

first_10 =
  1..10_000_000
  |> Stream.map(&(&1 * 2))
  |> Stream.filter(&(rem(&1, 7) == 0))
  |> Enum.take(10)

이 코드는 1천만 개를 다 계산하지 않습니다. 조건을 만족하는 10개를 찾는 순간 멈춥니다.

파이프라인에서 흔히 하는 실수: Enum 연쇄로 중간 리스트 폭증

다음은 실무에서 정말 자주 보는 패턴입니다.

users
|> Enum.filter(& &1.active)
|> Enum.map(& &1.email)
|> Enum.uniq()
|> Enum.sort()

데이터가 작으면 문제 없습니다. 하지만 users가 수십만 이상이면:

  • filter 결과 리스트
  • map 결과 리스트
  • uniq 내부 자료구조 및 결과 리스트
  • sort를 위한 전체 물질화

가 누적됩니다.

개선 1: 중간 변환은 Stream, 마지막만 Enum

users
|> Stream.filter(& &1.active)
|> Stream.map(& &1.email)
|> Enum.uniq()
|> Enum.sort()

여기서도 uniq, sort는 전체를 알아야 하므로 결국 물질화가 필요합니다. 하지만 filtermap의 중간 리스트는 제거됩니다.

개선 2: “전체 정렬”이 정말 필요한지 다시 묻기

정렬은 비용이 큽니다. 상위 N개만 필요하면 전부 정렬하지 말고, 요구사항 자체를 바꾸거나 알고리즘을 바꾸는 게 더 큽니다.

예를 들어 “조건을 만족하는 이메일 100개만 필요”라면:

users
|> Stream.filter(& &1.active)
|> Stream.map(& &1.email)
|> Stream.uniq()
|> Enum.take(100)

단, Enum.uniq/1는 내부적으로 전체를 다 보지 않아도 동작할 것 같지만, 실제로는 중복 제거를 위해 상태를 유지하며 진행합니다. 그래도 take와 함께면 100개를 채우는 순간 멈추므로, 데이터 분포에 따라 큰 이득이 납니다.

이런 “조기 종료 설계”는 레이트리밋/쿼터 환경에서 재시도 비용을 줄이는 사고방식과도 닮아 있습니다. 대규모 외부 호출 파이프라인을 설계할 때는 이 글도 같이 보면 좋습니다: Gemini API 429 쿼터·레이트리밋 재시도 설계

Stream을 썼는데 느려졌다? 그럴 수 있습니다

Stream은 만능이 아닙니다. 다음 케이스에서는 Enum이 더 빠를 수 있습니다.

1) 데이터가 작고, 파이프라인이 짧다

Stream은 지연 평가를 위한 구조체와 클로저 체인을 만듭니다. 작은 리스트에서는 오버헤드가 이득을 상쇄할 수 있습니다.

2) 어차피 전체를 한 번에 물질화해야 한다

최종적으로 Enum.to_list/1로 전부 만들고, 중간 단계도 1~2개뿐이라면 Enum이 더 단순하고 빠를 수 있습니다.

3) 디버깅/로깅을 잘못 끼워 넣었다

IO.inspect/2를 스트림 중간에 넣으면 “언제 실행되는지”가 헷갈릴 수 있습니다.

stream =
  1..5
  |> Stream.map(fn x ->
    IO.inspect(x, label: "mapped")
    x * 2
  end)

# 여기서야 출력이 발생
Enum.to_list(stream)

지연 실행 특성을 모르면 “왜 로그가 안 찍히지?” 같은 혼란이 생깁니다.

실전 레시피: Stream으로 만들고, Enum으로 끝내라

가장 안전한 기본 전략은 이겁니다.

  • 변환(map, filter, flat_map)은 Stream
  • 최종 소비(sum, to_list, take, find, reduce)는 Enum

예시: 큰 로그 라인에서 에러 코드만 뽑아 상위 20개만 보고 싶을 때

lines = File.stream!("./app.log")

lines
|> Stream.filter(&String.contains?(&1, "ERROR"))
|> Stream.map(fn line ->
  # 예: "... code=E123 ..." 형태라고 가정
  case Regex.run(~r/code=(\w+)/, line) do
    [_, code] -> code
    _ -> nil
  end
end)
|> Stream.reject(&is_nil/1)
|> Enum.take(20)

여기서 핵심은 File.stream!/1 자체가 스트림이라는 점입니다. 파일 전체를 메모리에 올리지 않고도 처리할 수 있습니다.

조기 종료가 필요한 곳: reduce_while 패턴

“조건 만족 시 즉시 중단”은 성능 최적화에서 가장 강력한 무기 중 하나입니다.

result =
  1..10_000_000
  |> Stream.map(&(&1 * 3))
  |> Enum.reduce_while([], fn x, acc ->
    if rem(x, 11) == 0 do
      {:halt, [x | acc]}
    else
      {:cont, acc}
    end
  end)

Enum.reduce_while/3는 스트림을 소비하면서 진행하다가 :halt를 만나면 즉시 멈춥니다.

이런 중단 설계는 “블로킹 때문에 전체가 멈춰 서는” 문제를 피하는 관점과도 연결됩니다. IO가 섞이는 파이프라인에서 멈춤/병목을 진단하는 사고법은 Rust 사례지만 참고가 됩니다: Rust Tokio runtime 멈춤? 블로킹 I/O 진단법

Stream에서 조심할 점: 리소스 수명과 단발성 스트림

1) 스트림은 재사용이 아니라 “재소비” 문제를 봐야 한다

스트림은 본질적으로 “열거 가능한 연산”입니다. 어떤 스트림은 여러 번 소비할 수 있지만, 파일/소켓처럼 외부 리소스 기반 스트림은 소비 타이밍이 중요합니다.

s = File.stream!("./app.log")

# 첫 소비
Enum.take(s, 1)

# 두 번째 소비: 파일을 다시 여는 게 아니라 같은 스트림을 다시 열거하려고 하며,
# 기대와 다르게 동작하거나 이미 진행된 상태로 보일 수 있습니다.
Enum.take(s, 1)

안전하게 하려면 “스트림을 변수로 오래 들고 있기”보다, 필요한 범위에서 바로 소비하거나, 다시 읽어야 한다면 스트림을 다시 생성하는 방식이 더 명확합니다.

2) Stream.chunk_every/2와 메모리

chunk_every는 청크 단위로 버퍼링합니다. 큰 청크를 잡으면 메모리 이점이 줄어듭니다. 반대로 너무 작은 청크는 오버헤드가 커질 수 있어 적절한 균형이 필요합니다.

1..1_000_000
|> Stream.chunk_every(10_000)
|> Stream.map(fn chunk -> Enum.sum(chunk) end)
|> Enum.to_list()

벤치마크로 감 잡기: Benchee 사용

최적화는 감이 아니라 측정으로 해야 합니다. Elixir에서는 benchee가 사실상 표준입니다.

# mix.exs에 {:benchee, "~> 1.3", only: :dev} 추가 후

list = Enum.to_list(1..1_000_000)

Benchee.run(%{
  "enum" => fn ->
    list
    |> Enum.map(&(&1 * 2))
    |> Enum.filter(&(rem(&1, 3) == 0))
    |> Enum.take(100)
  end,
  "stream" => fn ->
    list
    |> Stream.map(&(&1 * 2))
    |> Stream.filter(&(rem(&1, 3) == 0))
    |> Enum.take(100)
  end
})

여기서 중요한 비교 포인트는 take(100)처럼 조기 종료가 들어갈 때 Stream이 크게 이길 가능성이 높다는 점입니다. 반면 전부 to_list로 물질화하면 차이가 줄거나, 데이터가 작으면 Enum이 이길 수도 있습니다.

선택 가이드: 언제 무엇을 쓰나

  • 입력이 크고, 변환 단계가 여러 개이며, 일부만 필요하거나 조기 종료가 가능하다: Stream 중심 + 마지막에 Enum 소비
  • 입력이 작고 단순하다: Enum이 더 단순하고 빠를 수 있음
  • 정렬/유니크/그룹핑처럼 전체를 알아야 하는 연산이 핵심이다: Stream으로 중간 비용은 줄이되, 근본적으로는 “전체 물질화 비용”이 남는다는 점을 인정하고 알고리즘/요구사항을 재검토
  • IO(파일, 네트워크)가 섞인다: File.stream!/1 같은 스트리밍을 활용하되, 소비 시점과 리소스 수명에 주의

마무리: 파이프라인 최적화의 본질은 “언제, 얼마나” 계산하느냐

EnumStream의 차이는 단순히 “lazy vs eager”가 아니라, 파이프라인을 설계할 때 중간 결과를 생성할지, 필요한 만큼만 계산하고 멈출지, 리소스를 어떤 생명주기로 다룰지를 결정하는 도구 차이입니다.

가장 실용적인 결론은 다음 한 줄로 요약됩니다.

  • 변환은 Stream으로 쌓고, 결과는 Enum으로 소비하되, 조기 종료(take, find, reduce_while)를 적극적으로 설계하라.

이 원칙만 지켜도 Elixir 파이프라인의 메모리 스파이크와 불필요한 CPU 사용을 꽤 자주 줄일 수 있습니다.