| 유형 | 예 | 특징 | 대응 |
|---|---|---|---|
| 일시적(transient) | 네트워크 타임아웃, DB 락 타임아웃, 커넥션 풀 고갈, 외부 API 429/503, 데드락 | 시간이 지나면 저절로 해결됨. 같은 입력으로 다시 하면 성공할 가능성이 높음 | 재시도 (횟수 제한 + 백오프) |
| 영구적(permanent) | 숫자 컬럼에 문자, 필수 필드 누락, 존재하지 않는 참조 키, API 400 Bad Request | 입력 데이터 자체의 문제. 몇 번을 다시 해도 같은 결과 | 스킵 (기록 후 건너뜀) |
| 치명적(fatal) | DB 접속 불가, 디스크 풀, 설정 파일 없음, OOM, 인증 만료 | 이 건만의 문제가 아님. 다음 건도 전부 실패할 것 | 중단 (즉시 FAILED, 알림) |
분류 기준은 "같은 입력으로 다시 하면 결과가 달라지는가"와 "다른 건도 영향을 받는가"입니다.
다시 하면 결과가 달라지는가?
┌────────┴────────┐
예 아니오
│ │
재시도 다른 건도 실패하는가?
(transient) ┌──────┴──────┐
예 아니오
│ │
중단 스킵
(fatal) (permanent)분류는 예외 타입으로 표현합니다. TransientApiException 은 재시도, PermanentApiException 과 IllegalArgumentException 은 스킵, 그 밖의 예외는 중단. 예외 계층을 이렇게 설계하는 것이 배치 코드의 절반입니다.
시도 1 ──✗──▶ 대기 100ms ──▶ 시도 2 ──✗──▶ 대기 200ms ──▶ 시도 3 ──✗──▶ 대기 400ms ──▶ 시도 4 ──✓
(maxAttempts=4)
시도 4 ──✗──▶ RetryExhaustedException (원인 예외를 cause 로 보존)| 요소 | 왜 필요한가 |
|---|---|
| 최대 횟수 | 무한 재시도는 배치를 영원히 매달아 둔다. 3~5회가 관행 |
| 백오프(대기) | 즉시 재시도는 상대가 아직 회복되지 않은 상태에서 다시 때리는 것. 락 경합이라면 상대 트랜잭션이 끝날 시간을 줘야 한다 |
| 지수 증가 | 대기를 100, 200, 400, 800 으로 늘리면 짧은 장애는 빨리, 긴 장애는 상대에 부담 없이 기다린다 |
| 최대 대기 | 지수 증가가 무한히 커지지 않도록 상한(예: 5초) |
| 재시도 가능 예외 판별 | 영구 오류를 재시도하면 시간만 낭비. 화이트리스트로 명시 |
| 리스너 | 재시도가 일어났다는 사실을 로그로 남겨야 "왜 30분 더 걸렸는지" 알 수 있다 |
지터(jitter)가 필요한 이유 — thundering herd
외부 API 가 1초간 죽었다가 살아났습니다. 워커 100개가 동시에 실패하고, 모두 정확히 200ms 후에 재시도하면 API 는 같은 순간에 100개 요청을 받아 다시 죽습니다. 그리고 400ms 후에 또 동시에... 이것이 thundering herd(우르르 몰려드는 무리) 문제입니다.
지터 없음: |100 요청| |100 요청| |100 요청| ← 매번 스파이크
지터 있음: | 3| 7|12| 9|15|11| 8|10| 6|13| 4| 2| ... ← 분산대기 시간에 랜덤(예: 50%~100%)을 곱하면 재시도가 시간축에 흩어집니다. AWS 가 권장하는 "full jitter" 는 random(0, base × 2^attempt) 입니다.
| 정책 | 대기 시간 (attempt 1, 2, 3, 4) | 용도 |
|---|---|---|
fixed(200) |
200, 200, 200, 200 | 단순, 내부 시스템 |
exponential(100, 2, 5000) |
100, 200, 400, 800 | 외부 API 기본 |
exponentialWithJitter(100, 2, 5000) |
50 |
워커 다수, 외부 API 권장 |
재시도는 "같은 요청을 다시 보내는 것"입니다. 첫 요청이 실제로는 성공했는데 응답만 유실된 경우(타임아웃의 흔한 실체), 재시도는 이중 처리가 됩니다. 결제 API 를 재시도해 두 번 결제되는 사고가 이렇게 납니다. 04 레슨의 멱등 키가 여기서 다시 필요합니다. 재시도하는 모든 외부 호출에는 멱등 키가 있어야 하고, 없다면 재시도 대신 스킵 + 수동 확인이 안전합니다.
건 처리 ──✗──▶ 스킵 가능 예외?
├─ 아니오 → 던짐 (중단)
└─ 예 → skipCount++
├─ skipCount > skipLimit → SkipLimitExceededException (중단)
└─ 아니면 → 데드 레터에 기록, 다음 건으로| 요소 | 왜 필요한가 |
|---|---|
| 스킵 가능 예외 화이트리스트 | 아무 예외나 스킵하면 치명적 오류(DB 다운)도 "건너뛰고" 100만 건을 전부 스킵한 뒤 정상 종료한다 |
| skipLimit | 0.1% 오류는 정상, 10% 면 입력 파일 문제. 한도를 넘으면 실패시켜 사람이 보게 한다 |
| 데드 레터(dead letter) | 스킵 건을 사유·원본과 함께 별도 기록. 기록 없는 스킵은 데이터 유실 |
| 스킵 건수 리포트 | 총 건수 대비 스킵 비율을 매일 비교. 갑자기 늘면 상류 시스템 변경 신호 |
"데드 레터"는 메시지 큐 용어에서 왔습니다. 배달할 수 없는 편지를 모아 두는 우체국의 사서함입니다.
04 레슨에서 청크 안의 한 건이 실패하면 청크 전체를 롤백했습니다. 이제 실패 건만 제외합니다.
청크 (200건) 시작
│
├─ 건 1: parse ✓ → api.send ✓ → sent 에 추가
├─ 건 2: parse ✗ (NumberFormatException)
│ └ 스킵 가능? 예 → skipCount++ (한도 검사) → 데드 레터 기록
├─ 건 3: parse ✓ → api.send ✗ Transient
│ └ 재시도 1 (2ms) ✗ → 재시도 2 (4ms) ✓ → sent 에 추가
├─ 건 4: parse ✓ → api.send ✗ Transient ✗ ✗ ✗
│ └ RetryExhausted (cause=Transient) → 스킵 가능? 예 → 데드 레터
├─ 건 5: parse ✓ → api.send ✗ Permanent (400)
│ └ 재시도 대상 아님 → 즉시 → 스킵 가능? 예 → 데드 레터
├─ 건 6: db 접속 불가 (SQLException)
│ └ 재시도 대상 아님 → 스킵 가능? 아니오 → 던짐 → 배치 FAILED
│
└─ (건 6 이 없었다면) writer.write(sent) → 커밋: 200건 중 197건| 단계 | 판단 | 결과 |
|---|---|---|
| 1 | 재시도 가능 예외인가? | 예 → 백오프 후 재시도 (최대 N회) |
| 2 | 재시도 소진 또는 재시도 불가 | 스킵 정책에 넘김 |
| 3 | 스킵 가능 예외인가? (RetryExhausted 는 cause 로 판별) | 예 → 한도 검사 → 데드 레터 → 다음 건 |
| 4 | 스킵 불가 | 던짐 → 청크 롤백 → 배치 FAILED |
Spring Batch 는 여기에 한 단계가 더 있습니다. Writer(청크 쓰기)에서 실패하면 어느 건이 원인인지 모르므로, 청크를 롤백한 뒤 한 건씩 다시 처리하며 실패 건을 찾아 스킵합니다(scan 모드). 이 레슨은 건별 처리 단계에서 실패를 잡는 단순한 형태를 다룹니다.
재시도 범위가 어디까지인지가 중요합니다.
| 재시도 범위 | 설명 | 주의 |
|---|---|---|
| 건별 연산(API 호출 하나) | 이 레슨의 방식. 가볍고 안전 | 호출이 멱등해야 함 |
| 청크 전체 | 청크 롤백 후 청크 처음부터 다시. DB 데드락 처리에 사용 | 청크 안의 외부 호출이 반복됨 → 멱등 키 필수 |
| Step/Job 전체 | 04 의 재시작. 사람이 또는 스케줄러가 트리거 | 체크포인트 필요 |
DB 데드락(SQLTransientException)은 건별이 아니라 청크 트랜잭션 전체가 롤백되므로 청크 단위 재시도가 맞습니다. 외부 API 타임아웃은 건별 재시도가 맞습니다. 둘을 혼동하면 "API 는 성공했는데 청크 재시도로 또 호출"이 됩니다.
외부 API 가 완전히 죽어 있는데 100만 건 각각을 4번씩 재시도하면 400만 번 실패하고 백오프까지 합쳐 몇 시간을 낭비합니다. 서킷 브레이커(circuit breaker)는 연속 실패가 임계치를 넘으면 회로를 열어 일정 시간 동안 호출 자체를 하지 않고 즉시 실패시킵니다.
CLOSED (정상) ──연속 실패 ≥ 5──▶ OPEN (즉시 실패, 30초) ──시간 경과──▶ HALF-OPEN (1건 시험)
▲ │
└──────────────────── 성공 ◀───────────────────────────────────────────┘
실패 → 다시 OPEN배치에서는 "OPEN 이 되면 배치를 FAILED 로 중단하고 나중에 재시작"이 보통 맞습니다. 상대가 죽었을 때 계속 두드리는 것보다 체크포인트를 남기고 멈추는 것이 낫습니다. 구현은 Resilience4j 같은 라이브러리가 있지만, 원리는 "연속 실패 카운터 + 상태 + 타이머"뿐입니다.
| 항목 | 의미 | 이상 징후 |
|---|---|---|
| 총 건수 | 읽은 건수 | 어제보다 크게 다르면 입력 문제 |
| 성공 | 쓰기까지 완료 | - |
| 스킵 | 데드 레터 건수 | 비율 급증 = 상류 변경 |
| 재시도 횟수 | 일시 오류 총량 | 급증 = 외부 시스템/네트워크 불안정 |
| 재시도 소진 | 끝내 실패 | 0 이 아니면 확인 |
| 소요 시간 / 처리 속도 | 시간 창 관리 | 추세 상승 = 용량 계획 |
총 건수 = 성공 + 스킵 이 맞아떨어져야 합니다. 안 맞으면 어딘가에서 건이 조용히 사라진 것입니다.