JAVA

Java Stream API 기초: 생성 → 중간연산 → 최종연산으로 끝내는 filter/map/collect 패턴

IT Lab 2026. 2. 22. 10:00

Java 17 기준 Stream 파이프라인의 기본 흐름(생성-중간-최종)과 filter/map/collect 실전 패턴을 예제로 빠르게 익힙니다.

 

람다식을 배웠는데, 막상 리스트를 가공하려고 하면 for 문이 다시 늘어나기 시작할 때가 있어요. “필터링하고, 변환하고, 원하는 형태로 모으는” 작업이 반복되면 코드가 금방 지저분해집니다. 이럴 때 Stream API의 기본 흐름만 잡아두면, 대부분의 컬렉션 가공이 깔끔하게 정리됩니다.

핵심 개념: Java Stream API 파이프라인(생성 → 중간연산 → 최종연산)

Stream 파이프라인에서 생성-중간연산-최종연산으로 이어지는 흐름도

Stream은 한마디로 데이터를 ‘흐름’으로 보고 단계별로 가공하는 파이프라인이에요. 중요한 포인트는 딱 3가지입니다.

  1. 생성(Source): 어디서 데이터를 가져올지 결정합니다. (list.stream(), Stream.of(...), Files.lines(...) 등)
  2. 중간연산(Intermediate): 흐름을 “가공”합니다. 대표적으로 filter, map, sorted, distinct 등이 있고, 지연(lazy) 평가라서 바로 실행되지 않습니다.
  3. 최종연산(Terminal): 결과를 “만들어 확정”합니다. (collect, toList, count, forEach 등) 최종연산이 호출되는 순간 중간연산이 실제로 수행됩니다.

비유하자면, 정수기처럼 생각하시면 이해가 빨라요.

  • 물을 넣는 단계(생성) → 필터를 거치는 단계(중간연산) → 컵에 받는 단계(최종연산)
    중간연산만 잔뜩 붙여도 “컵에 받기” 전까지는 물이 실제로 흐르지 않는 것(지연 평가)이 핵심입니다.

Stream에서 가장 자주 쓰는 3종 세트: filter / map / collect

  • filter(Predicate): 조건에 맞는 요소만 남깁니다. (선별)
  • map(Function): 요소를 다른 형태로 바꿉니다. (변환)
  • collect(...) 또는 toList(): 원하는 자료구조로 모읍니다. (수집)

특히 실무에서는 “엔티티/DTO 목록 → 조건 필터 → 필요한 필드 추출 → 리스트/맵으로 수집” 패턴이 정말 자주 나옵니다.

중간연산/최종연산 선택 가이드(요약)

목적 주로 쓰는 API 결과
조건에 맞는 것만 남기기 filter Stream 유지(중간)
형태/타입 변환 map / mapToInt Stream 유지(중간)
리스트로 만들기 toList()(Java 16+) / collect(toList()) List(최종)
맵으로 만들기 collect(toMap(...)) Map(최종)
그룹화/집계 collect(groupingBy(...)) Map(최종)
존재/전체 검사 anyMatch / allMatch boolean(최종)

Java 17 기준으로 Stream.toList()수정 불가능(unmodifiable) 리스트를 반환합니다. “나중에 add 해야 하는 리스트”라면 Collectors.toCollection(ArrayList::new) 같은 선택이 필요합니다.

flowchart LR
  A["Source(생성)"] --> B["Intermediate(중간연산)"]
  B --> C["Intermediate(중간연산)"]
  C --> D["Terminal(최종연산)"]

Stream은 생성에서 시작해 중간연산을 거친 뒤, 최종연산에서 결과가 확정됩니다.

코드 예제: filter/map/collect 실전 패턴 한 번에 실행해 보기 (Java 17)

아래 코드는 그대로 복붙해서 실행할 수 있는 단일 파일 예제입니다.

  • 생성: List.of(...)stream()
  • 중간연산: filter / map
  • 최종연산: toList(), collect(groupingBy), collect(toMap)
  • 실무에서 자주 겪는 “toMap 키 중복”도 안전하게 처리합니다.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

public class StreamBasicsDemo {

    public static void main(String[] args) {
        List<User> users = List.of(
                new User(1L, "Alice", "ACTIVE", 29),
                new User(2L, "Bob", "INACTIVE", 41),
                new User(3L, "Chris", "ACTIVE", 17),
                new User(4L, "Daisy", "ACTIVE", 41),
                new User(5L, "Bob", "ACTIVE", 33) // 이름 중복 intentionally
        );

        // 1) filter + map + toList: "활성 사용자 이름 목록"
        List<String> activeNames = users.stream()                 // 생성
                .filter(u -> u.status().equals("ACTIVE"))         // 중간: 필터
                .map(User::name)                                  // 중간: 변환
                .toList();                                        // 최종: 수집 (unmodifiable)

        System.out.println("[activeNames] " + activeNames);

        // 2) filter + sorted + map + collect(toCollection):
        // "성인(>=19) 활성 사용자 이름을 나이 내림차순으로, 그리고 '수정 가능한' 리스트로"
        List<String> editableAdultActiveNames = users.stream()
                .filter(u -> u.status().equals("ACTIVE"))
                .filter(u -> u.age() >= 19)
                .sorted(Comparator.comparingInt(User::age).reversed())
                .map(User::name)
                // toList()는 unmodifiable일 수 있으니, 수정 필요하면 명시적으로 컬렉션을 선택합니다.
                .collect(Collectors.toCollection(ArrayList::new));

        editableAdultActiveNames.add("NEW_USER");
        System.out.println("[editableAdultActiveNames] " + editableAdultActiveNames);

        // 3) groupingBy: "상태별로 사용자 그룹핑"
        Map<String, List<User>> usersByStatus = users.stream()
                .collect(Collectors.groupingBy(User::status));

        System.out.println("[usersByStatus] " + usersByStatus);

        // 4) toMap: "id -> user" 맵 만들기 (키 유일할 때 가장 깔끔)
        Map<Long, User> userById = users.stream()
                .collect(Collectors.toMap(User::id, Function.identity()));

        System.out.println("[userById] " + userById);

        // 5) toMap 키 중복 처리: "name -> user" (이름 중복이 있을 수 있음)
        // 충돌 시 어떤 값을 남길지 merge 함수를 반드시 정의합니다.
        Map<String, User> userByNameKeepOlder = users.stream()
                .collect(Collectors.toMap(
                        User::name,
                        Function.identity(),
                        (u1, u2) -> u1.age() >= u2.age() ? u1 : u2 // 나이가 더 많은 사용자 유지
                ));

        System.out.println("[userByNameKeepOlder] " + userByNameKeepOlder);
    }

    record User(long id, String name, String status, int age) { }
}

실무 팁

💡 실무에서는: toList() 결과가 “수정 불가능”일 수 있다는 점을 전제로 설계해 보세요

  • Java 17에서 stream().toList()unmodifiable List를 반환합니다.
  • 결과를 후처리로 add/remove 해야 한다면 아래처럼 의도를 코드에 드러내는 편이 안전합니다.
    • collect(Collectors.toCollection(ArrayList::new))
  • 반대로 “절대 수정되면 안 되는 결과”라면 toList()가 오히려 버그를 줄여줍니다(방어적 설계).

💡 실무에서는: Collectors.toMap()은 “키 중복”이 나면 바로 예외가 터집니다

  • toMap(keyMapper, valueMapper)는 키가 중복되면 IllegalStateException이 발생합니다.
  • 데이터 특성상 중복 가능성이 조금이라도 있다면, 처음부터 merge 함수를 넣어두는 습관이 좋습니다.
    • 예: (oldV, newV) -> oldV 또는 “최신 값 우선”, “큰 값 우선” 같은 정책을 명확히 정하세요.

핵심 요약: Stream은 생성 → 중간연산 → 최종연산 파이프라인으로 읽으면 됩니다.
핵심 요약: filter/map/collect 조합만 익혀도 실무 컬렉션 가공의 70% 이상이 정리됩니다.
핵심 요약: toList() 불변성, toMap() 키 중복 예외는 현장에서 특히 자주 밟는 포인트입니다.

다음 글: [Stream 실전 활용 & 주의점]