- Published on
Java Stream NPE·성능 잡는 Gatherers 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 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는 다음 순서로 점진 도입하는 게 안전합니다.
- NPE가 자주 나는 구간에
mapNotNull같은 작은 gatherer부터 도입 - 외부 호출/DB 호출이 있는 파이프라인에 청크 gatherer 적용
- 조기 종료/윈도우 등 “상태 기반 로직”으로 확장
- 공통 gatherer는
MoreGatherers같은 유틸 클래스로 표준화
정리
- 스트림에서 NPE는 대부분 “중간 변환에서 null이 섞이는 것”과 “null 컬렉션 flatMap”에서 발생합니다.
Gatherers는 중간 연산에서 상태/제어를 표현할 수 있어,filter남발이나collect과적을 줄입니다.- 실전에서 효과가 큰 패턴은
mapNotNull, 청크 처리(windowFixed), 조기 종료(takeUntil)입니다. - 병렬 스트림/관측 가능성/측정 기반 튜닝을 같이 챙기면, 기능 추가가 곧 성능 개선으로 이어집니다.
다음 단계로는, 실제 서비스의 “가장 비싼 파이프라인” 하나를 골라 mapNotNull과 배치 처리부터 적용해 보세요. NPE 감소는 바로 체감되고, 호출 단위 최적화는 비용(시간/인프라)까지 줄여줍니다.