Published on

Java Stream NPE·성능 잡는 Gatherers 실전

Authors

서버 코드에서 Stream을 예쁘게 만들려다 오히려 NPE가 늘고, 중간 연산이 과해져 성능이 떨어지는 경우가 많습니다. 특히 map/flatMap/filter를 길게 이어 붙이면서 null이 섞이거나, groupingBy 같은 종단 연산에 모든 데이터를 몰아 넣어 메모리를 쓰는 패턴이 흔합니다.

최근 Java에서 추가된 Gatherers는 스트림 파이프라인의 “중간 단계에서 상태를 가진 변환”을 더 명시적이고 안전하게 구성하도록 도와줍니다. 핵심은 중간 연산을 커스텀하면서도 스트림의 흐름(지연 평가, 단일 패스)을 유지할 수 있다는 점입니다.

이 글은 “이론 소개”보다, NPE를 줄이고 성능을 올리는 실전 패턴을 중심으로 정리합니다.

참고: 성능 튜닝은 결국 병목을 찾아야 합니다. 프론트 렌더링에서 Long Task를 잡듯이, 서버도 측정 기반으로 접근해야 합니다. 병목을 찾는 관점은 Chrome 렌더링 느림 - Long Task 잡는 법 글의 사고방식이 그대로 통합니다.

1) Stream에서 NPE가 터지는 대표 패턴

패턴 A: map 체인 중간에 null이 섞임

아래 코드는 getCustomer()getAddress()null이면 바로 터집니다.

var cities = orders.stream()
    .map(Order::getCustomer)
    .map(Customer::getAddress)
    .map(Address::getCity)
    .toList();

전통적인 해결은 Optional로 감싸거나, 중간중간 filter(Objects::nonNull)을 넣는 방식인데, 파이프라인이 길어질수록 가독성이 급격히 떨어집니다.

패턴 B: flatMap에서 null 컬렉션

var skus = orders.stream()
    .flatMap(o -> o.getItems().stream()) // getItems()가 null이면 NPE
    .map(Item::getSku)
    .toList();

이건 “null 컬렉션”이 섞이는 데이터 모델에서 특히 자주 발생합니다.

2) Gatherers가 해결해주는 것: 상태/제어를 중간으로 끌어오기

Gatherers는 스트림 중간에서 다음 같은 일을 “명시적으로” 할 수 있게 해줍니다.

  • null 안전 변환: null을 만나면 건너뛰거나 기본값으로 치환
  • 배치/청크 처리: N개씩 묶어서 downstream에 흘려보내기
  • 윈도우/인접 처리: 이전 값과 현재 값을 함께 보고 처리
  • 조기 종료: 조건을 만족하면 더 이상 흘려보내지 않기

그리고 중요한 점은, 이런 로직을 collect로 몰아 넣지 않고 중간 연산으로 유지할 수 있어 파이프라인이 단순해집니다.

주의: Gatherers는 비교적 최신 기능이므로, 실제 적용 전 프로젝트의 Java 버전/빌드 환경을 확인하세요.

3) 실전 1: null-safe mapNotNull로 NPE 제거

가장 먼저 추천하는 패턴은 “map을 하되 결과가 null이면 제거”입니다. Kotlin의 mapNotNull과 동일한 의도죠.

아래는 Gatherer를 직접 정의해 null 결과를 자동으로 drop하는 예시입니다.

import java.util.function.Function;
import java.util.stream.Gatherer;

public final class MoreGatherers {

  public static <T, R> Gatherer<T, ?, R> mapNotNull(Function<? super T, ? extends R> mapper) {
    return Gatherer.of((state, element, downstream) -> {
      R mapped = mapper.apply(element);
      if (mapped != null) {
        downstream.push(mapped);
      }
      return true; // 계속 진행
    });
  }
}

사용은 아래처럼 깔끔해집니다.

var cities = orders.stream()
    .gather(MoreGatherers.mapNotNull(Order::getCustomer))
    .gather(MoreGatherers.mapNotNull(Customer::getAddress))
    .gather(MoreGatherers.mapNotNull(Address::getCity))
    .toList();

왜 이게 “성능”에도 도움이 되나?

  • filter(Objects::nonNull)를 여러 번 섞는 대신, 변환과 필터링을 한 단계로 합칩니다.
  • 특히 데이터가 크고 파이프라인이 길수록, 중간 객체/람다 호출 비용이 누적됩니다.

물론 마이크로 최적화처럼 보일 수 있지만, NPE 방지 + 파이프라인 단순화만으로도 값어치가 큽니다.

4) 실전 2: null 컬렉션을 안전하게 flatMap

flatMap에서 null 리스트 때문에 터지는 문제는 “빈 스트림으로 치환”이 정답인 경우가 많습니다.

import java.util.Collection;
import java.util.stream.Stream;

static <T> Stream<T> streamOfNullableCollection(Collection<T> c) {
  return c == null ? Stream.empty() : c.stream();
}

var skus = orders.stream()
    .flatMap(o -> streamOfNullableCollection(o.getItems()))
    .map(Item::getSku)
    .toList();

여기서 한 단계 더 나가면, 아예 Gatherer로 “null 컬렉션을 비우고 flatten”까지 중간에서 처리할 수 있습니다. 다만 flatten은 downstream push를 여러 번 해야 하므로, 팀 컨벤션에 따라 유틸 함수로 두는 편이 더 단순할 때도 있습니다.

5) 실전 3: 청크(배치)로 DB/외부 API 호출 비용 줄이기

스트림으로 ID를 뽑아놓고, 외부 API를 1건씩 호출하면 성능이 망가집니다. 흔한 안티패턴입니다.

// 안티패턴: N번 호출
var users = userIds.stream()
    .map(userService::fetchUser) // 외부 호출
    .toList();

이럴 때 N개씩 묶어서 bulk API로 던지면 호출 횟수가 줄고, 네트워크/DB round-trip이 크게 감소합니다.

Gatherers의 배치 개념(예: fixed window/청크)을 사용하면 파이프라인을 유지한 채로 배치 단위로 흘릴 수 있습니다.

예시(개념 코드):

var users = userIds.stream()
    .gather(java.util.stream.Gatherers.windowFixed(200))
    .flatMap(batch -> userService.fetchUsers(batch).stream())
    .toList();

포인트

  • collect로 한 번에 다 모으지 않고도 “중간에서 묶어서” 처리 가능
  • 대량 데이터에서 메모리 피크를 낮추고, 호출 횟수를 통제

이 방식은 DB 쿼리 튜닝에서 N+1을 없애는 것과 같은 결입니다. 대량 조인/룩업이 느릴 때 인덱스와 파이프라인을 조정하듯, 호출 단위를 재설계해야 합니다. 관련 관점은 MongoDB $lookup 느림? 인덱스·pipeline 튜닝도 참고할 만합니다.

6) 실전 4: 조기 종료로 불필요한 계산 막기

스트림은 findFirst, anyMatch 같은 종단 연산에서 조기 종료가 되지만, 중간에서 “특정 조건 이후는 더 이상 의미 없다” 같은 도메인 규칙을 넣고 싶을 때가 있습니다.

예를 들어 이벤트 로그를 시간순으로 처리하다가, 특정 체크포인트를 만나면 이후는 무시해도 되는 경우입니다.

개념적으로는 아래처럼 “조건을 만족하면 더 이상 downstream에 push하지 않음”을 구현할 수 있습니다.

import java.util.function.Predicate;
import java.util.stream.Gatherer;

public static <T> Gatherer<T, boolean[], T> takeUntil(Predicate<? super T> stopCondition) {
  return Gatherer.of(
      () -> new boolean[] { false },
      (state, element, downstream) -> {
        if (state[0]) return false;
        downstream.push(element);
        if (stopCondition.test(element)) {
          state[0] = true;
          return false; // 중단
        }
        return true;
      }
  );
}

사용:

var processed = events.stream()
    .gather(takeUntil(e -> e.type() == EventType.CHECKPOINT))
    .map(this::expensiveTransform)
    .toList();

효과

  • 비싼 map/파싱/정규화 작업을 조건 이후에 수행하지 않음
  • 전체 처리량이 큰 파이프라인에서 체감 성능 개선이 큼

7) Gatherers 적용 시 주의할 점 (실무 체크리스트)

1) 병렬 스트림과의 궁합

상태를 가지는 gatherer는 병렬 처리에서 의미가 달라질 수 있습니다.

  • 순서 의존(윈도우, take-until 등)이 있으면 parallel()과 섞지 않는 게 안전합니다.
  • 병렬화가 필요하면 “순서 독립적인 gatherer”인지부터 검토하세요.

2) 예외 vs null 정책을 팀에서 통일

null을 drop할지, 기본값으로 치환할지, 즉시 예외를 던질지는 팀 규칙이 필요합니다.

  • 데이터 품질이 중요하면: 빠르게 실패(예외)
  • 입력이 더럽고 복구가 목적이면: drop/기본값

중요한 건 “파이프라인 중간에서 조용히 사라지는 데이터”가 디버깅을 어렵게 만들 수 있다는 점입니다. 필요하면 카운팅/로깅 gatherer를 추가해 관측 가능성을 확보하세요.

3) 측정 없이 바꾸지 않기

Gatherers는 중간 연산을 더 강력하게 만들지만, 무조건 빠르게 만드는 마법은 아닙니다.

  • 처리량/지연시간/GC를 같이 보세요.
  • 운영 환경에서 OOM/GC가 이슈라면, 메모리 관점의 진단도 병행해야 합니다. 이런 종류의 문제를 추적하는 접근은 Linux OOM Killer 로그 추적과 메모리 누수 진단 같은 글의 흐름이 도움이 됩니다.

8) 마이그레이션 전략: 한 번에 갈아엎지 말기

실무에서는 기존 스트림 유틸이 이미 많습니다. Gatherers는 다음 순서로 점진 도입하는 게 안전합니다.

  1. NPE가 자주 나는 구간에 mapNotNull 같은 작은 gatherer부터 도입
  2. 외부 호출/DB 호출이 있는 파이프라인에 청크 gatherer 적용
  3. 조기 종료/윈도우 등 “상태 기반 로직”으로 확장
  4. 공통 gatherer는 MoreGatherers 같은 유틸 클래스로 표준화

정리

  • 스트림에서 NPE는 대부분 “중간 변환에서 null이 섞이는 것”과 “null 컬렉션 flatMap”에서 발생합니다.
  • Gatherers는 중간 연산에서 상태/제어를 표현할 수 있어, filter 남발이나 collect 과적을 줄입니다.
  • 실전에서 효과가 큰 패턴은 mapNotNull, 청크 처리(windowFixed), 조기 종료(takeUntil)입니다.
  • 병렬 스트림/관측 가능성/측정 기반 튜닝을 같이 챙기면, 기능 추가가 곧 성능 개선으로 이어집니다.

다음 단계로는, 실제 서비스의 “가장 비싼 파이프라인” 하나를 골라 mapNotNull과 배치 처리부터 적용해 보세요. NPE 감소는 바로 체감되고, 호출 단위 최적화는 비용(시간/인프라)까지 줄여줍니다.