1. Stream이란?
Stream은 컬렉션의 데이터를 선언형 방식으로 처리하기 위한 API입니다. Java 8에서 도입됐으며 반복 방법보다 무엇을 처리할지에 집중하게 해 줍니다.
기존 반복문은 처리 순서를 직접 작성합니다.
List<String> names = List.of("Kim", "Lee", "Park");
for (String name : names) {
if (name.startsWith("K")) {
System.out.println(name);
}
}
Stream에서는 원하는 조건과 결과를 연산으로 표현합니다.
List<String> result = names.stream()
.filter(name -> name.startsWith("K"))
.toList();
Stream.toList()는 Java 16부터 지원됩니다. Java 8~15에서는collect(Collectors.toList())를 사용합니다.
2. Stream의 특징
원본 데이터를 변경하지 않는다
List<Integer> numbers = List.of(3, 2, 1);
List<Integer> result = numbers.stream()
.sorted()
.toList();
System.out.println(numbers); // [3, 2, 1]
Stream은 원본 컬렉션을 변경하지 않고 새로운 결과를 만듭니다.
한 번만 사용할 수 있다
Stream<Integer> stream = numbers.stream();
stream.forEach(System.out::println);
stream.forEach(System.out::println); // IllegalStateException
최종 연산을 수행한 Stream은 소비된 상태이므로 다시 사용할 수 없습니다.
지연 실행한다
중간 연산은 최종 연산이 호출될 때까지 실행되지 않습니다.
numbers.stream()
.filter(n -> n > 5); // 아직 실행되지 않는다.
numbers.stream()
.filter(n -> n > 5)
.toList(); // 최종 연산에서 실행된다.
3. Stream 파이프라인
flowchart LR
Source["Source<br/>Collection·Array"] --> Create["Stream 생성"]
Create --> Intermediate["중간 연산<br/>filter·map·sorted"]
Intermediate --> Terminal["최종 연산<br/>toList·count·reduce"]
Terminal --> Result["결과"]
members.stream()
.filter(Member::active)
.map(Member::name)
.sorted()
.toList();
중간 연산은 Stream을 반환해 연속으로 연결할 수 있고, 최종 연산은 파이프라인을 실행해 결과를 만듭니다.
4. Stream 생성
Collection에서는 stream()을 사용합니다.
List<String> list = List.of("A", "B", "C");
Stream<String> stream = list.stream();
배열은 Arrays.stream()으로 변환합니다.
String[] array = {"A", "B", "C"};
Stream<String> stream = Arrays.stream(array);
값을 직접 전달할 수도 있습니다.
Stream<Integer> stream = Stream.of(1, 2, 3);
5. 중간 연산
중간 연산은 Stream을 반환하며 최종 연산이 호출되기 전까지 지연됩니다.
filter()
조건을 만족하는 요소만 통과시킵니다.
numbers.stream()
.filter(n -> n % 2 == 0);
flowchart LR
Input["1 · 2 · 3 · 4"] --> Filter{"짝수인가?"}
Filter --> Output["2 · 4"]
map()
각 요소를 다른 값이나 타입으로 변환합니다.
members.stream()
.map(Member::name);
flowchart LR
Members["Stream<Member>"] --> Map["map(Member::name)"]
Map --> Names["Stream<String>"]
sorted()
numbers.stream().sorted();
numbers.stream().sorted(Comparator.reverseOrder());
members.stream()
.sorted(Comparator.comparing(Member::age));
distinct(), limit(), skip()
stream.distinct(); // 중복 제거
stream.limit(5); // 앞에서 5개 선택
stream.skip(10); // 앞에서 10개 건너뛰기
6. 최종 연산
최종 연산이 호출되면 Stream 파이프라인이 실행되고 Stream은 소비됩니다.
결과 수집
List<Member> result = members.stream().toList();
반복 처리와 개수 계산
members.stream().forEach(System.out::println);
long count = members.stream().count();
요소 검색
Optional<Member> first = members.stream().findFirst();
findFirst(), findAny(), max(), min()은 값이 없을 수 있으므로 Optional<T>를 반환합니다.
조건 검사
boolean hasAdult = members.stream()
.anyMatch(member -> member.age() >= 30);
boolean allActive = members.stream()
.allMatch(Member::active);
boolean noMinor = members.stream()
.noneMatch(member -> member.age() < 20);
7. Collectors
collect()는 Stream의 요소를 List, Map, 그룹 등의 가변 결과 컨테이너로 수집합니다.
toMap()
Map<Long, Member> memberMap = members.stream()
.collect(Collectors.toMap(
Member::id,
Function.identity()
));
키가 중복될 가능성이 있다면 병합 함수를 명시해야 합니다.
Map<String, Member> memberMap = members.stream()
.collect(Collectors.toMap(
Member::name,
Function.identity(),
(existing, replacement) -> existing
));
groupingBy()
Map<Integer, List<Member>> membersByAge = members.stream()
.collect(Collectors.groupingBy(Member::age));
flowchart LR
Members["Member 목록"] --> Group["groupingBy(Member::age)"]
Group --> Age20["20 → Kim"]
Group --> Age35["35 → Lee, Han"]
partitioningBy()
조건 결과가 true와 false인 두 그룹으로 분리합니다.
Map<Boolean, List<Member>> partitioned = members.stream()
.collect(Collectors.partitioningBy(Member::active));
joining()
String names = members.stream()
.map(Member::name)
.collect(Collectors.joining(", "));
결과는 Kim, Lee, Park와 같은 하나의 문자열입니다.
8. reduce()
reduce()는 여러 요소를 하나의 값으로 축약합니다.
int sum = numbers.stream()
.reduce(0, Integer::sum);
int max = numbers.stream()
.reduce(Integer.MIN_VALUE, Math::max);
String names = members.stream()
.map(Member::name)
.reduce("", (a, b) -> a.isEmpty() ? b : a + ", " + b);
합계, 평균, 최댓값, 최솟값은 기본형 특화 Stream을 사용하면 더 간결하고 boxing 비용도 줄일 수 있습니다.
int sum = members.stream()
.mapToInt(Member::age)
.sum();
OptionalDouble average = members.stream()
.mapToInt(Member::age)
.average();
9. 타입 흐름 이해하기
List<String> activeMemberNames = members.stream()
.filter(Member::active)
.map(Member::name)
.toList();
flowchart LR
List["List<Member>"] --> Stream1["Stream<Member>"]
Stream1 -->|"filter"| Stream2["Stream<Member>"]
Stream2 -->|"map"| Stream3["Stream<String>"]
Stream3 -->|"toList"| Result["List<String>"]
filter()는 요소의 타입을 유지하고 map()은 요소의 타입을 변경할 수 있습니다.
10. 실무에서 자주 사용하는 패턴
Entity를 DTO로 변환
return memberRepository.findAll().stream()
.filter(Member::active)
.map(MemberResponse::from)
.toList();
List를 Map으로 변환
Map<Long, Member> memberMap = members.stream()
.collect(Collectors.toMap(
Member::id,
Function.identity()
));
기준별 그룹 생성
Map<Integer, List<Member>> membersByAge = members.stream()
.collect(Collectors.groupingBy(Member::age));
존재 여부 확인
boolean exists = members.stream()
.anyMatch(member -> member.age() >= 30);
11. Stream 사용 시 주의사항
복잡한 비즈니스 규칙을 람다에 숨기지 않는다
다음 코드는 조건의 의미를 파악하기 어렵습니다.
.filter(member ->
member.getPoint() > 1000 &&
member.getOrders().size() > 10 &&
member.getStatus() == Status.ACTIVE
)
DDD에서는 규칙을 도메인 객체의 이름 있는 메서드로 옮기는 편이 좋습니다.
.filter(Member::isLoyalCustomer)
부수 효과를 피한다
map()이나 filter() 안에서 외부 상태를 변경하면 파이프라인을 이해하기 어렵고 병렬 처리에서도 문제가 될 수 있습니다. Stream 연산은 가능한 한 순수 함수로 구성합니다.
무조건 반복문을 대체하지 않는다
분기와 상태 변경이 많은 복잡한 처리에서는 일반 반복문이 더 읽기 쉬울 수 있습니다. Stream은 변환·필터·집계 파이프라인이 명확할 때 사용합니다.
12. 주요 메서드 사용 빈도
| 메서드 | 사용 빈도 |
|---|---|
filter() |
⭐⭐⭐⭐⭐ |
map() |
⭐⭐⭐⭐⭐ |
toList() |
⭐⭐⭐⭐⭐ |
findFirst() |
⭐⭐⭐⭐⭐ |
anyMatch() |
⭐⭐⭐⭐⭐ |
sorted() |
⭐⭐⭐⭐☆ |
Collectors.toMap() |
⭐⭐⭐⭐☆ |
Collectors.groupingBy() |
⭐⭐⭐⭐☆ |
Collectors.partitioningBy() |
⭐⭐⭐☆☆ |
reduce() |
⭐⭐☆☆☆ |
13. Spring과 DDD에서 자주 보는 코드
public List<MemberResponse> getActiveMembers() {
return memberRepository.findAll().stream()
.filter(Member::active)
.sorted(Comparator.comparing(Member::name))
.map(MemberResponse::from)
.toList();
}
flowchart LR
Repository["Repository 결과"] --> Stream["stream()"]
Stream --> Filter["filter()<br/>활성 회원 선택"]
Filter --> Sort["sorted()<br/>이름 정렬"]
Sort --> Map["map()<br/>Entity → DTO"]
Map --> List["toList()<br/>결과 반환"]
이 흐름에는 Stream의 핵심인 생성, 선택, 정렬, 변환, 수집이 모두 포함돼 있습니다.
Optional과의 관계
Stream과 Optional은 서로 다른 API지만 일부 최종 연산은 결과가 없을 가능성을 표현하기 위해 Optional을 반환합니다.
members.stream().findFirst(); // Optional<Member>
members.stream().findAny(); // Optional<Member>
members.stream().max(...); // Optional<Member>
members.stream().min(...); // Optional<Member>
Optional의 생성과 orElse(), orElseThrow(), ifPresent(), map()은 별도 주제로 나누어 학습하는 편이 명확합니다.
핵심 정리
- Stream은 원본 컬렉션을 변경하지 않습니다.
- Stream은 최종 연산 후 재사용할 수 없습니다.
- 중간 연산은 지연되며 최종 연산에서 실행됩니다.
filter()는 요소를 선택하고map()은 값과 타입을 변환합니다.- 복잡한 비즈니스 규칙과 부수 효과를 람다에 숨기지 않습니다.
Stream의 핵심은 반복문을 짧게 쓰는 것이 아니라 데이터 처리 과정을 읽을 수 있는 파이프라인으로 표현하는 것입니다.