스택큐힙리스트

Kotlin Sequence 내부 구현으로 배우는 Lazy Iterator 원리 본문

개발

Kotlin Sequence 내부 구현으로 배우는 Lazy Iterator 원리

스택큐힙리스트 2025. 7. 29. 08:28
반응형

1. Sequence가 List·Set과 다른 진짜 이유

Sequence<T>는 데이터를 담지 않고 “필요할 때 만들어” 내보냅니다. filter → map → take 같은 여러 단계도 요소당 한 번씩 흐르기 때문에 중간 컬렉션이 전혀 생기지 않죠. Kotlin 공식 문서가 강조하듯, 이는 eager 컬렉션과 구조적으로 다릅니다.

한마디로: 이터레이터를 감춘 파이프라인.


2. 파이프라인 안쪽: TransformingSequence · FilteringSequence

  • 모든 중간 연산(map / filter 등)은 새 Sequence 구현체를 리턴합니다.
  • map()을 호출하면 TransformingSequence가 생성되어 원본 sequence와 lambda를 보관만 합니다.
  • 실제 변환은 내부 Iterator의 next()가 불릴 때 실행됩니다.
public fun <T, R> Sequence<T>.map(
    transform: (T) -> R
): Sequence<R> = TransformingSequence(this, transform)

TransformingSequence.iterator()는 원본 iterator를 들고 있다가, next()가 호출되면
transformer(it)로 값을 가공해 넘겨줍니다. 즉 “pull” 모델이라 불필요한 요소는 건드리지 않습니다.


3. 진짜 Lazy의 비밀 — SequenceBuilderIterator와 코루틴

sequence { … } 빌더 안에서 yield()를 쓰면 경량 코루틴이 생성됩니다.
SequenceBuilderIterator가 Iterator + SequenceScope를 함께 구현하고,
yield() 시점에서 suspend → 소비자에게 값 반환 → 다음 next() 때 이어서 실행 흐름을 만듭니다.
이 덕분에 무한 시퀀스도 메모리 걱정 없이 다룰 수 있죠.


4. 중간 vs 종단 연산 – 호출 순간이 다르다

  • Intermediate: filter, map, distinct … → 또 다른 Sequence 반환, 아직 실행 안 됨.
  • Terminal: first, toList, sum … → 그제서야 iterator 루프가 돌며 모든 연산 수행.
    이 디자인 덕분에 take(1) 같은 종단 연산이 앞쪽에 오면 필요한 만큼만 작업하고 멈춥니다.

5. 퍼포먼스·메모리 관점 팁

  • 작은 컬렉션 + 1~2 단계라면 일반 List 연산이 인라인 최적화 덕분에 더 빠를 수도.
  • 체인이 길거나 데이터가 크면 Sequence로 중간 객체 할당을 0으로 줄이는 게 유리.
  • Sequence마다 객체를 하나씩 만들기 때문에 람다 캡처가 많은 경우에는 GC 압박이 생길 수 있다.
  • 일부 Sequence(예: iterator {} 빌더)는 1회 순회 전제이므로, 재사용하려면 .toList() 등으로 복사하거나 별도 시퀀스를 새로 만들어야 한다.

6. 알아두면 좋은 내부 클래스 스냅샷

  • TransformingSequence – map·flatMap류
  • FilteringSequence – filter·takeWhile류
  • FlatteningSequence – flatMap 내부에서 다시 iterator 뽑을 때 사용
  • ConstrainedOnceSequence – “한 번만 돌 수 있다”는 계약을 명시
  • SequenceBuilderIterator – sequence {} 코루틴 빌더의 핵심

이들은 모두 Iterator 구현체를 생성할 때 로직을 실행하므로, JVM JIT가 인라이닝 최적화를 쉽게 적용할 수 있다는 장점도 함께 가져갑니다.


7. 실전 코드 패턴

// 기존 리스트를 지연 평가 파이프라인으로
val lazy = listOf(1, 2, 3, 4, 5)
    .asSequence()
    .filter { it % 2 == 1 }   // 아직 실행 안 됨
    .map { it * it }

println(lazy.first())         // 1만 계산
println(lazy.sum())           // 남은 9 + 25 계산

first() 호출 직후 파이프라인이 끊기므로 1·9·25 세 값만 가공했다는 점이 핵심!


8. 마무리 – 언제 Sequence를 쓸까?

상황 추천
대량 데이터 스트림, 무한 스트림 ✅ Sequence
데이터 적고 연산 단계도 1~2개 🟡 컬렉션이 더 빠를 수도
연산 순서를 최적으로 배치해 필터 → take 처럼 일찍 끊기고 싶을 때 ✅ Sequence
반응형
Comments