Contents
see List개요
Java 8의 Stream API는 컬렉션 처리를 선언적이고 간결하게 만들어줍니다. 하지만 잘못 사용하면 for 루프보다 느리고, 디버깅도 어려워집니다. 이 글에서는 Stream API의 고급 패턴, 성능 최적화 기법, 흔한 실수를 다룹니다.
Stream 파이프라인 기본 원리
Stream은 Source → Intermediate Operations → Terminal Operation 구조로 동작합니다. Lazy evaluation을 통해 필요한 만큼만 연산합니다.
List 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 totalByCustomer = orders.stream()
.collect(Collectors.groupingBy(
Order::customerId,
Collectors.summingDouble(Order::amount)
));
// 날짜별, 제품별 중첩 그룹핑
Map> ordersByDateAndProduct = orders.stream()
.collect(Collectors.groupingBy(
Order::date,
Collectors.groupingBy(
Order::productId,
Collectors.counting()
)
));
// 조건부 분할 (금액 1000 이상/이하)
Map> partitionedOrders = orders.stream()
.collect(Collectors.partitioningBy(o -> o.amount >= 1000));
List highValueOrders = partitionedOrders.get(true);
List 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 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 seen = new HashSet<>();
boolean hasDuplicate = list.stream()
.anyMatch(e -> !seen.add(e)); // anyMatch는 short-circuit
// 나쁜 예: 전체 정렬 후 3개 선택
List top3 = list.stream()
.sorted()
.limit(3)
.collect(Collectors.toList());
// 좋은 예: 부분 정렬 (힙 사용)
List 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 result = IntStream.range(0, 100)
.parallel() // 오버헤드만 발생
.boxed()
.collect(Collectors.toList());
// 좋은 예: 큰 데이터 + 복잡한 연산
List results = largeDataSet.parallelStream()
.map(this::expensiveComputation) // CPU 집약적
.collect(Collectors.toList());
// 주의: 공유 상태 변경 금지!
List results = new ArrayList<>(); // 스레드 안전하지 않음!
list.parallelStream()
.forEach(s -> results.add(s.toUpperCase())); // 경쟁 조건!
// 올바른 방법
List results = list.parallelStream()
.map(String::toUpperCase)
.collect(Collectors.toList()); // 스레드 안전
실전 패턴 모음
flatMap으로 중첩 구조 평탄화
class Department {
List employees;
}
class Employee {
List skills;
}
// 모든 직원의 스킬 목록 추출
List allSkills = departments.stream()
.flatMap(dept -> dept.employees.stream()) // List
.flatMap(emp -> emp.skills.stream()) // List
.distinct()
.sorted()
.collect(Collectors.toList());
// Optional의 flatMap
Optional result = Optional.of("user123")
.flatMap(this::findUserById) // Optional
.flatMap(user -> user.getEmail()) // Optional
.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 processed = list.stream()
.map(s -> s.length() > 10 ? s.substring(0, 10) : s)
.collect(Collectors.toList());
// Optional 필터링
List activeAdmins = users.stream()
.filter(User::isActive)
.filter(user -> user.getRole().equals("ADMIN"))
.collect(Collectors.toList());
// null 안전 변환
List emails = users.stream()
.map(User::getEmail)
.filter(Objects::nonNull) // null 제거
.collect(Collectors.toList());
// 또는 Optional 활용
List 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 코드를 작성해보시기 바랍니다.