F-LAB JAVA · 2주차 · Phase 5 · 컬렉션 프레임워크 내부 구조
🚀 Phase 5 시작 — 코드 실무 직결의 컬렉션 내부 들여다보기
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
List, Set, Map은 "데이터 보관 방식"의 3가지 추상화다.
List = "순서가 있는 컬렉션", Set = "중복 없는 컬렉션", Map = "키-값 짝".
같은 데이터라도 어느 추상화를 선택하느냐에 따라 메모리 구조, 시간 복잡도, 의도가 모두 달라진다.
Phase 5의 출발점.
| 자료구조 | 비유 |
|---|---|
| List | 선반에 책을 순서대로 놓음 (인덱스로 찾기) |
| Set | 우표 컬렉션 (중복 없음, 순서 무관) |
| Map | 전화번호부 (이름 → 번호 매핑) |
같은 "정보 보관"이지만 목적이 다름.
선택을 잘못하면 코드도 비효율도 따라옴.
1. 컬렉션 프레임워크의 큰 그림
2. List — "순서 있는 컬렉션"
3. Set — "중복 없는 컬렉션"
4. Map — "키-값 매핑"
5. 시간 복잡도 비교
6. Map이 Collection의 자식이 아닌 이유
7. ILIC 실무 — 자료구조 선택 가이드
8. 흔한 실수 + 잘못된 선택의 사고
9. 면접 질문 + 자기 점검
박승제씨가 1주차 Phase 6에서 본 것:
1주차 컬렉션 학습:
- ArrayList vs LinkedList 비교
- HashMap의 내부 동작 (PPT까지)
- 자료구조 선택 기준 개요
→ 각 자료구조를 따로 봤음.
Phase 5의 차이:
- 자료구조 간 관계 (인터페이스 계층)
- 내부 메모리 구조 정밀
- 배열 확장 정책 코드로 검증
- 시간 복잡도의 진짜 의미
- 자료구조 잘못 선택 시 실제 사고
Iterable
│
▼
Collection ──────────────────┐
│ │
├── List │
│ ├── ArrayList │
│ ├── LinkedList │
│ ├── Vector (legacy) │
│ └── CopyOnWriteArrayList
│ │
├── Set │
│ ├── HashSet │
│ ├── LinkedHashSet │
│ └── TreeSet │
│ │
└── Queue / Deque │
├── LinkedList │
├── ArrayDeque │
└── PriorityQueue │
│
Map (Collection 밖!) │
├── HashMap │
├── LinkedHashMap │
├── TreeMap │
├── ConcurrentHashMap │
└── Hashtable (legacy) │
1. Iterable이 최상위
→ "순회 가능한 모든 것"
→ for-each 가능
2. Collection이 List/Set/Queue의 부모
→ "데이터 모음"의 공통 동작
3. Map은 Collection 밖
→ "데이터 모음"이 아니라 "매핑"
→ 다른 추상화
→ 이 그림이 자료구조 선택의 출발점.
List의 정의:
- 순서를 가진 요소들의 컬렉션
- 중복 허용
- 인덱스로 접근 가능
- "위치" 개념이 의미 있음
List<String> shipments = new ArrayList<>();
shipments.add("BL-001");
shipments.add("BL-002");
shipments.add("BL-003");
shipments.get(0); // "BL-001"
shipments.get(1); // "BL-002"
여기서 "순서" = 삽입 순서.
List의 순서:
✓ 삽입한 순서가 보존됨
✓ 위치(인덱스)가 의미 있음
✗ 정렬된 순서가 아님 (그건 별도 sort 필요)
"순서가 있다"는 정확히 무엇을 의미하는가?
답:
1. 삽입 순서 (Insertion Order):
- 넣은 그대로 유지
- List, LinkedHashSet, LinkedHashMap의 특성
2. 정렬 순서 (Sorted Order):
- 자동으로 정렬됨
- TreeSet, TreeMap의 특성
List는 삽입 순서.
박승제씨가 알아야 할 가장 첫 구분.
List<Shipment> list = new ArrayList<>();
// 추가
list.add(s); // 끝에 추가
list.add(0, s); // 특정 위치에 삽입
// 조회
list.get(0); // 인덱스로 조회
list.indexOf(s); // 객체 위치 찾기
list.contains(s); // 포함 여부
// 수정
list.set(0, newS); // 특정 위치 변경
// 삭제
list.remove(0); // 인덱스로 삭제
list.remove(s); // 객체로 삭제
// 순회
for (Shipment s : list) { ... }
list.forEach(this::process);
list.stream()...
List<String> list = new ArrayList<>();
list.add("A");
list.add("A");
list.add("A");
list.size(); // 3 ← 모두 보관
→ 같은 값을 N번 넣으면 N번 보관.
// Repository 결과
List<Shipment> shipments = repository.findAll();
// DTO 변환
List<ShipmentResponse> responses = shipments.stream()
.map(ShipmentResponse::from)
.toList();
// 다중 화물 정보
public class Shipment {
private List<Cargo> cargoes; // 한 운송에 여러 화물
}
가장 흔하게 쓰는 컬렉션.
Set의 정의:
- 중복 없는 요소들의 컬렉션
- 순서 보장 없음 (HashSet 기준)
- 인덱스 없음
- "포함 여부"가 의미 있음
Set<String> set = new HashSet<>();
set.add("A");
set.add("A");
set.add("A");
set.size(); // 1 ← 1개만 보관
HashSet이 어떻게 중복을 막나?
1. add("A") 호출:
- "A".hashCode() 계산
- 해시 테이블의 해당 버킷 검색
- 같은 hashCode + equals true인 객체 있나?
- 있으면 → 추가 안 함
- 없으면 → 추가
→ hashCode + equals 가 핵심.
HashSet은 어떻게 중복을 막는가?
답:
1. 내부적으로 HashMap 사용
2. 추가하려는 객체의 hashCode() 계산
3. 해당 버킷에서 equals() 로 동일성 검사
4. 이미 있으면 추가 거부, 없으면 추가
→ Set의 동작은 결국 HashMap의 키 관리.
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 s = (Shipment) o;
return Objects.equals(id, s.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
규칙:
HashSet:
- 순서 없음
- O(1) add/contains/remove
- 가장 빠름
LinkedHashSet:
- 삽입 순서 유지
- HashSet보다 약간 느림
TreeSet:
- 정렬 순서 (Comparable / Comparator)
- O(log n) add/contains/remove
- 정렬 필요 시
// 중복 제거가 필요한 곳
Set<String> uniqueRoutes = shipments.stream()
.map(Shipment::getRoute)
.collect(Collectors.toSet());
// 권한 체크
Set<Permission> userPermissions = ...;
if (userPermissions.contains(Permission.ADMIN)) { ... }
// 처리된 ID 추적
Set<Long> processedIds = new HashSet<>();
for (Shipment s : all) {
if (processedIds.contains(s.getId())) continue;
process(s);
processedIds.add(s.getId());
}
"있나 없나?"가 중요할 때 사용.
Map의 정의:
- 키와 값의 쌍을 저장
- 키는 중복 불가, 값은 중복 가능
- 키로 값을 조회
- "조회"가 핵심 동작
Map<Long, Shipment> map = new HashMap<>();
// 추가/갱신
map.put(1L, shipment1);
map.put(1L, shipment2); // 1L이 이미 있으면 값 교체
// 조회
Shipment s = map.get(1L);
// 포함 여부
map.containsKey(1L);
map.containsValue(shipment1);
// 삭제
map.remove(1L);
// 순회
for (Map.Entry<Long, Shipment> entry : map.entrySet()) {
Long key = entry.getKey();
Shipment value = entry.getValue();
}
map.forEach((id, s) -> { ... });
HashMap:
- 순서 없음
- O(1) put/get/remove
- 가장 빠름
- 일반적 선택
LinkedHashMap:
- 삽입 순서 또는 접근 순서 유지
- HashMap보다 약간 느림
- LRU 캐시 구현 가능
TreeMap:
- 키 정렬 순서
- O(log n)
- 범위 검색 가능 (subMap, headMap, tailMap)
ConcurrentHashMap:
- 스레드 안전
- 멀티스레드 환경
- 일반 HashMap보다 약간 느림
Map.Entry<Long, Shipment> entry = ...;
entry.getKey(); // 키
entry.getValue(); // 값
// Java 9+
Map.entry(1L, shipment); // 불변 Entry 생성
// 캐시
private final Map<Long, Shipment> cache = new HashMap<>();
// 빠른 조회
Map<String, Cargo> cargoesByCode = cargoes.stream()
.collect(Collectors.toMap(Cargo::getCode, c -> c));
// 그룹핑
Map<String, List<Shipment>> byRoute = shipments.stream()
.collect(Collectors.groupingBy(Shipment::getRoute));
// 카운팅
Map<String, Long> countByStatus = shipments.stream()
.collect(Collectors.groupingBy(
Shipment::getStatus,
Collectors.counting()));
"키로 값 찾기"가 필요할 때.
O(1) : 상수 시간 (입력 크기 무관)
O(log n) : 로그 시간 (입력 크기 두 배 → 1 단계 더)
O(n) : 선형 시간 (입력 크기 비례)
O(n²) : 이차 시간 (입력 크기 제곱)
| 작업 | ArrayList | LinkedList |
|---|---|---|
get(i) | O(1) | O(n) |
add(e) (끝) | O(1) amortized | O(1) |
add(0, e) (앞) | O(n) | O(1) |
add(i, e) (중간) | O(n) | O(n) |
remove(0) | O(n) | O(1) |
remove(end) | O(1) | O(1) |
contains(e) | O(n) | O(n) |
indexOf(e) | O(n) | O(n) |
관찰:
→ Unit 5.2, 5.3에서 정밀.
| 작업 | HashSet | TreeSet |
|---|---|---|
add(e) | O(1) | O(log n) |
contains(e) | O(1) | O(log n) |
remove(e) | O(1) | O(log n) |
| 정렬 순회 | O(n log n) | O(n) |
→ HashSet이 일반적으로 빠름.
→ TreeSet은 정렬 필요 시.
| 작업 | HashMap | TreeMap |
|---|---|---|
put(k, v) | O(1) | O(log n) |
get(k) | O(1) | O(log n) |
remove(k) | O(1) | O(log n) |
containsKey(k) | O(1) | O(log n) |
| 키 정렬 순회 | O(n log n) | O(n) |
| 범위 검색 | O(n) | O(log n) |
→ Phase 5.4에서 HashMap 내부 정밀.
순서가 중요? + 인덱스 접근?
→ ArrayList
앞/뒤 자주 추가/삭제?
→ LinkedList 또는 ArrayDeque
중복 제거?
→ HashSet
중복 제거 + 정렬?
→ TreeSet
키로 빠른 조회?
→ HashMap
키 정렬 + 범위 검색?
→ TreeMap
삽입 순서 + 빠른 조회?
→ LinkedHashMap (LRU 캐시 패턴)
멀티스레드 + Map?
→ ConcurrentHashMap
// Collection 인터페이스
public interface Collection<E> extends Iterable<E> {
boolean add(E e);
boolean remove(Object o);
int size();
// ...
}
// Map 인터페이스 (Collection이 아님!)
public interface Map<K, V> {
V put(K key, V value);
V get(Object key);
int size();
// ...
}
이유:
Map은 직접 순회 안 되지만, 세 가지 view 제공:
Map<Long, Shipment> map = ...;
// 1. 키만
Set<Long> keys = map.keySet();
// 2. 값만
Collection<Shipment> values = map.values();
// 3. 키-값 쌍
Set<Map.Entry<Long, Shipment>> entries = map.entrySet();
각 view는 Map과 연결:
// ❌ Map은 직접 for-each 안 됨
for (??? entry : map) { ... }
// ✓ entrySet으로 우회
for (Map.Entry<Long, Shipment> entry : map.entrySet()) {
Long k = entry.getKey();
Shipment v = entry.getValue();
}
// ✓ forEach 람다
map.forEach((k, v) -> { ... });
→ Java 8+ 의 forEach(BiConsumer) 가 가장 편함.
수학적으로 Map은 함수:
함수 f: K → V
f("hello") = 42
f("world") = 17
→ 키마다 값이 결정
→ 같은 키로 두 번 호출하면 같은 값 (단, mutable Map)
Collection은 "set/list of elements"지만, Map은 "K에서 V로의 사상".
→ 본질이 다름. 그래서 인터페이스도 분리.
// 1. DB 결과 보관 → List
List<Shipment> shipments = repository.findAll();
// 2. 중복 제거 → Set
Set<String> uniqueOrigins = shipments.stream()
.map(Shipment::getOrigin)
.collect(Collectors.toSet());
// 3. ID로 빠른 조회 → Map
Map<Long, Shipment> shipmentsById = shipments.stream()
.collect(Collectors.toMap(Shipment::getId, s -> s));
// 4. 그룹핑 → Map<K, List<V>>
Map<String, List<Shipment>> byRoute = shipments.stream()
.collect(Collectors.groupingBy(Shipment::getRoute));
// ❌ List에서 contains 빈번 호출
List<Long> processedIds = new ArrayList<>();
for (Shipment s : allShipments) { // 10만 건
if (processedIds.contains(s.getId())) continue;
// O(n) × 10만 = 10^10 비교!
process(s);
processedIds.add(s.getId());
}
// ✓ Set 사용
Set<Long> processedIds = new HashSet<>();
for (Shipment s : allShipments) {
if (processedIds.contains(s.getId())) continue;
// O(1) × 10만 = 10만 비교
process(s);
processedIds.add(s.getId());
}
성능 차이:
ArrayList의 Object[] 배열:
10000 항목 = 80KB (참조)
HashSet의 내부 HashMap:
10000 항목 = 약 320KB (배열 + Node)
TreeMap:
10000 항목 = 약 400KB (트리 노드)
→ 메모리 약 5배 차이.
→ 메모리 부족 환경에선 고려.
도메인 객체 보관:
List<Shipment>
조회 키로 사용:
Map<Long, Shipment>
권한/태그/상태 집합:
Set<Permission>, Set<Tag>
캐싱:
Caffeine + Map (라이브러리)
설정 상수:
Set.of(...) // Java 9+
Map.of(k1, v1, ...)
List.of(...)
// Java 9+
List<String> immutableList = List.of("A", "B", "C");
Set<String> immutableSet = Set.of("A", "B", "C");
Map<String, Integer> immutableMap = Map.of("A", 1, "B", 2);
장점:
ILIC 상수 정의 시 적극 활용.
// 멀티스레드 환경
ConcurrentHashMap<Long, Shipment> sharedMap = new ConcurrentHashMap<>();
// vs
Map<Long, Shipment> map = Collections.synchronizedMap(new HashMap<>());
// vs (Java 8+)
ConcurrentMap<Long, Shipment> concurrentMap = ...;
ConcurrentHashMap:
compute, merge 같은 atomic 메서드→ 멀티스레드 공유 Map은 항상 ConcurrentHashMap.
List<Long> ids = ...; // 큰 리스트
for (Item i : items) {
if (ids.contains(i.getId())) { // O(n) × m = O(n×m)
process(i);
}
}
해결:
Set<Long> idSet = new HashSet<>(ids);
for (Item i : items) {
if (idSet.contains(i.getId())) { // O(1) × m = O(m)
process(i);
}
}
public class Shipment {
private Long id;
private String blNo;
// equals/hashCode 없음!
}
Set<Shipment> set = new HashSet<>();
set.add(new Shipment(1L, "BL"));
set.add(new Shipment(1L, "BL"));
set.size(); // 2 ← 의도와 다름!
→ 모든 Entity, VO는 equals + hashCode 반드시 오버라이드.
→ Unit 5.4에서 더 깊이.
public class Cargo {
private String code;
// equals/hashCode가 code 기반
}
Map<Cargo, Integer> map = new HashMap<>();
Cargo c = new Cargo("A");
map.put(c, 1);
c.setCode("B"); // ❌ 키의 hashCode 변경!
map.get(c); // null (찾지 못함)
→ Map 키는 불변 객체 권장.
TreeMap<String, Integer> map = new TreeMap<>();
map.put(null, 1); // ❌ NullPointerException
이유:
→ HashMap은 null 키 허용, TreeMap은 불가.
// ❌ 멀티스레드에서 HashMap
private static final Map<Long, Shipment> cache = new HashMap<>();
void process(Shipment s) {
cache.put(s.getId(), s); // 동시 호출 시 데이터 깨짐
}
해결:
private static final ConcurrentMap<Long, Shipment> cache = new ConcurrentHashMap<>();
// Java 16+
List<Shipment> list = stream.toList(); // 불변 List 반환
// Java 8+
List<Shipment> list = stream.collect(Collectors.toList()); // 가변 List
// 차이를 모르면 add() 시도 시 UnsupportedOperationException
Set<String> set = new HashSet<>();
set.add("A");
set.add("B");
set.add("C");
for (String s : set) {
System.out.println(s); // 어떤 순서? 보장 없음!
}
해결:
// ❌ 100만 건이 들어올 거라 알면서
List<Shipment> list = new ArrayList<>(); // 기본 크기 10
for (int i = 0; i < 1_000_000; i++) {
list.add(...); // 매번 확장 → 비효율
}
// ✓ 초기 크기 지정
List<Shipment> list = new ArrayList<>(1_000_000);
→ Unit 5.2 ArrayList 확장 정책에서 자세히.
| Q | 핵심 답변 |
|---|---|
| List/Set/Map 차이? | 순서/중복/키-값 |
| "순서가 있다" 의미? | 삽입 순서 (정렬 순서 아님) |
| HashSet이 중복 막는 방법? | 내부 HashMap의 키 관리 (hashCode + equals) |
| Map이 Collection 자식 아닌 이유? | 단일 요소 vs 키-값 쌍. 본질 다름 |
| List에서 contains는? | O(n). 빈번하면 Set으로 |
| HashMap의 평균 get/put 시간? | O(1) |
| TreeMap의 시간 복잡도? | O(log n). 정렬 필요 시 |
| ArrayList vs LinkedList? | 랜덤 접근 vs 앞/뒤 추가 |
| hashCode/equals 계약? | equals true → hashCode 같음 |
| 멀티스레드 Map? | ConcurrentHashMap |
1. 3가지 추상화의 본질
2. 시간 복잡도가 선택 좌우
3. ILIC 표준 패턴
이번 Unit에서 자료구조의 큰 그림을 봤다면, 다음은 ArrayList의 내부를 직접 들여다보기.
🚀 Phase 5 — 컬렉션 내부 구조
✅ Unit 5.1 List/Set/Map의 본질적 차이 ← 여기
⏭ Unit 5.2 ArrayList 내부
⏭ Unit 5.3 LinkedList 내부
⏭ Unit 5.4 삽입/삭제 효율의 진짜 이유
✅ Phase 1 — 자바 변수 ↔ 메모리 매핑 (1.1 ~ 1.6 완주)
✅ Phase 2 — JVM 메서드 실행 메커니즘 (2.1 ~ 2.4 완주)
✅ Phase 3 — 바이트코드와 상수 풀 (3.1 ~ 3.4 완주, 정점)
✅ Phase 4 — G1 GC 심화 (4.1 ~ 4.5 완주, 운영 마스터)
🚀 Phase 5 — 컬렉션 내부 구조 (1/4 진행)
⏭ Phase 6 — Reflection & Iterator
⏭ Phase 7 — Buffer