F-LAB JAVA · 2주차 · Phase 6 · Reflection & Iterator
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
for-each 가 내부적으로 무엇을 호출하나?ConcurrentModificationException 는 정확히 언제 발생하나?Iterator는 "컬렉션 내부 구조를 노출하지 않고 요소를 하나씩 꺼내는 통일된 방법"이다.
ArrayList든 LinkedList든 HashSet이든 — Iterator 인터페이스 하나로 같은 코드로 순회 가능.
for-each 문은 결국 Iterator 호출의 syntactic sugar이며,
Java 8의 Stream도 Iterator의 진화형(Spliterator) 위에서 동작한다.
| 시스템 | 비유 |
|---|---|
| Iterable (컬렉션) | TV 모델마다 다른 내부 구조 |
| Iterator | TV마다 다른 리모컨 |
| hasNext() | "다음 채널 있나?" 버튼 |
| next() | "다음 채널로" 버튼 |
| for-each | "채널 순회" 매크로 버튼 |
| fail-fast | 시청 중 누가 TV 만지면 알람 |
→ TV 종류와 무관하게 같은 인터페이스로 다룸.
1. Iterator 패턴이 해결한 문제
2. Iterable과 Iterator의 분리
3. for-each의 실체
4. fail-fast — ConcurrentModificationException
5. ListIterator — 양방향 순회
6. Spliterator — Stream의 기반
7. ILIC 실무 — Iterator 활용
8. 흔한 실수 + 디버깅
9. 면접 + 자기 점검
// ArrayList 순회 (인덱스)
ArrayList<Shipment> arr = ...;
for (int i = 0; i < arr.size(); i++) {
Shipment s = arr.get(i);
}
// LinkedList 순회 (Node 따라가기)
LinkedList<Shipment> link = ...;
Node<Shipment> cur = link.first;
while (cur != null) {
Shipment s = cur.item;
cur = cur.next;
}
// HashSet 순회 (해시 테이블 순회)
HashSet<Shipment> set = ...;
// ... 더 복잡
→ 각 컬렉션마다 내부 구조에 의존하는 코드.
→ 컬렉션 바꾸면 코드 다시 작성.
// 모든 컬렉션에 같은 코드
Iterator<Shipment> it = collection.iterator();
while (it.hasNext()) {
Shipment s = it.next();
}
ArrayList든, LinkedList든, HashSet이든 동일.
→ Iterator 패턴의 본질: 내부 구조 캡슐화.
GoF Iterator Pattern:
목적:
집합 객체의 내부 구조를 노출하지 않고
그 원소들에 순차적으로 접근하는 방법 제공
구성:
- Iterator (인터페이스)
- ConcreteIterator (구현체)
- Aggregate (컬렉션)
- ConcreteAggregate (구현체)
자바 표준 라이브러리는 이 패턴을 언어 차원에 통합.
// 1. Iterable — "순회 가능한 것"
public interface Iterable<T> {
Iterator<T> iterator(); // Iterator 만들어줘
}
// 2. Iterator — "순회 도구"
public interface Iterator<E> {
boolean hasNext();
E next();
default void remove() { ... }
}
→ 분리의 이점:
ArrayList:
┌────────────────────────┐
│ elementData: [A,B,C,D] │
│ size: 4 │
│ │
│ iterator() ─────────────┼──► 새 Iterator 객체
└────────────────────────┘ ┌──────────────┐
│ cursor: 0 │
│ list: ref │
│ hasNext() │
│ next() │
└──────────────┘
→ Iterator 객체는 컬렉션 위에서 움직이는 커서.
public interface Iterable<T> {
Iterator<T> iterator();
default void forEach(Consumer<? super T> action) {
for (T t : this) {
action.accept(t);
}
}
default Spliterator<T> spliterator() {
return Spliterators.spliteratorUnknownSize(iterator(), 0);
}
}
→ Iterable을 구현하면 for-each 사용 가능.
public interface Iterator<E> {
boolean hasNext();
E next();
default void remove() {
throw new UnsupportedOperationException("remove");
}
default void forEachRemaining(Consumer<? super E> action) {
while (hasNext()) action.accept(next());
}
}
핵심 두 메서드:
hasNext(): 다음 요소 있나?next(): 다음 요소 가져오기 + 커서 이동ArrayList<String> list = new ArrayList<>(List.of("A", "B", "C"));
Iterator<String> it = list.iterator();
it.hasNext(); // true
it.next(); // "A", cursor → 1
it.hasNext(); // true
it.next(); // "B", cursor → 2
it.hasNext(); // true
it.next(); // "C", cursor → 3
it.hasNext(); // false
it.next(); // NoSuchElementException
→ Iterator는 자기 위치를 기억.
→ 한 컬렉션에서 여러 Iterator → 각자 별개 위치.
Iterable과 Iterator를 분리한 이유는?
답:
1. 다중 Iterator 지원 — 같은 컬렉션에서 여러 순회 동시
2. 상태 분리 — 순회 상태가 컬렉션 외부에
3. 컬렉션은 자료구조, Iterator는 순회 알고리즘 — 책임 분리
4. 재진입 가능 — iterator() 호출 시마다 새 순회
// ArrayList 내부 (간략화)
private class Itr implements Iterator<E> {
int cursor; // 다음 호출에서 반환할 인덱스
int lastRet = -1; // 마지막 반환한 인덱스
int expectedModCount = modCount;
public boolean hasNext() {
return cursor != size;
}
public E next() {
checkForComodification();
int i = cursor;
if (i >= size) throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length) throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
public void remove() {
if (lastRet < 0) throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
→ 모든 자바 컬렉션의 Iterator는 비슷한 패턴.
for (Shipment s : shipments) {
process(s);
}
위 코드는 컴파일 시 다음으로 변환:
Iterator<Shipment> it = shipments.iterator();
while (it.hasNext()) {
Shipment s = it.next();
process(s);
}
→ for-each는 Iterator 호출의 syntactic sugar.
int[] arr = {1, 2, 3};
for (int n : arr) { ... }
배열은 Iterable 아님. 컴파일러가 다음으로 변환:
for (int i = 0; i < arr.length; i++) {
int n = arr[i];
// ...
}
→ 배열의 for-each는 인덱스 루프.
→ 객체 컬렉션의 for-each와 동작 다름.
for-each는 무엇을 호출하는가?
답:
iterator() 메서드 → Iterator 사용shipments.forEach(s -> process(s));
shipments.forEach(this::process);
Iterable.forEach(Consumer):
내부 구현:
default void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}
→ 결국 같은 Iterator 사용. 표현만 다름.
for (Shipment s : shipments) {
if (s.isInvalid()) break; // 종료
if (s.isSkippable()) continue; // 다음
if (s.isFinal()) return; // 메서드 종료
process(s);
}
이런 제어가 forEach 람다에선 어려움:
shipments.forEach(s -> {
if (s.isInvalid()) break; // ❌ 컴파일 에러
if (s.isSkippable()) return; // ✓ 람다 종료 (=continue 효과)
});
→ break, 외부 return은 람다에서 불가.
→ 복잡한 제어 필요하면 for-each가 더 적합.
List<Shipment> list = new ArrayList<>();
list.add(s1); list.add(s2); list.add(s3);
for (Shipment s : list) {
if (s.isExpired()) {
list.remove(s); // ❌ ConcurrentModificationException
}
}
→ 순회 중 컬렉션 변경 → ConcurrentModificationException.
// ArrayList 내부
protected transient int modCount = 0;
public boolean add(E e) {
modCount++; // 변경 카운트 증가
// ...
}
public boolean remove(Object o) {
modCount++;
// ...
}
Iterator 내부:
private class Itr implements Iterator<E> {
int expectedModCount = modCount; // Iterator 생성 시 캐싱
public E next() {
checkForComodification(); // 변경 여부 검사
// ...
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
→ Iterator는 "내가 만들어졌을 때의 modCount"를 기억.
→ 다음 호출 시 현재 modCount와 비교.
→ 다르면 누가 컬렉션 변경했다는 의미 → 예외.
fail-fast = "빨리 실패"
목적:
컬렉션 변경을 일찍 감지해서
예측 불가능한 동작 방지
대안:
- 변경을 무시 (silent failure)
- 변경에 적응 (복잡, 비용)
자바의 선택:
→ 즉시 예외 발생 → 버그 조기 발견
ConcurrentModificationException은:
- "best effort" 검사
- 100% 보장 X
- 변경이 modCount에 반영되는 타이밍에 따라 다름
특히 멀티스레드:
- 두 스레드가 동시 변경 시 race condition
- 예외 안 발생할 수도
- 또는 다른 이상한 상황
→ 검출 못 했다고 안전하다는 의미 아님. 그냥 멀티스레드 컬렉션 쓰지 말 것.
// ❌ list.remove()
for (Shipment s : list) {
if (s.isExpired()) {
list.remove(s);
}
}
// ✓ Iterator.remove()
Iterator<Shipment> it = list.iterator();
while (it.hasNext()) {
Shipment s = it.next();
if (s.isExpired()) {
it.remove(); // ✓ 안전
}
}
Iterator.remove() 동작:
list.removeIf(Shipment::isExpired);
→ Iterator 사용 + 안전.
→ 가장 간결.
내부 구현:
default boolean removeIf(Predicate<? super E> filter) {
boolean removed = false;
final Iterator<E> each = iterator();
while (each.hasNext()) {
if (filter.test(each.next())) {
each.remove();
removed = true;
}
}
return removed;
}
일부 컬렉션은 fail-safe 방식:
// ConcurrentHashMap, CopyOnWriteArrayList 등
ConcurrentHashMap<Long, Shipment> map = new ConcurrentHashMap<>();
// ... 데이터
for (Map.Entry<Long, Shipment> e : map.entrySet()) {
map.put(2L, newShipment); // 변경 OK
}
// 예외 없음
fail-safe:
→ ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentLinkedQueue 등.
ConcurrentModificationException 은 정확히 언제 발생?
답:
1. Iterator로 순회 중
2. 컬렉션이 Iterator 외부에서 변경됨
3. 다음 next(), hasNext(), remove() 호출 시
4. modCount != expectedModCount 검사 실패
5. 즉시 예외
→ fail-fast 메커니즘.
public interface ListIterator<E> extends Iterator<E> {
// Iterator의 메서드 + 추가:
boolean hasPrevious();
E previous();
int nextIndex();
int previousIndex();
void set(E e); // 현재 위치 수정
void add(E e); // 현재 위치 추가
}
→ List 전용. Set/Map은 ListIterator 없음.
List<Shipment> list = ...;
ListIterator<Shipment> it = list.listIterator();
while (it.hasNext()) {
Shipment s = it.next(); // 앞으로
}
// 끝까지 갔다가 역순회
while (it.hasPrevious()) {
Shipment s = it.previous(); // 뒤로
}
List<Shipment> list = ...;
ListIterator<Shipment> it = list.listIterator(5); // 인덱스 5부터
it.next(); // 인덱스 5의 요소
→ 임의 위치에서 순회 시작 가능.
ListIterator<Shipment> it = list.listIterator();
while (it.hasNext()) {
Shipment s = it.next();
if (s.needsUpdate()) {
it.set(updated); // 현재 위치 교체
}
if (s.needsExtra()) {
it.add(extra); // 현재 위치에 추가
}
}
set: 마지막 next/previous로 반환한 위치 교체.
add: 현재 cursor 위치에 삽입.
LinkedList<Shipment> list = ...;
ListIterator<Shipment> it = list.listIterator(0);
while (it.hasNext()) {
if (조건) {
it.add(newItem); // O(1)
}
}
→ LinkedList + ListIterator = 진짜 O(1) 삽입.
→ Unit 5.4의 진짜 강점.
ArrayList.listIterator(i):
- 즉시 인덱스 i 위치
- O(1)
LinkedList.listIterator(i):
- 인덱스 i까지 사슬 따라가기
- O(n)
- 한 번 위치 설정 후엔 next/previous O(1)
// 일반적 사용: 거의 안 함
// 대부분 for-each, Stream, removeIf 사용
// 특수 시나리오:
ListIterator<Task> it = tasks.listIterator();
while (it.hasNext()) {
Task t = it.next();
if (t.shouldReplace()) {
it.set(t.replace());
}
if (t.shouldExpand()) {
it.add(t.subTask1());
it.add(t.subTask2());
}
}
→ 순회 중 추가 작업 필요 시.
→ 박승제씨가 직접 쓸 일은 드물지만 알아둘 것.
Java 8 Stream 도입:
- 병렬 처리 (parallelStream)
- 함수형 스타일
문제:
Iterator로는 병렬 처리 어려움
→ 새로운 인터페이스 필요
해결:
Spliterator (Splittable Iterator)
public interface Spliterator<T> {
boolean tryAdvance(Consumer<? super T> action); // next처럼
Spliterator<T> trySplit(); // 분할!
long estimateSize();
int characteristics();
}
핵심 메서드 trySplit():
List<Integer> list = List.of(1, 2, 3, 4, 5, 6, 7, 8);
Spliterator<Integer> sp = list.spliterator();
Spliterator<Integer> sp2 = sp.trySplit();
// sp: 5, 6, 7, 8
// sp2: 1, 2, 3, 4
→ 두 부분을 각 스레드에서 병렬 처리 가능.
list.stream()
.filter(...)
.map(...)
.forEach(...);
내부:
1. list.spliterator() 호출
2. Stream 파이프라인 구성
3. 종료 연산 (forEach) 시:
- 순차: Spliterator를 Iterator처럼 사용
- 병렬 (parallelStream): trySplit() 반복 호출하여 분할
4. 각 부분 처리
5. 결과 합치기 (reduce)
→ Stream의 기반은 Spliterator.
public static final int ORDERED; // 순서 있음 (List)
public static final int DISTINCT; // 중복 없음 (Set)
public static final int SORTED; // 정렬됨 (TreeSet)
public static final int SIZED; // 크기 알려짐
public static final int IMMUTABLE; // 변경 불가
public static final int CONCURRENT; // 멀티스레드 안전
→ Stream이 최적화 결정에 사용.
→ 예: SORTED면 sorted() 연산 skip.
Spliterator는 왜 등장했나?
답:
1. Stream의 병렬 처리 지원
2. Iterator만으로는 분할 어려움
3. trySplit으로 컬렉션을 나누고 병렬 처리
4. characteristics로 Stream 최적화
// ✓ for-each
for (Shipment s : shipments) {
process(s);
}
// ✓ Stream
shipments.stream()
.filter(Shipment::isActive)
.forEach(this::process);
// ✓ forEach
shipments.forEach(this::process);
→ 이것만으로 충분.
// ✓ 가장 권장
shipments.removeIf(Shipment::isExpired);
// 또는 Stream
List<Shipment> active = shipments.stream()
.filter(s -> !s.isExpired())
.toList();
→ ConcurrentModificationException 회피.
// ❌ HashMap 공유
private final Map<Long, Shipment> cache = new HashMap<>();
void process(Shipment s) {
cache.put(s.getId(), s);
for (Map.Entry<Long, Shipment> e : cache.entrySet()) {
// ...
}
}
→ 두 스레드 동시 호출 시 깨짐.
// ✓ ConcurrentHashMap
private final ConcurrentHashMap<Long, Shipment> cache = new ConcurrentHashMap<>();
void process(Shipment s) {
cache.put(s.getId(), s);
for (Map.Entry<Long, Shipment> e : cache.entrySet()) {
// weakly consistent — 안전하지만 변경 반영 보장 X
}
}
// 그룹핑
Map<String, List<Shipment>> byRoute = shipments.stream()
.collect(Collectors.groupingBy(Shipment::getRoute));
// 카운팅
Map<String, Long> countByStatus = shipments.stream()
.collect(Collectors.groupingBy(
s -> s.getStatus().name(),
Collectors.counting()));
// 합계
BigDecimal totalFreight = shipments.stream()
.map(Shipment::getFreight)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 정렬 + 변환
List<ShipmentResponse> sorted = shipments.stream()
.sorted(Comparator.comparing(Shipment::getCreatedAt).reversed())
.map(ShipmentResponse::from)
.toList();
→ Stream + 람다 = 모던 자바 컬렉션 처리.
// 병렬 처리
shipments.parallelStream()
.forEach(this::process);
언제 도움 되나?
언제 도움 안 됨?
→ ILIC 일반 코드에선 순차 stream으로 충분.
→ 병렬은 측정 후 도입.
// JPA Pagination
Pageable pageable = PageRequest.of(0, 100);
Page<Shipment> page;
do {
page = repository.findAll(pageable);
page.forEach(this::process);
pageable = pageable.next();
} while (page.hasNext());
// 또는 Stream (Spring Data)
try (Stream<Shipment> stream = repository.findAllByActiveTrue()) {
stream.forEach(this::process);
}
→ Iterator/Stream 패턴 활용.
직접 사용:
- 거의 for-each / Stream으로 충분
- 가끔 Iterator.remove() 필요
이해해야 하는 곳:
- fail-fast 메커니즘 (디버깅)
- ConcurrentModificationException 원인
- Stream의 내부 (Spliterator)
- 멀티스레드 컬렉션
for (Shipment s : list) {
if (s.isExpired()) {
list.remove(s); // ❌ CME
}
}
해결:
list.removeIf(Shipment::isExpired);
// ❌ for-each에서 인덱스 못 씀
int i = 0;
for (Shipment s : list) {
if (i == 5) { ... }
i++;
}
해결:
IntStream.range(0, list.size())
.forEach(i -> {
Shipment s = list.get(i);
// ...
});
Stream<Shipment> stream = list.stream();
stream.forEach(...);
stream.forEach(...); // ❌ IllegalStateException: stream has already been operated upon
Stream은 단일 소비.
해결:
list.stream().forEach(...);
list.stream().forEach(...); // 새 stream
Iterator<Shipment> it = list.iterator();
while (it.hasNext()) {
Shipment s = list.iterator().next(); // ❌ 매번 새 Iterator!
}
해결:
Iterator<Shipment> it = list.iterator();
while (it.hasNext()) {
Shipment s = it.next(); // 같은 Iterator
}
List<Shipment> list = getList(); // null 가능
for (Shipment s : list) { // NPE!
// ...
}
해결:
List<Shipment> list = getList();
if (list != null) {
for (Shipment s : list) { ... }
}
// 또는 Optional/getOrDefault
for (Shipment s : Objects.requireNonNullElse(list, List.of())) { ... }
// 또는 Stream의 안전한 처리
Optional.ofNullable(list).orElse(List.of()).forEach(this::process);
ConcurrentHashMap<Long, Shipment> map = ...;
int size = map.size(); // 정확한 size?
map.put(...); // 동시 변경
// size 시점과 순회 시점이 다름 → 다를 수 있음
for (Map.Entry<Long, Shipment> e : map.entrySet()) {
// ...
}
ConcurrentHashMap의 size, iterator는 weakly consistent.
→ 정확성이 중요하면 lock 또는 readWriteLock 사용.
shipments.stream()
.filter(s -> s.getWeight().compareTo(LIMIT) > 0)
.map(this::transform)
.forEach(this::process);
디버깅:
shipments.stream()
.filter(s -> s.getWeight().compareTo(LIMIT) > 0)
.peek(s -> log.debug("After filter: {}", s))
.map(this::transform)
.peek(s -> log.debug("After map: {}", s))
.forEach(this::process);
# IntelliJ의 Stream Debugger
# 디버그 모드에서 Stream 단계별 확인 가능
# 로그 추가
.peek(s -> log.debug(...))
| Q | 핵심 답변 |
|---|---|
| Iterator 패턴이 해결한 문제? | 컬렉션 내부 노출 없이 통일 순회 |
| for-each의 실체? | iterator() 호출 + while(hasNext()) |
| ConcurrentModificationException? | 순회 중 외부 변경 시. modCount 검사 |
| 안전한 순회 중 삭제? | Iterator.remove() 또는 removeIf |
| fail-fast vs fail-safe? | 즉시 예외 vs 스냅샷 사용 |
| ListIterator vs Iterator? | 양방향, set/add, List 전용 |
| Spliterator의 등장? | Stream 병렬 처리 위해 |
| Stream의 기반은? | Spliterator |
| ConcurrentHashMap 순회? | weakly consistent |
| Stream 재사용? | 불가. 단일 소비 |
1. Iterator 패턴 = 컬렉션 내부 은닉 + 통일 순회
2. fail-fast 메커니즘
3. ILIC 실무
이번 Phase 6에서 자료구조의 동적 다룸을 봤다면, 마지막 Phase 7은 Buffer 메커니즘.
→ 2주차의 마지막 Phase.
✅ 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 — 컬렉션 내부 구조 (5.1 ~ 5.4 완주, 자료구조 마스터)
🚀 Phase 6 — Reflection & Iterator
✅ Unit 6.1 Reflection의 본질
✅ Unit 6.2 Iterator 패턴의 본질 ← 여기
⏭ Phase 7 — Buffer (마지막)
누적 25개 Unit
2주차 약 92% 완주