- Published on
Kotlin Flow vs Java Stream - 성능·병렬·예외
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/클라이언트 Kotlin 프로젝트를 하다 보면 Flow 를 기본값처럼 쓰게 되지만, JVM 생태계에서는 여전히 Stream 이 강력한 도구입니다. 둘은 겉보기엔 “데이터를 흘려보내며 변환한다”는 점이 비슷하지만, 설계 철학이 완전히 다릅니다.
Stream: 동기(synchronous) pull 기반 컬렉션 처리 파이프라인. CPU 바운드 변환에 최적화.Flow: 비동기(asynchronous) suspend 기반 스트림. I/O, 이벤트, 백프레셔, 취소/구조적 동시성에 최적화.
이 글에서는 성능(오버헤드/할당), 병렬(스레드/스케줄링), 예외(전파/취소) 관점에서 둘을 비교하고, 선택 기준을 체크리스트로 제공합니다.
1) 실행 모델: pull 기반 Stream vs suspend 기반 Flow
Java Stream은 “한 번에 끝나는” 동기 파이프라인
Stream 은 최종 연산(collect, sum 등)이 호출될 때까지 지연(lazy)되지만, 실행 자체는 호출 스레드에서 동기적으로 진행됩니다. 기본적으로 백프레셔나 취소 개념이 없고, 데이터 소스는 보통 컬렉션/배열/Spliterator 입니다.
List<Integer> out = list.stream()
.filter(x -> x % 2 == 0)
.map(x -> x * 2)
.toList();
Kotlin Flow는 “중간에 멈출 수 있는” 비동기 파이프라인
Flow 는 각 연산자가 suspend 로 연결되며, 생산자와 소비자가 코루틴 컨텍스트에서 협력합니다. collect 가 실행을 트리거하는 점은 비슷하지만, 실행 중간에 일시정지/재개, 취소, 컨텍스트 전환이 자연스럽게 들어갑니다.
val out = mutableListOf<Int>()
flowOf(1, 2, 3, 4)
.filter { it % 2 == 0 }
.map { it * 2 }
.collect { out += it }
정리하면,
- CPU 계산을 “빨리 끝내는 배치 처리”는
Stream이 단순하고 빠른 경우가 많고 - I/O, 이벤트, 긴 파이프라인, 취소 가능한 작업은
Flow가 본질적으로 맞습니다.
2) 성능: 오버헤드, 할당, 컨텍스트 전환
Stream이 유리한 구간: 순수 CPU 변환과 낮은 추상화 비용
Stream 은 JIT 최적화가 잘 먹는 편이고, 특히 primitive 스트림(IntStream, LongStream)을 쓰면 박싱/언박싱을 줄일 수 있습니다.
int sum = IntStream.range(0, 1_000_000)
.filter(x -> (x & 1) == 0)
.map(x -> x * 2)
.sum();
이런 형태는 코루틴 suspend 체인을 타는 Flow 보다 “기본 오버헤드”가 낮은 경우가 흔합니다.
Flow가 불리해질 수 있는 지점: suspend 체인과 디스패처 전환
Flow 는 연산자마다 suspend 경계가 생길 수 있고, flowOn/buffer/channelFlow 같은 연산을 섞으면 컨텍스트 전환과 버퍼링 비용이 추가됩니다.
val sum = (0 until 1_000_000).asFlow()
.filter { (it and 1) == 0 }
.map { it * 2 }
.flowOn(Dispatchers.Default) // 컨텍스트 전환 비용
.reduce { acc, v -> acc + v }
다만 이 코드는 “굳이 Flow로 할 이유가 없는” 전형적인 예시입니다. Flow 의 강점은 CPU 루프가 아니라 비동기 경계가 있을 때 드러납니다.
성능 결론(현실적인 기준)
- 메모리 내 컬렉션을 변환/집계:
Stream또는 Kotlin 컬렉션 API가 단순하고 빠를 가능성이 큼 - I/O 대기, 외부 호출, 이벤트 스트림:
Flow가 구조적으로 안전하고 전체 처리량이 좋아지는 경우가 많음 - “성능”은 평균 처리시간뿐 아니라 취소/타임아웃/백프레셔 시나리오에서의 안정성까지 포함해서 보세요.
3) 병렬 처리: parallelStream과 Flow의 concurrency는 다르다
Stream 병렬은 ForkJoinPool 기반이고, 항상 빨라지지 않는다
parallelStream() 은 내부적으로 ForkJoinPool.commonPool() 을 사용하며, 데이터 분할(Spliterator)과 병합(Collector) 비용이 성능을 좌우합니다. 특히 소스가 잘 쪼개지지 않거나, 병합 비용이 큰 collector를 쓰면 오히려 느려집니다.
long count = list.parallelStream()
.filter(this::expensiveCheck)
.count();
병렬 스트림이 느린 케이스를 더 깊게 보고 싶다면, 내부 분할과 collector 진단 관점의 글도 함께 참고하세요.
병렬 스트림 사용 시 체크
- 작업이 충분히 “무거운가” (스레드 분할 오버헤드 대비)
- 소스가 잘 분할되는가 (
ArrayList는 유리,LinkedList는 불리) - 공유 상태/락/IO가 섞이지 않았는가
Flow는 “병렬 컬렉션 처리”가 아니라 “동시성 파이프라인”이다
Flow 에는 parallel() 같은 내장 병렬화가 없습니다. 대신 코루틴으로 동시성을 구성합니다.
- 업스트림과 다운스트림을 분리하려면
buffer - 변환을 병렬로 수행하려면
flatMapMerge또는map내부에서async
suspend fun fetch(id: Int): String {
delay(50) // I/O 대기 가정
return "item-$id"
}
val results = (1..100).asFlow()
.flatMapMerge(concurrency = 16) { id ->
flow { emit(fetch(id)) }
}
.toList()
이 코드는 CPU를 쥐어짜는 병렬 루프가 아니라, I/O 대기를 겹쳐서 처리량을 올리는 전형적인 Flow 사용법입니다.
Flow 동시성에서 중요한 점
concurrency를 무작정 키우면 외부 시스템에 부하를 줌Dispatchers.IO와Dispatchers.Default를 목적에 맞게 분리- 백프레셔는
buffer크기와 소비 속도로 제어
4) 예외 처리: 전파 방식과 “취소”의 의미
Stream의 예외: 즉시 전파, checked exception은 불편
Stream 파이프라인에서 예외가 발생하면 보통 즉시 호출자에게 던져지며, 처리 중이던 작업은 중단됩니다. 문제는 람다 안에서 checked exception을 던지기 어렵다는 점입니다.
list.stream()
.map(x -> {
if (x < 0) throw new IllegalArgumentException("negative");
return x * 2;
})
.toList();
병렬 스트림에서는 예외가 래핑되어 올라오거나, 다른 워커에서 진행 중이던 작업이 일정 부분 더 실행될 수 있어 디버깅이 까다로울 수 있습니다.
Flow의 예외: 구조적 동시성과 함께 “취소”로 확장된다
Flow 는 예외가 발생하면 해당 코루틴 컨텍스트가 취소되고, 연결된 자식 코루틴까지 전파됩니다. 즉 예외 처리는 단순 throw가 아니라 취소 전파까지 포함합니다.
val f = flow {
emit(1)
error("boom")
}
val recovered = f
.catch { e -> emit(-1) }
.onCompletion { cause ->
// cause가 null이면 정상 완료
}
.toList()
Flow 예외 처리에서 자주 하는 실수
catch는 업스트림 예외만 잡습니다.collect블록 내부에서 터진 예외는catch로 잡히지 않습니다.
flowOf(1, 2, 3)
.catch { emit(-1) } // collect 내부 예외는 못 잡음
.collect { v ->
if (v == 2) error("collector failed")
}
이 경우는 try/catch 를 collect 바깥에 두거나, onEach 로 옮겨 업스트림에서 처리해야 합니다.
5) 백프레셔와 메모리: Flow는 기본 제공, Stream은 설계 범위 밖
Stream은 기본적으로 “현재 스레드에서 끝까지 처리”하는 모델이라, 생산 속도/소비 속도 불일치 문제(백프레셔)가 크게 드러나지 않습니다. 대신 대용량 데이터를 한 번에toList()하면 메모리가 터집니다.Flow는 생산자와 소비자가 분리될 수 있어 백프레셔가 중요합니다.buffer를 크게 잡으면 처리량은 늘 수 있지만 메모리 사용량도 함께 늘어납니다.
val f = flow {
repeat(1_000_000) { emit(it) }
}
// 무작정 buffer를 키우면 메모리 사용량이 증가
f.buffer(capacity = 1024)
.collect { /* consume */ }
6) 무엇을 선택할까: 실전 체크리스트
Java Stream을 우선 고려
- 입력이 컬렉션/배열이고, 처리 결과를 즉시 얻는 배치 작업
- 작업이 순수 CPU 변환이며 I/O가 거의 없음
- primitive 집계가 많아
IntStream류가 효과적 - 병렬화가 필요하고 데이터 분할이 잘 되며 collector가 가벼움
Kotlin Flow를 우선 고려
- 네트워크/DB/파일 등 I/O 대기가 많고, 동시성으로 처리량을 올리고 싶음
- 취소/타임아웃/리트라이가 중요한 파이프라인
- UI 이벤트, 메시지 큐, SSE/웹소켓처럼 “끝나지 않는 스트림”
- 백프레셔, 버퍼링, 컨텍스트 전환을 명시적으로 다루고 싶음
7) 마이그레이션/혼용 팁: Stream과 Flow를 함께 쓰는 방법
Kotlin에서 Stream을 써야 할 때
JVM 라이브러리가 Stream 을 반환하거나, Java 코드와 경계가 많다면 Kotlin에서도 Stream 을 유지하는 편이 단순합니다.
val sum = java.util.stream.IntStream.range(0, 100)
.filter { it % 2 == 0 }
.sum()
Flow에서 CPU 바운드 작업을 분리하기
I/O flow 안에 무거운 CPU 작업이 섞이면 Dispatchers.Default 로 분리하세요.
fun heavy(x: Int): Int {
// CPU 바운드 가정
return x * x
}
val out = (1..1000).asFlow()
.map { v -> withContext(Dispatchers.Default) { heavy(v) } }
.toList()
이 패턴은 남용하면 컨텍스트 전환이 과해질 수 있으니, CPU 구간을 묶어서 처리하거나 flowOn 을 적절히 사용합니다.
8) 결론: “성능”을 단일 숫자로 비교하지 말 것
Kotlin Flow vs Java Stream 비교에서 가장 흔한 함정은, 같은 문제를 두 도구로 억지로 풀고 마이크로벤치 숫자만 보는 것입니다.
Stream은 컬렉션 기반 동기 처리의 비용을 최소화하는 방향으로 최적화되어 있고Flow는 비동기 경계, 취소, 백프레셔, 동시성이라는 운영 이슈를 코드 구조로 흡수합니다.
따라서 선택 기준은 이렇게 정리하는 게 실용적입니다.
- 컬렉션 변환/집계 배치 작업이면
Stream - I/O 동시성 파이프라인이면
Flow - 병렬이 필요하면
Stream은Spliterator/Collector를 먼저 점검하고,Flow는flatMapMerge와 디스패처/버퍼 정책을 먼저 설계하세요.
추가로 병렬 스트림이 기대보다 느릴 때의 원인 분석은 아래 글이 바로 연결됩니다.