Published on

Java Stream 병렬 처리 순서 깨짐·성능 역전 해결

Authors

서버 사이드 Java에서 parallelStream()을 켰는데 결과 순서가 뒤섞이거나, 기대와 달리 처리 시간이 늘어나는 경우가 자주 있습니다. 특히 로그/리포트 생성처럼 입력 순서가 의미가 있는 작업에서 forEach()를 병렬로 돌리면 출력이 뒤죽박죽이 되기 쉽고, DB/HTTP 호출 같은 I/O 작업을 병렬 스트림에 얹으면 포크조인 풀(ForkJoinPool) 고갈로 전체 서비스가 느려지기도 합니다.

이 글에서는 Java Stream 병렬 처리의 순서 보장 문제와 성능 역전(병렬화했는데 더 느려짐)을 원인별로 분해하고, 실제 운영 환경에서 안전하게 고치는 방법을 코드로 정리합니다. 분산 시스템에서 리트라이/데드라인이 폭주하면 지연이 증폭되듯, 병렬 스트림도 잘못 쓰면 병렬성이 오히려 병목을 증폭시킵니다. 관련해서는 gRPC MSA에서 데드라인·리트라이 폭주 막는 법도 함께 읽으면 “동시성 제어 실패가 만드는 역전” 패턴을 이해하는 데 도움이 됩니다.

1) 왜 순서가 깨질까: Encounter Order와 터미널 연산

Stream에는 **Encounter Order(만나는 순서)**라는 개념이 있습니다.

  • List 기반 스트림은 기본적으로 encounter order가 있습니다.
  • HashSet, HashMapkeySet() 등은 encounter order가 없습니다.
  • 병렬 스트림은 작업을 쪼개서 처리하므로, 순서를 강제하지 않는 연산을 쓰면 결과가 섞여 보입니다.

가장 흔한 함정은 다음입니다.

  • parallelStream().forEach(...)는 순서를 보장하지 않습니다.
  • parallelStream().forEachOrdered(...)는 encounter order를 보장하지만, 그만큼 병렬 이점이 줄어들 수 있습니다.

예제: 로그 출력이 뒤섞이는 경우

List<Integer> ids = IntStream.rangeClosed(1, 20).boxed().toList();

ids.parallelStream().forEach(id -> {
    System.out.println("process " + id);
});

위 코드는 출력 순서가 섞입니다. 출력 자체가 공유 자원(System.out)에 대한 경쟁이기도 합니다.

해결 1: 순서가 필요하면 forEachOrdered

ids.parallelStream().forEachOrdered(id -> {
    System.out.println("process " + id);
});

다만 forEachOrdered는 내부적으로 순서를 맞추기 위한 조정 비용이 생겨, CPU 바운드 작업에서는 성능이 떨어질 수 있습니다.

해결 2: 출력/부수효과를 분리하고, 순서가 있는 컬렉션으로 수집

병렬 처리의 핵심은 부수효과(side effect)를 줄이고 결과를 모아서 후처리하는 것입니다.

List<String> lines = ids.parallelStream()
    .map(id -> "process " + id)
    .toList(); // encounter order를 유지하는 수집

lines.forEach(System.out::println);

이 방식은 병렬 구간에서 공유 자원 접근을 제거해 예측 가능성이 좋아집니다.

2) 성능 역전의 대표 원인 7가지

parallelStream()이 느려지는 이유는 보통 “병렬화 오버헤드”가 아니라, 병렬화에 부적합한 작업을 병렬로 만들어서입니다.

원인 A: 작업 단위가 너무 작다(분할/스케줄링 오버헤드)

예를 들어 단순 덧셈/문자열 길이 계산 같은 매우 작은 작업은 병렬화 오버헤드가 더 큽니다.

long sum = IntStream.range(0, 10_000)
    .parallel()
    .map(i -> i + 1)
    .sum();

이런 경우는 단일 스레드가 더 빠를 수 있습니다.

원인 B: I/O 바운드 작업을 공용 포크조인 풀에 태웠다

parallelStream()은 기본적으로 ForkJoinPool.commonPool()을 사용합니다. 이 풀은 CPU 코어 수 기반으로 스레드 수가 제한됩니다.

  • DB 호출, 외부 HTTP 호출, 파일 I/O는 대기 시간이 길어 스레드를 묶어둡니다.
  • 공용 풀이 막히면, 같은 풀을 쓰는 다른 병렬 작업까지 연쇄로 느려질 수 있습니다.

이 문제는 “리트라이 폭주로 스레드가 묶이며 지연이 증폭”되는 현상과 유사합니다. 병렬성을 키운다고 처리량이 늘지 않고, 오히려 큐잉과 컨텍스트 스위칭만 늘어납니다.

원인 C: 공유 상태 경쟁(락, 동기화, 원자 연산)

병렬 스트림에서 다음 패턴은 거의 항상 성능을 망칩니다.

List<Integer> out = new ArrayList<>();
ids.parallelStream().forEach(out::add); // 위험: ArrayList는 스레드 안전하지 않음

심지어 Collections.synchronizedList(...)로 감싸도 락 경쟁이 발생합니다.

원인 D: 박싱/언박싱과 GC 압력

Stream<Integer>는 박싱 객체를 대량 생성할 수 있습니다. 가능한 경우 IntStream, LongStream 같은 primitive 스트림을 쓰면 GC 부담이 줄어듭니다.

원인 E: limit, findFirst 같은 순서 의존 연산

병렬에서 findFirst는 encounter order를 유지해야 하므로 비용이 커집니다. 순서가 중요하지 않다면 findAny가 더 유리합니다.

원인 F: 데이터 소스의 분할성이 나쁘다

ArrayList는 분할이 쉽지만, LinkedList는 분할 비용이 큽니다. 병렬 스트림은 Spliterator로 분할하는데, 소스의 분할 효율이 낮으면 병렬성이 잘 안 나옵니다.

원인 G: 코어 수 대비 과도한 병렬 작업/동시 실행

한 JVM에서 여러 컴포넌트가 동시에 parallelStream()을 쓰면 공용 풀이 경쟁하며 성능이 요동칩니다.

3) 순서 보장과 성능을 동시에 잡는 패턴

순서를 지키면서도 병렬 처리 이점을 최대한 살리려면, “병렬 구간”과 “순서 정렬/출력 구간”을 분리하는 것이 핵심입니다.

패턴 1: 인덱스를 붙여 병렬 처리 후 정렬

입력 순서를 반드시 복원해야 하고, 중간 처리 비용이 큰 경우에 유용합니다.

record Indexed<T>(int index, T value) {}

List<String> result = IntStream.range(0, ids.size())
    .parallel()
    .mapToObj(i -> new Indexed<>(i, heavyCompute(ids.get(i))))
    .sorted(Comparator.comparingInt(Indexed::index))
    .map(Indexed::value)
    .toList();
  • 장점: 병렬 처리 구간은 순서 제약이 거의 없음
  • 단점: sorted 비용이 추가됨(하지만 forEachOrdered보다 유리한 경우가 있음)

패턴 2: Collectors.toConcurrentMap 등 병렬 친화 수집기 사용

공유 리스트에 add하지 말고, 병렬 수집기를 사용합니다.

Map<Integer, String> map = ids.parallelStream()
    .collect(Collectors.toConcurrentMap(
        id -> id,
        id -> heavyCompute(id)
    ));

순서가 필요하면 마지막에 키 정렬로 출력 순서를 제어합니다.

List<String> ordered = map.entrySet().stream()
    .sorted(Map.Entry.comparingByKey())
    .map(Map.Entry::getValue)
    .toList();

패턴 3: 순서가 필요 없는 곳은 findAny로 바꾸기

Optional<Integer> any = ids.parallelStream()
    .filter(this::expensivePredicate)
    .findAny();

findFirst 대비 병렬 효율이 좋아질 수 있습니다.

4) I/O 작업은 parallelStream() 대신 “별도 풀 + 비동기”로

DB/HTTP 같은 I/O는 parallelStream()에 태우기보다 CompletableFuture와 별도 ExecutorService로 격리하는 편이 안전합니다. 공용 포크조인 풀 고갈을 피하고, 동시성도 명시적으로 제어할 수 있습니다.

예제: 외부 API 호출을 제한된 동시성으로 수행

ExecutorService ioPool = Executors.newFixedThreadPool(32); // 서비스 특성에 맞게 조정

try {
    List<CompletableFuture<String>> futures = ids.stream()
        .map(id -> CompletableFuture.supplyAsync(() -> callExternalApi(id), ioPool))
        .toList();

    // 입력 순서 유지
    List<String> responses = futures.stream()
        .map(CompletableFuture::join)
        .toList();

    // 후처리
    responses.forEach(System.out::println);
} finally {
    ioPool.shutdown();
}

여기서 중요한 점은 다음입니다.

  • 동시성(스레드 수)을 명시적으로 제한
  • 결과 합치기 단계에서 입력 순서를 자연스럽게 유지
  • 공용 풀에 영향을 주지 않음

MSA 환경에서 데드라인/리트라이 정책으로 부하를 제어하듯, I/O 병렬성도 “무제한 병렬”이 아니라 “제어된 동시성”이 핵심입니다. 자세한 패턴은 gRPC MSA에서 데드라인·리트라이 폭주 막는 법에서의 사고방식과도 연결됩니다.

5) ForkJoinPool.commonPool 튜닝과 격리 전략

5.1 공용 풀 병렬도(parallelism) 조정

JVM 옵션으로 공용 풀 크기를 조정할 수 있습니다.

  • 시스템 프로퍼티: java.util.concurrent.ForkJoinPool.common.parallelism

예: 코어 수보다 약간 크게/작게 조정해 볼 수 있지만, 만능 해결책은 아닙니다. 특히 I/O 바운드 작업을 공용 풀에 계속 태우면 병목은 반복됩니다.

5.2 병렬 스트림을 “전용 풀”에서 실행하기

병렬 스트림은 기본적으로 공용 풀을 쓰기 때문에, 서비스 내 다른 컴포넌트와 간섭이 생깁니다. 격리하려면 전용 ForkJoinPool에서 실행하는 패턴을 고려할 수 있습니다.

ForkJoinPool pool = new ForkJoinPool(8);

try {
    List<String> out = pool.submit(() ->
        ids.parallelStream()
            .map(this::heavyCompute)
            .toList()
    ).join();

    out.forEach(System.out::println);
} finally {
    pool.shutdown();
}

이 방식은 “병렬 스트림을 쓰되, 공용 풀 간섭을 피한다”는 점에서 유용합니다.

6) 순서 관련 연산 선택 가이드

병렬 처리에서 순서 제약은 곧 비용입니다. 아래처럼 의도를 명확히 하면 성능이 안정됩니다.

  • 출력/부수효과: 가능하면 병렬 구간 밖으로 이동
  • 순서가 반드시 필요: forEachOrdered 또는 인덱스 부여 후 정렬
  • 첫 원소가 필요: findFirst(비용 증가 감수)
  • 아무 원소나 필요: findAny(병렬 친화)
  • 상위 N개 필요: limit는 병렬에서 비용이 커질 수 있으니, 대안으로 unordered() + 다른 전략을 검토

unordered()로 최적화 힌트 주기

스트림 소스가 encounter order를 가지더라도, 결과에서 순서가 중요하지 않다면 unordered()가 최적화에 도움될 수 있습니다.

long count = ids.parallelStream()
    .unordered()
    .filter(this::expensivePredicate)
    .count();

7) 실전 체크리스트: “순서 깨짐”과 “성능 역전”을 빠르게 진단

7.1 순서 깨짐 진단

  • 소스가 encounter order를 가지는가(List vs Set)?
  • 터미널 연산이 forEach인가 forEachOrdered인가?
  • 중간에 unordered()를 넣었는가?
  • 병렬 구간에서 System.out, 로거, 공유 컬렉션 등 부수효과가 있는가?

7.2 성능 역전 진단

  • 작업이 CPU 바운드인가 I/O 바운드인가?
  • 작업 단위가 충분히 큰가(병렬 오버헤드 대비)?
  • 공유 락/원자 연산 경쟁이 있는가?
  • 박싱 객체가 과도하게 생성되는가(primitive 스트림 고려)?
  • 공용 포크조인 풀을 다른 기능도 같이 쓰고 있는가(간섭)?
  • findFirst, limit, sorted 등 순서/전역 연산이 병목인가?

성능 문제를 “병렬로 돌렸는데 더 느려졌다” 수준에서 끝내지 말고, 병목이 락 경쟁인지, 풀 고갈인지, 순서 제약인지 분해해 보면 해결이 빨라집니다. DB에서 데드락이 폭증할 때도 결국은 “경합 구조”를 뜯어고쳐야 하듯, 병렬 스트림도 구조적 원인을 제거해야 합니다. 관련 사고 확장은 MySQL InnoDB 데드락 폭증 원인·튜닝 7단계와도 통합니다.

8) 결론: 병렬 스트림은 “간단한 CPU 작업”에, 순서와 I/O는 분리

정리하면 다음 원칙이 가장 실전에서 잘 맞습니다.

  1. 병렬 스트림은 CPU 바운드, 순수 함수형 변환(map/filter) 위주로 사용
  2. 순서가 필요하면 forEachOrdered를 무조건 고집하지 말고 “수집 후 순서 복원”을 고려
  3. I/O 바운드 작업은 parallelStream() 대신 전용 풀의 CompletableFuture로 동시성을 제어
  4. 공용 ForkJoinPool 간섭이 의심되면 전용 풀 격리 또는 병렬 처리 전략 자체를 재검토

이 기준으로 리팩터링하면, parallelStream()이 가져오는 순서 깨짐과 성능 역전을 대부분 안정적으로 해결할 수 있습니다.