groupingBy()와 toMap() 은 내부 아이디어가 매우 비슷하다.
둘 다
"Key를 만들어서 Map에 저장한다."
는 공통점이 있기 때문이다.
1. toMap()
Map<String, Integer> result =
names.stream()
.collect(Collectors.toMap(
name -> name,
String::length
));
흐름
Kim -> key: Kim value: 3
Jo -> key: Jo value: 2
Park -> key: Park value: 4
↓
{
Kim=3,
Jo=2,
Park=4
}
즉,
원소 하나 → Key 하나, Value 하나
이다.
2. groupingBy()
Map<Integer, List<String>> result =
names.stream()
.collect(Collectors.groupingBy(
String::length
));
흐름
Kim -> key: 3
Jo -> key: 2
Park -> key: 4
Lee -> key: 3
↓
{
2=[Jo],
3=[Kim, Lee],
4=[Park]
}
즉,
원소 하나 → Key 하나, Value는 List에 계속 추가
이다.
그림으로 보면
toMap
Kim
│
├── key : Kim
└── value : 3
↓
Map
Kim → 3
groupingBy
Kim
│
└── key : 3
↓
Map
3 → [Kim]
Lee
│
└── key : 3
↓
Map
3 → [Kim, Lee]
그래서 내부적으로도 비슷하다.
toMap
개념적으로
for (String name : names) {
K key = ...;
V value = ...;
map.put(key, value);
}
groupingBy
개념적으로
for (String name : names) {
K key = ...;
map.computeIfAbsent(key,
k -> new ArrayList<>());
map.get(key).add(name);
}
즉,
groupingBy는 Map의 value가 Collection인 특수한 toMap이라고 생각해도 된다.
그래서 차이는 딱 하나다.
toMap
Map<K, V>
한 Key에는 Value가 하나만 있다.
Kim -> 3
Jo -> 2
Park-> 4
중복 Key가 나오면
IllegalStateException
이 발생한다.
groupingBy
Map<K, List<V>>
한 Key에 여러 개가 들어갈 수 있다.
3 -> [Kim, Lee]
2 -> [Jo]
4 -> [Park]
중복 Key가 나오는 것이 정상 동작이다.
실무에서는 이렇게 기억하면 된다.
toMap()
──────────────
원소
│
▼
(key, value)
│
▼
Map<K, V>
groupingBy()
────────────────
원소
│
▼
key
│
▼
같은 key끼리 자동으로 List에 모음
│
▼
Map<K, List<T>>
더 재미있는 관점
사실 groupingBy()는 아래와 같은 toMap()을 내부적으로 편하게 만든 API라고 생각해도 된다.
Collectors.toMap(
User::getDepartment,
user -> List.of(user),
(list1, list2) -> {
List<User> merged = new ArrayList<>(list1);
merged.addAll(list2);
return merged;
}
)
이걸 너무 자주 쓰니까 JDK에서
Collectors.groupingBy(...)
라는 전용 Collector를 만들어 준 것이다.
한 줄로 정리
groupingBy()가 toMap()과 비슷해 보이는 이유는 둘 다 "원소를 Key 기준으로 Map에 저장한다"는 동일한 아이디어를 사용하기 때문이다.
차이는
- toMap()은 Key → Value 하나
- groupingBy()는 Key → Value(List/Set/Downstream 결과 여러 개)
같은 목적이라면 groupingBy()와 toMap()의 성능 차이는 거의 의미가 없다.
오히려 어떤 자료구조를 만들고 어떤 작업을 하는지가 더 중요하다.
1. toMap()
Map<String, Integer> result =
names.stream()
.collect(Collectors.toMap(
name -> name,
String::length
));
내부적으로는 거의
Map<String, Integer> map = new HashMap<>();
for (String name : names) {
map.put(name, name.length());
}
와 비슷하다.
시간복잡도
N개의 원소
put() : O(1) 평균
전체 : O(N)
2. groupingBy()
Map<Integer, List<String>> result =
names.stream()
.collect(Collectors.groupingBy(
String::length
));
내부적으로는
Map<Integer, List<String>> map = new HashMap<>();
for (String name : names) {
int key = name.length();
map.computeIfAbsent(key, k -> new ArrayList<>());
map.get(key).add(name);
}
와 거의 같다.
시간복잡도
N개의 원소
computeIfAbsent() : O(1)
add() : O(1)
전체 : O(N)
3. 성능은 거의 비슷하다.
둘 다
원소 하나
↓
Hash 계산
↓
Map 조회
↓
저장
을 반복한다.
그래서
O(N)
이다.
4. 오히려 메모리 차이가 더 크다.
toMap()
Kim -> 3
Jo -> 2
Park -> 4
Map
Kim -> Integer
Jo -> Integer
Park-> Integer
Value가 하나만 있다.
groupingBy()
Kim
Lee
Park
↓
Map
3 -> List(Kim, Lee)
4 -> List(Park)
List 객체가 추가로 생성된다.
즉,
HashMap
+
ArrayList
+
ArrayList 내부 배열
등이 생긴다.
5. 다운스트림을 쓰면 더 효율적일 수도 있다.
예를 들어
비효율
users.stream()
.collect(groupingBy(User::getDept))
↓
Map<String, List<User>>
를 만들고
다시
list.size()
를 호출한다.
효율
users.stream()
.collect(groupingBy(
User::getDept,
counting()
));
바로
Map<String, Long>
을 만든다.
List를 만들지 않는다.
메모리도 적게 쓰고 성능도 조금 더 좋다.
실무에서는
거의 항상
성능보다 목적에 맞는 Collector를 선택하는 것이 중요하다.
예를 들어
toMap()
을 억지로 써서
(key, List.of(value), merge)
처럼 만드는 것보다
groupingBy()
가 훨씬 읽기 쉽고 유지보수하기 좋다.
한 줄 정리
toMap()과 groupingBy()는 모두 평균 O(N)이며 내부적으로 HashMap 기반으로 동작한다. 성능 차이는 크지 않으며, groupingBy()는 List 생성 등의 추가 메모리 비용이 있지만 다운스트림 Collector(counting(), mapping() 등)를 활용하면 불필요한 Collection 생성을 줄여 오히려 더 효율적으로 사용할 수 있다.
'language > java' 카테고리의 다른 글
| Reflection 원리 (0) | 2026.06.16 |
|---|---|
| 번외편 - Java Comparator vs JavaScript sort() (0) | 2026.06.04 |
| 번외편) Typescript와 Generic 비교 (0) | 2026.06.01 |
| Factory Method Pattern과 Strategy Pattern 비교 (0) | 2026.06.01 |
| List.of에 대하여 (0) | 2026.06.01 |
댓글