Stream map과 flatMap

mongBrown·2026년 5월 17일

Stream map과 flatMap

Stream에서 mapflatMap은 둘 다 변환 연산이다. 차이는 mapper가 뭘 반환하느냐에서 갈린다.


map

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

flatMap

문제는 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>, 펼쳐서 합침

Optional에서의 flatMap

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>>>이 됐을 것이다.


언제 map, 언제 flatMap인가

타입으로 판단하면 명확하다. mapper가 T 같은 단일 값을 반환하면 map, Stream<T>Optional<T>처럼 한 번 더 감싼 타입을 반환하면 flatMap이다. 중첩이 생기면 flatMap이 필요하다는 신호다.


지연 평가

filtermap 같은 중간 연산은 선언만 해도 즉시 실행되지 않는다. 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 vs reduce

최종 연산에는 두 가지 방향이 있다.

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 + nsum += n과 1:1로 대응된다.

합계, 곱, 최댓값처럼 하나의 값으로 줄여야 할 때는 reduce, 컬렉션에 담아야 할 때는 collect다.

profile
화이팅!

0개의 댓글