- Published on
Java 21 스트림 Gatherer로 파이프라인 성능 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Java 스트림을 많이 쓰다 보면, 코드가 선언적으로 깔끔해지는 대신 성능 비용이 눈에 띄는 순간이 옵니다. 특히 map-filter-flatMap-collect가 길게 이어지거나, 중간 단계에서 불필요한 객체가 생성되거나, 같은 데이터를 여러 번 훑는 형태가 되면 GC 압력과 CPU 사용량이 빠르게 증가합니다.
Java 21에서는 스트림 파이프라인을 더 유연하게 구성할 수 있는 Gatherer(표준 API)가 도입되어, 여러 중간 연산을 하나의 단계로 합치거나, 상태를 가진 변환(윈도잉, 배칭, 중복 제거, lookahead 등)을 더 직접적으로 구현할 수 있게 됐습니다. 이 글에서는 Gatherer를 “새로운 문법”이 아니라 “파이프라인 성능 튜닝 도구”로 보고, 어떤 병목을 줄일 수 있는지와 실제 코드 패턴을 중심으로 설명합니다.
관련해서 스트림에서 NPE와 성능을 함께 잡는 실전 패턴은 다음 글도 함께 참고하면 좋습니다: Java Stream NPE·성능 잡는 Gatherers 실전
스트림 파이프라인이 느려지는 대표 원인
스트림은 기본적으로 요소를 하나씩 흘려 보내는 파이프라인이지만, 아래 패턴에서 비용이 커집니다.
1) 중간 연산 체인이 길어질수록 호출 오버헤드 증가
각 단계는 람다 호출과 내부 어댑터를 거칩니다. JIT 최적화가 잘 되더라도 “단계 수”가 많아지면 분기와 호출이 누적됩니다.
2) flatMap 남용으로 인한 객체/이터레이터 생성
flatMap(x -> x.stream())는 내부적으로 새로운 스트림/스플리터레이터를 계속 만들 수 있습니다. 특히 작은 컬렉션을 대량으로 펼칠 때 비용이 큽니다.
3) 중간 컬렉션 생성
map 중간에 toList() 같은 수집을 끼우면 사실상 파이프라인이 끊기며, 메모리 할당과 데이터 복사가 발생합니다.
4) 상태 기반 처리(윈도우, 배치, 중복 제거)를 억지로 구현
스트림은 원래 “상태 없는 변환”에 최적화되어 있습니다. 상태를 억지로 넣으면 AtomicInteger, 외부 mutable, peek 남용 같은 형태가 되기 쉽고, 병렬 스트림에서는 더 위험해집니다.
Gatherer는 이런 문제를 “중간 단계를 합치고, 상태를 표준 방식으로 캡슐화”하는 방향으로 완화합니다.
Gatherer 기본 개념: 중간 연산을 직접 만든다
Gatherer는 스트림의 중간 연산을 사용자가 정의할 수 있게 해주는 API입니다. 핵심은 다음입니다.
- 입력 요소를 받아 0개 이상 출력 요소로 변환 가능
- 내부 상태를 가질 수 있음(예: 버퍼, 카운터, 윈도우)
- 파이프라인의 한 단계로 들어가므로, 여러 연산을 하나로 합쳐 호출/어댑터 오버헤드를 줄일 수 있음
사용 형태는 대략 다음처럼 “스트림에 gather를 붙이는” 방식입니다.
import java.util.stream.Stream;
import java.util.stream.Gatherer;
Stream<Integer> s = Stream.of(1, 2, 3);
Stream<Integer> tuned = s.gather(/* Gatherer 구현 또는 빌더 */);
실제 구현은 JDK가 제공하는 Gatherers 유틸(표준 제공)과 커스텀 Gatherer를 조합하는 방식이 일반적입니다.
튜닝 포인트 1: map+filter를 한 단계로 합치기
가장 흔한 병목은 map 후 filter를 연달아 두는 패턴입니다. 예를 들어 문자열을 파싱해서 숫자로 바꾼 뒤, 조건에 맞는 것만 남기는 코드는 흔합니다.
기존 방식
List<Integer> ids = lines.stream()
.map(String::trim)
.map(Integer::parseInt)
.filter(i -> i > 0)
.toList();
이 코드는 읽기 쉽지만, 단계가 3개입니다. trim 결과 문자열, 파싱 과정, 필터 단계가 각각 별도의 람다 호출 경로를 가집니다.
Gatherer로 합치기(개념)
아래는 “파싱 실패는 스킵”하고 “양수만 통과”시키는 변환을 하나로 합친 예시입니다. 실제 프로젝트에서는 예외 비용을 줄이기 위해 예외 없는 파서를 쓰거나, 사전 검증을 넣는 편이 더 좋습니다.
import java.util.stream.Gatherer;
static Gatherer<String, ?, Integer> parsePositiveInt() {
return Gatherer.of(
// initializer
() -> null,
// integrator: (state, element, downstream)
(state, s, downstream) -> {
String t = s.trim();
if (t.isEmpty()) return true;
int v;
try {
v = Integer.parseInt(t);
} catch (NumberFormatException e) {
return true; // skip
}
if (v > 0) downstream.push(v);
return true;
},
// finisher
(state, downstream) -> {}
);
}
List<Integer> ids = lines.stream()
.gather(parsePositiveInt())
.toList();
왜 빨라질 수 있나
- 중간 단계 수 감소로 호출/어댑터 오버헤드 감소
- 불필요한 중간 결과를 만들지 않고 바로 조건 통과 요소만
push - “파싱 실패 스킵” 같은 분기 로직이 한 곳에 모여 분기 예측 관점에서도 유리해질 때가 있음
주의할 점은, 이게 항상 더 빠르다는 보장은 없고, 데이터 크기와 JIT 최적화, 예외 발생 빈도에 따라 결과가 달라집니다. 하지만 “단계가 길어지는 파이프라인”에서는 측정해볼 가치가 큽니다.
튜닝 포인트 2: flatMap을 배칭/버퍼링으로 대체하기
flatMap은 편하지만, 내부적으로 많은 스트림 객체를 만들기 쉬운 구조입니다. 예를 들어 이벤트를 모아서 DB에 배치로 쓰고 싶을 때, 아래처럼 작성하면 배치 단위 리스트를 만들기 위해 중간 컬렉션이 반복 생성됩니다.
기존 방식(배치 만들기 위해 보통 외부 mutable이 필요)
List<List<Event>> batches = new ArrayList<>();
List<Event> buf = new ArrayList<>(500);
for (Event e : events) {
buf.add(e);
if (buf.size() == 500) {
batches.add(buf);
buf = new ArrayList<>(500);
}
}
if (!buf.isEmpty()) batches.add(buf);
스트림으로 억지로 옮기면 더 지저분해지거나, AtomicInteger 같은 상태가 튀어나오기 쉽습니다.
Gatherer로 배치 만들기
import java.util.*;
import java.util.stream.Gatherer;
static <T> Gatherer<T, ?, List<T>> batch(int size) {
if (size <= 0) throw new IllegalArgumentException("size");
class State {
final ArrayList<T> buf = new ArrayList<>(size);
}
return Gatherer.of(
State::new,
(st, item, downstream) -> {
st.buf.add(item);
if (st.buf.size() == size) {
downstream.push(List.copyOf(st.buf));
st.buf.clear();
}
return true;
},
(st, downstream) -> {
if (!st.buf.isEmpty()) downstream.push(List.copyOf(st.buf));
}
);
}
List<List<Event>> batches = eventStream.gather(batch(500)).toList();
성능 관점 체크포인트
List.copyOf는 불변 스냅샷을 만들므로 안전하지만, 복사가 발생합니다. 정말 극한 튜닝이 필요하면 “버퍼를 넘기고 새 버퍼를 할당”하는 방식으로 복사를 줄일 수 있습니다.- 배치 처리 후 바로 DB에 쓰는 구조라면
toList()로 다시 모으지 말고,forEach로 흘려 보내는 편이 메모리 효율적입니다.
eventStream
.gather(batch(500))
.forEach(repo::bulkInsert);
대량 처리에서 배치 튜닝은 DB 커넥션/풀 고갈과도 연결됩니다. 배치가 너무 작으면 왕복이 늘고, 너무 크면 트랜잭션/락/메모리가 커집니다. 관련해서 서버 측 병목 진단 관점은 Spring Boot HikariCP 커넥션 고갈 원인·해결도 함께 보면 좋습니다.
튜닝 포인트 3: 윈도우(슬라이딩) 처리로 단일 패스 유지
로그/메트릭 처리에서 “최근 N개 평균”, “이전 값과 비교” 같은 윈도우 연산은 흔합니다. 보통은 인덱스를 쓰거나, List로 모아놓고 다시 계산합니다.
Gatherer로 슬라이딩 윈도우를 만들면 한 번의 패스로 처리할 수 있습니다.
import java.util.*;
import java.util.stream.Gatherer;
static Gatherer<Integer, ?, IntSummaryStatistics> windowStats(int windowSize) {
class State {
final ArrayDeque<Integer> q = new ArrayDeque<>(windowSize);
long sum = 0;
}
return Gatherer.of(
State::new,
(st, v, downstream) -> {
st.q.addLast(v);
st.sum += v;
if (st.q.size() > windowSize) {
int removed = st.q.removeFirst();
st.sum -= removed;
}
if (st.q.size() == windowSize) {
IntSummaryStatistics stats = new IntSummaryStatistics();
// window 내 min/max는 큐를 훑어야 하므로 비용이 있음
// 평균만 필요하면 sum/windowSize로 충분
for (int x : st.q) stats.accept(x);
downstream.push(stats);
}
return true;
},
(st, downstream) -> {}
);
}
List<IntSummaryStatistics> stats = Stream.of(1,2,3,4,5,6)
.gather(windowStats(3))
.toList();
여기서 진짜 튜닝 포인트는 “필요한 값만 유지”하는 것입니다.
- 평균만 필요하면
sum만 들고 가면 되고,min/max까지 필요하면 monotonic queue 같은 자료구조로 더 최적화할 수 있습니다. - 핵심은 스트림을 끊지 않고 상태를 Gatherer 내부로 숨겨, 외부 mutable과 재순회를 피한다는 점입니다.
튜닝 포인트 4: 조기 종료와 필터링을 앞당기기
스트림 튜닝의 정석은 “비싼 연산 전에 걸러라”입니다. Gatherer를 쓰면, 여러 조건을 한 번에 검사하고 필요할 때만 downstream으로 흘려 보낼 수 있어, 비싼 map을 뒤로 미루는 구조를 만들기 쉽습니다.
예를 들어, JSON 파싱 같은 무거운 작업이 뒤에 있다면, 앞에서 문자열 길이/프리픽스/간단 정규 조건을 먼저 적용해 통과율을 낮추는 것이 효과적입니다.
static Gatherer<String, ?, String> fastReject() {
return Gatherer.of(
() -> null,
(st, s, downstream) -> {
// 매우 싼 조건으로 먼저 컷
if (s == null || s.length() < 10) return true;
if (!s.startsWith("{\"type\"")) return true;
downstream.push(s);
return true;
},
(st, downstream) -> {}
);
}
stream
.gather(fastReject())
.map(this::heavyJsonParse)
.forEach(this::handle);
이 패턴은 서버리스/컨테이너 환경에서 CPU 예산이 타이트할수록 체감이 큽니다. 런타임 튜닝 전반의 관점은 GCP Cloud Run 503와 콜드스타트 지연 원인·튜닝 같은 글과도 연결됩니다.
병렬 스트림과 Gatherer: 기대와 주의
성능을 위해 parallelStream()을 켜는 순간, 상태를 가진 연산은 매우 조심해야 합니다.
- Gatherer가 내부 상태를 갖는 경우, 병렬 처리에서 상태 병합 규칙이 중요합니다.
- 무조건 병렬이 빠르지 않습니다. 요소가 작고 연산이 가벼우면 분할/스케줄링 비용이 더 큽니다.
- 배치 생성 같은 Gatherer는 병렬에서 “배치 경계”가 의미를 잃을 수 있습니다(순서/그룹이 깨질 수 있음).
실무적으로는 다음 순서로 접근하는 것이 안전합니다.
- 순차 스트림에서 Gatherer로 단계 수/할당을 줄여 기본 성능을 확보
- 병렬화는 CPU 집약적이며 결합 법칙이 성립하는 연산에서만 제한적으로 적용
- JMH로 측정해서 결정
JMH로 효과를 검증하는 최소 템플릿
튜닝은 측정 없이는 의미가 없습니다. 아래는 JMH로 “기존 스트림”과 “Gatherer 적용”을 비교하는 매우 단순한 뼈대입니다.
import org.openjdk.jmh.annotations.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.*;
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@State(Scope.Thread)
public class StreamGathererBench {
List<String> lines;
@Setup
public void setup() {
lines = IntStream.range(0, 1_000_00)
.mapToObj(i -> String.valueOf(i))
.toList();
}
@Benchmark
public List<Integer> baseline() {
return lines.stream()
.map(String::trim)
.map(Integer::parseInt)
.filter(i -> i % 2 == 0)
.toList();
}
@Benchmark
public List<Integer> gatherer() {
return lines.stream()
.gather(parseEvenInt())
.toList();
}
static Gatherer<String, ?, Integer> parseEvenInt() {
return Gatherer.of(
() -> null,
(st, s, downstream) -> {
int v = Integer.parseInt(s);
if ((v & 1) == 0) downstream.push(v);
return true;
},
(st, downstream) -> {}
);
}
}
측정 시에는 다음을 같이 보세요.
- 처리량(throughput)뿐 아니라 할당률(alloc rate), GC 횟수
- 입력 데이터 분포(필터 통과율, 예외 발생률)
- 워밍업/측정 반복 수를 충분히 확보
언제 Gatherer가 특히 유리한가
정리하면, Gatherer는 아래 상황에서 “성능 튜닝 카드”로 가치가 큽니다.
map/filter/flatMap체인이 길고, 중간 객체가 많이 생기는 파이프라인- 배치, 윈도우, 중복 제거처럼 상태가 필요한 변환을 스트림 스타일로 유지하고 싶을 때
- 중간 수집(
toList)을 제거하고, 단일 패스로 흘려 보내고 싶을 때
반대로, 단순한 2~3단계 스트림이거나 데이터가 작다면, Gatherer 도입이 복잡도만 올릴 수 있습니다. “핫패스”에서만 제한적으로 적용하는 것이 좋습니다.
결론
Java 21의 Gatherer는 스트림을 단순히 “더 멋진 함수형 문법”으로 만드는 기능이 아니라, 파이프라인 단계 수를 줄이고 상태 기반 처리를 표준화하여 CPU/할당/GC 비용을 줄일 수 있는 실전 튜닝 도구입니다.
접근 순서는 간단합니다.
- 병목 파이프라인을 찾고
- 불필요한 단계와 중간 컬렉션을 제거하며
- 배치/윈도우/조기 필터링 같은 상태 연산을 Gatherer로 캡슐화하고
- JMH로 실제 개선을 확인합니다.
스트림을 계속 쓰면서도 성능을 포기하지 않으려면, Java 21 Gatherer는 한 번쯤 반드시 실험해볼 만한 선택지입니다.