HashSet — 해시 기반 중복 없는 컬렉션
HashSet 내부 HashMap 기반 구조, 해시 버킷과 충돌 처리(연결 리스트→트리), equals/hashCode 계약, load factor와 capacity 튜닝, 그리고 HashSet을 올바르게 사용하기 위한 실전 패턴
지난 글에서 레거시 Vector와 Stack을 살펴봤다. 이번에는 HashSet을 다룬다. HashSet은 Java에서 가장 많이 쓰이는 Set 구현체로, 중복 없는 원소 저장과 O(1) 평균 검색 성능을 제공한다.
HashSet은 HashMap 위에 올라탄 Set
HashSet의 핵심을 한 줄로 표현하면: HashMap<E, Object>의 키 집합을 Set처럼 노출하는 래퍼다.
// java.util.HashSet 내부 (간략화)
private transient HashMap<E, Object> map;
private static final Object PRESENT = new Object(); // 더미 값
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
public boolean contains(Object o) {
return map.containsKey(o);
}
public boolean remove(Object o) {
return map.remove(o) == PRESENT;
}
원소는 HashMap의 키로, 더미 싱글턴 PRESENT는 항상 동일한 값으로 저장된다. 덕분에 키의 중복 감지 로직(hashCode + equals)이 그대로 Set의 중복 제거 로직으로 활용된다.
해시 버킷과 충돌 처리
원소를 추가할 때 내부에서 일어나는 과정:
element.hashCode()계산- 보조 해시 함수 적용:
(h = key.hashCode()) ^ (h >>> 16)(높은 비트와 낮은 비트 믹싱) - 버킷 인덱스 계산:
hash & (capacity - 1) - 해당 버킷에 원소 삽입 (충돌 시 연결 리스트로 체이닝)
Java 8 이후 버킷 하나에 원소가 8개 이상 쌓이면 연결 리스트를 Red-Black Tree로 전환한다. 이로써 최악의 경우 O(n) 검색이 O(log n)으로 개선된다.
import java.util.HashSet;
import java.util.Set;
Set<String> fruits = new HashSet<>();
fruits.add("apple");
fruits.add("banana");
fruits.add("apple"); // 중복 — 무시됨
System.out.println(fruits.size()); // 2
System.out.println(fruits.contains("banana")); // true
System.out.println(fruits); // 순서 보장 없음
equals/hashCode 계약
HashSet이 올바르게 작동하려면 원소 클래스가 equals와 hashCode를 일관되게 구현해야 한다.
계약 핵심: equals() 가 true를 반환하면 반드시 같은 hashCode()를 반환해야 한다. 반대는 성립하지 않아도 된다(충돌). equals만 재정의하고 hashCode를 재정의하지 않으면 같은 값을 가진 두 객체가 다른 버킷에 저장되어 중복 원소가 생긴다.
import java.util.Objects;
public class Point {
private final int x, y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y); // 두 필드 모두 반영
}
}
Set<Point> points = new HashSet<>();
points.add(new Point(1, 2));
points.add(new Point(1, 2)); // 중복 제거
System.out.println(points.size()); // 1
Objects.hash(...) 또는 IDE 생성 코드를 쓰면 안전하다. Java 14+ record는 equals/hashCode를 자동으로 올바르게 생성해준다.
load factor와 initialCapacity 튜닝
// 기본: capacity=16, loadFactor=0.75
Set<String> s1 = new HashSet<>();
// 예상 원소 수를 알 때: initialCapacity 지정 (rehash 방지)
// 원소 수 N을 저장하려면 capacity >= N / loadFactor
Set<String> s2 = new HashSet<>(200); // 최소 150개 원소 수용
// loadFactor 조정: 낮을수록 메모리 낭비, 높을수록 충돌 증가
Set<String> s3 = new HashSet<>(16, 0.5f); // 공간-성능 트레이드오프
loadFactor기본값 0.75는 공간 효율과 충돌률의 균형점이다.- 원소 수를 미리 알고 있다면
initialCapacity = (int)(expectedSize / loadFactor) + 1로 설정해 rehash 비용을 줄인다.
집합 연산
Set<Integer> a = new HashSet<>(Set.of(1, 2, 3, 4));
Set<Integer> b = new HashSet<>(Set.of(3, 4, 5, 6));
// 합집합
Set<Integer> union = new HashSet<>(a);
union.addAll(b); // {1, 2, 3, 4, 5, 6}
// 교집합
Set<Integer> intersection = new HashSet<>(a);
intersection.retainAll(b); // {3, 4}
// 차집합
Set<Integer> diff = new HashSet<>(a);
diff.removeAll(b); // {1, 2}
성능 특성
| 연산 | 평균 | 최악 (Java 8 이후) |
|---|---|---|
add(e) | O(1) | O(log n) |
remove(o) | O(1) | O(log n) |
contains(o) | O(1) | O(log n) |
| 순회 | O(capacity + size) | O(capacity + size) |
순회 비용에 capacity가 포함되는 점 주의 — 버킷이 크고 원소가 적으면 빈 버킷을 다 건너뛰어야 한다.
지난 글: Vector와 Stack — 레거시 스레드 안전 컬렉션
다음 글: LinkedHashSet — 삽입 순서를 유지하는 Set
읽어주셔서 감사합니다. 😊