Published on

Kotlin Flow vs Java Stream - 성능·병렬·예외

Authors

서버/클라이언트 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.IODispatchers.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/catchcollect 바깥에 두거나, 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
  • 병렬이 필요하면 StreamSpliterator/Collector 를 먼저 점검하고, FlowflatMapMerge 와 디스패처/버퍼 정책을 먼저 설계하세요.

추가로 병렬 스트림이 기대보다 느릴 때의 원인 분석은 아래 글이 바로 연결됩니다.