List<String> all = Files.readAllLines(path) 로 100만 행 CSV 를 읽었다고 합시다. 한 행이 평균 44자라면 힙에는 무엇이 생길까요?
| 객체 | 개수 | 개당 크기 (64bit, 압축 OOP) | 합계 |
|---|---|---|---|
String 객체 (헤더 12 + hash 4 + coder 1 + value 참조 4 + 패딩) |
1,000,000 | 24 B | 24 MB |
byte[] 값 (LATIN1 이면 1바이트/문자, 헤더 16 + 44 + 패딩) |
1,000,000 | 64 B | 64 MB |
ArrayList 의 Object[] 참조 배열 (4 B × 용량, 확장 여유 포함) |
1 | ~6 MB | 6 MB |
| 합계 | ~94 MB |
파일은 44 MB 인데 힙은 약 94 MB 입니다. 한글이 있으면 String 이 UTF-16 으로 저장되어(2바이트/문자) 약 140 MB, 각 행을 파싱해 Transaction 객체와 필드별 String 으로 쪼개면 행당 200 B 이상, 즉 200 MB 를 넘습니다. 1천만 행이면 2 GB. 기본 힙(물리 메모리의 1/4)을 넘는 순간 OOM 입니다.
JVM 힙 (-Xmx 로 상한)
┌───────────────────────────────────────────────────────┐
│ Young (Eden + Survivor) │ Old │
│ 새 객체가 태어나는 곳 │ 오래 살아남은 객체 │
│ 짧게 살고 죽으면 Minor GC 로 │ readAllLines 의 List 는 │
│ 빠르게 회수 │ 계속 참조되어 여기 쌓임 │
└───────────────────────────────────────────────────────┘
청크 처리: 청크 객체는 Young 에서 태어나 청크 끝에 죽음 → Old 로 안 감 → 힙 일정
전체 로딩: 모두 살아있음 → Old 가 차오름 → Full GC 반복 → OOMGC 는 참조되지 않는 객체만 회수합니다. all 변수가 리스트를 참조하고, 리스트가 100만 String 을 참조하는 한 아무것도 회수되지 않습니다. OOM 은 "메모리가 부족"해서가 아니라 "버릴 수 없는 객체가 힙보다 많아서" 발생합니다.
운영 데이터 없이도 재현할 수 있습니다. 힙을 작게 제한하면 됩니다.
java -Xmx32m Main 1000000 # 힙 32MB 로 100만 행[1] 전체 로딩 단계에서 readAllLines 가 OOM 을 던지고, 같은 힙에서 스트리밍은 끝까지 돕니다. 이 실험을 CI 에 넣어 두면 "전체 로딩" 코드가 들어오는 순간 테스트가 실패합니다. 유용한 JVM 옵션:
| 옵션 | 용도 |
|---|---|
-Xmx256m |
힙 상한. 운영과 같은 값으로 로컬 테스트 |
-Xms256m |
초기 힙. Xmx 와 같게 두면 힙 확장 비용 없음 |
-XX:+HeapDumpOnOutOfMemoryError |
OOM 시 힙 덤프 파일 생성 → 무엇이 힙을 채웠는지 분석(MAT, VisualVM) |
-Xlog:gc |
GC 로그. Full GC 가 반복되면 OOM 직전 신호 |
┌──────────────── 반복 ────────────────┐
▼ │
reader.read() × N ──▶ List<I> chunk (N건) │
│ │
▼ processor.process() × N │
List<O> outs │
│ │
▼ writer.write(outs) ◀── 커밋 포인트
│ │
chunk, outs 참조 해제 ──▶ 다음 GC 에서 회수
│
(reader 가 null 반환할 때까지) ──────────┘
힙 사용량: ▁▂▃▁▂▃▁▂▃▁▂▃ (청크마다 오르내림, 상한 일정)
전체 로딩: ▁▂▃▄▅▆▇█ OOM핵심은 세 가지입니다.
세 번째가 자주 깨집니다. Reader 는 스트리밍인데 Writer 가 results.add(...) 로 리스트에 모으면 결국 전체 로딩입니다. 예제에서 집계 맵(TreeMap<상품, 합계>)만 남기는 이유는 그 크기가 입력 크기가 아니라 상품 수에 비례하기 때문입니다.
| 청크 크기 | 장점 | 단점 |
|---|---|---|
| 작음 (1~10) | 실패 시 롤백 범위 작음, 메모리 최소 | 커밋/flush 횟수 많음 → I/O 오버헤드, 처리량 저하 |
| 중간 (100~1000) | I/O 효율과 롤백 범위의 균형. 실무 관행 | - |
| 큼 (10,000+) | 커밋 횟수 최소 | 메모리 증가, 실패 시 재처리 범위 큼, DB 락 오래 잡음, undo 로그 증가 |
처리량
▲ ┌───── 평탄 (I/O 오버헤드가 이미 무시할 수준)
│ ┌─────┘
│ ┌────┘
│ ┌───┘
│──┘
└────────────────────────────▶ 청크 크기
1 10 100 1000 10000
▲
여기부터는 커져도 이득이 거의 없고 위험만 커짐경험칙: 100 으로 시작해서 측정합니다. 건당 처리가 무거우면(외부 API 호출) 더 작게, 건당 처리가 가볍고 DB 배치 INSERT 가 병목이면 500~1000 까지. 한 건의 크기가 크면(수 KB 의 JSON) 메모리 계산을 다시 합니다. 청크 하나 = chunkSize × 건당 메모리 × 2(입력 + 출력)가 힙의 몇 % 인지 확인하세요.
파일 기반 Reader 는 BufferedReader 를 감싸고 read() 마다 readLine() 을 호출하면 됩니다. Iterator 로도 쓰고 싶다면 한 줄 미리 읽기(look-ahead) 가 필요합니다. hasNext() 는 "다음 줄이 있는가"를 알아야 하는데, 파일은 읽어 보기 전에는 끝인지 알 수 없기 때문입니다.
hasNext(): nextLine 이 비어 있으면 readLine() 으로 채운다 → null 이면 false
next(): nextLine 을 돌려주고 비운다재시작을 위해 skip(n) 과 현재 줄 번호 lineNo() 도 갖춥니다(04 레슨에서 사용).
입력이 DB 라면 "한 건씩"이 아니라 "한 페이지씩" 가져와 그 안에서 한 건씩 넘깁니다. 두 가지 방식이 있습니다.
-- offset 방식: 페이지가 뒤로 갈수록 느려진다
SELECT * FROM orders ORDER BY id LIMIT 1000 OFFSET 990000;
-- → DB 는 991,000 행을 읽고 990,000 행을 버린다. 100만 건이면 마지막 페이지가 첫 페이지보다 1000배 느림
-- 키셋(keyset, seek) 방식: 항상 같은 속도
SELECT * FROM orders WHERE id > :lastId ORDER BY id LIMIT 1000;
-- → 인덱스에서 lastId 다음부터 1000행만 읽는다. 마지막 처리한 id 를 기억하면 재시작도 쉽다| 방식 | 속도 | 재시작 | 조건 |
|---|---|---|---|
| offset | 뒤로 갈수록 느림 O(offset) | 페이지 번호 저장. 도중에 행이 삭제되면 건너뜀 발생 | 정렬 키 없어도 됨 |
| 키셋 | 일정 O(pageSize) | 마지막 키 저장. 삽입/삭제에 안전 | 유일한 정렬 키(PK) 필요 |
DB 커서 (fetchSize) |
일정 | 어려움(커넥션 끊기면 커서 소멸) | 트랜잭션 유지 필요, 락 주의 |
의사코드로 키셋 Reader 는 이렇습니다.
class KeysetReader implements ItemReader<Order> {
long lastId = 0;
Deque<Order> page = new ArrayDeque<>();
public Order read() {
if (page.isEmpty()) {
page.addAll(dao.findAfter(lastId, 1000)); // WHERE id > ? ORDER BY id LIMIT 1000
if (page.isEmpty()) return null;
}
Order o = page.poll();
lastId = o.id();
return o;
}
}배치에서는 키셋 방식이 기본입니다. offset 은 "페이지가 몇 개 안 될 때"만 씁니다.
while (true) {
List<I> chunk = readChunk(reader); // 루프 지역 변수
...
writer.write(outs);
} // chunk, outs 스코프 종료 → 참조 없음 → 회수 가능지역 변수의 스코프가 끝나면 참조가 사라집니다. JIT 는 실제로는 마지막 사용 지점 이후부터 이미 회수 가능하다고 판단합니다. chunk.clear() 나 chunk = null 을 굳이 쓸 필요는 없지만, 청크 리스트를 루프 밖에서 재사용한다면 clear() 가 필수입니다(01 레슨 Step 처럼).
반대로 회수를 막는 실수들:
addAllMap<id, Item> 으로 "중복 검사"를 하며 전체 id 를 모음 → id 만 모아도 1억 건이면 수 GB10시간짜리 배치가 지금 어디쯤인지 모르면 운영자는 죽었는지 살았는지 판단할 수 없습니다.
[progress] chunk#250 read=250,000 1,180,000 items/s ETA 0s (25.0%)| 항목 | 계산 | 용도 |
|---|---|---|
| 처리 건수 | 누적 read | 진행 여부 확인 |
| 속도 | read / 경과 초 | 어제와 비교, 병목 감지 |
| ETA | (전체 − 처리) / 속도 | 시간 창 안에 끝나는지 |
| 진행률 | 처리 / 전체 | 전체 건수를 모르면 생략(파일은 크기 비율로 근사 가능) |
너무 자주 찍으면 로그 I/O 가 병목이 됩니다. N 청크마다 또는 N 초마다 하나가 적당합니다.
CPU 가 남는데 처리가 느리다면 청크를 병렬로 처리합니다. 구조는 "읽기는 한 스레드, 가공+쓰기는 워커 풀"입니다.
Reader (1 thread) ──chunk1──▶ [worker 1] process+write
순차 읽기 ──chunk2──▶ [worker 2] process+write
──chunk3──▶ [worker 3] process+write
──chunk4──▶ (Semaphore 대기: in-flight 한도 초과)| 조건 | 설명 |
|---|---|
| 순서 무관 | 합계·집계·건별 독립 변환은 OK. "직전 행과 비교", "누적 잔액" 은 불가 |
| Writer 스레드 안전 | ConcurrentHashMap, 동기화된 파일 쓰기, 스레드별 DB 커넥션 |
| 백프레셔(backpressure) | 읽기가 가공보다 빠르면 큐가 쌓여 OOM. Semaphore 로 살아있는 청크 수 제한 |
| 병목 위치 | 병목이 디스크/DB 라면 병렬화해도 안 빨라진다. CPU 바운드(파싱, 계산, 암호화)일 때 효과 |
| 실패 처리 | 한 워커의 예외를 잡아 전체를 중단시키고, 어느 청크가 실패했는지 기록 |
JDK 21 의 가상 스레드는 I/O 대기가 많은 건별 작업(외부 API 호출 1만 건)에 적합하고, CPU 바운드 청크 병렬화에는 플랫폼 스레드 풀(newFixedThreadPool(코어 수))이 맞습니다.