빌드노트
TreeSet에 같은 점수의 객체가 하나만 남는 이유
TreeSet은 비교 결과가 0이면 equals가 false인 객체도 같은 원소로 취급한다. 짧은 JShell 조회로 add·size·contains의 차이를 확인하고, equals와 일치하는 동점 기준을 추가할지 원본 목록을 정렬할지 결정한다.
이름이 다른 두 객체를 점수순으로 모으려고 TreeSet에 넣었는데 하나만 남는다면, 생성자에 넘긴 Comparator가 점수만 비교하는지 확인한다. TreeSet은 비교 결과가 0인 두 객체를 같은 원소로 취급한다. 동점인 객체를 구별할 비교 기준을 추가할지, 반복 입력까지 보존하는 목록을 쓸지 결정해야 한다. TreeSet 공식 문서 (새 탭에서 열림)
자기 코드에서는 빠진 객체와 남은 객체의 equals 결과, 그리고 그 집합이 쓰는 비교자의 compare 결과를 나란히 본다. equals가 false여도 비교자가 0을 반환하면 같은 원소로 처리된다. 객체를 각각 new로 만들었다는 사실만으로 두 자리를 차지하지는 않는다.
add는 false인데 contains는 true다
다음은 이름과 점수만 가진 합성 객체로 따로 실행한 예제다. JDK 17 이상에서 터미널에 jshell --execution local을 입력한 뒤 아래 코드를 붙여 넣어, 동등성·추가 결과·실제 저장 원소를 확인한다. JShell이 각 조회식의 값을 보여 주므로 출력용 코드를 따로 붙일 필요가 없다.
jshell 명령을 찾지 못하거나 코드가 오류로 끝나면 아래 결과를 재현한 상태가 아니다. jshell --version으로 실행 버전을 확인하고, 설치한 JDK의 명령 경로가 잡혀 있는지 본다.
import java.util.*;
record Player(String name, int score) {}
var a = new Player("Ara", 90);
var b = new Player("Bora", 90);
var scoreOnly = Comparator.comparingInt(Player::score);
var scores = new TreeSet<Player>(scoreOnly);
a.equals(b)
scoreOnly.compare(a, b)
scores.add(a)
scores.add(b)
scores.size()
scores.contains(b)
scores
Windows의 OpenJDK 17.0.2에서 a.equals(b)는 false, 비교 결과는 0이었다. 두 add는 순서대로 true, false였고, 크기는 1인데 contains(b)는 true였다. 마지막 조회에는 [Player[name=Ara, score=90]]만 남았다. JShell이 앞에 붙이는 $번호와 별개로 이 값들을 읽으면 된다.
scoreOnly는 이름을 읽지 않는다. 그래서 Bora의 추가를 거절한 뒤에도 같은 점수의 원소를 찾는 contains(b)는 성공한다. 조회 결과가 true라는 사실만으로 Bora 자체가 저장됐다고 판단하면 안 된다.
Comparator 문서 (새 탭에서 열림)는 모든 비교 대상에 대해 compare(x, y) == 0과 x.equals(y)의 참·거짓이 같을 때 동등성 기준이 일치한다고 설명한다. TreeSet이 일반적인 Set 계약을 지키려면 이 일치가 필요하다. 위처럼 어긋나도 TreeSet의 비교에 따른 동작은 정의돼 있지만, equals를 기준으로 기대한 집합 동작과 달라진다.
동점을 구별할 필드까지 비교한다
서로 다른 이름을 모두 보관하려면 점수가 같을 때 이름을 비교하도록 thenComparing을 붙인다. 같은 JShell 세션에서 원래의 두 객체를 새 집합에 다시 담아 확인한다.
var players = new TreeSet<Player>(scoreOnly.thenComparing(Player::name));
players.addAll(List.of(a, b));
players
실제 조회에는 Ara, Bora 두 원소가 이름순으로 남았다. thenComparing은 앞 비교가 0일 때만 다음 기준을 적용하므로, 이제 동점이어도 이름이 다르면 최종 비교는 0이 아니다. 별도 확인에서도 두 add가 모두 true, 크기가 2였다. 다만 새로 만든 Player("Ara", 90)의 추가는 false였다. 같은 값의 반복까지 보관하는 집합으로 바뀐 것은 아니다. thenComparing 계약 (새 탭에서 열림)
여기서는 record의 기본 equals가 이름과 점수를 모두 비교하고, 비교자도 같은 두 필드로 0을 판단한다. 이름이 null이 아닌 이 타입에서는 두 기준이 일치한다. Record의 기본 메서드 설명 (새 탭에서 열림)
실제 타입에서는 항목의 식별 기준을 먼저 정한다. 이름까지 같은 항목도 구별해야 한다면 이름만 붙여서는 부족하다. 반대로 equals가 ID만 비교하는 타입은 점수 뒤에 ID를 붙여도, 같은 ID의 점수가 다르면 equals=true인데 비교 결과는 0이 아닐 수 있다. 동점 기준을 추가한 뒤에도 비교가 0인 조건과 equals가 참인 조건이 같은지 확인한다.
동점일 때 무조건 1을 반환하면 순서를 뒤집어도 둘 다 양수가 되어 비교자 계약을 어긴다. 임의의 identity hash를 붙이는 것도 항목의 동등성 기준을 정하는 해법이 아니다. 비교자는 부호의 대칭성·추이성과, 동점인 두 값이 제3의 값에 대해 같은 비교 부호를 내는 조건을 지켜야 한다. compare 계약 (새 탭에서 열림)
정렬만 필요하면 원본을 목록에 담는다
입력마다 한 행씩 남겨야 한다면 원본 데이터를 ArrayList에 담아 정렬한다. 같은 세션의 a를 두 번 넣어 반복도 보존되는지 확인한다.
var rows = new ArrayList<>(List.of(b, a, a));
rows.sort(scoreOnly);
rows
실제 목록에는 Bora, Ara, Ara 세 원소가 이 순서로 남았다. 점수만 비교하는 함수도 목록 정렬에는 쓸 수 있다. List.sort는 비교상 동점인 항목의 기존 순서를 유지한다. List.of 자체는 변경할 수 없으므로 여기서는 ArrayList로 복사한 뒤 정렬했다. List의 중복 허용과 sort 계약 (새 탭에서 열림)
이미 원소가 줄어든 TreeSet을 목록으로 복사해도 거절된 항목은 돌아오지 않는다. 새 비교자를 쓰든 목록을 쓰든 원본 입력에서 다시 담아야 한다. 동등한 값을 하나로 유지할 목적이면 동등성 기준과 일치하는 TreeSet을, 모든 입력을 보존하며 순서만 바꿀 목적이면 목록 정렬을 선택한다.
이 글의 실행 범위는 null 없는 이름과 정수 점수를 가진 불변 합성 객체, 한 스레드의 OpenJDK 17.0.2다. Java SE 21 문서와 실행 버전에 해당하는 Java SE 17의 TreeSet 문서 (새 탭에서 열림)에서 비교 기준에 관한 같은 계약을 확인했다. JDK 21에서 별도 실행하거나 사용자 정의 equals·비교자, 삽입 후 값 변경, 동시 접근을 검증한 결과는 아니다. 자기 코드에서 equals=false, compare=0이 나오지 않는다면 실제 집합에 연결된 비교자와 추가 직전 데이터를 다시 확인해야 한다.