1. 소개
학생들의 목록이 있고, 각 학생마다 취미 목록이 있다고 가정해 봅시다. 예를 들어:
List<List<String>> hobbies = List.of(
List.of("수영", "체스"),
List.of("축구"),
List.of("프로그래밍", "독서", "영화")
);
여러분의 과제: 모든 취미를 하나의 목록으로 모아 학생들이 전반적으로 무엇에 관심이 있는지 알아내는 것입니다. 논리적으로는 map을 사용해 보려 할 겁니다:
List<Stream<String>> streams = hobbies.stream()
.map(list -> list.stream())
.collect(Collectors.toList());
무엇을 얻었나요? 스트림들의 목록, 즉 Stream<Stream<String>>입니다. 우리가 원하는 것은 각 취미를 바로 다룰 수 있는 단순한 Stream<String>이었죠.
여러분 앞에 상자가 있고, 그 안에 또다른 장난감 상자들이 들어 있다고 상상해 보세요. map은 각 상자를 꺼내 “상자” 자체를 보여줍니다(Stream<Stream<String>>). 하지만 우리는 상자를 뒤적이지 않고 장난감 전체(Stream<String>)를 바로 보고 싶습니다.
2. flatMap: 중첩 컬렉션을 “펼치기”
상자를 “풀어서” 평탄한 스트림을 얻으려면 flatMap이 필요합니다.
각 요소에 대해 스트림을 반환하는 함수를 받고, 그 모든 내부 스트림을 하나로 “평탄화”합니다.
flatMap 문법
Stream<T> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper)
쉽게 말하면: flatMap은 각 요소에 대해 Stream을 반환해 주길 기대하고, 그렇게 반환된 것들을 하나의 큰 스트림으로 “폅니다”.
예시: 모든 학생의 취미를 합치기
List<String> allHobbies = hobbies.stream()
.flatMap(list -> list.stream())
.collect(Collectors.toList());
System.out.println(allHobbies);
// [수영, 체스, 축구, 프로그래밍, 독서, 영화]
각 취미 목록에 대해 list.stream()을 호출해(한 학생의 취미 스트림) 얻고, flatMap이 모든 스트림을 하나의 큰 스트림으로 “평탄화”했습니다 — 이제 우리는 “취미들의 스트림”이 아니라 모든 취미를 직접 볼 수 있습니다.
시각적 도식
hobbies.stream()
|
|---> [수영, 체스] -> stream
|---> [축구] -> stream
|---> [프로그래밍, ...] -> stream
|
flatMap: 모든 것을 하나의 Stream<String>으로 결합
왜 map으로는 해결되지 않을까?
map을 사용하면 Stream<Stream<String>>이 되므로 불편합니다. 예를 들어 모든 문자열을 바로 순회할 수 없고, 추가적인 순회가 필요합니다.
3. flatMap의 실전 사용 예
문자열을 문자로 분해하기 (List<String> → Stream<Character>)
문자열 목록이 있고, 모든 문자열에 등장하는 문자 전체의 스트림을 얻고 싶다고 합시다:
List<String> words = List.of("Java", "Stream");
List<Character> characters = words.stream()
.flatMap(word -> word.chars().mapToObj(ch -> (char) ch))
.collect(Collectors.toList());
System.out.println(characters);
// [J, a, v, a, S, t, r, e, a, m]
설명:
- word.chars()는 IntStream(문자 코드의 스트림)을 반환합니다.
- mapToObj는 코드를 문자로 바꿉니다.
- flatMap은 얻어진 문자 스트림들을 하나로 합칩니다.
Optional 다루기: Stream<Optional<T>> → Stream<T>
Optional 객체들의 목록이 있고, 실제로 값이 존재하는 것들만의 스트림을 얻고 싶다고 합시다:
List<Optional<String>> optionals = List.of(
Optional.of("Java"),
Optional.empty(),
Optional.of("Stream")
);
List<String> present = optionals.stream()
.flatMap(opt -> opt.stream())
.collect(Collectors.toList());
System.out.println(present);
// [Java, Stream]
포인트:
Optional<T>은(Java 9부터) stream() 메서드를 제공하여 빈 스트림 또는 단일 요소 스트림을 반환합니다. flatMap은 모든 비어 있지 않은 값을 하나의 스트림으로 합칩니다.
4. 새로운 mapMulti: flatMap이 필요 없을 때
왜 mapMulti가 도입되었을까?
Java 16에 도입된 mapMulti는 flatMap과 유사하게 동작하지만 조금 더 효율적이고 유연합니다.
flatMap은 강력하지만, 각 요소에 대해 0개, 1개 또는 여러 개의 요소를 반환하고 싶더라도 반드시 새로운 Stream을 만들어야 한다는 단점이 있습니다. 단지 요소들을 “펼치기”만 하면 되는 경우에는 비효율적일 수 있죠.
mapMulti는 중간 구조를 만들지 않고 Consumer를 통해 결과 스트림에 필요한 값들을 직접 추가할 수 있게 해 주는, flatMap의 개선된 버전입니다. 스트림을 반환하는 대신, 결과 스트림에 어떤 요소들을 추가할지 Consumer로 직접 지정합니다.
mapMulti 문법
<R> Stream<R> mapMulti(BiConsumer<? super T, ? super Consumer<R>> mapper)
각 요소마다 함수 mapper가 호출되며, 이 함수는 요소 자체와 새로운 값을 “담을” Consumer를 전달받습니다.
예시: 한 번에 필터링과 펼치기
숫자 목록이 있고, 짝수는 스트림에 두 번 추가하고 홀수는 아예 추가하지 않으려 한다고 합시다(즉, 필터링과 “펼치기”를 동시에 수행):
List<Integer> numbers = List.of(1, 2, 3, 4);
List<Integer> result = numbers.stream()
.mapMulti((number, consumer) -> {
if (number % 2 == 0) {
consumer.accept(number);
consumer.accept(number); // 두 번 추가
}
// 홀수는 아무 것도 하지 않음(필터링)
})
.collect(Collectors.toList());
System.out.println(result);
// [2, 2, 4, 4]
flatMap과의 비교
같은 작업을 flatMap으로 작성하면 다음과 같습니다:
List<Integer> result = numbers.stream()
.flatMap(number -> number % 2 == 0
? Stream.of(number, number)
: Stream.empty())
.collect(Collectors.toList());
여기서는 각 요소마다 Stream.of(...) 또는 Stream.empty()를 만들어야 하므로, 비효율적일 수 있습니다.
5. flatMap을 쓸 때와 mapMulti를 쓸 때
- map — 각 요소를 하나의 요소로 변환할 때.
- flatMap — 각 요소를 여러 요소의 스트림으로 변환할 때.
- mapMulti — 중간 스트림을 만들지 않고 여러 요소를 생성하고 싶을 때(특히 뜨거운 루프에서 더 효율적).
또 다른 예: Map<Integer, List<String>>를 Stream<Pair(Integer, String)>로 펼치기
id → 취미 목록이라는 맵이 있다고 합시다.
Map<Integer, List<String>> studentHobbies = Map.of(
1, List.of("수영", "체스"),
2, List.of("축구"),
3, List.of("프로그래밍", "독서", "영화")
);
List<String> allHobbies = studentHobbies.values().stream()
.flatMap(Collection::stream)
.collect(Collectors.toList());
System.out.println(allHobbies);
// [수영, 체스, 축구, 프로그래밍, 독서, 영화]
이제 (id, 취미) 쌍을 얻고 싶다면:
List<String> pairs = studentHobbies.entrySet().stream()
.flatMap(entry -> entry.getValue().stream()
.map(hobby -> entry.getKey() + ": " + hobby))
.collect(Collectors.toList());
System.out.println(pairs);
// [1: 수영, 1: 체스, 2: 축구, 3: 프로그래밍, 3: 독서, 3: 영화]
mapMulti(Java 16+)로 작성하면:
List<String> pairs = studentHobbies.entrySet().stream()
.mapMulti((entry, consumer) -> {
for (String hobby : entry.getValue()) {
consumer.accept(entry.getKey() + ": " + hobby);
}
})
.collect(Collectors.toList());
장점:
mapMulti는 중간 스트림을 만들지 않고 결과 스트림으로 값을 직접 “배출”합니다.
시각화: flatMap vs mapMulti
| 메서드 | 함수가 무엇을 반환하는가 | 어떻게 결합하는가 | 중간 컬렉션? |
|---|---|---|---|
|
단일 요소 | 단순히 | 아니오 |
|
스트림(Stream) | 결합 | 예(중간 Stream) |
|
Consumer (0..n회) | 결합 | 아니오(직접 추가) |
6. flatMap과 mapMulti 사용 시 흔한 실수
오류 №1: Stream<T> 대신 Stream<Stream<T>>를 얻음. 컬렉션의 컬렉션을 다룰 때 map을 flatMap 대신 쓰는 경우가 종종 있습니다. 그 결과 불필요한 반복문을 추가로 작성하게 됩니다.
오류 №2: 반환 타입이 올바르지 않음. flatMap의 함수는 Stream을 반환해야 하며, List나 배열을 반환하면 안 됩니다.
오류 №3: 비효율성. 간단한 경우에는 flatMap이 잘 동작하지만, 각 요소마다 Stream.of나 Stream.empty()를 만들어야 한다면 과할 수 있습니다. 이런 작업에는 mapMulti가 더 좋습니다.
오류 №4: mapMulti는 오래된 Java 버전에서 동작하지 않음. mapMulti는 Java 16에서 추가되었습니다. 더 오래된 JDK에서는 이 메서드를 사용할 수 없습니다.
오류 №5: null 문제. flatMap에서 null을 반환하지 마세요 — “빈” 경우에는 항상 Stream.empty()를 반환해야 합니다.
오류 №6: 중간 컬렉션 남발. 추가적인 리스트나 스트림을 만들지 말고, 가능하다면 mapMulti에서 consumer로 요소를 직접 추가하세요.
GO TO FULL VERSION