- Published on
Kotlin Sequence vs Java Stream - 지연평가 버그 5가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Kotlin을 쓰다 보면 Sequence를, Java 코드나 라이브러리와 섞이면 Stream을 자연스럽게 만나게 됩니다. 둘 다 “지연평가(lazy evaluation)”를 전면에 내세우지만, 평가 시점, 재사용 가능성, 부작용 처리, 리소스 수명, 병렬 처리 모델이 달라서 똑같이 생긴 파이프라인이 전혀 다른 결과를 내기도 합니다.
이 글은 Kotlin Sequence와 Java Stream을 비교하면서, 지연평가 때문에 실무에서 실제로 터지는 버그 5가지를 재현 코드와 함께 정리합니다. 마지막에는 예방 체크리스트도 제공합니다.
지연평가 버그는 “코드는 맞아 보이는데 운영에서만 이상하다” 유형이 많습니다. 비슷한 결의 디버깅 글로는 Next.js App Router 렌더링 폭증 진단 - RSC 캐시·useMemo, OpenAI 429 Rate Limit 해결 - 백오프·큐·배치도 함께 참고하면 좋습니다.
Kotlin Sequence와 Java Stream 핵심 차이 요약
공통점
- 중간 연산(
map,filter)은 보통 지연되고, 최종 연산(toList,collect,forEach,count)에서 실행됩니다. - 파이프라인을 조합해 불필요한 계산을 줄일 수 있습니다.
차이점(버그로 이어지는 지점)
- Stream은 AutoCloseable이며, I/O나 DB 커서 같은 리소스를 물고 있을 수 있습니다.
- **Stream은 1회 소비(one-shot)**가 원칙입니다. 최종 연산 후 재사용하면 예외가 납니다.
- Sequence는 재사용 가능처럼 보이지만, 내부에서 캡처한 이터레이터나 상태에 따라 사실상 1회성처럼 동작할 수 있습니다.
- Stream 병렬화는
parallel()로 쉽게 켤 수 있지만, 부작용이 섞이면 결과가 비결정적이기 쉽습니다.
버그 1) “안 돌았는데요?”: 최종 연산 누락으로 사이드 이펙트 미발생
지연평가 파이프라인에 로그, 메트릭, 캐시 갱신 같은 부작용을 넣고 “실행됐다”고 착각하는 케이스입니다. 실제로는 최종 연산이 없으면 아무것도 실행되지 않습니다.
Kotlin 재현
fun main() {
val seq = listOf(1, 2, 3).asSequence()
.map {
println("map: $it")
it * 2
}
// 여기서는 아무것도 출력되지 않는다
// seq만 만들고 소비하지 않았기 때문
// 최종 연산이 있어야 실행된다
val result = seq.toList()
println(result)
}
Java 재현
import java.util.*;
public class Main {
public static void main(String[] args) {
var stream = List.of(1,2,3).stream()
.map(x -> {
System.out.println("map: " + x);
return x * 2;
});
// 여기서는 아무것도 출력되지 않는다
var result = stream.toList();
System.out.println(result);
}
}
예방 패턴
- 부작용은 가급적 파이프라인 밖으로 빼고, 파이프라인은 “값 변환”에만 쓰기
- 정말 필요하다면
onEach(Kotlin) 또는peek(Java)를 쓰되, 반드시 최종 연산이 뒤따르게 하기
버그 2) “두 번 쓰면 터진다”: Stream 재사용으로 IllegalStateException
Java Stream은 최종 연산을 한 번 수행하면 닫힌 것으로 취급됩니다. 같은 변수를 다시 쓰면 런타임 예외가 발생합니다. 지연평가 때문에 “아직 안 돈 것 같은데”라는 착시가 있어 더 자주 밟습니다.
Java 재현
import java.util.*;
public class Main {
public static void main(String[] args) {
var s = List.of(1,2,3).stream().filter(x -> x > 1);
long c1 = s.count();
System.out.println(c1);
// 두 번째 최종 연산: IllegalStateException
long c2 = s.count();
System.out.println(c2);
}
}
Kotlin에서는 왜 덜 터지나
Kotlin Sequence는 보통 “다시 순회 가능”한 형태로 만들어지기 때문에 같은 변수로 여러 번 toList()를 호출해도 동작하는 경우가 많습니다.
하지만 이것도 항상 안전한 건 아닙니다. 아래 버그 3처럼 “실제로는 1회성인 소스”를 감싸면 Kotlin에서도 동일한 문제가 발생합니다.
예방 패턴
- Stream은 변수로 들고 다니지 말고, 필요하면 **공급자(supplier)**로 감싸기
import java.util.function.Supplier;
import java.util.stream.Stream;
Supplier<Stream<Integer>> supplier = () -> List.of(1,2,3).stream().filter(x -> x > 1);
long c1 = supplier.get().count();
long c2 = supplier.get().count();
- 또는 처음부터
toList()로 물질화(materialize)해서 재사용하기
버그 3) “Sequence도 1회성이다”: Iterator 캡처로 데이터 유실
Kotlin Sequence는 “지연 평가 + 반복 가능”처럼 느껴지지만, 구현을 잘못하면 내부에서 Iterator를 캡처해서 사실상 1회성이 됩니다. 특히 외부 시스템에서 받은 Iterator를 그대로 감싸는 경우가 위험합니다.
Kotlin 재현
fun main() {
val it = listOf(1, 2, 3).iterator()
// 잘못된 패턴: iterator를 캡처해버림
val seq: Sequence<Int> = Sequence { it }
println(seq.toList()) // [1, 2, 3]
println(seq.toList()) // [] 이미 iterator가 소진됨
}
왜 지연평가가 문제를 키우나
Sequence { it }는 “필요할 때 이터레이터를 준다”처럼 보이지만, 실제로는 항상 같은 이터레이터 인스턴스를 반환합니다. 첫 소비가 끝나면 두 번째 소비는 빈 결과가 됩니다.
올바른 패턴
- 매번 새로운 이터레이터를 생성하도록 만들기
fun main() {
val source = listOf(1, 2, 3)
val seq: Sequence<Int> = Sequence { source.iterator() }
println(seq.toList()) // [1, 2, 3]
println(seq.toList()) // [1, 2, 3]
}
- 외부 커서, 네트워크 페이지네이터처럼 “본질적으로 1회성”이면, 애초에
Sequence를 재사용하려 하지 말고 결과를 물질화하거나 supplier 패턴을 쓰기
버그 4) “리소스가 먼저 닫혔다”: Stream 지연평가와 try-with-resources의 조합
Java Stream은 파일, DB 커서 같은 자원을 물고 있는 경우가 많습니다. 지연평가 때문에 파이프라인을 만들어 놓고, 실제 소비는 블록 밖에서 하게 되면 이미 닫힌 리소스를 읽게 됩니다.
Java 재현: 파일 라인 Stream
import java.nio.file.*;
import java.io.*;
import java.util.stream.*;
public class Main {
static Stream<String> bad(Path p) throws IOException {
try (Stream<String> lines = Files.lines(p)) {
// 지연평가 파이프라인만 구성하고 반환
return lines.filter(s -> !s.isBlank());
}
}
public static void main(String[] args) throws Exception {
var s = bad(Path.of("./app.log"));
// 여기서 소비하려 하면 이미 lines가 닫혀있다
System.out.println(s.count());
}
}
이 코드는 런타임에 IllegalStateException: stream has already been operated upon or closed 류로 터지거나, 구현에 따라 더 미묘한 실패로 이어질 수 있습니다.
해결
- Stream을 반환하지 말고, 블록 안에서 최종 연산까지 끝내기
static long good(Path p) throws IOException {
try (var lines = Files.lines(p)) {
return lines.filter(s -> !s.isBlank()).count();
}
}
- 또는 정말로 “지연된 소비”를 외부로 넘겨야 한다면, Stream이 아니라
Iterable이나 callback 기반 API로 설계하기
이 문제는 운영 장애로 이어지기 쉽습니다. 리소스 수명과 지연 실행이 꼬이면 증상이 간헐적이라 원인 추적이 어려운데, 비슷한 형태의 “환경에서만 터지는” 문제를 추적하는 방법론은 systemd 서비스가 계속 재시작될 때 원인 추적법도 참고할 만합니다.
버그 5) “순서가 바뀌었다/중복됐다”: 병렬 Stream과 부작용의 결합
Java Stream은 parallel() 한 줄로 병렬화가 됩니다. 문제는 지연평가 파이프라인에 부작용이 섞이면, 실행 순서가 보장되지 않아 로그 순서가 꼬이거나, 공유 컬렉션에 중복/누락이 생기거나, 레이스 컨디션이 터집니다.
Java 재현: 공유 리스트에 추가
import java.util.*;
public class Main {
public static void main(String[] args) {
var out = new ArrayList<Integer>();
// 잘못된 패턴: 병렬 + 공유 mutable 상태
List.of(1,2,3,4,5,6,7,8,9,10)
.parallelStream()
.map(x -> x * 2)
.forEach(out::add);
// 크기가 10이 아닐 수도 있고, 순서도 보장되지 않는다
System.out.println(out);
}
}
해결
- 병렬 처리에서는
collect로 모으고, thread-safe collector를 사용하거나 기본toList()로 수집하기
import java.util.*;
public class Main {
public static void main(String[] args) {
var out = List.of(1,2,3,4,5,6,7,8,9,10)
.parallelStream()
.map(x -> x * 2)
.toList();
System.out.println(out);
}
}
- 부작용이 반드시 필요하다면 병렬화를 끄거나, 동기화된 자료구조를 쓰되 성능과 정확성 트레이드오프를 명확히 하기
Kotlin Sequence는?
Kotlin Sequence 자체는 병렬 처리를 기본 제공하지 않습니다. 그래서 같은 실수를 덜 하긴 하지만, 대신 코루틴 Flow나 병렬 컬렉션 처리로 넘어가는 순간 유사한 문제가 다시 등장합니다. 핵심은 “지연평가 파이프라인에서 부작용을 최소화”입니다.
실무 체크리스트: 지연평가 버그를 줄이는 규칙
- 최종 연산이 언제 실행되는지를 코드 리뷰에서 명확히 확인한다 (
toList,collect,count,first,any등) - Stream은 절대 재사용하지 않는다. 필요하면 supplier 또는 물질화로 해결한다
- Sequence는 재사용 가능해 보여도, 소스가 1회성인지(이터레이터 캡처, 커서 기반, 네트워크 페이지네이션) 점검한다
- Stream으로 I/O를 다룰 때는 리소스 범위 안에서 최종 연산까지 완료한다
- 병렬 Stream에서 공유 mutable 상태를 건드리지 않는다. 수집은
collect또는toList()로 한다 - 디버깅을 위해
peek나onEach를 넣었다면, 운영 코드에 남기기 전에 부작용과 비용을 재검토한다
마무리
Kotlin Sequence와 Java Stream은 겉으로는 비슷하지만, 지연평가의 “실행 시점”과 “수명”을 둘러싼 규칙이 달라서 버그가 생깁니다. 특히 Stream은 1회 소비와 closeable 리소스라는 특성이 강하고, Sequence는 재사용 가능해 보이는 착시 때문에 1회성 소스를 감싸는 순간 데이터 유실이 발생합니다.
팀에서 두 생태계를 혼용한다면, 위 5가지 패턴을 린트 규칙이나 코드 리뷰 체크리스트로 고정해두는 것만으로도 장애 확률이 눈에 띄게 내려갑니다.