
SOFTMOA TECHNOLOGY
Java Stream API 고급 패턴과 성능 최적화
소프트모아가 정리한 기술 기록입니다.
Contents
작성일 2026. 02. 11.
개요
Java 8의 Stream API는 컬렉션 처리를 선언적이고 간결하게 만들어줍니다. 하지만 잘못 사용하면 for 루프보다 느리고, 디버깅도 어려워집니다. 이 글에서는 Stream API의 고급 패턴, 성능 최적화 기법, 흔한 실수를 다룹니다.
Stream 파이프라인 기본 원리
Stream은 Source → Intermediate Operations → Terminal Operation 구조로 동작합니다. Lazy evaluation을 통해 필요한 만큼만 연산합니다.
List<String> result = list.stream() // Source
.filter(s -> s.length() > 3) // Intermediate (lazy)
.map(String::toUpperCase) // Intermediate (lazy)
.collect(Collectors.toList()); // Terminal (실제 연산)
// 주의: 중간 연산은 터미널 연산이 없으면 실행되지 않음!
list.stream()
.filter(s -> {
System.out.println("Filtering: " + s); // 출력 안 됨!
return s.length() > 3;
}); // 터미널 연산 없음
고급 Collector 패턴
그룹핑과 분할
class Order {
String customerId;
String productId;
double amount;
LocalDate date;
}
// 고객별 주문 총액
Map<String, Double> totalByCustomer = orders.stream()
.collect(Collectors.groupingBy(
Order::customerId,
Collectors.summingDouble(Order::amount)
));
// 날짜별, 제품별 중첩 그룹핑
Map<LocalDate, Map<String, Long>> ordersByDateAndProduct = orders.stream()
.collect(Collectors.groupingBy(
Order::date,
Collectors.groupingBy(
Order::productId,
Collectors.counting()
)
));
// 조건부 분할 (금액 1000 이상/이하)
Map<Boolean, List<Order>> partitionedOrders = orders.stream()
.collect(Collectors.partitioningBy(o -> o.amount >= 1000));
List<Order> highValueOrders = partitionedOrders.get(true);
List<Order> lowValueOrders = partitionedOrders.get(false);
커스텀 Collector
// 통계 정보를 한 번에 수집하는 커스텀 Collector
class Stats {
long count;
double sum;
double min = Double.MAX_VALUE;
double max = Double.MIN_VALUE;
void accept(double value) {
count++;
sum += value;
min = Math.min(min, value);
max = Math.max(max, value);
}
Stats combine(Stats other) {
count += other.count;
sum += other.sum;
min = Math.min(min, other.min);
max = Math.max(max, other.max);
return this;
}
double average() { return sum / count; }
}
Collector<Double, Stats, Stats> statsCollector = Collector.of(
Stats::new, // Supplier
Stats::accept, // Accumulator
Stats::combine, // Combiner
Function.identity() // Finisher
);
Stats stats = orders.stream()
.map(Order::amount)
.collect(statsCollector);
System.out.println("Average: " + stats.average());
System.out.println("Min: " + stats.min);
성능 최적화 기법
1. Short-circuit 연산 활용
// 나쁜 예: 모든 요소 검사
boolean hasDuplicate = list.stream()
.distinct()
.count() < list.size();
// 좋은 예: 첫 중복 발견 즉시 종료
Set<String> seen = new HashSet<>();
boolean hasDuplicate = list.stream()
.anyMatch(e -> !seen.add(e)); // anyMatch는 short-circuit
// 나쁜 예: 전체 정렬 후 3개 선택
List<String> top3 = list.stream()
.sorted()
.limit(3)
.collect(Collectors.toList());
// 좋은 예: 부분 정렬 (힙 사용)
List<String> top3 = list.stream()
.collect(Collectors.collectingAndThen(
Collectors.toList(),
l -> l.stream()
.sorted()
.limit(3)
.collect(Collectors.toList())
));
2. Primitive Stream 활용
// 나쁜 예: 박싱/언박싱 오버헤드
int sum = list.stream()
.map(s -> s.length()) // Integer 박싱
.reduce(0, Integer::sum); // 언박싱
// 좋은 예: IntStream 사용
int sum = list.stream()
.mapToInt(String::length) // 박싱 없음
.sum();
// 통계 한 번에
IntSummaryStatistics stats = list.stream()
.mapToInt(String::length)
.summaryStatistics();
System.out.println("Count: " + stats.getCount());
System.out.println("Sum: " + stats.getSum());
System.out.println("Average: " + stats.getAverage());
System.out.println("Min: " + stats.getMin());
System.out.println("Max: " + stats.getMax());
3. 병렬 스트림 주의사항
// 병렬 스트림이 느린 경우
// 1. 데이터가 작을 때 (< 10,000개)
// 2. 연산이 간단할 때 (단순 필터링)
// 3. 순서가 중요할 때 (findFirst, limit)
// 4. 상태를 공유할 때 (스레드 안전하지 않음)
// 나쁜 예: 작은 데이터 + 간단한 연산
List<Integer> result = IntStream.range(0, 100)
.parallel() // 오버헤드만 발생
.boxed()
.collect(Collectors.toList());
// 좋은 예: 큰 데이터 + 복잡한 연산
List<ComplexResult> results = largeDataSet.parallelStream()
.map(this::expensiveComputation) // CPU 집약적
.collect(Collectors.toList());
// 주의: 공유 상태 변경 금지!
List<String> results = new ArrayList<>(); // 스레드 안전하지 않음!
list.parallelStream()
.forEach(s -> results.add(s.toUpperCase())); // 경쟁 조건!
// 올바른 방법
List<String> results = list.parallelStream()
.map(String::toUpperCase)
.collect(Collectors.toList()); // 스레드 안전
실전 패턴 모음
flatMap으로 중첩 구조 평탄화
class Department {
List<Employee> employees;
}
class Employee {
List<String> skills;
}
// 모든 직원의 스킬 목록 추출
List<String> allSkills = departments.stream()
.flatMap(dept -> dept.employees.stream()) // List<Employee>
.flatMap(emp -> emp.skills.stream()) // List<String>
.distinct()
.sorted()
.collect(Collectors.toList());
// Optional의 flatMap
Optional<String> result = Optional.of("user123")
.flatMap(this::findUserById) // Optional<User>
.flatMap(user -> user.getEmail()) // Optional<String>
.map(String::toUpperCase);
Teeing Collector (Java 12+)
// 한 번의 순회로 두 개의 집계 수행
record MinMax(Integer min, Integer max) {}
MinMax minMax = list.stream()
.collect(Collectors.teeing(
Collectors.minBy(Integer::compareTo),
Collectors.maxBy(Integer::compareTo),
(min, max) -> new MinMax(min.orElse(null), max.orElse(null))
));
// 평균과 중앙값 동시 계산
record AvgMedian(double avg, double median) {}
AvgMedian stats = numbers.stream()
.collect(Collectors.teeing(
Collectors.averagingDouble(Double::doubleValue),
Collectors.collectingAndThen(
Collectors.toList(),
list -> {
Collections.sort(list);
return list.get(list.size() / 2);
}
),
AvgMedian::new
));
복잡한 필터링과 변환
// 조건부 변환
List<String> processed = list.stream()
.map(s -> s.length() > 10 ? s.substring(0, 10) : s)
.collect(Collectors.toList());
// Optional 필터링
List<User> activeAdmins = users.stream()
.filter(User::isActive)
.filter(user -> user.getRole().equals("ADMIN"))
.collect(Collectors.toList());
// null 안전 변환
List<String> emails = users.stream()
.map(User::getEmail)
.filter(Objects::nonNull) // null 제거
.collect(Collectors.toList());
// 또는 Optional 활용
List<String> emails = users.stream()
.map(user -> user.getEmail().orElse(null))
.filter(Objects::nonNull)
.collect(Collectors.toList());
활용 팁
- 성능 측정 필수: JMH로 벤치마크하여 for 루프와 비교하세요.
- 병렬 스트림 신중히: 10,000개 이상의 데이터에서 CPU 집약적 연산에만 사용하세요.
- Primitive Stream 우선: IntStream, LongStream, DoubleStream으로 박싱 오버헤드를 제거하세요.
- 상태 공유 금지: Stream 연산에서 외부 변수를 변경하지 마세요.
- 디버깅: peek()으로 중간 결과를 확인할 수 있지만, 프로덕션 코드에서는 제거하세요.
- 가독성 우선: 복잡한 Stream 체인은 가독성을 해칠 수 있습니다. 적절히 분할하세요.
마무리
Stream API는 강력하지만 만능은 아닙니다. 간단한 루프는 for문이 더 빠르고 명확할 수 있습니다. 성능이 중요한 핫패스에서는 반드시 벤치마크를 수행하세요.
Stream의 진가는 복잡한 데이터 변환, 그룹핑, 집계 연산에서 발휘됩니다. 선언적 스타일은 코드의 의도를 명확하게 전달하고, 유지보수성을 높입니다.
이 가이드의 패턴들을 프로젝트에 적용하여 더 간결하고 효율적인 Java 코드를 작성해보시기 바랍니다.
Share this
Java
Showing 1 to 10 of 66 entries
