공공부하자개발 · 영어 학습 노트
자바
실무 배치대용량 데이터 처리0/6 완료
  • 01배치 프로세스 개념과 아키텍처
  • 02대용량 파일 I/O (NIO, Buffered)
  • 03Chunk 단위 처리와 OOM 방지
  • 04트랜잭션: 커밋과 롤백 시뮬레이션
  • 05Skip과 Retry 로직 구현
  • 06메서드 활용 패턴 (배치 유틸)
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 실무 배치 › 03 / 6

Chunk 단위 처리와 OOM 방지

섹션 7진행 0 / 6
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 OOM 은 어떻게 발생하는가 — 100만 건의 메모리 계산

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 입니다.

text
JVM 힙 (-Xmx 로 상한)
┌───────────────────────────────────────────────────────┐
│ Young (Eden + Survivor)     │ Old                     │
│ 새 객체가 태어나는 곳         │ 오래 살아남은 객체        │
│ 짧게 살고 죽으면 Minor GC 로 │ readAllLines 의 List 는  │
│ 빠르게 회수                  │ 계속 참조되어 여기 쌓임   │
└───────────────────────────────────────────────────────┘
   청크 처리: 청크 객체는 Young 에서 태어나 청크 끝에 죽음 → Old 로 안 감 → 힙 일정
   전체 로딩: 모두 살아있음 → Old 가 차오름 → Full GC 반복 → OOM

GC 는 참조되지 않는 객체만 회수합니다. all 변수가 리스트를 참조하고, 리스트가 100만 String 을 참조하는 한 아무것도 회수되지 않습니다. OOM 은 "메모리가 부족"해서가 아니라 "버릴 수 없는 객체가 힙보다 많아서" 발생합니다.

2.2 -Xmx 와 OOM 재현 실험

운영 데이터 없이도 재현할 수 있습니다. 힙을 작게 제한하면 됩니다.

text
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 직전 신호

2.3 청크 지향 처리의 원리

text
                 ┌──────────────── 반복 ────────────────┐
                 ▼                                        │
   reader.read() × N  ──▶  List<I> chunk (N건)           │
                             │                            │
                             ▼ processor.process() × N    │
                           List<O> outs                   │
                             │                            │
                             ▼ writer.write(outs)   ◀── 커밋 포인트
                             │                            │
                    chunk, outs 참조 해제 ──▶ 다음 GC 에서 회수
                                                          │
                 (reader 가 null 반환할 때까지) ──────────┘

   힙 사용량:  ▁▂▃▁▂▃▁▂▃▁▂▃  (청크마다 오르내림, 상한 일정)
   전체 로딩:  ▁▂▃▄▅▆▇█ OOM

핵심은 세 가지입니다.

  1. Reader 는 한 건씩 준다(01, 02 레슨). 그래서 N건만 모을 수 있다.
  2. 청크 리스트는 루프 안에서만 산다. 루프가 한 바퀴 돌면 참조가 사라지고 GC 대상이 된다.
  3. Writer 는 청크를 받은 즉시 외부로 내보낸다(DB 커밋, 파일 flush). 결과를 힙에 모아 두지 않는다.

세 번째가 자주 깨집니다. Reader 는 스트리밍인데 Writer 가 results.add(...) 로 리스트에 모으면 결국 전체 로딩입니다. 예제에서 집계 맵(TreeMap<상품, 합계>)만 남기는 이유는 그 크기가 입력 크기가 아니라 상품 수에 비례하기 때문입니다.

2.4 청크 크기는 어떻게 정하는가

청크 크기 장점 단점
작음 (1~10) 실패 시 롤백 범위 작음, 메모리 최소 커밋/flush 횟수 많음 → I/O 오버헤드, 처리량 저하
중간 (100~1000) I/O 효율과 롤백 범위의 균형. 실무 관행 -
큼 (10,000+) 커밋 횟수 최소 메모리 증가, 실패 시 재처리 범위 큼, DB 락 오래 잡음, undo 로그 증가
text
처리량
  ▲                 ┌───── 평탄 (I/O 오버헤드가 이미 무시할 수준)
  │           ┌─────┘
  │      ┌────┘
  │  ┌───┘
  │──┘
  └────────────────────────────▶ 청크 크기
     1   10  100  1000  10000
                 ▲
             여기부터는 커져도 이득이 거의 없고 위험만 커짐

경험칙: 100 으로 시작해서 측정합니다. 건당 처리가 무거우면(외부 API 호출) 더 작게, 건당 처리가 가볍고 DB 배치 INSERT 가 병목이면 500~1000 까지. 한 건의 크기가 크면(수 KB 의 JSON) 메모리 계산을 다시 합니다. 청크 하나 = chunkSize × 건당 메모리 × 2(입력 + 출력)가 힙의 몇 % 인지 확인하세요.

2.5 스트리밍 Reader — Iterator 패턴

파일 기반 Reader 는 BufferedReader 를 감싸고 read() 마다 readLine() 을 호출하면 됩니다. Iterator 로도 쓰고 싶다면 한 줄 미리 읽기(look-ahead) 가 필요합니다. hasNext() 는 "다음 줄이 있는가"를 알아야 하는데, 파일은 읽어 보기 전에는 끝인지 알 수 없기 때문입니다.

text
hasNext(): nextLine 이 비어 있으면 readLine() 으로 채운다 → null 이면 false
next():    nextLine 을 돌려주고 비운다

재시작을 위해 skip(n) 과 현재 줄 번호 lineNo() 도 갖춥니다(04 레슨에서 사용).

2.6 DB 페이징 — offset 방식 vs 키셋(커서) 방식

입력이 DB 라면 "한 건씩"이 아니라 "한 페이지씩" 가져와 그 안에서 한 건씩 넘깁니다. 두 가지 방식이 있습니다.

sql
-- 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 는 이렇습니다.

java
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 은 "페이지가 몇 개 안 될 때"만 씁니다.

2.7 GC 와 참조 해제 — 청크는 언제 회수되는가

java
while (true) {
    List<I> chunk = readChunk(reader);   // 루프 지역 변수
    ...
    writer.write(outs);
}                                         // chunk, outs 스코프 종료 → 참조 없음 → 회수 가능

지역 변수의 스코프가 끝나면 참조가 사라집니다. JIT 는 실제로는 마지막 사용 지점 이후부터 이미 회수 가능하다고 판단합니다. chunk.clear() 나 chunk = null 을 굳이 쓸 필요는 없지만, 청크 리스트를 루프 밖에서 재사용한다면 clear() 가 필수입니다(01 레슨 Step 처럼).

반대로 회수를 막는 실수들:

  • Writer 가 청크를 필드 리스트에 addAll
  • 로그 출력용으로 "지금까지 처리한 항목"을 모아 둠
  • 람다가 큰 객체를 캡처해 계속 살아 있음
  • Map<id, Item> 으로 "중복 검사"를 하며 전체 id 를 모음 → id 만 모아도 1억 건이면 수 GB

2.8 진행률 로깅 — 건수, 속도, ETA

10시간짜리 배치가 지금 어디쯤인지 모르면 운영자는 죽었는지 살았는지 판단할 수 없습니다.

text
[progress] chunk#250  read=250,000  1,180,000 items/s  ETA 0s  (25.0%)
항목 계산 용도
처리 건수 누적 read 진행 여부 확인
속도 read / 경과 초 어제와 비교, 병목 감지
ETA (전체 − 처리) / 속도 시간 창 안에 끝나는지
진행률 처리 / 전체 전체 건수를 모르면 생략(파일은 크기 비율로 근사 가능)

너무 자주 찍으면 로그 I/O 가 병목이 됩니다. N 청크마다 또는 N 초마다 하나가 적당합니다.

2.9 병렬 청크 처리 — 언제 가능하고 무엇이 위험한가

CPU 가 남는데 처리가 느리다면 청크를 병렬로 처리합니다. 구조는 "읽기는 한 스레드, 가공+쓰기는 워커 풀"입니다.

text
  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(코어 수))이 맞습니다.

핵심 원리
  • 2.1 OOM 은 어떻게 발생하는가 — 100만 건의 메모리 계산
  • 2.2 -Xmx 와 OOM 재현 실험
  • 2.3 청크 지향 처리의 원리
  • 2.4 청크 크기는 어떻게 정하는가
  • 2.5 스트리밍 Reader — Iterator 패턴
  • 2.6 DB 페이징 — offset 방식 vs 키셋(커서) 방식
  • 2.7 GC 와 참조 해제 — 청크는 언제 회수되는가
  • 2.8 진행률 로깅 — 건수, 속도, ETA
  • 2.9 병렬 청크 처리 — 언제 가능하고 무엇이 위험한가
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제