- Published on
Java 스트림 vs Kotlin Flow - 대용량 성능 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
대용량 컬렉션을 다룰 때 Stream 과 Flow 를 같은 “파이프라인”으로 묶어 생각하면 위험합니다. 둘 다 map/filter 같은 연산자를 제공하지만, 실행 모델과 비용 구조가 다릅니다.
- Java
Stream: 기본적으로 동기(synchronous) + pull 기반. 터미널 연산(collect,sum,forEach)이 호출될 때 한 번에 실행됩니다. - Kotlin
Flow: 기본적으로 비동기(suspending) + cold stream.collect시점에 실행되며,emit과collect사이에 서스펜션/컨텍스트 전환 비용이 개입할 수 있습니다.
이 글은 “대용량 컬렉션 성능·메모리 튜닝” 관점에서, 어떤 경우에 Stream 이 유리하고 어떤 경우에 Flow 가 유리한지, 그리고 각각을 쓸 때 실무에서 흔히 터지는 병목을 문법 레벨로 정리합니다.
참고로, 성능 문제는 애플리케이션 내부만이 아니라 인프라 타임아웃/리트라이로 증폭되기도 합니다. 대량 처리 배치가 API 호출을 동반한다면 AWS ALB 502/504 급증 - 타임아웃 7곳 점검 같은 체크리스트도 함께 보길 권합니다.
1) 실행 모델 차이: “지연 평가”는 같아도 비용이 다르다
Java Stream의 핵심 비용
Java Stream 은 중간 연산이 lazy지만, 실제 비용은 주로 다음에서 발생합니다.
- 객체 박싱/언박싱(
IntStream을 쓰지 않으면Integer박싱) - 람다 캡처로 인한 할당(특히 반복적으로 캡처되는 클로저)
collect(toList())같은 전체 물질화(materialization)parallelStream()의 스레드 풀 경쟁/분할 비용
Kotlin Flow의 핵심 비용
Flow 는 컬렉션이 아니라 비동기 시퀀스에 가깝습니다.
- 각 요소마다
emit/collect가 서스펜션 포인트가 될 수 있음 flowOn/buffer/channelFlow사용 시 컨텍스트 전환 및 채널 비용- 잘못된
toList()사용으로 전체 물질화 + 코루틴 오버헤드 동시 발생
정리하면, “순수 CPU 연산으로 대량 리스트를 변환”하는 문제는 Flow 가 본질적으로 더 비싸질 수 있습니다. 반대로 “대량 데이터 소스를 조금씩 읽고, I/O 대기와 함께 처리”하는 문제는 Flow 가 더 자연스럽고 안전합니다.
2) 대용량 컬렉션에서 가장 먼저 할 일: 물질화 방지
대용량 처리에서 메모리 폭발의 1순위는 List 로 한 번에 모으는 습관입니다.
Java: collect(toList()) 를 마지막 순간까지 미루기
import java.util.*;
import java.util.stream.*;
class LargeStream {
static long sumEvenSquares(List<Integer> input) {
// 중간에 List로 모으지 않고, 바로 primitive 스트림으로 합산
return input.stream()
.mapToInt(Integer::intValue)
.filter(x -> (x & 1) == 0)
.map(x -> x * x)
.asLongStream()
.sum();
}
}
포인트는 mapToInt/IntStream 같은 primitive 스트림을 써서 박싱을 제거하고, 결과가 필요하다면 sum/count 같은 터미널로 바로 끝내는 것입니다.
Kotlin: 컬렉션이면 Flow보다 Sequence 가 먼저
컬렉션을 “그냥 변환”하는데 Flow 를 쓰면, 코루틴/서스펜션 비용만 추가될 수 있습니다. 이때는 Sequence 가 더 적합합니다.
fun sumEvenSquares(input: List<Int>): Long {
return input.asSequence()
.filter { (it and 1) == 0 }
.map { it * it }
.map { it.toLong() }
.sum()
}
Sequence는 동기 + lazy + pull 기반으로, 컬렉션 변환에 오버헤드가 적습니다.Flow는 “비동기 스트림”이 필요한 순간(예: 네트워크/DB/파일)로 한정하는 게 보통 유리합니다.
3) Java Stream 메모리/성능 튜닝 실전 문법
3-1) 박싱 제거: IntStream/LongStream 우선
long total = users.stream()
.mapToLong(User::getScore) // LongStream
.filter(score -> score >= 0)
.sum();
Stream<Long> 로 처리하면 각 원소가 Long 객체가 되어 GC 압력이 커집니다.
3-2) flatMap 은 조심: 중간 컬렉션 생성 패턴 제거
나쁜 패턴은 “각 원소를 리스트로 바꾼 뒤 flatMap”입니다.
Stream<String> tokens = lines.stream()
.flatMap(line -> Arrays.stream(line.split("\\s+")));
split 은 내부적으로 배열을 만들고 정규식 비용도 큽니다. 대용량 텍스트라면 토크나이저를 재사용하거나, 정규식 대신 수동 파서를 고려해야 합니다.
3-3) parallelStream() 은 만능이 아니다
parallelStream() 은 다음 조건에서 특히 손해를 봅니다.
- 원소 수는 많지만 연산이 가벼워 분할/병합 오버헤드가 더 큼
- 공유 리소스(락, DB 커넥션, 파일 핸들)를 건드림
ForkJoinPool.commonPool과 다른 작업이 경쟁
병렬화가 필요하면 parallelStream() 보다 명시적 Executor + 배치 처리가 안정적인 경우가 많습니다.
3-4) 조기 종료: findFirst/anyMatch/limit
대용량에서 “끝까지 다 도는” 루프를 막는 것이 가장 큰 최적화입니다.
boolean hasBad = events.stream()
.anyMatch(e -> e.severity() >= 900);
4) Kotlin Flow 성능/메모리 튜닝 실전 문법
Flow는 “대용량 컬렉션” 자체보다, 대용량 데이터 소스를 스트리밍할 때 강합니다. 다만 기본 문법을 잘못 쓰면 오히려 더 느리고 메모리를 더 씁니다.
4-1) CPU 바운드 변환은 flowOn(Dispatchers.Default) 를 신중히
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun Flow<Int>.heavyCpuMap(): Flow<Int> =
this
.map { x -> x * x }
.flowOn(Dispatchers.Default) // 업스트림을 Default로
주의점:
flowOn은 경계를 만들고 컨텍스트 전환이 발생할 수 있습니다.- 작은 작업에 남발하면 오히려 손해입니다.
4-2) 백프레셔와 버퍼: buffer 는 메모리와 맞바꾸는 옵션
val processed = sourceFlow
.buffer(capacity = 256) // 생산자-소비자 속도 차를 완충
.map { transform(it) }
buffer는 처리량을 올릴 수 있지만, 버퍼 크기만큼 원소를 더 들고 있게 됩니다.- 대용량에서 무작정
buffer()를 크게 잡으면 RSS가 튀고 GC가 늘어납니다.
4-3) 동시성 맵: flatMapMerge vs map + async
I/O 바운드(HTTP 호출, DB 조회)를 병렬로 날리고 싶을 때 flatMapMerge 가 깔끔합니다.
val results: Flow<Result> = ids.asFlow()
.flatMapMerge(concurrency = 32) { id ->
flow {
emit(fetchRemote(id))
}
}
튜닝 포인트:
concurrency는 외부 시스템 한계(커넥션 풀, QPS, 타임아웃)와 같이 잡아야 합니다.- 동시성을 올리면 처리량이 늘 수 있지만, 실패율이 함께 오르면 전체는 더 느려집니다.
대량 병렬 호출에서 타임아웃이 늘면 ALB/게이트웨이에서 502/504로 보일 수 있으니, 앞서 링크한 타임아웃 점검 글과 함께 보는 것을 권합니다.
4-4) channelFlow 는 최후의 수단
channelFlow 는 강력하지만 채널/코루틴을 더 만들고, 구조가 복잡해져 누수/취소 전파 실수가 늘어납니다.
가능하면 flow {} + 표준 연산자 조합으로 해결하고, 정말로 “콜백 기반 API를 Flow로 래핑”해야 할 때만 쓰는 편이 안정적입니다.
4-5) 대량 데이터를 리스트로 모으지 말기: toList() 경계 설정
suspend fun consume(flow: Flow<Item>) {
// 나쁜 예: 대용량 전체를 메모리에 적재
// val all = flow.toList()
// 좋은 예: 스트리밍 처리
flow.collect { item ->
handle(item)
}
}
정말로 배치 단위가 필요하면 “전체”가 아니라 “청크”로 자르는 방식을 씁니다.
suspend fun <T> Flow<T>.chunked(size: Int): Flow<List<T>> = flow {
val buf = ArrayList<T>(size)
collect { v ->
buf.add(v)
if (buf.size == size) {
emit(buf.toList())
buf.clear()
}
}
if (buf.isNotEmpty()) emit(buf.toList())
}
이 패턴은 메모리 상한을 size 로 고정할 수 있어 대용량에서 예측 가능해집니다.
5) “대용량 컬렉션”을 정말로 Flow로 처리해야 하는 경우
컬렉션이 이미 메모리에 있는데도 Flow가 필요한 경우는 보통 다음입니다.
- 처리 중간에 서스펜딩 I/O가 섞여 있고, 취소/타임아웃 전파가 중요함
- UI/서버에서 스트리밍 응답처럼 “점진적으로 결과를 내보내야” 함
- backpressure 를 명시적으로 다루고 싶음
예: 파일에서 줄을 읽고, 원격 API로 enrichment 후, 결과를 즉시 저장하는 파이프라인.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.nio.file.*
fun fileLines(path: Path): Flow<String> = flow {
Files.newBufferedReader(path).use { br ->
while (true) {
val line = br.readLine() ?: break
emit(line)
}
}
}
suspend fun pipeline(path: Path) {
fileLines(path)
.filter { it.isNotBlank() }
.map { parse(it) }
.flatMapMerge(concurrency = 16) { row ->
flow { emit(enrich(row)) }
}
.collect { enriched ->
save(enriched)
}
}
이런 형태는 Stream 으로도 만들 수 있지만, 취소/구조적 동시성/에러 전파를 고려하면 Flow 쪽이 코드가 더 안전해지는 경우가 많습니다.
6) 튜닝 체크리스트: 무엇을 측정하고 무엇을 바꿀까
공통: 먼저 “끝까지 도는지”부터 확인
- 조기 종료 연산이 가능한가 (
limit,take,anyMatch) - 불필요한 정렬/그룹핑/중복 제거가 있는가 (
sorted,distinct,groupBy)
Java Stream 체크
- 박싱이 있는가:
Stream<Integer>대신IntStream collect(toList())로 중간 결과를 쌓고 있지 않은가parallelStream()이 외부 I/O를 건드리지 않는가
Kotlin Flow 체크
- 컬렉션 변환인데 Flow를 쓰고 있지 않은가 (
Sequence로 대체 가능) flowOn/buffer를 남발해 컨텍스트 전환/메모리 사용을 키우지 않았는가- 병렬 I/O는
flatMapMerge(concurrency = N)로 제어되고 있는가 toList()가 “전체 적재”로 이어지지 않는가
데이터/스토리지 관점도 함께 보기
대량 처리에서 메모리/CPU만 보고 끝내면, 결국 DB가 병목이 되는 경우가 많습니다. 예를 들어 대량 삭제/업데이트 배치 후 성능이 계속 나쁘다면 테이블 bloat나 vacuum 이슈일 수 있습니다. 이 경우 PostgreSQL VACUUM 안됨? bloat·wraparound 10분 진단 같은 점검 글이 직접적인 도움이 됩니다.
7) 결론: 선택 기준을 한 문장으로 정리
- “메모리에 있는 대용량 컬렉션을 빠르게 변환/집계”가 목적이면: Java는
Stream+ primitive 스트림, Kotlin은Sequence우선. - “대용량 데이터를 소스에서 조금씩 읽고, I/O와 함께 흘려보내며, 취소/동시성/백프레셔가 중요”하면: Kotlin
Flow가 강력.
핵심은 도구 비교가 아니라, 물질화(리스트로 모으기) 경계를 어디에 둘지, 그리고 동시성을 얼마나 줄지를 먼저 결정하는 것입니다. 이 두 가지가 잡히면, Stream이든 Flow든 대용량에서도 예측 가능한 성능과 메모리 사용량을 만들 수 있습니다.