Published on

Java 스트림 vs Kotlin Flow - 대용량 성능 튜닝

Authors

대용량 컬렉션을 다룰 때 StreamFlow 를 같은 “파이프라인”으로 묶어 생각하면 위험합니다. 둘 다 map/filter 같은 연산자를 제공하지만, 실행 모델과 비용 구조가 다릅니다.

  • Java Stream: 기본적으로 동기(synchronous) + pull 기반. 터미널 연산(collect, sum, forEach)이 호출될 때 한 번에 실행됩니다.
  • Kotlin Flow: 기본적으로 비동기(suspending) + cold stream. collect 시점에 실행되며, emitcollect 사이에 서스펜션/컨텍스트 전환 비용이 개입할 수 있습니다.

이 글은 “대용량 컬렉션 성능·메모리 튜닝” 관점에서, 어떤 경우에 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든 대용량에서도 예측 가능한 성능과 메모리 사용량을 만들 수 있습니다.