Stream에서 map과 flatMap은 둘 다 변환 연산이다. 차이는 mapper가 뭘 반환하느냐에서 갈린다.
map은 스트림의 각 요소를 하나의 다른 값으로 변환한다. VO를 DTO로 바꾸거나 특정 필드만 뽑아낼 때 쓴다.
List<Order> orders = ...;
// Order → orderId 하나씩 꺼내기
List<Long> orderIds = orders.stream()
.map(order -> order.getId()) // Order 하나 → Long 하나
.toList();
mapper가 단일 값을 반환하면 스트림 타입도 단순하게 변한다. Stream<Order> → Stream<Long>.
Optional에서도 마찬가지다.
Optional<User> optUser = Optional.of(user);
Optional<String> name = optUser.map(u -> u.getName()); // User → String
문제는 mapper가 단일 값이 아니라 Stream이나 컬렉션을 반환할 때다.
주문 목록에서 각 주문의 상품 전체를 뽑아야 한다고 해보자. order.getItems()는 List<Item>을 반환한다.
// map 쓰면
Stream<List<Item>> result = orders.stream()
.map(order -> order.getItems()); // Order → List<Item>
원하는 건 Stream<Item>인데 Stream<List<Item>>이 나온다. List가 한 겹 더 감싸진 것이다. 이 상태로는 Item에 바로 접근할 수 없다.
flatMap은 변환과 동시에 중첩을 한 단계 펼쳐준다.
// flatMap 쓰면
Stream<Item> result = orders.stream()
.flatMap(order -> order.getItems().stream()); // Order → Stream<Item>, 펼쳐서 합침
getEmail()처럼 Optional을 반환하는 메서드를 map에 넣으면 두 겹이 생긴다.
// map 쓰면 두 겹
Optional<Optional<String>> result = optUser.map(u -> u.getEmail());
// flatMap 쓰면 한 겹
Optional<String> result = optUser.flatMap(u -> u.getEmail());
체이닝할 때 특히 유용하다.
Optional<String> city = optUser
.flatMap(u -> u.getAddress()) // Optional<Address>
.flatMap(a -> a.getCity()); // Optional<String>
map을 썼다면 Optional<Optional<Optional<String>>>이 됐을 것이다.
타입으로 판단하면 명확하다. mapper가 T 같은 단일 값을 반환하면 map, Stream<T>나 Optional<T>처럼 한 번 더 감싼 타입을 반환하면 flatMap이다. 중첩이 생기면 flatMap이 필요하다는 신호다.
filter나 map 같은 중간 연산은 선언만 해도 즉시 실행되지 않는다. collect()나 findFirst() 같은 최종 연산이 호출되는 순간 비로소 실행된다.
names.stream()
.filter(name -> name.startsWith("A"))
.map(String::toUpperCase)
.findFirst(); // 이 시점에 위 두 연산이 실행됨
실행 방식도 다르다. 리스트 전체를 filter 돌리고 전체를 map 돌리는 게 아니라, 원소 하나씩 파이프라인 전체를 통과시킨다.
Alice → filter(통과) → map → findFirst 획득 → 종료
Bob → 처리 안 함
Charlie → 처리 안 함
David → 처리 안 함
1억 개 중에 하나만 찾으면 되는 상황에서 나머지를 건드리지 않는다는 게 지연 평가의 핵심 이점이다.
단, 중간 연산에 side effect를 넣으면 문제가 된다.
stream.filter(x -> {
System.out.println(x); // 언제 찍힐지 보장 없음
return x > 0;
}).map(x -> x * 2);
// 최종 연산 없음 → println도 안 찍힘
최종 연산을 빠뜨리면 중간 연산 전체가 실행되지 않는다. 컴파일 에러도 안 나고 조용히 아무 일도 일어나지 않기 때문에 디버깅이 어렵다.
최종 연산에는 두 가지 방향이 있다.
collect는 결과를 컨테이너에 담을 때 쓴다.
List<String> names = stream.collect(Collectors.toList()); // 또는 .toList()
Map<Long, Item> map = stream.collect(Collectors.toMap(Item::getId, i -> i));
reduce는 여러 요소를 누적해서 값 하나로 줄일 때 쓴다.
// for문으로 짜면
int sum = 0;
for (int n : numbers) sum += n;
// reduce로 짜면
int sum = numbers.stream()
.reduce(0, (acc, n) -> acc + n);
// ↑ ↑
// 초기값 acc += n 과 동일한 구조
두 번째 파라미터 (acc, n) -> acc + n이 sum += n과 1:1로 대응된다.
합계, 곱, 최댓값처럼 하나의 값으로 줄여야 할 때는 reduce, 컬렉션에 담아야 할 때는 collect다.