Java에서 Stream toMap()으로 객체 리스트를 Map으로 변환하는 코드는 겉으로 보면 매우 짧습니다. stream() 뒤에 collect(Collectors.toMap(...))만 붙이면 끝나기 때문입니다. 그런데 막상 수업 실습이나 개인 연습에서 손으로 작성해 보면, 문법보다도 key를 무엇으로 잡을지, 중복 키를 허용할지, 순서를 유지해야 하는지 같은 조건에서 더 자주 막힙니다. 짧은 코드일수록 전제가 숨어 있기 때문에, 예제 한 줄만 보고 넘어가면 다음 변형 문제에서 바로 흔들리기 쉽습니다.
처음에는 Map.Entry 리스트로 구조를 익히고, 그다음 사용자 정의 객체 리스트에 적용해 보면 toMap()의 쓰임새가 더 분명해집니다. 특히 초보 단계에서는 “스트림 문법을 외우는 것”보다 “리스트의 각 객체에서 어떤 필드를 꺼내 key와 value로 쓸지 정하는 과정”을 분리해서 보는 편이 이해가 빠릅니다.
가장 기본적인 toMap() 형태부터 보면 흐름이 잡힙니다
아래 예시는 (key, value) 쌍이 들어 있는 리스트를 Map<String, Integer>로 바꾸는 가장 단순한 코드입니다.
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class StreamToMapExample {
public static void main(String[] args) {
List<Map.Entry<String, Integer>> entries = Arrays.asList(
new AbstractMap.SimpleEntry<>("A", 10),
new AbstractMap.SimpleEntry<>("B", 20),
new AbstractMap.SimpleEntry<>("C", 30)
);
Map<String, Integer> result = entries.stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue
));
result.forEach((k, v) -> System.out.println(k + " -> " + v));
}
}
실행 결과:
A -> 10
B -> 20
C -> 30
이 코드는 세 단계로 읽으면 편합니다.
1. entries.stream()으로 리스트를 스트림으로 만든다. 2. 각 요소에서 key와 value를 꺼낸다. 3. 그 값을 이용해 최종 Map을 수집한다.
문장으로 바꾸면 “리스트 안의 각 Entry에서 키와 값을 꺼내서 Map에 담는다” 정도입니다. 초보자 입장에서는 이 설명이 메서드 시그니처를 보는 것보다 훨씬 직관적입니다.
List<Map.Entry<...>>와 Map은 비슷해 보여도 역할이 다릅니다
수업에서 자주 나오는 질문 중 하나가 “이미 Entry 리스트면 거의 Map 아닌가요?”입니다. 모양은 비슷해 보여도 실제 역할은 다릅니다.
List<Map.Entry<String, Integer>>key-value쌍이 순서대로 들어 있는 목록Map<String, Integer>- 키를 통해 값을 찾는 자료구조
즉, 전자는 데이터를 나열한 컬렉션이고, 후자는 키 기반 조회를 위한 구조입니다. 이 차이를 이해하지 못하면 왜 굳이 toMap()으로 변환하는지 감이 잘 안 옵니다.
직접 정리해 보니 초보 단계에서 이 부분을 놓치면, 스트림 문제를 “문법 맞추기”로만 보게 됩니다. 하지만 실제로는 자료구조의 목적이 바뀌는 순간이기 때문에, 단순 형 변환처럼 생각하면 안 됩니다. 리스트는 같은 키가 여러 번 나와도 일단 담을 수 있지만, Map은 같은 키를 하나만 가져야 한다는 점이 바로 다음 문제로 이어집니다.
toMap()에서 가장 먼저 정해야 하는 것은 key와 value입니다
기본 형태는 아래와 같습니다.
Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)
여기서 하는 일은 단순합니다.
- 첫 번째 인자: 각 요소에서 어떤 값을
key로 사용할지 - 두 번째 인자: 각 요소에서 어떤 값을
value로 사용할지
메서드 참조가 익숙하지 않다면 람다식으로 풀어 써도 됩니다.
Collectors.toMap(
e -> e.getKey(),
e -> e.getValue()
)
수업에서 자주 본 실수는 메서드 참조 표기만 따라 쓰고, 실제로 무엇이 키가 되는지 생각하지 않는 경우입니다. 예를 들어 사용자 객체를 다룰 때 이름을 키로 둘지, 아이디를 키로 둘지에 따라 중복 가능성이 완전히 달라집니다. 문법은 같은데 결과의 안정성이 달라지는 이유가 여기에 있습니다.
중복 key가 있으면 왜 예외가 발생할까요
toMap() 기본형은 중복 키를 허용하지 않습니다. 아래처럼 A가 두 번 나오면 문제가 생깁니다.
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class DuplicateKeyExample {
public static void main(String[] args) {
List<Map.Entry<String, Integer>> entries = Arrays.asList(
new AbstractMap.SimpleEntry<>("A", 10),
new AbstractMap.SimpleEntry<>("B", 20),
new AbstractMap.SimpleEntry<>("A", 50)
);
Map<String, Integer> result = entries.stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue
));
System.out.println(result);
}
}
이 상황에서 Map은 같은 키 A에 대해 어느 값을 남길지 스스로 결정할 수 없습니다. 기존 값 10을 남겨야 하는지, 새 값 50으로 덮어써야 하는지 규칙이 없기 때문입니다. 그래서 예외가 발생합니다.
이 지점은 실무에서도 먼저 확인하게 되는 부분입니다. 객체 리스트를 Map으로 바꾸는 작업 자체는 어렵지 않지만, 키가 정말 유일한지 확인하지 않고 바로 수집하는 습관은 나중에 디버깅 시간을 크게 늘립니다. 특히 사용자 이름, 상품명, 부서명처럼 중복 가능성이 있는 필드를 키로 잡는 경우가 많기 때문에 더 조심해야 합니다.
중복 키를 처리하려면 병합 함수를 함께 써야 합니다
새 값으로 덮어쓰고 싶다면 병합 함수를 추가하면 됩니다.
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class DuplicateKeySolved {
public static void main(String[] args) {
List<Map.Entry<String, Integer>> entries = Arrays.asList(
new AbstractMap.SimpleEntry<>("A", 10),
new AbstractMap.SimpleEntry<>("B", 20),
new AbstractMap.SimpleEntry<>("A", 50)
);
Map<String, Integer> result = entries.stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(oldValue, newValue) -> newValue
));
result.forEach((k, v) -> System.out.println(k + " -> " + v));
}
}
실행 결과:
A -> 50
B -> 20
반대로 기존 값을 유지하고 싶다면 아래처럼 바꾸면 됩니다.
Map<String, Integer> result = entries.stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(oldValue, newValue) -> oldValue
));
이 차이는 작아 보이지만 의도는 아주 다릅니다.
oldValue유지: 먼저 들어온 데이터를 신뢰newValue유지: 나중에 들어온 값으로 갱신
실습에서 코드는 맞게 썼는데 결과가 예상과 다른 경우를 보면, 대부분 병합 규칙을 문제에서 요구한 방식으로 읽지 않은 경우가 많았습니다. 그래서 연습할 때는 “중복 키를 허용하는가?”보다 “허용한다면 어느 값을 남길 것인가?”까지 문장으로 적어두는 편이 좋습니다.
순서가 중요하다면 LinkedHashMap을 같이 고려해야 합니다
작은 예제에서는 출력 순서가 우연히 입력과 같아 보여서 그냥 넘어가기 쉽습니다. 하지만 일반적인 Map은 입력 순서를 보장한다고 생각하면 위험합니다. 순서를 유지해야 한다면 LinkedHashMap을 명시하는 편이 안전합니다.
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class KeepOrderExample {
public static void main(String[] args) {
List<Map.Entry<String, Integer>> entries = Arrays.asList(
new AbstractMap.SimpleEntry<>("B", 20),
new AbstractMap.SimpleEntry<>("A", 10),
new AbstractMap.SimpleEntry<>("C", 30)
);
Map<String, Integer> result = entries.stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(oldValue, newValue) -> newValue,
LinkedHashMap::new
));
result.forEach((k, v) -> System.out.println(k + " -> " + v));
}
}
네 번째 인자로 LinkedHashMap::new를 주면, 입력 순서를 유지하는 맵으로 수집할 수 있습니다.
실무에서 먼저 확인하게 되는 지점도 비슷합니다. 정말 순서가 필요한 데이터인지, 단지 화면 출력 예시 때문에 순서가 중요해 보이는 것인지 구분해야 합니다. 순서가 요구사항이라면 컬렉션 선택까지 코드에 드러내는 편이 유지보수에 유리합니다.
사용자 정의 객체 리스트를 Map으로 바꾸는 예제가 더 실전적입니다
Map.Entry 예제는 구조를 익히기에 좋지만, 실제 Java 코드에서는 보통 도메인 객체 리스트를 다루게 됩니다. 예를 들어 학생 이름과 점수가 담긴 객체가 있다고 해보겠습니다.
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
class StudentScore {
private final String name;
private final int score;
public StudentScore(String name, int score) {
this.name = name;
this.score = score;
}
public String getName() {
return name;
}
public int getScore() {
return score;
}
}
public class StudentScoreToMap {
public static void main(String[] args) {
List<StudentScore> students = Arrays.asList(
new StudentScore("Kim", 90),
new StudentScore("Lee", 85),
new StudentScore("Park", 95)
);
Map<String, Integer> scoreMap = students.stream()
.collect(Collectors.toMap(
StudentScore::getName,
StudentScore::getScore
));
scoreMap.forEach((name, score) ->
System.out.println(name + " -> " + score));
}
}
실행 결과:
Kim -> 90
Lee -> 85
Park -> 95
이 예제를 해 보면 toMap()은 Entry 전용 기능이 아니라, 객체의 필드 둘을 골라 맵 구조로 재구성하는 방법이라는 점이 확실해집니다. 이름을 키로 쓰는 방식은 설명용으로는 쉬우나, 실제 상황에서는 이름 중복 가능성이 있을 수 있으므로 보통은 고유 식별자를 키로 두는지 먼저 점검하게 됩니다.
수업에서 자주 본 실수는 문법보다 데이터 가정입니다
이 주제를 설명할 때 반복해서 보게 되는 실수는 대체로 비슷합니다.
- 키가 중복되지 않을 것이라고 미리 단정한다.
Map결과가 항상 입력 순서를 따를 것이라 생각한다.- 메서드 참조 문법만 따라 쓰고 어떤 필드를 꺼내는지 놓친다.
List와Map의 차이를 자료구조 관점보다 출력 결과로만 이해한다.
직접 정리해 보니 toMap()은 쉬운 메서드라기보다, 짧은 코드 안에 여러 판단을 숨겨 놓은 메서드에 가깝습니다. 반복문으로 put()을 하나씩 호출할 때는 중간 과정이 눈에 보이지만, 스트림은 선언형으로 짧게 표현되기 때문에 오히려 가정을 점검하지 않으면 더 위험합니다. 그래서 학습할 때는 코드부터 쓰기보다 다음 네 가지를 먼저 적어보면 좋습니다.
- 어떤 필드가 key 후보인가
- 어떤 필드가 value 후보인가
- key 중복 가능성이 있는가
- 결과에서 순서 유지가 필요한가
이 작은 점검만 해도 오류를 많이 줄일 수 있습니다.
수업 실습 문제
문제 1. 중복 키를 새 값으로 덮어쓰기
다음 데이터를 Map<String, Integer>로 변환하세요.
("A", 100)("B", 200)("A", 300)
조건:
- 같은 키가 나오면 새 값을 유지합니다.
- 최종 결과는
A -> 300,B -> 200형태가 되어야 합니다.
풀이 코드
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Practice1 {
public static void main(String[] args) {
List<Map.Entry<String, Integer>> entries = Arrays.asList(
new AbstractMap.SimpleEntry<>("A", 100),
new AbstractMap.SimpleEntry<>("B", 200),
new AbstractMap.SimpleEntry<>("A", 300)
);
Map<String, Integer> result = entries.stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(oldValue, newValue) -> newValue
));
result.forEach((k, v) -> System.out.println(k + " -> " + v));
}
}
병합 함수에서 newValue를 반환했기 때문에 중복 키가 나오면 마지막 값이 남습니다.
수업 실습 문제 2. 기존 값을 유지하도록 바꿔 보기
같은 입력 데이터를 사용하되, 이번에는 먼저 들어온 값을 유지하세요.
("A", 100)("B", 200)("A", 300)
조건:
- 최종 결과는
A -> 100,B -> 200
풀이 코드
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Practice2 {
public static void main(String[] args) {
List<Map.Entry<String, Integer>> entries = Arrays.asList(
new AbstractMap.SimpleEntry<>("A", 100),
new AbstractMap.SimpleEntry<>("B", 200),
new AbstractMap.SimpleEntry<>("A", 300)
);
Map<String, Integer> result = entries.stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(oldValue, newValue) -> oldValue
));
result.forEach((k, v) -> System.out.println(k + " -> " + v));
}
}
같은 toMap()이라도 병합 함수 하나로 정책이 달라진다는 점을 확인하는 연습입니다.
수업 실습 문제 3. 객체 리스트를 Map으로 변환하기
Product(code, price) 클래스를 만들고, List<Product>를 Map<String, Integer>로 바꿔 보세요.
조건:
code는 keyprice는 value
풀이 코드
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
class Product {
private final String code;
private final int price;
public Product(String code, int price) {
this.code = code;
this.price = price;
}
public String getCode() {
return code;
}
public int getPrice() {
return price;
}
}
public class Practice3 {
public static void main(String[] args) {
List<Product> products = Arrays.asList(
new Product("P100", 1500),
new Product("P200", 2300),
new Product("P300", 3200)
);
Map<String, Integer> priceMap = products.stream()
.collect(Collectors.toMap(
Product::getCode,
Product::getPrice
));
priceMap.forEach((code, price) ->
System.out.println(code + " -> " + price));
}
}
여기까지 손으로 직접 입력해 보면 toMap()이 더 이상 낯설지 않게 느껴집니다. 단순한 시험용 문법이 아니라, 리스트 데이터를 조회하기 좋은 구조로 바꾸는 실용적인 패턴이라는 감각이 생기기 때문입니다.
코드를 쓰기 전에 체크하면 좋은 기준
실습이나 과제에서 toMap()을 쓰기 전에는 아래 항목만 확인해도 실수를 많이 줄일 수 있습니다.
- key로 사용할 필드가 정말 고유한가
- value로 넣을 필드가 요구사항과 맞는가
- 중복 키가 생길 수 있다면 어느 값을 남길 것인가
- 결과
Map에서 입력 순서를 유지해야 하는가 - 메서드 참조가 헷갈리면 람다식으로 바꿔 읽어볼 수 있는가
짧은 스트림 코드는 보기에는 간단하지만, 실제로는 데이터 조건을 먼저 판단해야 안전합니다. 그래서 toMap()을 배울 때는 문법 암기보다도 입력 데이터의 성질을 먼저 읽는 습관이 훨씬 중요합니다.
참고 자료
- [Oracle Java Collectors API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Collectors.html)
- [Oracle Java Stream API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html)
- [Oracle Map API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Map.html)

저는 대학에서 컴퓨터 관련 학과 학생들을 가르치고 있는 교수 김기태입니다. Spring Boot, React, Python 등을 활용한 풀스택 웹 개발과 최신 AI 서비스 설계를 주로 다루고 있으며, 학생들이 현업 실무에 바로 적용할 수 있는 기술 역량을 키울 수 있도록 강의와 실습을 설계하고 있습니다.


