Stream 중간 연산 — filter·map·flatMap·distinct·sorted·peek

Stream 중간 연산 전체 정리 — Stateless(filter·map·flatMap·peek·mapToInt)와 Stateful(sorted·distinct·limit·skip·takeWhile·dropWhile) 분류, flatMap의 평탄화 원리, Java 9 takeWhile/dropWhile, Java 16 mapMulti, 병렬 스트림에서의 주의사항

· 5 min read · PALDYN Team

지난 글에서 다양한 방법으로 스트림을 만드는 법을 살펴봤다. 이번에는 파이프라인의 중간 단계인 중간 연산(Intermediate Operation) 을 다룬다. 중간 연산은 항상 Stream<T>를 반환하므로 메서드 체이닝으로 파이프라인을 구성한다.

중간 연산의 두 가지 분류

중간 연산은 이전 원소가 필요한지에 따라 StatelessStateful로 나뉜다.

Stream 중간 연산 분류와 특성

Stateless 연산은 현재 원소만 보고 독립적으로 처리하므로 병렬 스트림에서도 성능이 좋다. Stateful 연산은 이전 원소를 알아야 하기 때문에 전체 버퍼링이 필요하거나 스레드 간 동기화가 필요하다.

filter — 조건 필터링

List<Integer> nums = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// 짝수만
List<Integer> evens = nums.stream()
    .filter(n -> n % 2 == 0)
    .toList(); // [2, 4, 6, 8, 10]

// 여러 조건 조합
List<String> words = List.of("apple", "banana", "avocado", "cherry");
words.stream()
     .filter(s -> s.startsWith("a"))
     .filter(s -> s.length() > 5)
     .toList(); // [avocado]

// Predicate.not으로 부정
words.stream()
     .filter(Predicate.not(String::isEmpty))
     .toList();

map — 원소 변환

map()Function<T, R>을 받아 원소를 다른 타입이나 값으로 변환한다.

List<String> names = List.of("alice", "bob", "charlie");

// 대문자 변환
List<String> upper = names.stream()
    .map(String::toUpperCase)
    .toList(); // [ALICE, BOB, CHARLIE]

// 길이 추출
List<Integer> lengths = names.stream()
    .map(String::length)
    .toList(); // [5, 3, 7]

// 객체 변환
record Person(String name, int age) {}

List<String> result = people.stream()
    .map(Person::name)
    .toList();

기본 타입으로 변환할 때는 mapToInt, mapToLong, mapToDouble을 사용해 박싱 비용을 제거한다.

int totalAge = people.stream()
    .mapToInt(Person::age)
    .sum(); // 박싱 없이 합산

flatMap — 스트림 평탄화

flatMap()은 원소를 Stream<R>으로 변환한 뒤 그 스트림들을 하나의 스트림으로 합친다. Stream<Stream<R>>Stream<R>로 만드는 연산이다.

flatMap 동작 원리

// 각 문장을 단어로 분리
List<String> sentences = List.of("hello world", "java stream");
List<String> words = sentences.stream()
    .flatMap(s -> Arrays.stream(s.split(" ")))
    .toList(); // [hello, world, java, stream]

// 중첩 리스트 펼치기
List<List<Integer>> nested = List.of(
    List.of(1, 2), List.of(3, 4), List.of(5, 6)
);
List<Integer> flat = nested.stream()
    .flatMap(Collection::stream)
    .toList(); // [1, 2, 3, 4, 5, 6]

flatMapToInt, flatMapToLong, flatMapToDouble도 있다.

distinct / sorted

List<Integer> duped = List.of(3, 1, 4, 1, 5, 9, 2, 6, 5, 3);

// 중복 제거 (equals/hashCode 기반)
duped.stream().distinct().toList(); // [3, 1, 4, 5, 9, 2, 6]

// 자연 순서 정렬
duped.stream().sorted().toList(); // [1, 1, 2, 3, 3, 4, 5, 5, 6, 9]

// 커스텀 정렬
List<String> words = List.of("banana", "apple", "cherry");
words.stream()
     .sorted(Comparator.comparingInt(String::length))
     .toList(); // [apple, banana, cherry]

// 역순
words.stream()
     .sorted(Comparator.reverseOrder())
     .toList(); // [cherry, banana, apple]

limit / skip

List<Integer> nums = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// 앞 3개만
nums.stream().limit(3).toList(); // [1, 2, 3]

// 앞 3개 건너뛰기
nums.stream().skip(3).toList(); // [4, 5, 6, 7, 8, 9, 10]

// 페이지네이션 패턴 (페이지 2, 페이지당 3개)
int page = 2, size = 3;
nums.stream()
    .skip((long)(page - 1) * size)
    .limit(size)
    .toList(); // [4, 5, 6]

takeWhile / dropWhile (Java 9+)

정렬된 스트림에서 조건이 깨지는 시점을 기준으로 분리한다.

List<Integer> sorted = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// 조건이 true인 동안만 통과 (5를 만나면 중단)
sorted.stream()
      .takeWhile(n -> n < 5)
      .toList(); // [1, 2, 3, 4]

// 조건이 false가 된 시점부터 통과
sorted.stream()
      .dropWhile(n -> n < 5)
      .toList(); // [5, 6, 7, 8, 9, 10]

정렬되지 않은 스트림에서는 결과가 구현에 따라 다를 수 있다.

peek — 중간 관찰

파이프라인 중간에 원소를 확인할 때 사용한다. 주로 디버깅 목적이다.

List<String> result = words.stream()
    .filter(s -> s.length() > 4)
    .peek(s -> System.out.println("filter 통과: " + s))
    .map(String::toUpperCase)
    .peek(s -> System.out.println("map 결과: " + s))
    .toList();

peek은 사이드이펙트 전용이다. 데이터를 수정하거나 업무 로직에 사용하면 안 된다. 특히 병렬 스트림에서는 호출 순서가 보장되지 않는다.

mapMulti (Java 16+)

flatMap의 대안으로 Consumer 기반 push 방식을 사용한다. Consumer를 통해 원소를 하나씩 밀어 넣어 중간 스트림 객체를 생성하지 않으므로 성능이 유리할 수 있다.

List<Integer> result = List.of(1, 2, 3).stream()
    .<Integer>mapMulti((n, consumer) -> {
        consumer.accept(n);
        consumer.accept(n * 10);
    })
    .toList(); // [1, 10, 2, 20, 3, 30]

지난 글: Stream 생성 — of·iterate·generate·Builder·파일·정규식

다음 글: Stream 종료 연산 — collect·reduce·forEach·count·find·match


읽어주셔서 감사합니다. 😊