F-LAB JAVA · 2주차 · Phase 5 · 컬렉션 프레임워크 내부 구조
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
new ArrayList<>(1000) 의 진짜 의미와 효과는?ArrayList = "Object[] 배열을 감싼 동적 컬렉션"이다.
내부는 단순한 배열이지만, 공간 부족 시 1.5배로 새 배열 생성 + 데이터 복사하는 메커니즘이 핵심.
이 메커니즘 덕에 평균 O(1)로 추가 가능하지만, 중간 삽입/삭제는 O(n) 의 메모리 복사 비용을 가진다.
| 시스템 | 비유 |
|---|---|
| ArrayList | 책 10권 들어가는 책장 |
| 공간 부족 | 11번째 책 들어왔는데 자리 없음 |
| 1.5배 확장 | 15권짜리 새 책장 사고 → 10권 옮기고 → 11번째 추가 |
| amortized O(1) | 평균적으로는 빠름. 가끔 비싼 작업 |
| 중간 삽입 | 5번 위치에 책 끼우려면 → 5번 이후 모든 책 한 칸씩 밀어내기 |
1. ArrayList의 내부 — Object[] 배열
2. 기본 크기 10의 의미
3. 1.5배 확장 정책 정밀
4. 1.5배인 이유 — 트레이드오프
5. amortized O(1) 분석
6. 중간 삽입/삭제의 O(n) 비용
7. ILIC 실무 — 초기 크기와 성능
8. 흔한 실수 + 디버깅
9. 면접 질문 + 자기 점검
public class ArrayList<E> {
transient Object[] elementData; // 실제 데이터 저장
private int size; // 현재 요소 개수
private static final int DEFAULT_CAPACITY = 10;
}
→ ArrayList는 단순한 Object 배열 + 카운터.
Object[] elementData:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│ │ │ │ │ │ │ │ │ │ │ ← 10칸 (기본 크기)
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
0 1 2 3 4 5 6 7 8 9
ArrayList의 size:
현재 들어있는 요소 수
ArrayList의 capacity:
배열의 총 크기 (elementData.length)
size ≠ capacity 가 핵심.
ArrayList<String> list = new ArrayList<>(); // size=0, capacity=10 (lazy)
list.add("A"); // size=1
list.add("B"); // size=2
list.add("C"); // size=3
메모리:
elementData:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │B │C │ │ │ │ │ │ │ │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
0 1 2 3 4 ...
size = 3
capacity = 10
get(i):
public E get(int index) {
return (E) elementData[index]; // 배열 인덱스 직접 접근
}
→ O(1). 매우 빠름.
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
DEFAULTCAPACITY_EMPTY_ELEMENTDATA = 빈 배열 (크기 0).
처음 add() 호출 시 비로소 크기 10 배열 생성.
new ArrayList<>() 직후:
elementData = [] (크기 0)
size = 0
list.add("A") 호출 후:
elementData = [A, null, null, ..., null] (크기 10)
size = 1
이유:
ArrayList<String> list = new ArrayList<>();
내부적으로 Object[] 사용.
컴파일 시점에 제네릭 타입 정보 소거 (Type Erasure).
런타임:
Object[] elementData = new Object[10];
get(0):
Object obj = elementData[0];
return (String) obj; // 컴파일러가 자동 캐스팅
→ Java 제네릭의 한계 (primitive 사용 불가, 배열 생성 어려움).
→ Phase 6에서 Reflection과 함께 더 깊이.
private static final int DEFAULT_CAPACITY = 10;
왜 10?
1998년 Java 1.2 컬렉션 프레임워크 도입 시:
- Java 1.0/1.1 의 Vector도 기본 10
- 호환성 + 익숙함 유지
- 10은 "인간이 다루는 작은 수" 의 자연스러운 선택
작은 크기 (예: 4):
큰 크기 (예: 100):
10:
가상 통계:
조사: 일반 Java 앱에서 만들어지는 ArrayList의 크기 분포
크기 0-5: 45% ← 매우 작은 리스트
크기 6-15: 30% ← 10에 잘 맞는 크기
크기 16-100: 15%
크기 100+: 10%
→ 10이 75% 케이스 커버
// 무심코 자주 만드는 ArrayList
public ShipmentResponse process(Shipment s) {
List<String> warnings = new ArrayList<>();
// ... 보통 0-3개 추가
return new ShipmentResponse(s, warnings);
}
대부분 0-3개 → 10이면 충분. 확장 한 번도 안 일어남.
// ArrayList 내부 (Java 8+)
private void ensureCapacityInternal(int minCapacity) {
if (minCapacity > elementData.length) {
grow(minCapacity);
}
}
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5배!
if (newCapacity < minCapacity) {
newCapacity = minCapacity;
}
elementData = Arrays.copyOf(elementData, newCapacity);
}
핵심: oldCapacity + (oldCapacity >> 1)
>> 1 = 비트 시프트 = 2로 나눔oldCapacity + oldCapacity/2 = oldCapacity * 1.5초기: capacity 10
1번 확장: 10 + 5 = 15
2번 확장: 15 + 7 = 22
3번 확장: 22 + 11 = 33
4번 확장: 33 + 16 = 49
5번 확장: 49 + 24 = 73
6번 확장: 73 + 36 = 109
7번 확장: 109 + 54 = 163
8번 확장: 163 + 81 = 244
9번 확장: 244 + 122 = 366
10번 확장: 366 + 183 = 549
...
→ 크기가 1.5의 지수로 증가.
add() 호출 → 공간 부족 감지
Step 1: 새 capacity 계산
newCapacity = oldCapacity + oldCapacity/2
Step 2: 새 배열 할당 (Heap)
Object[] newArray = new Object[newCapacity];
Step 3: 기존 데이터 복사
System.arraycopy(elementData, 0, newArray, 0, size);
Step 4: 참조 교체
elementData = newArray;
(기존 배열은 GC 대상)
Step 5: 새 요소 추가
elementData[size++] = newElement;
시점 1: capacity 10, size 9
elementData ───► ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │B │C │D │E │F │G │H │I │ │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
시점 2: add("J") — 아직 자리 있음
elementData ───► ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │B │C │D │E │F │G │H │I │J │ size=10
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
시점 3: add("K") — 자리 없음! 확장 발동
1. 새 배열 (15) 생성
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
2. 데이터 복사
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │B │C │D │E │F │G │H │I │J │ │ │ │ │ │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
3. K 추가
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │B │C │D │E │F │G │H │I │J │K │ │ │ │ │ size=11
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
4. elementData를 새 배열로 교체
기존 배열은 GC 대상
→ 확장 = 새 배열 + 데이터 복사.
→ O(n) 비용.
ArrayList<Integer> list = new ArrayList<>();
for (int i = 0; i < 100; i++) {
list.add(i);
// i=9 시점: capacity 10, size 10
// i=10 시점: 확장! capacity 15
// i=14 시점: size 15
// i=15 시점: 확장! capacity 22
// ...
}
# Reflection으로 capacity 확인
java -cp ... com.example.CapacityCheck
Java ArrayList: 1.5배
C++ std::vector: 2배 (구현마다 다름)
Python list: 1.125배 + 작은 패딩
JavaScript Array: 보통 2배
Rust Vec: 2배
→ 1.5배 vs 2배가 가장 일반적.
2배 확장:
10 → 20 → 40 → 80 → 160 → 320 → ...
1.5배 확장:
10 → 15 → 22 → 33 → 49 → 73 → ...
같은 요소 수에서:
2배: 더 많은 미사용 공간
1.5배: 더 빡빡한 사용
예: 100개 요소 보관
2배: capacity 128 (28개 낭비)
1.5배: capacity 109 (9개 낭비)
가설:
2배 확장 시:
capacity: 4 → 8 → 16 → 32 → 64
64 확장 시점에:
- 새 배열 64
- 기존 비워진 메모리: 4+8+16+32 = 60
- 새 배열 64 > 60
→ 비워진 공간 재사용 불가
1.5배 확장 시:
capacity: 4 → 6 → 9 → 13 → 19 → 28 → 42
42 확장 시점에:
- 새 배열 42
- 기존: 4+6+9+13+19+28 = 79 > 42
→ 이론적으로 재사용 가능
실제로는 GC가 처리해서 큰 차이 없지만, 이론적 근거.
1억 요소까지 도달:
2배: log2(10⁸) ≈ 27번 확장
1.5배: log1.5(10⁸) ≈ 46번 확장
→ 1.5배가 확장 더 자주.
1.5배인 이유는? (3배·2배가 아닌)
답:
1. 2배는 메모리 낭비 ↑ (사용 안 한 공간 많음)
2. 3배는 더 심함
3. 1.5배가 균형점:
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
배열 최대 크기는 약 21억 (Integer.MAX_VALUE).
-8 은 일부 JVM의 헤더 영역 고려ILIC에서 21억 요소 ArrayList는 만들 일 없음.
→ 그러나 알아둘 만한 한계.
일반 O(1): 항상 상수 시간
amortized O(1): 평균적으로 상수 시간
(일부 작업은 O(n)이지만, 그 비용을 다음 작업들이 분담)
list.add(e); // 보통 O(1)
// 확장 시점에만 O(n)
시나리오: 100개 요소 추가.
i=0~9 : 빠른 add (10번)
i=10 : 확장 발생 → 10개 복사 (10 작업)
i=11~14 : 빠른 add (4번)
i=15 : 확장 발생 → 15개 복사 (15 작업)
i=16~21 : 빠른 add (6번)
i=22 : 확장 발생 → 22개 복사 (22 작업)
...
총 작업량:
빠른 add: 100번
확장 시 복사: 10 + 15 + 22 + 33 + 49 + 73 = 202
총: 302 작업으로 100개 추가
평균: 3.02 작업 / 추가
→ 100개 → 300작업. 100만개 → ~300만 작업.
→ 선형 증가 → amortized O(1).
가끔 비싼 작업 (확장)이 있지만,
빠른 작업들의 평균치로 분담되어
평균은 상수 시간
비유:
amortized O(1): 평균 O(1)
worst case: O(n) (확장 시점)
실시간 시스템에서는 worst case가 중요.
→ 실시간 환경에선 미리 capacity 지정.
JMH 벤치마크 (가상):
list.add() 평균 시간:
capacity 미지정: ~30ns (확장 포함)
capacity 지정: ~10ns (확장 없음)
→ 큰 List 생성 시 capacity 지정이 3배 빠름.
List<String> list = new ArrayList<>();
list.add("A"); list.add("B"); list.add("D"); list.add("E");
// 1번 위치에 "C" 삽입
list.add(1, "C");
내부 동작:
Before:
┌──┬──┬──┬──┐
│A │B │D │E │
└──┴──┴──┴──┘
0 1 2 3
Step 1: 인덱스 1 이후를 한 칸씩 뒤로 이동
arraycopy(elementData, 1, elementData, 2, 3) ← 3개 이동
┌──┬──┬──┬──┬──┐
│A │B │B │D │E │ ← 잠시 중복
└──┴──┴──┴──┴──┘
0 1 2 3 4
Step 2: 인덱스 1에 "C" 저장
elementData[1] = "C"
┌──┬──┬──┬──┬──┐
│A │C │B │D │E │
└──┴──┴──┴──┴──┘
❌ 아직 잘못됨. 위 그림은 단계 설명 위해 잠시.
실제로는:
Step 1: arraycopy로 [B, D, E]를 1번에서 2번부터 복사
Step 2: 1번에 C
결과:
┌──┬──┬──┬──┬──┐
│A │C │B │D │E │
└──┴──┴──┴──┴──┘
→ 1번 이후의 모든 요소 이동.
→ 최악의 경우 (앞에 삽입) n번 이동 → O(n).
list.remove(1); // 1번 요소 삭제
내부 동작:
Before:
┌──┬──┬──┬──┬──┐
│A │C │B │D │E │
└──┴──┴──┴──┴──┘
Step 1: 2번 이후를 한 칸씩 앞으로 이동
arraycopy(elementData, 2, elementData, 1, 3)
┌──┬──┬──┬──┬──┐
│A │B │D │E │E │ ← 마지막에 중복
└──┴──┴──┴──┴──┘
Step 2: 마지막 슬롯을 null로
elementData[size-1] = null;
size--;
┌──┬──┬──┬──┬──┐
│A │B │D │E │ │
└──┴──┴──┴──┴──┘
size = 4
→ 삭제도 O(n).
list.add(e): O(1) amortized
list.add(0, e): O(n) — 모든 요소 이동
list.add(i, e): O(n-i)
list.remove(size-1): O(1) — 끝만
list.remove(0): O(n) — 모든 요소 이동
list.remove(i): O(n-i)
→ 끝은 빠름, 중간/앞은 느림.
// 잘못된 사용
List<Shipment> queue = new ArrayList<>();
while (hasNext()) {
queue.add(0, next()); // ❌ 매번 O(n) 이동!
}
while (!queue.isEmpty()) {
process(queue.remove(0)); // ❌ 매번 O(n)!
}
10000건 처리 시:
해결:
// LinkedList 또는 ArrayDeque 사용
Deque<Shipment> queue = new ArrayDeque<>();
queue.offerFirst(s); // O(1)
queue.pollFirst(); // O(1)
ArrayList의 중간 삽입/삭제 비용이 O(n)인 이유?
답:
// 큰 List 알면 미리 지정
List<Shipment> shipments = new ArrayList<>(1000);
// vs
List<Shipment> shipments = new ArrayList<>(); // 기본 10
벤치마크 (1000개 추가):
→ 3배 빠름.
new ArrayList<>(1000)처럼 초기 크기를 지정하는 게 왜 효율적인가?
답:
1. 확장 발생 안 함 (10 → 15 → 22 → ... → 1000+)
2. 메모리 복사 비용 0
3. 새 배열 할당도 1번뿐
4. GC 부담 적음 (중간 배열들 만들지 않음)
5. amortized 효과 의존 안 함
// DB 쿼리 결과 — Page 정보로 크기 알면
Page<Shipment> page = repository.findAll(PageRequest.of(0, 100));
List<Shipment> result = new ArrayList<>(page.getTotalElements());
for (Shipment s : page) {
result.add(s);
}
// Map → List 변환 — 크기 명확
List<Shipment> shipmentList = new ArrayList<>(shipmentMap.values());
// Stream → List
// Collectors.toList()는 내부적으로 ArrayList 만듦. 크기 모르므로 기본
List<Shipment> list = stream.collect(Collectors.toList());
// 패턴 1: 보통 작은 List → 기본 크기
public ShipmentResponse process(Shipment s) {
List<String> warnings = new ArrayList<>();
if (s.isOverweight()) warnings.add("Overweight");
if (s.isHazardous()) warnings.add("Hazardous");
return ShipmentResponse.from(s, warnings);
}
// 패턴 2: 큰 List 예상 → 크기 지정
public List<Shipment> findByRoute(String route) {
long count = repository.countByRoute(route);
List<Shipment> result = new ArrayList<>((int) count);
// ...
}
// 패턴 3: 정확한 크기 변환
public List<Long> extractIds(Collection<Shipment> shipments) {
List<Long> ids = new ArrayList<>(shipments.size());
for (Shipment s : shipments) {
ids.add(s.getId());
}
return ids;
}
List<Shipment> list = new ArrayList<>(10000);
// ... 100개만 add
list.add(...);
// ...
// 메모리 절약을 위해
((ArrayList<Shipment>) list).trimToSize();
// elementData 크기를 size(=100)에 맞춤
// → 9900개 슬롯 메모리 회수
ILIC에서:
큰 데이터 처리 시:
// ❌ 메모리 폭발
List<Shipment> all = repository.findAll(); // 100만 건
for (Shipment s : all) {
process(s);
}
// ✓ 스트림
try (Stream<Shipment> stream = repository.findAllStream()) {
stream.forEach(this::process);
}
→ Unit 4.4 거대 객체와 연결.
// ❌ 큐처럼 사용
List<Task> queue = new ArrayList<>();
queue.add(0, task);
queue.remove(0);
→ ArrayDeque, LinkedList 사용.
// ❌
List<Shipment> result = new ArrayList<>();
for (Shipment s : huge1MillionStream) {
result.add(s);
}
// 확장 약 27번 발생!
해결:
List<Shipment> list = ...;
for (int i = 0; i < list.size(); i++) {
if (list.get(i).isExpired()) {
list.remove(i); // ❌ 인덱스 꼬임
}
}
해결:
// 방법 1: Iterator
Iterator<Shipment> it = list.iterator();
while (it.hasNext()) {
if (it.next().isExpired()) {
it.remove(); // ✓ 안전
}
}
// 방법 2: removeIf (Java 8+)
list.removeIf(Shipment::isExpired);
// 방법 3: Stream
list = list.stream()
.filter(s -> !s.isExpired())
.collect(Collectors.toList());
List<Shipment> big = ...;
List<Shipment> sub = big.subList(0, 10);
big.clear(); // ❌ sub도 영향받음!
subList는 원본의 view.
해결:
List<Shipment> sub = new ArrayList<>(big.subList(0, 10)); // 복사본
List<String> list = Arrays.asList("A", "B", "C");
list.add("D"); // ❌ UnsupportedOperationException
Arrays.asList:
해결:
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
// 또는 Java 9+
List<String> list = List.of("A", "B", "C"); // 완전 불변
List<Long> ids = ...; // 10000건
for (Item i : items) {
if (ids.contains(i.getId())) { // O(n) × m
// ...
}
}
→ Unit 5.1 흔한 실수 1과 동일. Set으로 변환.
ArrayList<Shipment> list = ...;
list.size(); // ✓ 보임 (요소 개수)
// list.capacity(); // ❌ ArrayList에 없음!
ArrayList는 public capacity() 메서드 없음.
// ❌ Vector 사용
Vector<Shipment> v = new Vector<>();
Vector:
→ Vector는 절대 사용 X.
| Q | 핵심 답변 |
|---|---|
| ArrayList 내부? | Object[] 배열 + size |
| 기본 크기? | 10 (Lazy 초기화) |
| 확장 정책? | 1.5배. oldCapacity + (oldCapacity >> 1) |
| 1.5배인 이유? | 2배보다 메모리 효율. 3배보다 균형 |
| add() 시간 복잡도? | amortized O(1) |
| 중간 삽입 비용? | O(n). 메모리 복사 |
| 초기 크기 지정 효과? | 확장 0회. 성능 ↑ |
| trimToSize? | size에 맞게 capacity 축소 |
| Arrays.asList? | 고정 크기. add/remove 불가 |
| Vector? | Legacy. synchronized 무거움 |
1. ArrayList = Object[] + size
2. 1.5배 확장 정책
oldCapacity + (oldCapacity >> 1)3. 성능 최적화
이번 Unit에서 ArrayList의 배열 기반을 봤다면, 다음은 LinkedList의 노드 연결 구조.
🚀 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 (2.1 ~ 2.4 완주)
✅ Phase 3 (3.1 ~ 3.4 완주, 정점)
✅ Phase 4 (4.1 ~ 4.5 완주)
🚀 Phase 5 (2/4 진행)
⏭ Phase 6
⏭ Phase 7