Java 17 기준 Stream 파이프라인의 기본 흐름(생성-중간-최종)과 filter/map/collect 실전 패턴을 예제로 빠르게 익힙니다.
람다식을 배웠는데, 막상 리스트를 가공하려고 하면 for 문이 다시 늘어나기 시작할 때가 있어요. “필터링하고, 변환하고, 원하는 형태로 모으는” 작업이 반복되면 코드가 금방 지저분해집니다. 이럴 때 Stream API의 기본 흐름만 잡아두면, 대부분의 컬렉션 가공이 깔끔하게 정리됩니다.
핵심 개념: Java Stream API 파이프라인(생성 → 중간연산 → 최종연산)

Stream은 한마디로 데이터를 ‘흐름’으로 보고 단계별로 가공하는 파이프라인이에요. 중요한 포인트는 딱 3가지입니다.
- 생성(Source): 어디서 데이터를 가져올지 결정합니다. (
list.stream(),Stream.of(...),Files.lines(...)등) - 중간연산(Intermediate): 흐름을 “가공”합니다. 대표적으로
filter,map,sorted,distinct등이 있고, 지연(lazy) 평가라서 바로 실행되지 않습니다. - 최종연산(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 실전 활용 & 주의점]
'JAVA' 카테고리의 다른 글
| Java Optional 올바르게 쓰기: of vs ofNullable, 안티패턴, 실무 가이드라인 (0) | 2026.02.23 |
|---|---|
| Java Stream 실전 활용 & 주의점: groupingBy, flatMap, Optional 연계부터 병렬 스트림 함정까지 (0) | 2026.02.22 |
| Java 람다식 — 콜백 지옥 탈출 (함수형 인터페이스부터 메서드 레퍼런스까지) (0) | 2026.02.21 |
| Java 제네릭 실전 패턴: 제네릭 메서드·인터페이스 설계와 자주 만나는 에러 해결법 (0) | 2026.02.21 |
| Java 와일드카드 완전 정복 — <? extends T>, <? super T> 그리고 PECS 원칙 (0) | 2026.02.20 |