개요

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 코드를 작성해보시기 바랍니다.