| 성질 | 의미 | 배치에서의 질문 |
|---|---|---|
| Atomicity (원자성) | 전부 반영되거나 전부 취소 | "원자성의 단위"를 무엇으로 할 것인가 — 전체? 건별? 청크? |
| Consistency (일관성) | 제약 조건이 항상 만족 | 청크 중간 상태(A 는 출금됐고 B 는 입금 전)가 커밋되지 않도록 |
| Isolation (격리성) | 다른 트랜잭션에 중간 상태 노출 안 됨 | 배치가 도는 동안 온라인 조회가 반쯤 처리된 데이터를 보지 않도록. 긴 트랜잭션은 락으로 온라인을 막음 |
| Durability (지속성) | 커밋되면 장애에도 남음 | 커밋 = 재시작 시 다시 할 필요 없는 지점 |
배치 설계의 핵심은 첫 줄입니다. 원자성의 단위를 청크로 정한다.
전체 1 트랜잭션: [───────────── 100만 건 ─────────────] commit
실패 시 전부 롤백. undo 로그 수 GB. 락 수 시간. 재시작 = 처음부터
건별 커밋: [1]c[2]c[3]c ... [1,000,000]c
커밋 100만 번 = fsync 100만 번 = 매우 느림 (커밋당 1~10ms → 수 시간)
청크 커밋(1000): [────1000────]c[────1000────]c ... [────1000────]c
커밋 1000번. 실패 시 최대 1000건 롤백. 재시작 = 마지막 커밋 청크 다음부터| 기준 | 전체 | 건별 | 청크 |
|---|---|---|---|
| 커밋 횟수 | 1 | N | N / chunk |
| 롤백 범위 | 전체 | 1건 | 청크 |
| 락 유지 시간 | 배치 전체 | 극히 짧음 | 청크 처리 시간 |
| 재시작 지점 | 처음 | 실패 건 | 마지막 커밋 청크 |
| 성능 | 커밋 비용은 최소지만 undo/락으로 오히려 느려짐 | 커밋 비용이 지배 | 균형 |
커밋이 비싼 이유는 디스크 동기화(fsync) 때문입니다. DB 는 커밋 시 로그를 디스크에 확실히 쓴 뒤에야 성공을 반환합니다(Durability). SSD 라도 fsync 는 ms 단위이고, 100만 번이면 그것만으로 수십 분입니다. 청크로 묶으면 그 비용이 1/1000 이 됩니다.
청크 1 ─commit─▶ 청크 2 ─commit─▶ 청크 3 ─commit─▶ 청크 4 ─(장애)
▲
└── 마지막 커밋 포인트 = 재시작 지점
재시작: 청크 4 부터 다시 읽기 시작커밋된 것은 지속되고, 커밋되지 않은 것은 사라진다(DB 가 보장). 그래서 재시작 지점은 항상 "마지막 커밋 청크의 끝"입니다. 청크 4 의 일부가 처리되던 중 죽었다면, DB 는 그 미커밋 변경을 자동으로 버립니다(커넥션이 끊기면 롤백). 문제는 "청크 3 까지 커밋됐다"는 사실을 배치 프로그램이 어떻게 아는가입니다. 그것을 기록하는 것이 체크포인트입니다.
| 저장 위치 | 장점 | 단점 |
|---|---|---|
| DB 테이블 (같은 DB) | 데이터와 같은 트랜잭션으로 커밋 → 불일치 불가능 | 배치 메타 테이블 필요 |
| 파일 | DB 없는 배치에서 간단 | 데이터 커밋과 체크포인트 저장 사이에 죽으면 불일치(아래) |
| 다른 DB / Redis | 여러 배치 공유 | 마찬가지로 불일치 창(window) 존재 |
체크포인트에 무엇을 기록하는가:
skip.WHERE id > :lastId.체크포인트를 데이터와 다른 저장소에 기록하면 두 저장이 원자적이지 않습니다.
db.commit() ← 성공
(여기서 죽음)
checkpoint.save(lineNo=400) ← 실행 안 됨 → 체크포인트는 300 에 머묾
재시작: 301~400 을 다시 처리 → 이중 처리!이 창(window)은 아주 짧지만 0 이 아닙니다. 해결책은 두 가지입니다. 체크포인트를 데이터와 같은 DB, 같은 트랜잭션에 기록하거나(이상적), 멱등성으로 이중 처리를 무해하게 만듭니다(현실적, 어차피 필요).
멱등(idempotent)이란 "같은 연산을 여러 번 적용해도 결과가 한 번과 같다"는 뜻입니다. 재시작 배치는 반드시 일부 데이터를 다시 만나므로 멱등성 없이는 재시작이 불가능합니다.
| 기법 | 방법 | 예 |
|---|---|---|
| 처리 완료 마킹 | 건마다 "처리됨" 표시를 같은 트랜잭션에 기록. 재실행 시 표시가 있으면 건너뜀 | done:{txId} 키, orders.processed_at 컬럼 |
| Upsert | INSERT 대신 "있으면 UPDATE, 없으면 INSERT" | MERGE, INSERT ... ON CONFLICT UPDATE |
| 절대값 갱신 | balance = 900 처럼 절대값 대입은 재실행에 안전. 원본 상태는 알아야 함 |
등급 재산정 grade = calc(purchase) |
| 멱등 키 | 외부 API 호출에 유일 키를 붙여 서버가 중복을 거부 | 결제 API 의 Idempotency-Key 헤더 |
| 출력 덮어쓰기 | 결과 파일을 append 가 아니라 통째로 다시 생성 | 정산 파일 재생성 |
핵심 규칙: 완료 마킹은 데이터 변경과 같은 트랜잭션에서 커밋되어야 합니다. 마킹만 커밋되고 데이터가 롤백되면(또는 반대) 영원히 불일치입니다. 이 레슨의 TransferBatch 는 잔액 변경과 done:{txId} 를 같은 begin~commit 안에서 씁니다.
committed (확정) pending (트랜잭션 버퍼)
begin() { A=100 } {} ← 새 버퍼
put(A, 50) { A=100 } { A=50 } ← 버퍼에만
get(A) → pending 에 있으면 50 (자기 변경은 보인다 = read-your-writes)
rollback() { A=100 } null ← 버퍼 폐기
begin() { A=100 } {}
put(A, 50) { A=100 } { A=50 }
commit() { A=50 } null ← 버퍼를 확정에 반영| 시뮬레이션 | 실제 JDBC | 실제 DB 내부 |
|---|---|---|
begin() = 새 pending 맵 |
conn.setAutoCommit(false) |
트랜잭션 ID 발급 |
put() = pending 에 기록 |
stmt.executeUpdate() |
데이터 페이지 수정 + undo 로그 기록 |
get() = pending 우선 조회 |
같은 커넥션의 SELECT |
자기 트랜잭션 변경은 보임 |
commit() = pending → committed |
conn.commit() |
redo 로그 fsync, 락 해제 |
rollback() = pending 폐기 |
conn.rollback() |
undo 로그로 원복, 락 해제 |
| 커넥션 끊김 | 미커밋 자동 롤백 | 동일 |
실제 DB 는 "버퍼에 뒀다가 반영"이 아니라 "바로 수정하고 undo 로 되돌리기"지만, 커밋 전 변경은 격리되어 있고 취소 가능하다는 계약은 같습니다. 이 계약만 이해하면 됩니다.
청크 4 (건 301~400):
begin
301 apply ✓ 302 apply ✓ ... 349 apply ✓
350 apply ✗ InsufficientBalance!
rollback → 301~349 의 변경 49건이 전부 취소. 청크 1~3 (1~300) 은 커밋됐으므로 유지로그에 이것이 명확히 보여야 합니다.
청크 1 커밋 (줄 100까지, 처리 100건)
청크 2 커밋 (줄 200까지, 처리 100건)
청크 3 커밋 (줄 300까지, 처리 100건)
청크 4 롤백 (TX-000350: ACC-04 잔액 1009000 < 이체 999999999) -> 이 청크의 49건 변경 전부 취소, 이전 청크는 유지
청크 5 커밋 ...롤백된 청크를 어떻게 할지는 정책입니다. 이 레슨에서는 "기록하고 다음 청크로" 넘어갑니다(체크포인트 전진). 05 레슨에서 "실패 건만 빼고 나머지를 다시 커밋"하는 스킵 처리를 다룹니다. Spring Batch 는 청크 실패 시 건별로 다시 처리하며 실패 건만 스킵하는 방식을 씁니다.
DB 밖의 작업(메일 발송, 외부 결제 API, 타 시스템 파일 전송)은 rollback 이 없습니다. 이미 보낸 메일은 취소할 수 없습니다. 이럴 때는 반대 작업을 하는 별도 트랜잭션으로 논리적으로 되돌립니다.
| 원 작업 | 보상 작업 |
|---|---|
| 결제 승인 | 결제 취소 API |
| 포인트 적립 | 포인트 차감 |
| 재고 차감 | 재고 복구 |
| 메일 발송 | 정정 메일 (완전 취소 불가) |
보상은 완벽하지 않으므로, 순서를 잘 정합니다. 되돌릴 수 있는 것을 먼저, 되돌릴 수 없는 것을 마지막에. DB 변경(롤백 가능)을 먼저 하고 커밋 직전에 외부 API 를 호출하면, API 가 실패했을 때 DB 는 롤백하면 됩니다. API 성공 후 커밋이 실패하는 창은 남으며, 그때 보상 또는 멱등 키가 필요합니다.
파일에는 rollback 이 없지만 흉내 낼 수 있습니다.
| 목표 | 방법 |
|---|---|
| 전체 파일의 원자성 | 임시 파일에 쓰고 완료 후 ATOMIC_MOVE (02 레슨). 죽으면 .tmp 만 남음 |
| 청크 단위 커밋 | 청크마다 flush() + 쓴 줄 수를 체크포인트에 기록. 재시작 시 그 줄까지 truncate 후 append |
| 재시작 시 단순화 | 결과 파일은 재생성이 싸다면 통째로 다시 만든다. 정산 파일은 대부분 이쪽 |
RandomAccessFile.setLength(n) 또는 FileChannel.truncate(n) 으로 마지막 커밋 위치까지 잘라내면, 죽기 직전 반쯤 쓰인 청크가 제거됩니다. 이 레슨의 예제는 가장 단순한 "임시 파일 + 원자적 이동 + 재생성" 방식을 씁니다.
어제 배치가 안 끝났는데 오늘 스케줄이 또 시작하면 같은 데이터를 두 프로세스가 처리합니다. 방지책:
STARTED 가 있으면 시작 거부 (Spring Batch 기본 동작)FileChannel.tryLock() 으로 락 파일 획득 실패 시 종료SELECT ... FOR UPDATE NOWAIT 로 Job 행 잠금flock, 스케줄러의 "이전 인스턴스가 실행 중이면 시작 안 함" 옵션