data/orders.csv (10만 행)
│
▼ ChunkReader.nextChunk(1000) ← 체크포인트의 lastCommittedLine까지 건너뛰고 시작
┌────────────────────────────────────────────────────────────────────────┐
│ 청크 1개 = 트랜잭션 1개 work = new JobState() │
│ │
│ RawLine ──▶ OrderParser.parse ──▶ OrderRecord │
│ │ SkipException(FIELD_COUNT / NUMBER_FORMAT / ...) │
│ ▼ │
│ work.recordSkip(reason) │
│ │
│ OrderRecord ──▶ RetryTemplate.chargeWithRetry ──▶ 승인 코드 │
│ │ PaymentGateway.charge(order, attempt) │
│ │ TransientFailure → backoff → 재시도 (최대 3회) │
│ │ Declined → SkipException(DECLINED) │
│ │ 3회 소진 → SkipException(RETRY_EXHAUSTED)│
│ ▼ │
│ work.recordSuccess(order) ← byCategory / byPayType 집계 │
│ │
│ 청크 끝: committed.merge(work) ──▶ CheckpointStore.save(committed) │
│ └── 커밋 (메모리) └── 커밋 (디스크, 원자적 교체) │
│ 예외: work 폐기 ──▶ 롤백. committed와 체크포인트는 그대로 │
└────────────────────────────────────────────────────────────────────────┘
│ 모든 청크 완료
▼
SettlementWriter.write ──▶ settlement.tmp ──▶ ATOMIC_MOVE ──▶ settlement_2024-03-31.csv
BatchReport.print| 컴포넌트 | 책임 | 배치 레벨 레슨 |
|---|---|---|
SampleDataGenerator |
시드 고정 CSV 생성. 1.2% 오염 행 주입 | 02 파일 I/O |
ChunkReader |
헤더 건너뛰기, N행씩 반환, 시작 행까지 스킵 | 03 청크 |
OrderParser |
행 → OrderRecord. 실패는 SkipException(사유) |
05 스킵 |
PaymentGateway |
해시 기반 결정적 장애: 일시 5%, 영구 0.1% | — |
RetryTemplate |
일시 장애 최대 3회, 지수 백오프. 재시도 횟수 집계 | 05 재시도 |
JobState |
커밋된 누적 상태 또는 청크 작업 상태. merge = 커밋. Properties 직렬화 |
04 트랜잭션 |
CheckpointStore |
JobState를 파일로 저장/복원. 임시 파일 + 원자적 교체 |
04 체크포인트 |
SettlementJob |
파이프라인 조립. 청크 루프, 커밋/롤백, 장애 주입, 진행 로그 | 01 아키텍처 |
SettlementWriter |
정산 CSV 출력. 임시 파일 + ATOMIC_MOVE |
02 파일 I/O |
BatchReport |
최종 리포트 | 01 아키텍처 |
Main |
CSV 생성 → 1차 실행(장애) → 2차 실행(재개) → 검증 → 파일 확인 | — |
committed : JobState ← 체크포인트에서 복원. 마지막 커밋까지의 누적
work : JobState ← 이 청크만의 카운터·집계. 매 청크 새로 생성
성공: committed.merge(work) work의 숫자를 committed에 더하고 lastCommittedLine 갱신
checkpoint.save(committed) 디스크에 반영
실패: (work를 그냥 버림) committed는 한 글자도 안 바뀜 = 롤백DB 트랜잭션의 "작업 중 변경은 커밋 전까지 다른 세션에 보이지 않는다"를 두 객체로 흉내 냈다. 같은 클래스를 두 역할로 쓰므로 merge가 대칭적이고, 3단계의 Inventory 분리처럼 "가변 상태를 한 클래스에 격리"한 것이다.
| 방법 | 재현성 | 교육 효과 |
|---|---|---|
Random.nextDouble() < 0.05 |
실행마다 다름. 시드를 고정해도 스레드·호출 순서가 바뀌면 달라짐 | 출력 대조 불가 |
hash(orderId, attempt) % 100 < 5 |
항상 같음. 어떤 주문이 몇 번째 시도에서 실패하는지 고정 | 재시작 후 결과가 단일 실행과 같음을 증명할 수 있음 |
PaymentGateway.mix는 SplitMix64 계열의 비트 섞기다. 순차 ID(ORD-0000001, ORD-0000002...)의 해시가 규칙적으로 몰리지 않게 큰 홀수 상수를 곱하고 시프트-XOR한다.
# settlement checkpoint
lastCommittedLine=39001
chunksCommitted=39
read=39000
processed=38495
skipped=505
retried=1976
skip.QUANTITY=226
skip.NUMBER_FORMAT=121
cat.전자=12776,1548012000,24471690
pay.CARD=19212,1585372500,39632985
...Properties를 골랐다. JSON 라이브러리 없이 JDK만으로 읽고 쓸 수 있고, 사람이 열어서 확인할 수 있다. Writer로 저장하면 한글 키(cat.전자)도 그대로 쓰인다(OutputStream으로 저장하면 \uXXXX로 이스케이프된다).
| 파일 | 위험 | 해법 |
|---|---|---|
| 체크포인트 | 저장 도중 죽으면 반쪽 파일 → 재시작 시 파싱 실패 → 처음부터 | .tmp에 쓰고 Files.move(..., ATOMIC_MOVE) |
| 정산 결과 | 쓰는 도중 다운스트림이 읽음 → 합계 누락된 파일로 회계 처리 | 동일 |
ATOMIC_MOVE는 같은 파일 시스템 안에서 이름 변경(rename)이 원자적이라는 OS 보장을 쓴다. 파일은 "없음" 아니면 "완성" 두 상태만 갖는다.