Enum과 스트림: 파이프 연산자로 컬렉션 다루기
Enum과 스트림: 파이프 연산자로 컬렉션 다루기
엘릭서는 재귀로 코드를 짤 수도 있지만, 컬렉션에 하는 대부분의 작업은 Enum과 Stream 모듈이 도와줘요. 값의 변환·정렬·그룹화·필터링이 한 줄씩 이어지고, 그 흐름을 |> 파이프 연산자로 깔끔하게 연결할 수 있죠. 이 장에서는 즉시 실행(eager)하는 Enum과 지연 실행(lazy)하는 Stream의 차이도 함께 알아볼게요.
본문
Enum(열거 가능한 것들)
엘릭서는 enumerable이라는 개념과, 이를 다루는 Enum 모듈을 제공해요. 이미 리스트와 맵 두 가지 enumerable을 배웠죠.
iex> Enum.map([1, 2, 3], fn x -> x * 2 end)
[2, 4, 6]
iex> Enum.map(%{1 => 2, 3 => 4}, fn {k, v} -> k * v end)
[2, 12]
Enum 모듈은 enumerable에서 값을 변환·정렬·그룹화·필터링·추출하는 방대한 함수를 제공해요. 엘릭서 코드에서 개발자들이 가장 자주 쓰는 모듈 중 하나예요. Enum 모듈의 모든 함수 개요는 the Enum cheatsheet에서 볼 수 있어요.
엘릭서는 범위(Range)도 제공하는데, 이 역시 enumerable이에요.
iex> Enum.map(1..3, fn x -> x * 2 end)
[2, 4, 6]
iex> Enum.reduce(1..3, 0, &+/2)
6
Enum 모듈의 함수는 이름 그대로 데이터 구조 안의 값을 열거하는 데에만 써요. 특정 요소를 삽입하거나 갱신하는 같은 세부 작업은 데이터 타입에 맞는 모듈을 찾아야 해요. 예를 들어 리스트의 특정 위치에 요소를 넣고 싶다면 List.insert_at/3을 써야 해요. 범위(range)에 값을 넣는 건 말이 안 되니까요.
Enum의 함수는 다양한 데이터 타입에서 동작하므로 **다형적(polymorphic)**이라고 말해요. 특히 Enumerable 프로토콜을 구현한 어떤 데이터 타입에서든 동작하죠. 프로토콜은 나중 장에서 다루고, 지금은 stream이라는 특별한 종류의 enumerable로 넘어갈게요.
Eager vs Lazy (즉시 실행 vs 지연 실행)
Enum 모듈의 모든 함수는 eager(즉시 실행)예요. 대부분 enumerable을 받아서 리스트를 돌려주죠.
iex> odd? = fn x -> rem(x, 2) != 0 end
#Function<6.80484245/1 in :erl_eval.expr/5>
iex> Enum.filter(1..3, odd?)
[1, 3]
즉, Enum으로 여러 작업을 수행하면 결과에 도달할 때까지 각 작업이 중간 리스트를 만들어내요.
iex> 1..100_000 |> Enum.map(&(&1 * 3)) |> Enum.filter(odd?) |> Enum.sum()
7500000000
위 예시는 파이프라인으로 이어진 작업이에요. 범위에서 시작해 각 요소에 3을 곱하죠. 첫 작업은 100_000개 항목을 가진 리스트를 만들고 돌려줘요. 그다음 리스트에서 홀수 요소만 남겨 50_000개 항목의 새 리스트를 만들고, 마지막에 모든 항목을 더해요.
파이프 연산자
위 코드에서 쓴 |>는 파이프 연산자예요. 왼쪽 표현식의 출력을 가져와 오른쪽 함수 호출의 첫 번째 인자로 넘겨주죠. 데이터가 일련의 함수로 변환되는 흐름을 강조하는 게 목적이에요. |> 없이 위 예시를 다시 쓰면 얼마나 읽기 어려워지는지 볼게요.
iex> Enum.sum(Enum.filter(Enum.map(1..100_000, &(&1 * 3)), odd?))
7500000000
파이프 연산자에 대해 더 자세히 알고 싶다면 Kernel pipe operator 문서를 읽어보세요.
Stream(스트림)
Enum의 대안으로, 엘릭서는 지연(lazy) 연산을 지원하는 Stream 모듈을 제공해요.
iex> 1..100_000 |> Stream.map(&(&1 * 3)) |> Stream.filter(odd?) |> Enum.sum()
7500000000
스트림은 지연되고(lazy) 합성 가능한(composable) enumerable이에요.
위 예시에서 1..100_000 |> Stream.map(&(&1 * 3))는 실제 스트림, 즉 1..100_000 범위에 map 계산을 나타내는 데이터 타입을 돌려줘요.
iex> 1..100_000 |> Stream.map(&(&1 * 3))
#Stream<[enum: 1..100000, funs: [#Function<34.16982430/1 in Stream.map/2>]]>
게다가 스트림 작업을 여러 개 파이프로 이을 수 있으니 합성 가능하죠.
iex> 1..100_000 |> Stream.map(&(&1 * 3)) |> Stream.filter(odd?)
#Stream<[enum: 1..100000, funs: [...]]>
스트림은 중간 리스트를 만들지 않는 대신, 기본 스트림을 Enum 모듈에 넘길 때 호출되는 일련의 계산을 쌓아둬요. 스트림은 크고 어쩌면 무한한 컬렉션을 다룰 때 유용해요.
Stream 모듈의 많은 함수는 아무 enumerable을 받아 스트림을 돌려줘요. 스트림을 만드는 함수도 제공하죠. 예를 들어 Stream.cycle/1은 주어진 enumerable을 무한히 반복하는 스트림을 만들어요. 이런 스트림에 Enum.map/2 같은 함수를 호출하지 않도록 주의해야 해요. 영원히 반복될 테니까요.
iex> stream = Stream.cycle([1, 2, 3])
#Function<15.16982430/2 in Stream.unfold/2>
iex> Enum.take(stream, 10)
[1, 2, 3, 1, 2, 3, 1, 2, 3, 1]
또 하나 흥미로운 함수는 Stream.resource/3이에요. 리소스를 감싸서, 열거 직전에 열리고(open) 실패가 나더라도 이후에 닫히는 것을 보장해줘요. 예를 들어 File.stream!/1은 Stream.resource/3 위에 만들어져 파일을 스트리밍해요.
iex> "path/to/file" |> File.stream!() |> Enum.take(10)
위 예시는 선택한 파일의 첫 10줄을 가져와요. 큰 파일이나 네트워크 같은 느린 리소스를 다룰 때 스트림이 아주 유용한 이유예요.
Enum과 Stream 모듈은 범위가 넓은 함수를 제공하지만, 전부 외울 필요는 없어요. Enum.map/2, Enum.reduce/3처럼 이름에 map이나 reduce가 들어간 함수에 먼저 익숙해지면, 가장 중요한 사용 사례에 대한 감각이 자연스럽게 생겨요. 먼저 Enum 모듈에 집중하고, 느린 리소스나 크고 어쩌면 무한한 컬렉션처럼 지연 실행이 꼭 필요한 특정 상황에서만 Stream으로 넘어가도 돼요.