본문 바로가기
카테고리 없음

Java 스트림 튜닝방법 (GC, 메모리, 스루풋)

by info95686 2025. 10. 20.

Java 스트림 튜닝방법
Java 스트림 튜닝방법

Java 8부터 도입된 Stream API는 코드 가독성을 높이고 함수형 프로그래밍 스타일을 제공하지만, 대용량 데이터 처리에서는 오히려 성능 병목의 원인이 될 수 있습니다. 불필요한 객체 생성, 박싱·언박싱, 병렬 스트림 남용 등은 GC 부하를 증가시키고 전체 스루풋을 떨어뜨립니다. 이 글은 GC 부담을 줄이고, 메모리 효율을 개선하며, 스루풋(throughput)을 높이는 실전 스트림 튜닝 방법을 다룹니다.

GC 부하 줄이기: 객체 생성과 박싱 최소화

Stream의 가장 큰 함정 중 하나는 불필요한 객체 생성입니다. 특히 Stream<Integer>와 같은 래퍼 타입 스트림은 매번 Integer 객체를 만들어 메모리를 낭비합니다. 기본형 스트림(IntStream, LongStream, DoubleStream)을 사용하면 박싱과 언박싱 비용을 피할 수 있습니다.

// 비효율적인 방식
Stream<Integer> stream = list.stream();
int sum = stream.mapToInt(Integer::intValue).sum();

// 효율적인 방식
IntStream intStream = list.stream().mapToInt(i -> i);
int sum2 = intStream.sum();

이처럼 기본형 스트림을 사용하면 GC 발생 빈도가 감소하고, 100만 건 이상의 데이터 처리 시 최대 40%까지 성능 향상을 기대할 수 있습니다. 또한 Stream 체이닝(filter → map → sort → collect)을 과도하게 사용하면 중간 객체와 람다 인스턴스가 많아져 GC 부담이 늘어납니다. 조건문을 통합하거나 중간 연산 단계를 줄이는 것이 좋습니다.

// 불필요한 중간 연산
list.stream()
    .filter(s -> s.length() > 5)
    .map(String::toUpperCase)
    .filter(s -> s.contains("JAVA"))
    .collect(Collectors.toList());

// 개선된 예시
list.stream()
    .filter(s -> s.length() > 5 && s.toUpperCase().contains("JAVA"))
    .collect(Collectors.toList());

마지막으로 Collector의 효율도 중요합니다. Collectors.toList()는 ArrayList를 반복적으로 재할당할 수 있으므로, 결과 크기를 예측할 수 있다면 초기 용량을 지정한 커스텀 컬렉터를 사용해야 합니다.

Collector<String, ?, List<String>> customCollector =
    Collector.of(() -> new ArrayList<>(list.size()),
                 List::add,
                 (a, b) -> { a.addAll(b); return a; });

메모리 효율 높이기: 지연 계산과 스트림 크기 관리

Stream은 지연 계산(lazy evaluation)을 지원하지만, distinct(), sorted(), flatMap() 등의 연산은 내부 버퍼를 사용하므로 대용량 데이터에서 메모리 사용량이 급격히 늘어납니다. 예를 들어 distinct()는 내부 해시셋을 사용하므로 100만 건 이상의 데이터에서는 OutOfMemoryError가 발생할 수 있습니다. 이를 피하기 위해 데이터 크기를 예측하고, 필요하다면 중간 결과를 파일이나 DB에 분할 저장해야 합니다.

또한 Stream은 일회성 객체이므로 한 번 소모하면 다시 사용할 수 없습니다. 동일한 데이터를 반복적으로 stream()으로 래핑하는 것은 메모리를 낭비합니다. 반복 처리가 필요하다면 결과를 캐시하거나 forEach() 내부에서 직접 처리하는 방식이 효율적입니다.

병렬 스트림은 내부적으로 ForkJoinPool을 사용하며, 각 스레드가 별도의 스택 메모리를 갖습니다. 이 때문에 데이터가 크지 않거나 연산이 짧을 경우 오히려 성능이 떨어지고 메모리 소비가 늘어납니다. 병렬 스트림은 데이터가 크고 연산이 CPU 중심적이며 I/O가 없는 경우에만 적합합니다.

또한 limit()과 skip()은 데이터를 전부 읽은 뒤 걸러내므로 메모리 절감 효과가 거의 없습니다. 초대용량 데이터에서는 Stream.generate()Files.lines() 같은 I/O 기반 스트림을 사용하여 데이터를 순차적으로 처리해야 합니다.

스루풋 향상: 병렬 처리와 Collector 최적화

스루풋(throughput)은 단위 시간당 처리량을 의미하며, Stream 튜닝의 궁극적인 목표입니다. 병렬 스트림은 이를 향상시키는 가장 강력한 도구이지만, 조건을 잘못 맞추면 오히려 느려질 수 있습니다.

효율적인 병렬화 조건은 다음과 같습니다.

  • 데이터 크기가 매우 크고(수십만 건 이상)
  • 연산이 CPU 중심이며 I/O가 없고
  • 상태를 공유하지 않는 순수 함수형 구조일 것
long count = IntStream.range(0, 1_000_000)
                      .parallel()
                      .filter(n -> n % 2 == 0)
                      .count();

Collectors.groupingBy()는 내부적으로 맵을 병합해야 하므로 병렬 환경에서는 오버헤드가 큽니다. 이때는 groupingByConcurrent()를 사용하는 것이 좋습니다.

Map<String, Long> result =
    list.parallelStream()
        .collect(Collectors.groupingByConcurrent(Function.identity(), Collectors.counting()));

병렬 스트림은 기본적으로 공용 ForkJoinPool을 사용하기 때문에 서버 환경에서는 여러 요청이 동시에 병렬 연산을 수행하면 스레드 풀이 포화 상태가 됩니다. 직접 풀을 만들어 제어하는 것이 안정적입니다.

ForkJoinPool customPool = new ForkJoinPool(8);
customPool.submit(() ->
    list.parallelStream().forEach(this::process)
).join();

이렇게 하면 병렬 연산이 특정 풀 내에서만 수행되어 다른 서비스 요청과 충돌하지 않습니다. 또한 Collector를 커스터마이징하거나, 불필요한 병합 과정을 제거해 스루풋을 높일 수 있습니다.

결론: 스트림은 알고 써야 성능이 산다

Stream API는 코드의 가독성을 크게 높여주지만, 내부 동작을 이해하지 못하면 성능을 해칠 수 있습니다. 다음 세 가지 원칙을 기억해야 합니다.

  • GC 부하 최소화: 기본형 스트림 사용, 중간 연산 최소화
  • 메모리 효율 개선: distinct·sorted 주의, 병렬화 남용 금지
  • 스루풋 향상: Collector 최적화, 커스텀 ForkJoinPool 활용

Stream은 단순한 문법적 편의가 아니라 병렬 처리와 함수형 연산을 결합한 엔진입니다. 원리를 이해하고 적절히 설계하면 GC 정지 시간은 줄이고 처리량은 늘리며, 코드 품질과 성능을 동시에 잡을 수 있습니다.