본문 바로가기

Programming/Java & Spring 관련 내용 정리

[Spring Batch] 우리 정산 시스템은 오류를 어떻게 처리하고 있을까

 

우리 회사 정산 시스템에서 Spring Batch를 쓰고 있다.

배치에서 오류가 나면 어떻게 처리하고 있을까?

 

코드를 확인해보니 Spring의 .retry()RetryTemplate을 쓰지 않고,

Skip과 별도 Retry Job으로 오류를 처리하고 있었다.

 

왜 이런 전략을 택했는지 분석해봤다.

 

 


🔍 Skip — 데이터 오류 스킵이 아니라 멱등성 보장

 

정산 서버의 배치 Job 10개 이상에서 Skip 설정을 확인했는데,

대부분 같은 패턴이었다.

 

.faultTolerant() .skip(JobInstanceAlreadyCompleteException.class)

 

JobInstanceAlreadyCompleteException은 이미 완료된 Job을 다시 실행하려 할 때 발생한다.

즉, 데이터 품질 문제를 건너뛰는 게 아니라

"같은 배치를 두 번 돌려도 에러 없이 통과시키는" 용도였다.

 

💡 왜 이렇게 했을까?

정산 배치는 장애 시 재실행이 일상적이다.
네트워크 끊김, DB 타임아웃, 배포 중 중단 등으로 같은 Job을 다시 돌려야 하는 경우가 많다.
이때 "이미 완료된 건"에서 에러가 나면 배치 전체가 멈추므로,
완료된 건은 조용히 넘어가고 미처리 건만 처리하도록 설계한 것이다.

 

송금 처리는 다르다 — 무제한 Skip + 커스텀 정책

 

송금 처리 Job은 더 공격적인 Skip 전략을 쓰고 있었다.

 

.faultTolerant() .skipPolicy(this.skipPolicy) // 커스텀 SkipPolicy .skipLimit(Integer.MAX_VALUE) // 무제한 스킵

 

커스텀 SkipPolicy 내부:

 

public class WelcomePayRemitSkipPolicy implements SkipPolicy { @Override public boolean shouldSkip(Throwable t, long skipCount) { if (t instanceof ConstraintViolationException) { log.warn(...); return true; // 중복 거래는 무한 스킵 } return false; // 그 외 예외는 즉시 실패 } }

 

⚠️ 왜 무제한 스킵인가?

송금 Job은 chunk-size = 1로 건건이 커밋한다.
이미 송금 완료된 건을 다시 처리하려 하면 DB UNIQUE 제약에 걸려 ConstraintViolationException이 발생한다.

이때 skipLimit이 3이면, 재실행 시 완료된 건이 4개만 넘어도 배치가 멈춘다.
송금 배치가 중복 건 때문에 멈추면 미처리 송금이 지연되므로, 중복은 무제한 스킵하고 신규 건만 처리하는 전략이다.

 

작업 Skip 대상 목적
보험료/정산/운행 이력
10개 이상의 Job
JobInstanceAlreadyCompleteException 재실행 안전
완료된 건은 조용히 넘어감
송금 처리
커스텀 SkipPolicy
ConstraintViolationException
skipLimit = Integer.MAX_VALUE
중복 송금 방지
중복은 무한 스킵, 신규만 처리

 

 


🔁 Retry — Step 안에서 재시도하지 않는다

 

가장 흥미로운 부분이었다.

정산 시스템 전체에서 Spring의 .retry()RetryTemplate아예 쓰지 않는다.

 

대신 이렇게 동작한다:

 

1
송금 Job에서 외부 API 호출 실패
2
실패한 건을 오류 테이블에 저장 (failCount = 1)
3
별도 ErrorRetry Job이 주기적으로 오류 테이블을 읽어서 재시도
4
failCount에 따라 재시도 간격이 점점 늘어남
5
21회 초과 → CRITICAL_ERROR + Slack 알림 → 사람이 개입

 

점진적 재시도 간격

 

재시도 횟수 간격 누적 시간
1~6회 10초 ~1분
7~9회 1분 ~4분
10~12회 5분 ~19분
13~15회 10분 ~49분
16~18회 30분 ~2시간 19분
19~21회 1시간 ~5시간 19분
21회 초과 CRITICAL_ERROR + Slack 알림

 

💡 왜 Step 안에서 즉시 재시도하지 않았을까?

1. 외부 시스템 장애는 즉시 재시도해도 똑같이 실패한다.
송금은 외부 API 호출이다.
네트워크 타임아웃으로 실패했으면, 10초 뒤에 바로 재시도해도 같은 결과일 가능성이 높다.
Step 안에서 3번 즉시 재시도하는 것보다 시간을 두고 재시도하는 게 현실적이다.

2. 재시도 대상 건이 원래 Job의 처리를 막으면 안 된다.
RetryTemplate 방식은 재시도하는 동안 해당 chunk의 다른 건도 대기해야 한다.
오류 건을 별도 테이블로 빼면, 원래 Job은 신규 건만 빠르게 처리하고
오류 건은 별도 Job이 여유 있게 처리할 수 있다.

3. 실패 이력을 추적할 수 있다.
오류 테이블에 failCount, 마지막 실패 시간, 에러 내용이 남으므로
어떤 건이 몇 번 실패했는지 조회할 수 있고, 21회 초과 시 Slack으로 알림이 온다.

 

 


📖 기본 개념 복습: Skip과 Retry

 

위 코드를 이해하기 위한 기본 개념을 정리한다.

 

Skip이란

 

chunk 처리 중 설정된 Exception이 발생하면 해당 건만 건너뛰고 다음 건을 처리한다.

skipLimit을 초과하면 Step이 실패한다.

 

.faultTolerant() .skip(SomeException.class) // 이 예외가 발생하면 건너뜀 .skipLimit(3) // 3번까지 허용, 4번째부터 Step 실패

 

예를 들어 100건을 chunk-size 10으로 처리할 때,

10번째 chunk에서 skipLimit을 초과하면:

1~9번째 chunk(90건)는 이미 커밋되어 있고, 10번째 chunk만 롤백된다.

 

Retry란

 

DB DeadLock, 네트워크 타임아웃처럼 재시도하면 성공할 여지가 있는 예외에 사용한다.

 

// Step 설정에서 직접 retry .faultTolerant() .retry(SomeException.class) .retryLimit(3) // 또는 RetryTemplate으로 세밀한 제어 RetryTemplate retryTemplate = new RetryTemplateBuilder() .maxAttempts(3) .retryOn(SomeException.class) .build(); retryTemplate.execute( context -> { /* 재시도할 로직 */ }, context -> { /* 전부 실패 시 복구 */ } );

 

 


📝 정리

 

상황 전략 이유
이미 완료된 건이
다시 들어옴
Skip
JobInstanceAlreadyCompleteException
재실행 시 안전하게 통과
멱등성 보장
중복 거래가
UNIQUE 제약에 걸림
무제한 Skip
커스텀 SkipPolicy + MAX_VALUE
중복 때문에 배치가 멈추면
미처리 송금이 지연됨
외부 API 호출 실패 별도 Retry Job
10초 → 1분 → ... → 1시간 (최대 21회)
즉시 재시도해도 같은 결과
시간 간격 + 실패 이력 추적 필요

 

결국 "왜 실패하는가"에 따라 전략이 다르다.

 

이미 처리된 건이 다시 들어오면 → Skip으로 무시

외부 시스템 장애로 실패하면 → 별도 Job에서 시간을 두고 재시도

21번 재시도해도 안 되면 → 사람에게 알린다