빌드노트
toMap에 같은 ID를 넣었더니 IllegalStateException이 난다
Collectors.toMap은 같은 ID가 다시 나오면 값을 덮어쓰지 않고 예외를 던진다. JShell에서 정상·중복 입력을 비교하고, 수량 합산은 병합 함수로, 원본 행 보존은 groupingBy로 선택하는 기준을 확인한다.
목록을 ID별 Map으로 바꾸는 collect(Collectors.toMap(...))에서 IllegalStateException: Duplicate key ...가 발생하면, 먼저 키를 만드는 함수를 확인한다. 인자가 두 개인 toMap은 같은 키가 다시 나오면 값을 덮어쓰지 않고 예외를 던진다. 중복 ID를 잘못된 입력으로 거절할지, 값을 합칠지, 여러 행을 그대로 보관할지 정해야 수집 코드를 고를 수 있다.
Java 17의 두 인자 toMap 문서 (새 탭에서 열림)는 매핑된 키가 equals 기준으로 중복되면 수집할 때 IllegalStateException이 발생한다고 명시한다. 원본 객체 전체가 같은지가 아니라, 첫 번째 인자인 keyMapper가 만든 키가 같은지가 기준이다.
정상 목록에 ID 하나를 더 겹쳐 넣으면
아래는 운영 데이터 대신 만든 짧은 비교다. 준비물은 JDK 17에 포함된 JShell (새 탭에서 열림)이다. 터미널에서 jshell 명령을 실행할 수 있어야 한다. jshell --execution local로 시작한 뒤 다음 입력을 붙여 넣는다. 정상 목록은 수집되지만 마지막 줄은 중복 ID 때문에 실패하는지 확인한다.
import java.util.*;
import java.util.stream.*;
record Row(int id, int quantity) {}
var normal = List.of(new Row(1, 2), new Row(2, 5));
var rows = List.of(new Row(1, 2), new Row(2, 5), new Row(1, 3));
var uniqueMap = normal.stream().collect(
Collectors.toMap(Row::id, Row::quantity));
System.out.println(uniqueMap.get(1) + ", " + uniqueMap.get(2));
rows.stream().collect(Collectors.toMap(Row::id, Row::quantity));
Windows의 OpenJDK 17.0.2에서 정상 목록은 2, 5를 출력했다. 마지막 줄에서는 다음 예외가 나왔다. JShell이 붙이는 위치 정보는 생략했다.
java.lang.IllegalStateException: Duplicate key 1 (attempted merging values 2 and 3)
키를 만드는 Row::id가 두 행에서 모두 1을 반환했다. Row::quantity가 반환한 2와 3 중 어느 것을 쓸지 지정하지 않았으므로 수집이 완료되지 않는다. 이 호출에서 완성된 Map을 반환받지 못한다는 뜻이다.
값이 같으면 통과하는 것도 아니다. 별도 실행에서 List.of(new Row(1, 2), new Row(1, 2))를 같은 두 인자 toMap으로 수집하자 Duplicate key 1 (attempted merging values 2 and 2)가 발생했다. “같은 값이니 한 번만 담길 것”이라고 기대하면 이 경우도 놓친다.
실제 코드에서는 예외에 나온 키와 keyMapper를 함께 확인한다. ID가 원래 유일해야 한다면 기본 예외를 유지하고, 그 ID가 입력 목록에 두 번 들어온 경로를 바로잡는다. 원본 ID가 서로 달라도 키를 만드는 변환 결과가 같아질 수 있다.
같은 ID의 수량을 더해야 한다면
각 행이 더해야 할 수량이라면 세 번째 인자로 병합 함수를 준다. 앞에서 예외가 난 뒤에도 같은 JShell 세션에서 다음 입력으로 ID 1의 합계가 5가 되는지 확인할 수 있다.
var total = rows.stream().collect(
Collectors.toMap(Row::id, Row::quantity, Integer::sum));
System.out.println(total.get(1));
실제 출력은 5였다. ID 2의 값도 조회해 5가 유지됨을 확인했다. 세 인자 toMap 문서 (새 탭에서 열림)에서 병합 함수가 받는 대상은 같은 키에 연결된 값이다. 여기서는 Row 두 개가 아니라 Row::quantity로 뽑은 Integer 값 두 개를 더한다.
합산이 데이터의 의미와 맞는지 먼저 판단해야 한다. 같은 상품의 주문 수량 두 행이라면 더할 수 있지만, 같은 재고의 서로 다른 시점 잔량이라면 합계가 답이 아니다. 예외를 없애려고 (a, b) -> a를 넣는 것도 한 값을 버리겠다는 정책이다. 버려도 되는 근거가 없다면 원래 오류를 숨길 뿐이다.
같은 ID의 행을 모두 남겨야 한다면
중복 행을 나중에 비교하거나 하나의 ID에 여러 행이 있는 것이 정상이라면 groupingBy로 묶는다. 같은 세션에서 다음 입력을 실행해 ID 1의 두 행이 그대로 남는지 확인한다.
Map<Integer, List<Row>> grouped = rows.stream().collect(
Collectors.groupingBy(Row::id));
System.out.println(grouped.get(1));
[Row[id=1, quantity=2], Row[id=1, quantity=3]]
groupingBy 문서 (새 탭에서 열림)의 반환 구조는 Map<K, List<T>>다. 이 예제에서는 한 ID의 값 자리에 원본 행의 목록이 온다. 별도 조회에서 ID 2의 한 행도 남았고, 모든 그룹의 행 수를 더하면 입력과 같은 3이었다. 따라서 이 방식을 고르면 Map을 사용하는 쪽도 단일 수량 대신 행 목록을 처리하도록 바꿔야 한다.
여기서 실행한 범위는 작은 정수 입력과 순차 stream()이다. 예외 문구 전체의 버전별 동일성, 병렬 처리의 순서, 큰 수량의 합산 범위는 검증하지 않았다. Map의 출력 순서도 판단 기준으로 삼지 않았다. 실습을 마치면 JShell에 /exit를 입력한다.