- Published on
Kotlin Sequence에서 map이 두 번 실행될 때
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Kotlin에서 Sequence는 컬렉션과 달리 지연 평가(lazy evaluation) 를 기본으로 합니다. 그래서 map 같은 중간 연산은 그 시점에 실행되지 않고, toList(), count(), first() 같은 터미널 연산(terminal operation) 이 호출될 때 실제로 수행됩니다.
문제는 여기서 시작합니다. 같은 Sequence 파이프라인을 두 번 소비(consumption) 하면, map도 두 번 실행 됩니다. map 안에 로그, 메트릭 증가, DB 조회 같은 부작용(side effect)이나 비싼 연산이 있다면, 의도치 않은 중복 호출로 성능/정합성 문제가 생깁니다.
이 글에서는 “왜 두 번 실행되는가”를 정확히 설명하고, 중복 평가와 부작용을 방지하는 대표적인 패턴을 코드로 정리합니다.
참고로 이런 문제는 UI/서버 렌더링에서도 동일한 형태로 나타납니다. 예를 들어 React/Next.js에서 의존성 관리가 잘못되면 같은 계산이 여러 번 재실행되어 렌더링 폭증이 발생합니다. 개념적으로는 "지연된 계산을 어디서, 몇 번 소비하느냐" 의 문제라는 점에서 닮아 있습니다. 필요하면 Next.js App Router 렌더링 폭증 진단 - RSC 캐시·useMemo도 같이 보면 도움이 됩니다.
왜 Sequence.map이 두 번 실행되나
핵심은 Sequence가 반복자 기반의 계산 그래프 라는 점입니다. map은 값을 미리 만들어 두지 않고, “다음 원소가 필요할 때 변환 함수를 적용하라”는 규칙만 연결합니다.
즉 아래 코드에서 map은 선언 시점에 실행되지 않습니다.
val seq = sequenceOf(1, 2, 3)
.map { x ->
println("map: $x")
x * 2
}
실제 실행은 소비 시점에 일어납니다.
val a = seq.toList() // 여기서 map 실행
val b = seq.toList() // 여기서 map 또 실행
println(a)
println(b)
출력은 대략 다음과 같습니다.
map: 1
map: 2
map: 3
map: 1
map: 2
map: 3
“같은 Sequence인데 왜 다시 실행되지?”
Sequence는 기본적으로 결과를 캐시하지 않습니다. seq는 “계산을 수행하는 방법”을 들고 있을 뿐이고, toList()를 호출할 때마다 새 이터레이션이 시작되면서 map이 다시 적용됩니다.
여기서 중요한 구분이 있습니다.
- Sequence를 두 번 소비:
map이 두 번 실행될 수 있음 - Iterator를 두 번 소비: 대부분 불가능(한 번 소모하면 끝)
Sequence는 매 소비마다 내부적으로 새로운 Iterator를 만들어 낼 수 있기 때문에 “두 번 소비”가 가능합니다. 그 결과 map도 매번 다시 실행됩니다.
중복 평가가 위험해지는 전형적 상황
1) map 내부에 부작용이 있는 경우
var counter = 0
val seq = (1..3).asSequence()
.map {
counter++
it * 10
}
seq.toList()
seq.toList()
println(counter) // 6
부작용이 누적되면서 결과가 달라지거나, 외부 시스템 호출이 중복될 수 있습니다.
2) 비싼 연산/IO가 map에 있는 경우
fun fetchUserName(id: Long): String {
Thread.sleep(100) // 네트워크/DB 대기라고 가정
return "user-$id"
}
val names = (1L..5L).asSequence().map { fetchUserName(it) }
val firstPass = names.take(3).toList()
val secondPass = names.take(3).toList() // 동일 fetch가 또 일어남
3) 서로 다른 터미널 연산을 연달아 호출하는 경우
val seq = (1..100).asSequence().map { it * 2 }
val cnt = seq.count() // map 실행
val sum = seq.sum() // map 또 실행
이 패턴은 특히 “한 번 계산한 걸 재활용하고 싶은데”라는 요구가 생길 때 자주 터집니다.
방지법 1: 한 번만 소비하도록 구조 바꾸기
가장 안전한 방법은 터미널 연산을 한 번만 호출 하도록 로직을 재구성하는 것입니다.
예를 들어 count()와 sum()을 따로 호출하지 말고, 한 번의 루프로 필요한 값을 동시에 계산합니다.
data class Stats(val count: Int, val sum: Long)
fun statsOf(seq: Sequence<Int>): Stats {
var c = 0
var s = 0L
for (x in seq) {
c += 1
s += x
}
return Stats(c, s)
}
val seq = (1..100).asSequence().map { it * 2 }
val st = statsOf(seq)
장점: 중복 평가가 구조적으로 불가능
단점: 코드가 다소 명령형이 되며, 재사용성이 떨어질 수 있음
방지법 2: materialize 해서 캐시하기 (toList, toSet)
중복 소비가 필요하다면, 지연 평가를 끝내고 값을 컬렉션으로 고정(materialize) 하는 게 가장 흔한 해결책입니다.
val cached: List<Int> = (1..3).asSequence()
.map {
println("map: $it")
it * 2
}
.toList() // 여기서 한 번만 실행되고 결과가 저장됨
val a = cached
val b = cached
이제 a, b는 같은 리스트를 참조하므로 map은 재실행되지 않습니다.
언제 toList()가 정답인가
- 결과 크기가 크지 않음
- 이후에 여러 번 조회/집계가 필요함
map내부가 비싸거나 부작용이 있음
주의점:
- 메모리 사용량이 증가
- 무한 시퀀스에는 사용 불가
방지법 3: Sequence를 함수로 감싸 “매번 새로 만든다”를 명시하기
중복 실행이 의도된 것 이라면, 차라리 이를 코드에 드러내는 편이 좋습니다. Sequence 값을 공유하지 말고 “생성 함수”로 두는 방식입니다.
fun mappedSeq(): Sequence<Int> = (1..3).asSequence()
.map {
println("map: $it")
it * 2
}
val a = mappedSeq().toList()
val b = mappedSeq().toList()
이렇게 하면 독자는 “이건 매번 새로 계산하는구나”를 즉시 이해합니다.
방지법 4: 부작용을 map에서 분리하고 onEach로 의도를 표현하기
부작용이 꼭 필요하다면 map에 섞기보다 onEach를 써서 “변환”과 “관찰/로깅”을 분리하세요.
val seq = (1..3).asSequence()
.onEach { println("seen: $it") }
.map { it * 2 }
다만 이것은 중복 실행 자체를 막지는 못합니다. 대신 다음을 얻습니다.
map은 순수 변환으로 유지- 부작용이 어디서 발생하는지 명확
중복 실행을 막으려면 여전히 toList() 같은 캐싱이 필요합니다.
방지법 5: “한 번만 계산하고 여러 번 소비”가 필요하면 공유(share) 계층을 만든다
Kotlin 표준 Sequence에는 Rx/Flow처럼 share 연산자가 기본 제공되진 않습니다. 하지만 요구사항이 “비싼 계산을 한 번만 수행하고 여러 소비자에게 제공”이라면, 보통 아래 두 선택지 중 하나로 갑니다.
선택지 A: 컬렉션으로 캐시 후 재사용
가장 단순하고 실무에서 제일 많이 씁니다.
val shared = sourceSeq
.map { expensive(it) }
.toList()
val report1 = shared.filter { it.isValid }
val report2 = shared.groupBy { it.type }
선택지 B: Flow로 바꾸고 shareIn/stateIn 사용
비동기 스트림이거나, 구독자가 여러 개이고, 백프레셔/취소가 중요하면 Sequence보다 Flow가 맞습니다.
import kotlinx.coroutines.flow.*
val sharedFlow = sourceFlow
.map { expensive(it) }
.shareIn(scope, started = SharingStarted.WhileSubscribed(), replay = 0)
이 방식은 구조가 커지지만, “중복 실행 방지”를 스트림 레벨에서 다룰 수 있습니다.
디버깅 팁: 중복 실행을 빠르게 확인하는 방법
1) map 안에서 호출 횟수 카운트
var calls = 0
val seq = (1..5).asSequence().map { calls++; it * 2 }
seq.take(2).toList()
seq.take(2).toList()
println("calls=$calls") // 4
2) 터미널 연산이 몇 번 호출되는지 먼저 확인
중복 실행의 직접 원인은 대부분 “터미널 연산을 여러 번 호출”하는 구조입니다.
toList()를 두 번 호출했는가count()후any()같은 걸 또 호출했는가- 로깅/디버깅을 위해
toList()를 찍고 다시 본 처리를 했는가
이런 패턴은 서버 사이드 렌더링에서 계산이 반복되는 문제와도 닮아 있습니다. 계산 결과를 캐시하거나 메모이제이션하는 접근은 프론트엔드에서도 흔하며, 필요하면 Next.js RSC에서 Zustand 상태 분리로 렌더링 최적화 같은 글의 사고방식을 참고할 수 있습니다.
실전 가이드: 어떤 선택을 해야 하나
- 중복 소비가 필요 없다: 터미널 연산을 한 번만 호출하도록 재구성
- 중복 소비가 필요하고 결과가 유한/작다:
toList()로 materialize - 중복 소비가 의도된 재계산이다:
Sequence를 값으로 두지 말고 생성 함수로 표현 - 부작용이 있다:
map에서 분리(onEach) + 필요 시 캐싱 - 비동기/다중 구독/공유가 중요:
Flow로 전환 후 공유 연산 활용
결론
Sequence에서 map이 두 번 실행되는 건 버그가 아니라 지연 평가 모델의 정상 동작 입니다. 문제는 우리가 종종 Sequence를 “한 번 계산된 결과”처럼 취급하면서 생깁니다.
해결의 핵심은 간단합니다.
- “몇 번 소비되는가”를 먼저 파악하고
- 한 번만 소비하도록 바꾸거나
- 필요하면 결과를 materialize 해서 캐시하고
- 부작용은 변환 로직에서 분리한다
이 네 가지 원칙만 지켜도, 중복 평가로 인한 성능 저하와 예기치 않은 부작용을 대부분 제거할 수 있습니다.