F-LAB JAVA · 3주차 · Phase 2 · 컬렉션 프레임워크 전체 지도
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
Set 은 "중복 없는 모음" 이라는 단일 의미를 가진 인터페이스이고, 구현체 3형제는 각자 다른 트레이드오프를 제공한다.
HashSet 은 빠른 검색 (O(1) 평균) 을 위해 순서를 포기,
TreeSet 은 정렬된 순회 (O(log n)) 를 위해 약간의 속도 포기,
LinkedHashSet 은 삽입 순서 유지를 위해 약간의 메모리 추가.
박승제씨가 ILIC 에서 매일 만나는 "중복 제거 + 빠른 검색" 의 정답은 거의 항상 HashSet.
| 시스템 | 비유 |
|---|---|
| HashSet | 도서를 해시번호로 무작위 진열장에 — 빠르게 찾지만 순서 없음 |
| TreeSet | 도서를 ABC 순으로 정렬해 진열 — 보기 좋지만 정리 비용 |
| LinkedHashSet | 들어온 순서대로 진열 + 빠른 검색 — 약간 무겁지만 안정적 |
| 공통점 | 같은 책 두 권은 안 받음 (중복 제거) |
→ "중복 없음" 은 공통, "추가 조건" 으로 3형제 갈림.
1. Set 인터페이스의 본질
2. HashSet 의 내부 구조와 동작
3. TreeSet 의 내부 구조 (Red-Black Tree)
4. LinkedHashSet 의 내부 구조
5. 3형제 시간 복잡도 + 메모리 비교
6. hashCode 와 equals 계약 — Set 의 결정적 토대
7. ILIC 실무 — Set 선택 가이드
8. 흔한 실수 + 안티패턴
9. 면접 + 자기 점검
Set<E> 인터페이스의 정의:
"중복 없는 요소들의 모음"
규칙:
- 같은 요소는 단 한 번만 보유
- "같다" 의 기준은 equals (+ hashCode)
- 인덱스 없음 (List 와 다름)
- 순서는 구현체에 따라 다름
public interface Set<E> extends Collection<E> {
// 추가 — 이미 있으면 false 반환
boolean add(E e);
// 포함 여부 — Set 의 핵심 메서드 (빠른 검색)
boolean contains(Object o);
// 제거
boolean remove(Object o);
// 크기
int size();
// 순회
Iterator<E> iterator();
// 집합 연산 (default)
boolean addAll(Collection<? extends E> c); // 합집합
boolean retainAll(Collection<?> c); // 교집합
boolean removeAll(Collection<?> c); // 차집합
// ...
}
핵심:
add: 추가 (중복 시 false)contains: 빠른 검색 (Set 의 존재 이유)get(i), add(i, e) 없음)Collection 의 add:
"추가한다"
Set 의 add (오버라이드 의미):
"중복이 아니면 추가하고 true 반환, 중복이면 false 반환"
→ 같은 메서드 시그니처에 의미가 강화됨
수학적 의미:
Set = {x | x ∈ universe, 각 x 는 unique}
연산:
- 합집합 (union): A ∪ B = {x | x ∈ A or x ∈ B}
- 교집합 (intersection): A ∩ B = {x | x ∈ A and x ∈ B}
- 차집합 (difference): A - B = {x | x ∈ A and x ∉ B}
자바의 Set 은 이 수학적 의미를 자료구조로 구현.
@Service
public class ShipmentService {
// 처리된 운송장 추적
private final Set<Long> processedShipmentIds = new HashSet<>();
// 권한 집합
public boolean hasPermission(User user, Permission required) {
Set<Permission> userPermissions = user.getPermissions();
return userPermissions.contains(required); // O(1) 검색
}
// 중복 제거된 노선 목록
public Set<String> getUniqueRoutes() {
return repository.findAll().stream()
.map(Shipment::getRoute)
.collect(Collectors.toSet());
}
// 화물 분류 태그
private final Set<CargoTag> dangerousTags = Set.of(
CargoTag.HAZARDOUS,
CargoTag.EXPLOSIVE,
CargoTag.RADIOACTIVE
);
public boolean isDangerous(Cargo cargo) {
return !Collections.disjoint(cargo.getTags(), dangerousTags);
// disjoint: 교집합이 빈 집합인지
}
}
→ Set 의 활용 패턴들.
Set 을 List 대신 써야 하는 신호 3가지는?
답:
1. 중복 제거 가 필요할 때
2. "있는지 없는지" 검사 가 빈번할 때 (contains)
3. 순서가 중요하지 않을 때
이 3가지 중 하나라도 해당하면 Set 후보.
→ List 의 contains 는 O(n) 이지만 Set 은 O(1) (HashSet).
public class HashSet<E> implements Set<E>, ... {
private transient HashMap<E, Object> map; // ★ 내부는 HashMap!
private static final Object PRESENT = new Object();
public HashSet() {
map = new HashMap<>();
}
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;
}
}
놀라운 진실: HashSet 은 HashMap 을 감싼 wrapper!
Joshua Bloch 의 설계 결정:
- HashSet 을 새로 만들 필요 X
- HashMap 이 이미 "중복 없는 키" 를 처리
- 키만 사용하면 그게 Set
- 코드 재사용 + 유지보수 ↓
- 단점: 값 슬롯 메모리 약간 낭비 (PRESENT 참조)
→ "이미 있는 자료구조 재사용" 의 좋은 예.
Set<String> set = new HashSet<>();
set.add("BL-001");
내부 단계:
Step 1: "BL-001".hashCode() 계산
→ 정수 값 (예: 1234567890)
Step 2: HashMap.put("BL-001", PRESENT) 호출
→ 해시값으로 버킷 위치 결정
→ 버킷에 새 노드 추가
Step 3: 기존에 같은 키 있으면 PRESENT 반환
→ HashSet.add()는 false 반환 (이미 있음)
→ 없으면 null 반환 → HashSet.add()는 true
HashSet<String>:
┌─────────────────────────────────────┐
│ map (HashMap<String, Object>) │
│ ┌────────────────────────────────┐ │
│ │ table[]: │ │
│ │ [0] → null │ │
│ │ [1] → Node("BL-001", PRESENT) │ │
│ │ [2] → null │ │
│ │ [3] → Node("BL-002", PRESENT) │ │
│ │ → Node("BL-005", PRESENT) │ │ (해시 충돌 시 연결)
│ │ [4] → null │ │
│ │ ... │ │
│ └────────────────────────────────┘ │
└─────────────────────────────────────┘
PRESENT 는 static 객체 (단 1개)
→ 모든 노드의 값이 같은 PRESENT 객체 가리킴
→ 메모리 효율적
박승제씨가 1주차에서 만든 HashMap PPT 자료 의 내용이 그대로 적용:
→ HashSet 의 모든 성능 특성은 HashMap 의 특성과 동일.
Set<String> set = new HashSet<>();
set.add("A");
set.add("B");
set.add("C");
for (String s : set) {
System.out.println(s); // 어떤 순서?
}
예측 불가능:
→ Set 의 본질 ("순서 없음") 그대로.
→ 순서 필요하면 TreeSet 또는 LinkedHashSet.
시간 복잡도:
- add: O(1) 평균, O(n) 최악
- contains: O(1) 평균
- remove: O(1) 평균
- 순회: O(n + capacity)
공간 복잡도:
- 요소 n 개 보관 시 약 n / 0.75 = 1.33n 의 버킷
- + Node 객체 (next + hash + key + value)
- 약 40n bytes (대략)
→ ILIC 에서 가장 자주 쓰는 Set.
HashSet 의 add 가 O(1) 인 이유는?
답:
1. 내부 HashMap 사용
2. hashCode 로 버킷 위치 즉시 결정
3. 버킷의 요소 수가 적음 (LoadFactor 0.75)
4. 평균적으로 1번의 비교로 추가 완료
5. 단, 최악 (해시 충돌 많음) 에는 O(n)
public class TreeSet<E> implements NavigableSet<E>, ... {
private transient NavigableMap<E, Object> m; // 내부는 TreeMap!
private static final Object PRESENT = new Object();
public TreeSet() {
this(new TreeMap<E, Object>());
}
}
→ HashSet 과 같은 패턴: TreeMap 의 wrapper.
Red-Black Tree (RB Tree):
- 자가 균형 이진 탐색 트리
- 모든 노드가 RED 또는 BLACK
- 규칙으로 균형 유지
- 최악 O(log n) 보장
자가 균형 조건:
1. 루트는 BLACK
2. RED 노드의 자식은 BLACK
3. 모든 leaf 까지 BLACK 노드 수가 같음 (Black Height)
4. RED 가 연속 안 됨
→ 삽입/삭제 후에도 자동으로 균형 유지.
→ 깊이가 log(n) 보장.
삽입: 1, 2, 3, 4, 5, 6, 7
일반 BST (이진 탐색 트리) 였다면:
1
\
2
\
3
\ ← 거의 linked list
4
\
5
→ 깊이 7, 검색 O(n)
Red-Black Tree:
4 (BLACK)
/ \
2 6
/\ /\
1 3 5 7
→ 깊이 3, 검색 O(log n)
TreeSet<String> set = new TreeSet<>();
set.add("BL-003"); // 트리에 삽입
set.add("BL-001");
set.add("BL-002");
내부 단계:
add("BL-003"):
- 빈 트리 → 루트로
- 루트는 BLACK
add("BL-001"):
- "BL-001" < "BL-003" → 왼쪽
- RED 로 추가 후 균형 조정
add("BL-002"):
- "BL-002" < "BL-003" → 왼쪽으로
- "BL-002" > "BL-001" → 오른쪽
- 트리 회전으로 균형 조정
결과:
"BL-002" (BLACK)
/ \
"BL-001" "BL-003"
TreeSet<String> set = new TreeSet<>();
set.add("apple");
set.add("Banana");
set.add("3rd");
set.add("사과");
set.add("Apple");
for (String s : set) {
System.out.println(s);
}
// 출력:
// 3rd
// Apple
// Banana
// apple
// 사과
정렬 규칙 (자연 순서):
1. 숫자 0-9 (ASCII 48-57)
2. 대문자 A-Z (ASCII 65-90)
3. 소문자 a-z (ASCII 97-122)
4. 한글 (Unicode 0xAC00 ~ 0xD7A3)
→ String 의 compareTo() 동작.
TreeSet<Integer> set = new TreeSet<>();
set.addAll(List.of(1, 3, 5, 7, 9));
// 첫/마지막
set.first(); // 1
set.last(); // 9
// 특정 값 근처
set.floor(6); // 5 (6 이하 최댓값)
set.ceiling(6); // 7 (6 이상 최솟값)
set.lower(5); // 3 (5 미만 최댓값)
set.higher(5); // 7 (5 초과 최솟값)
// 부분 집합
set.headSet(5); // [1, 3]
set.tailSet(5); // [5, 7, 9]
set.subSet(3, 7); // [3, 5]
// 역순
set.descendingSet(); // [9, 7, 5, 3, 1]
→ TreeSet 의 가치는 정렬된 순회 + 범위 검색.
// Comparator 로 정렬 기준 변경
TreeSet<String> set = new TreeSet<>(Comparator.reverseOrder());
set.add("A");
set.add("C");
set.add("B");
// 순회: C, B, A
// 또는 객체 비교
TreeSet<Shipment> shipments = new TreeSet<>(
Comparator.comparing(Shipment::getCreatedAt)
);
→ Phase 6 (Comparable/Comparator) 에서 더 깊이.
시간 복잡도:
- add: O(log n)
- contains: O(log n)
- remove: O(log n)
- 순회 (정렬): O(n)
- first/last: O(log n) [실제로는 O(1) 캐시]
- subSet 등 범위: O(log n)
공간 복잡도:
- Node 객체에 부모/왼쪽/오른쪽 + color 비트 + key + value
- HashSet 보다 약 25% 더
TreeSet 이 HashSet 보다 느린데도 쓰는 이유는?
답:
1. 자동 정렬: 순회 시 정렬된 순서
2. 범위 검색: subSet, headSet, tailSet 같은 강력 API
3. first/last: 최솟값/최댓값 즉시 접근
4. floor/ceiling: 근사 검색
→ 정렬 또는 범위 검색이 필요하면 TreeSet.
→ 그 외엔 HashSet 이 빠름.
public class LinkedHashSet<E> extends HashSet<E> ... {
// HashSet 을 상속받지만, 부모의 다른 생성자 호출
public LinkedHashSet() {
super(16, .75f, true); // ★ true 가 핵심
}
}
// HashSet 의 패키지 private 생성자
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
// ↑ HashMap 이 아니라 LinkedHashMap!
}
핵심: LinkedHashSet 은 내부적으로 LinkedHashMap 사용.
LinkedHashMap = HashMap + 이중 연결 리스트
HashMap 의 기능:
- 해시 테이블 (O(1) 검색)
추가:
- 모든 노드를 삽입 순서대로 이중 연결
- 추가 prev/next 포인터
LinkedHashSet:
내부 LinkedHashMap.table[]:
[0] → Node("A")
[1] → null
[2] → Node("B")
...
추가로 이중 연결 리스트:
head → Node("A") ↔ Node("B") ↔ Node("C") ← tail
(삽입 순서대로)
각 Node:
- hash, key, value (HashMap 의 Node 그대로)
- next (해시 충돌 시 연결)
- before, after (이중 연결, 순서 보존)
→ HashSet 의 노드 + 추가 포인터 2개.
LinkedHashSet<String> set = new LinkedHashSet<>();
set.add("A");
set.add("C");
set.add("B");
for (String s : set) {
System.out.println(s);
}
// 출력:
// A
// C
// B
// → 삽입 순서!
순회 메커니즘:
HashSet 의 iterator():
- 버킷 순서대로 순회 (해시 기반, 예측 불가)
LinkedHashSet 의 iterator():
- 이중 연결 리스트 head 부터 순회
- 삽입 순서 그대로
한 노드당 메모리:
HashSet: ~ 40 bytes
LinkedHashSet: ~ 48 bytes (before/after 포인터 추가 8 bytes × 2 = 16, 하지만 정렬 인해 약 +8)
큰 차이 아님 (10-20%)
하지만 큰 컬렉션에선 누적 메모리 차이 큼
사용 시점:
- 순서를 보존하고 싶지만 (List 가 줄 수 있음)
- 중복 제거도 필요 (Set 의 특성)
- 빠른 검색도 필요 (HashSet 의 O(1))
대안:
- List + 매번 contains 검사 → O(n) 매번
- HashSet → 순서 X
- LinkedHashSet → 둘 다 만족
시간 복잡도:
- add: O(1) 평균 (HashSet 과 동일 + 약간의 포인터 갱신)
- contains: O(1) 평균
- remove: O(1) 평균
- 순회: O(n) — 더 정확히 O(n) (HashSet 은 O(n + capacity))
공간 복잡도:
- HashSet 보다 약 20% 더
// 시나리오 1 — 최근 본 운송장 목록 (중복 제거 + 순서 유지)
public class RecentShipmentTracker {
private final Set<Long> recentlyViewed = new LinkedHashSet<>();
private static final int MAX_SIZE = 10;
public void view(Long id) {
recentlyViewed.remove(id); // 있으면 제거
recentlyViewed.add(id); // 끝에 추가
if (recentlyViewed.size() > MAX_SIZE) {
Iterator<Long> it = recentlyViewed.iterator();
it.next();
it.remove(); // 가장 오래된 것 제거
}
}
}
// 시나리오 2 — 처리 순서를 기억해야 하는 ID 집합
public class OrderedProcessor {
private final Set<Long> processedInOrder = new LinkedHashSet<>();
public void process(Long id) {
if (processedInOrder.contains(id)) return;
// ... 처리
processedInOrder.add(id);
}
public List<Long> getProcessOrder() {
return new ArrayList<>(processedInOrder);
}
}
LinkedHashSet 은 언제 HashSet 대신 쓰는가?
답:
삽입 순서가 의미 있을 때:
HashSet 의 O(1) 성능도 필요할 때:
→ "순서 + 빠른 검색" 의 균형점.
| 작업 | HashSet | TreeSet | LinkedHashSet |
|---|---|---|---|
add(e) | O(1) 평균 | O(log n) | O(1) 평균 |
contains(e) | O(1) 평균 | O(log n) | O(1) 평균 |
remove(e) | O(1) 평균 | O(log n) | O(1) 평균 |
iterator 시작 | O(capacity) | O(log n) | O(1) |
| 순회 1바퀴 | O(n + capacity) | O(n) | O(n) |
first/last | (없음) | O(log n) | (없음) |
floor/ceiling | (없음) | O(log n) | (없음) |
n 개 요소 보관 시 메모리:
HashSet:
table[]: capacity × 4 bytes (참조) = (n/0.75) × 4
Node: n × ~36 bytes (hash + key + value + next)
PRESENT: 1 (공유)
────────────────────────────────────
대략: n × 41 bytes
TreeSet:
Entry: n × ~52 bytes (key + value + parent + left + right + color)
PRESENT: 1 (공유)
────────────────────────────────────
대략: n × 52 bytes (HashSet 보다 ~25% 더)
LinkedHashSet:
table[]: (n/0.75) × 4
LinkedNode: n × ~52 bytes (Node + before + after)
PRESENT: 1 (공유)
────────────────────────────────────
대략: n × 57 bytes (HashSet 보다 ~40% 더)
→ HashSet 이 메모리 가장 효율적.
→ LinkedHashSet 이 가장 무거움.
→ TreeSet 은 중간.
10,000 개 String 요소:
add 10,000 회:
HashSet: 5 ms
TreeSet: 15 ms
LinkedHashSet: 6 ms
contains 10,000 회:
HashSet: 3 ms
TreeSet: 12 ms
LinkedHashSet: 3 ms
순회 1바퀴:
HashSet: 2 ms
TreeSet: 2 ms (정렬된 순회)
LinkedHashSet: 1 ms (이중 연결 리스트가 더 빠름)
메모리:
HashSet: ~ 400 KB
TreeSet: ~ 520 KB
LinkedHashSet: ~ 570 KB
→ HashSet 이 거의 모든 작업에서 빠름.
→ LinkedHashSet 의 순회는 의외로 빠름 (캐시 친화적 이중 연결).
박승제씨의 Set 선택 트리:
1. 정렬된 순회나 범위 검색이 필요?
YES → TreeSet
NO → 2
2. 삽입 순서가 의미 있나?
YES → LinkedHashSet
NO → HashSet (90% 이 경우)
박승제씨가 ILIC 에서 사용:
90%: HashSet
- 중복 제거
- 빠른 검색
- 순서 무관
9%: LinkedHashSet
- 순서 보존이 필요한 곳
- 최근 본 목록 등
1%: TreeSet
- 정렬된 순회 필요
- 범위 검색 필요
- 도메인 객체 비교 (Phase 6 참고)
3형제 중 메모리 가장 효율적인 것과 그 이유는?
답:
→ ILIC 같은 큰 데이터셋에서 HashSet 우선.
Set 의 "중복 제거" 동작은 hashCode + equals 에 의존:
HashSet.add(e):
1. e.hashCode() 계산
2. 버킷 위치 결정
3. 그 버킷 안에서 e.equals(...) 로 동일 요소 검색
4. 있으면 추가 안 함, 없으면 추가
규약 1: equals 가 true 면 hashCode 도 같아야 함
a.equals(b) == true → a.hashCode() == b.hashCode()
규약 2: hashCode 같다고 반드시 equals true 는 아님
a.hashCode() == b.hashCode() ↛ a.equals(b)
(해시 충돌 가능)
규약 3: equals 와 hashCode 는 일관되어야 함
객체 상태 안 바뀌면 hashCode 도 안 바뀌어야 함
규약 4: x.equals(x) == true (반사성)
x.equals(y) == y.equals(x) (대칭성)
추이성: x.equals(y) && y.equals(z) → x.equals(z)
// ❌ equals 만 오버라이드, hashCode 안 함
public class Shipment {
private Long id;
@Override
public boolean equals(Object o) {
if (!(o instanceof Shipment)) return false;
return id.equals(((Shipment) o).id);
}
// hashCode 안 만듦 → Object 의 기본 사용 (참조 기반)
}
Set<Shipment> set = new HashSet<>();
Shipment s1 = new Shipment(1L);
Shipment s2 = new Shipment(1L);
s1.equals(s2); // true (equals 만 오버라이드)
s1.hashCode() == s2.hashCode(); // false (Object 기본)
set.add(s1);
set.contains(s2); // false! 못 찾음!
문제:
규약 위반의 결과 = Set 깨짐.
// ✓ equals + hashCode 둘 다
public class Shipment {
private Long id;
private String blNo;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Shipment)) return false;
Shipment other = (Shipment) o;
return Objects.equals(id, other.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
→ Java 7+ 의 Objects.hash 활용.
→ Lombok 의 @EqualsAndHashCode 도 권장.
@Getter
@EqualsAndHashCode(of = "id")
public class Shipment {
private Long id;
private String blNo;
// ...
}
@EqualsAndHashCode:
of = "id" 로 특정 필드만 사용public class MutableShipment {
private Long id;
@Override
public int hashCode() {
return id != null ? id.hashCode() : 0;
}
}
Set<MutableShipment> set = new HashSet<>();
MutableShipment s = new MutableShipment();
s.setId(1L);
set.add(s); // hashCode 기반 버킷에 저장
s.setId(2L); // 객체 상태 변경 → hashCode 변경!
set.contains(s); // false! 못 찾음
문제:
해결:
HashSet:
- hashCode + equals 사용
- 둘 다 오버라이드 필요
TreeSet:
- Comparable.compareTo() 또는 Comparator.compare() 사용
- hashCode 무관
- 단, compareTo 가 equals 와 일관되어야 함
public class Shipment implements Comparable<Shipment> {
@Override
public int compareTo(Shipment o) {
return id.compareTo(o.id);
}
// TreeSet 에선 hashCode 안 봄
// 하지만 HashSet 호환을 위해 hashCode + equals 도 권장
}
@Entity
@Getter
@NoArgsConstructor
@EqualsAndHashCode(of = "id")
public class Shipment {
@Id
@GeneratedValue
private Long id;
@Column(unique = true)
private String blNo;
// ... 더 많은 필드
// equals + hashCode 는 id 기반 (Lombok 자동)
}
→ JPA Entity 의 표준.
→ id 기반 동일성 → DB의 unique 보장.
HashSet 에 객체 추가했는데 contains 가 false 반환하는 가장 흔한 원인은?
답:
1. equals 오버라이드, hashCode 안 함
2. hashCode 오버라이드, equals 안 함
3. 둘 다 오버라이드했지만 일관성 없음
4. 객체의 hashCode 가 변경됨 (mutable 객체)
해결:
@EqualsAndHashCode 활용public class User {
@EqualsAndHashCode.Include
private Long id;
@ElementCollection
@Enumerated(EnumType.STRING)
private Set<Permission> permissions = new HashSet<>();
// ↑ HashSet — 빠른 contains, 순서 무관
}
public boolean canApprove(User user) {
return user.getPermissions().contains(Permission.APPROVER);
}
→ HashSet 적합.
public class ShipmentService {
public NavigableSet<Route> getActiveRoutes() {
return new TreeSet<>(
repository.findAllRoutes(),
Comparator.comparing(Route::getName)
);
// ↑ TreeSet — 정렬된 순회
}
public NavigableSet<Route> getRoutesAlphabetical(String prefix) {
TreeSet<Route> sorted = (TreeSet<Route>) getActiveRoutes();
return sorted.subSet(
new Route(prefix),
new Route(prefix + Character.MAX_VALUE)
);
// 범위 검색
}
}
→ TreeSet 적합 (정렬 + 범위 검색).
public class ShipmentHistoryService {
private final Set<Long> recentlyViewed = new LinkedHashSet<>();
// ↑ LinkedHashSet — 순서 유지 + 빠른 검색
public void recordView(Long shipmentId) {
recentlyViewed.remove(shipmentId); // 있으면 제거
recentlyViewed.add(shipmentId); // 끝에 추가
// 최대 10개 유지
while (recentlyViewed.size() > 10) {
Iterator<Long> it = recentlyViewed.iterator();
it.next();
it.remove();
}
}
}
→ LinkedHashSet 적합.
@Service
public class BatchProcessor {
// 매우 큰 데이터셋
public void processAll(List<Shipment> shipments) {
Set<Long> processed = new HashSet<>(shipments.size());
// ↑ HashSet + 초기 크기 지정 (확장 비용 회피)
for (Shipment s : shipments) {
if (processed.contains(s.getId())) continue;
process(s);
processed.add(s.getId());
}
}
}
→ HashSet + 초기 크기 지정.
@Service
public class ShipmentEventBus {
// ❌ 일반 HashSet — 멀티스레드 위험
private final Set<EventListener> listeners = new HashSet<>();
// ✓ ConcurrentHashMap.newKeySet
private final Set<EventListener> listeners = ConcurrentHashMap.newKeySet();
// ✓ CopyOnWriteArraySet (읽기 위주)
private final Set<EventListener> listeners = new CopyOnWriteArraySet<>();
public void register(EventListener l) {
listeners.add(l);
}
public void notifyAll(Event e) {
listeners.forEach(l -> l.onEvent(e));
}
}
→ 멀티스레드 환경의 Set 선택.
public class CargoClassifier {
// 일반 HashSet
private static final Set<CargoType> RESTRICTED =
new HashSet<>(Set.of(CargoType.HAZARDOUS, CargoType.EXPLOSIVE));
// ✓ EnumSet — Enum 전용, 매우 빠름
private static final Set<CargoType> RESTRICTED =
EnumSet.of(CargoType.HAZARDOUS, CargoType.EXPLOSIVE);
}
EnumSet:
→ Enum Set 은 항상 EnumSet 권장.
// Java 9+ 불변 Set
public static final Set<String> ALLOWED_STATUSES = Set.of(
"DRAFT", "ACTIVE", "COMPLETED"
);
// 변경 시도하면 UnsupportedOperationException
// 멀티스레드 안전
// 매우 가벼움
→ 상수 Set 은 Set.of 활용.
| 시나리오 | 선택 | 이유 |
|---|---|---|
| 일반 권한/태그 | HashSet | 빠른 검색 |
| 정렬 필요 | TreeSet | 자연 순회 |
| 순서 + 빠른 검색 | LinkedHashSet | 둘 다 만족 |
| 멀티스레드 | ConcurrentHashMap.newKeySet | 안전 |
| 읽기 위주 멀티스레드 | CopyOnWriteArraySet | 빠른 읽기 |
| Enum | EnumSet | 비트벡터 최적화 |
| 상수 | Set.of | 불변, 가벼움 |
| 큰 데이터 | HashSet + 초기 크기 | 확장 회피 |
Set 사용 검토:
타입 선택:
- HashSet (90%) 또는 다른 구현체?
- 정렬 필요한데 TreeSet 미사용?
- 순서 필요한데 HashSet?
- Enum 인데 EnumSet 미사용?
- 멀티스레드인데 일반 HashSet?
equals + hashCode:
- 도메인 객체에 둘 다 오버라이드?
- Lombok @EqualsAndHashCode 활용?
- id 기반 동일성 (Entity 의 경우)?
- 가변 객체 사용 안 함?
성능:
- 큰 컬렉션에 초기 크기 지정?
- List.contains → Set 변환?
- 자주 변하는 Set 의 캐시?
ILIC 에서 Set 을 잘못 선택하는 가장 흔한 케이스 3가지는?
답:
1. Enum 인데 HashSet 사용 → EnumSet 으로 변환
2. 정렬 필요한데 HashSet + 후처리 정렬 → TreeSet 으로
3. 멀티스레드인데 일반 HashSet → ConcurrentHashMap.newKeySet
→ ILIC 코드 리뷰 시 자주 발견되는 패턴.
// ❌ equals 안 오버라이드
public class Shipment {
private Long id;
// equals, hashCode 없음
}
Set<Shipment> set = new HashSet<>();
set.add(new Shipment(1L));
set.contains(new Shipment(1L)); // false!
원인: Object 의 기본 equals (참조 비교).
해결: equals + hashCode 오버라이드.
Set<MutableShipment> set = new HashSet<>();
MutableShipment s = new MutableShipment(1L);
set.add(s);
s.setId(2L); // hashCode 변경
set.contains(s); // false!
set.remove(s); // 못 제거!
해결:
TreeSet<String> set = new TreeSet<>();
set.add(null); // ❌ NullPointerException
이유:
해결:
// ❌ 매번 resize
Set<Long> ids = new HashSet<>(); // 초기 16
for (int i = 0; i < 1_000_000; i++) {
ids.add(...); // 매번 resize 발생
}
// ✓ 미리 크기 지정
Set<Long> ids = new HashSet<>(1_500_000);
// 초기 buckets ≈ 1,500,000 (capacity 미리 확보)
큰 Set 은 초기 크기 지정.
Set<String> immutable = Set.of("A", "B", "C");
immutable.add("D"); // ❌ UnsupportedOperationException
해결:
Set<String> mutable = new HashSet<>(Set.of("A", "B", "C"));
mutable.add("D"); // OK
// ❌ HashSet 은 순서 X
Set<String> set = new HashSet<>(List.of("C", "A", "B"));
// 순회 순서 예측 불가
// ❌ 정렬을 위해 List 변환 후 정렬
List<String> list = new ArrayList<>(set);
Collections.sort(list);
// 매번 정렬 비용
// ✓ TreeSet
TreeSet<String> sorted = new TreeSet<>(set);
// 자동 정렬
// ❌ HashSet 으로 Enum
Set<Status> statuses = new HashSet<>();
statuses.add(Status.ACTIVE);
statuses.add(Status.INACTIVE);
// ✓ EnumSet
Set<Status> statuses = EnumSet.of(Status.ACTIVE, Status.INACTIVE);
// 비트벡터, 매우 빠름
public void filterShipments(Set<Long> ids) {
ids.removeAll(processedIds); // 호출자 Set 변경!
}
// 호출
Set<Long> myIds = new HashSet<>(...);
service.filterShipments(myIds);
// myIds 가 의도치 않게 변경됨
해결:
public Set<Long> filterShipments(Set<Long> ids) {
Set<Long> result = new HashSet<>(ids);
result.removeAll(processedIds);
return result;
}
→ Phase 1 의 매개변수 안전 패턴.
박승제씨가 ILIC 에서 피할 것:
equals/hashCode:
- 한쪽만 오버라이드 → 항상 둘 다
- 가변 필드 기반 hashCode → 불변 필드 기반
- JPA Entity 에서 lazy field 사용 → id 기반
구현체 선택:
- Enum 에 HashSet → EnumSet
- 정렬 필요 시 매번 sort → TreeSet
- 멀티스레드에 HashSet → ConcurrentHashMap.newKeySet
성능:
- 큰 Set 에 초기 크기 미지정 → 지정
- 빈번 contains 에 List → Set 변환
안전:
- 매개변수 Set 변경 → 방어적 복사
- Set.of 변경 시도 → new HashSet<>()
| Q | 핵심 답변 |
|---|---|
| Set 의 정의? | 중복 없는 모음 |
| HashSet 내부 구조? | HashMap 의 wrapper |
| HashSet 이 빠른 이유? | 해시 테이블 O(1) |
| TreeSet 내부 구조? | Red-Black Tree (자가 균형 BST) |
| TreeSet 시간 복잡도? | O(log n) |
| LinkedHashSet 내부? | LinkedHashMap (HashMap + 이중 연결) |
| 3형제 선택 기준? | 정렬? → Tree, 순서? → Linked, 그 외 → Hash |
| hashCode+equals 계약? | equals true → hashCode 같아야 |
| 계약 위반 결과? | contains 못 찾음, Set 깨짐 |
| EnumSet 의 이점? | 비트벡터, 메모리/속도 최적 |
| TreeSet 정렬 순서? | 숫자 → 대문자 → 소문자 → 한글 |
| ConcurrentHashMap.newKeySet? | 멀티스레드 안전 Set |
1. Set 3형제의 본질
2. hashCode + equals 계약 = Set 의 토대
3. ILIC 실무 패턴
이번 Unit에서 Set 3형제를 봤다면, 다음은 List 3형제.
박승제씨가 2주차에서 본 ArrayList/LinkedList 내부 구조 위에:
→ 1·2주차 학습의 종합 + 3주차 신규 (Vector legacy).
🚀 Phase 2 — 컬렉션 프레임워크 전체 지도
✅ Unit 2.1 배열의 한계와 컬렉션의 등장
✅ Unit 2.2 Set 3형제 ← 여기
⏭ Unit 2.3 List 3형제 (ArrayList/LinkedList/Vector)
⏭ Unit 2.4 Queue (FIFO 자료구조)
⏭ Unit 2.5 Map 5형제
⏭ Unit 2.6 컬렉션 전체 지도 정리
✅ Phase 1 — Pass by Value (1.1 ~ 1.3 완주)
🚀 Phase 2 — 컬렉션 프레임워크 (2/6 진행)
총: 5/43 Unit 작성 (약 12%)
--