자바 컬렉션 한눈에 보기 3편: ArrayList부터 Iterator, fail-fast까지
들어가며
실무에서 가장 많이 쓰는 Collection을 하나만 고르라면 아마 List일 가능성이 높다.
문제는 List를 많이 쓴다고 해서 내부 동작까지 잘 안다고 보긴 어렵다는 점이다.
예를 들어 이런 질문은 전부 연결되어 있다.
ArrayList와LinkedList는 왜 이론과 실무 체감이 다를까ArrayList는 왜 크기가 계속 늘어날 수 있을까Iterator는 왜 필요한가- 왜
for-each중remove()는 터질 수 있을까 - fail-fast는 왜 존재하는가
- 왜
Iterator.remove()는 안전한가 CopyOnWriteArrayList는 왜 예외가 덜 보일까
이 구간은 사실 "리스트"만의 이야기가 아니라
반복 중 구조 변경을 자바 컬렉션이 어떻게 다루는가의 이야기다.
목차
- 10) ArrayList vs LinkedList
- 11) ArrayList 내부 구조와 resize 전략
- 12) Iterator는 왜 필요한가
- 13) for-each vs Iterator
- 14) fail-fast와 modCount
- 15) Iterator.remove는 왜 안전한가
- 16) CopyOnWriteArrayList는 왜 예외가 덜 보일까
10) ArrayList vs LinkedList
10-1) 왜 이론과 실무 체감이 다를까
자료구조 표를 처음 보면 보통 이렇게 배운다.
ArrayList: 조회 빠름, 중간 삽입/삭제 느림LinkedList: 조회 느림, 삽입/삭제 빠름
이 표 자체는 틀리지 않다.
문제는 실무에서는 LinkedList가 생각보다 잘 안 쓰인다는 점이다.
이유는 단순하다.
실무에서 많은 코드는 "중간 노드를 이미 쥐고 있는 상태에서 연결만 바꾸는 작업"보다,
순회와 인덱스 접근, 마지막 append가 훨씬 많기 때문이다.
10-2) ArrayList의 강점은 메모리 연속성이다
ArrayList는 내부적으로 배열을 쓴다.
즉 원소가 연속된 공간에 놓이므로 CPU 캐시 친화적이다.
반복문으로 훑을 때 다음 원소가 메모리상 가까운 곳에 있을 가능성이 높다.
이 점이 실무 체감 성능에서 꽤 크게 작용한다.
10-3) LinkedList의 숨은 비용은 포인터 추적이다
LinkedList는 노드가 메모리 여기저기에 흩어질 수 있고,
다음 노드로 갈 때마다 참조를 따라가야 한다.
즉 이론상 삽입/삭제가 O(1)일 수 있어도,
실제 사용에서는 아래 비용이 먼저 튄다.
- 특정 위치까지 이동하는 비용
- 순회 중 포인터 추적 비용
- 노드 객체 자체의 메모리 오버헤드
그래서 기본값은 대부분 ArrayList가 된다.

11) ArrayList 내부 구조와 resize 전략
11-1) ArrayList는 동적 배열이다
ArrayList는 "배열을 쓰지만 크기가 고정되지 않는" 구조다.
핵심은 아래 두 개념이다.
- 실제 데이터를 담는 배열
- 현재 사용 중인 원소 개수(
size)
배열 길이와 size는 다르다.
여유 공간이 남아 있으면 그냥 끝에 추가하고,
공간이 부족하면 더 큰 배열을 만든다.
11-2) append는 보통 단순하다
공간이 있으면 add()는 거의 아래 흐름이다.
elementData[size] = value;
size++;
즉 대부분의 append는 매우 싸다.
11-3) resize는 가끔 크게 복사한다
문제는 배열이 꽉 찼을 때다.
이때는 새 배열을 만들어 기존 데이터를 통째로 복사해야 한다.
OpenJDK 계열 구현을 기준으로 보면 보통 기존보다 약 1.5배 수준으로 늘어난다.
newCapacity = oldCapacity + (oldCapacity >> 1);
즉 ArrayList의 빠름은 복사가 없어서가 아니라,
복사를 가끔 몰아서 하도록 설계되었기 때문이다.

12) Iterator는 왜 필요한가
12-1) 인덱스 반복이 모든 컬렉션에 맞지는 않는다
ArrayList는 인덱스로 순회하기 쉽다.
for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
하지만 모든 컬렉션이 인덱스를 가지는 것은 아니다.
Set은 인덱스 개념이 없다LinkedList는 인덱스 접근이 비효율적일 수 있다- 트리 기반 컬렉션은 정렬 순서대로 순회해야 한다
즉 컬렉션마다 내부 구조는 다르지만,
개발자는 "하나씩 꺼내 본다"는 공통 작업을 원한다.
12-2) Iterator는 순회를 표준화한다
Iterator는 아주 단순한 인터페이스를 제공한다.
hasNext()next()
이 약속 덕분에 배열 기반이든 링크드 리스트든 트리든,
바깥에서는 같은 방식으로 순회할 수 있다.
즉 Iterator는 단순 문법이 아니라
순회 추상화의 공통 인터페이스다.

13) for-each vs Iterator
13-1) for-each는 사실 Iterator 문법 설탕이다
아래 코드는 간단해 보이지만,
for (String name : names) {
System.out.println(name);
}
의미상으로는 대략 아래와 비슷하다.
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.next();
System.out.println(name);
}
즉 for-each는 Iterator 없는 독립 문법이 아니라,
컬렉션 순회를 더 읽기 좋게 풀어 준 형태다.
13-2) 왜 for-each 중 remove()는 위험할까
아래 코드는 자주 보이는 실수다.
for (String name : names) {
if (name.isBlank()) {
names.remove(name);
}
}
여기서는 순회는 Iterator가 관리하지만,
삭제는 컬렉션 본체(List)가 직접 수행한다.
즉 순회 주체와 구조 변경 주체가 어긋난다.
바로 이 점이 예외와 fail-fast로 이어진다.
14) fail-fast와 modCount
14-1) fail-fast는 왜 존재할까
순회 중 구조가 바뀌면 아래 같은 애매한 문제가 생길 수 있다.
- 어떤 원소는 건너뛸 수 있다
- 어떤 원소는 두 번 볼 수 있다
- 내부 상태가 꼬여도 조용히 지나갈 수 있다
자바 컬렉션은 이런 상황을 조용히 방치하기보다,
가능하면 빨리 실패해서 버그를 드러내려 한다.
이것이 fail-fast의 핵심이다.
14-2) modCount는 구조 변경 횟수에 가깝다
많은 컬렉션 구현은 내부에 modCount 같은 값을 둔다.
원소 추가/삭제처럼 구조가 바뀌는 시점에 이 값이 증가한다.
Iterator는 생성될 때 현재 modCount를 기억해 둔다.
expectedModCount = list.modCount;
이후 순회 중 다시 확인했을 때 둘이 다르면 예외를 던진다.
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
즉 fail-fast는 완전한 동시성 보장이 아니라
순회 계약이 깨졌음을 조기 감지하는 장치다.

15) Iterator.remove는 왜 안전한가
15-1) 같은 삭제라도 주체가 다르다
list.remove()와 iterator.remove()는 겉보기에는 둘 다 삭제다.
하지만 내부에서는 큰 차이가 있다.
list.remove(): 컬렉션만 안다iterator.remove(): 컬렉션 구조와 현재 순회 상태를 같이 안다
즉 안전성 차이는 삭제 행위 자체보다
삭제를 수행하는 주체가 가진 정보량 차이에서 나온다.
15-2) Iterator.remove()는 순회 상태도 함께 갱신한다
Iterator.remove()는 보통 마지막 next()가 반환한 원소를 제거한다.
그리고 제거 후에는 내부 커서와 expectedModCount도 함께 갱신한다.
즉:
- 컬렉션 구조가 바뀐 사실을 iterator 자신도 알고 있고
- 다음 순회가 어디서부터 계속되어야 하는지도 맞춰진다
그래서 fail-fast 조건을 깨지 않고 진행할 수 있다.
15-3) 안전한 코드 예시
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.next();
if (name.isBlank()) {
iterator.remove();
}
}
이 코드는 순회와 삭제가 같은 주체 안에서 일어난다.
16) CopyOnWriteArrayList는 왜 예외가 덜 보일까
16-1) 철학이 다르다
일반 컬렉션은 같은 구조를 공유하면서 변경을 감시한다.
반면 CopyOnWriteArrayList는 변경 시 새 배열을 만들고 참조를 교체한다.
즉 읽기와 쓰기가 같은 배열 위에서 직접 싸우지 않는다.
16-2) iterator는 snapshot을 본다
CopyOnWriteArrayList의 iterator는 생성 시점의 배열을 잡고,
그 snapshot만 순회한다.
즉 이후 다른 스레드나 같은 스레드가 리스트를 수정해도,
현재 iterator는 처음 본 배열만 계속 읽는다.
그래서 일반 fail-fast 예외가 잘 보이지 않는다.
16-3) 장점과 단점이 분명하다
장점:
- 읽기 위주 환경에서 순회가 안정적이다
- 락 경쟁이 적고 iterator가 깨지지 않는다
단점:
- 쓰기마다 새 배열 복사가 필요하다
- write-heavy 환경에서는 비용이 크다
- 현재 iterator가 최신 상태를 보는 것은 아니다
즉 CopyOnWriteArrayList는 더 "관대한" 컬렉션이 아니라,
snapshot 일관성을 선택한 다른 trade-off의 컬렉션이다.

마치며
List 구간에서 중요한 것은 단순히 ArrayList와 LinkedList 이름 차이를 아는 것이 아니다.
- 왜
ArrayList가 실무 기본값이 되는지 - 왜
Iterator가 필요한지 - 왜 반복 중 구조 변경은 민감한 문제인지
- fail-fast가 왜 조기 감지 장치인지
- 왜
CopyOnWriteArrayList는 아예 다른 철학을 택하는지
이 흐름을 이해하면 "컬렉션을 돌다 예외가 났다"는 현상도 더 이상 뜬금없지 않다.
한 줄 정리
List와 Iterator 구간의 핵심은 자료구조 이름보다,
순회와 구조 변경을 자바 컬렉션이 어떤 일관성 모델로 다루는지 이해하는 것이다.
'CS > JAVA' 카테고리의 다른 글
| Java 동시성 한눈에 보기 : 프로세스, 스레드, Thread Pool, Sync/Async, CompletableFuture까지 (0) | 2026.03.26 |
|---|---|
| 자바 컬렉션 한눈에 보기 4편: HashSet과 TreeSet, 중복 판정의 기준 (0) | 2026.03.19 |
| 자바 컬렉션 한눈에 보기 1편: 해시부터 hashCode, equals, 충돌까지 (0) | 2026.03.19 |
| JVM 한눈에 보기 GC부터 ZGC까지: 큰 그림, 내부 구조, 구현체 비교, ZGC 심화 (1) | 2026.03.12 |
| JVM 한눈에 보기: Interpreter, JIT, Inline, Safepoint까지 (0) | 2026.03.11 |