본문 바로가기

Programming/Java & Spring 관련 내용 정리

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

 

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

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

 

내가 확인한 배치 Job 설정에서는 Step 단위의 .retry()RetryTemplate 기반 즉시 재시도는 사용하지 않고,

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

 

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

 

 


🔍 Skip — 어떤 예외를 건너뛰고 있나

 

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

대부분 같은 패턴이었다.

 

.faultTolerant() .skip(JobInstanceAlreadyCompleteException.class)

 

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

의도는 이미 완료된 실행을 재실행할 때 전체 흐름이 깨지는 것을 줄이려는 것으로 보인다.

다만 이 예외는 일반적인 row 단위 데이터 오류와 다르므로, 실제로 어느 실행 경로에서 발생하는지는 별도 확인이 필요하다.

 

💡 보험료/정산 배치 10개 이상에서 같은 패턴을 확인했다.

정산 배치는 장애 시 재실행이 일상적이다.
네트워크 끊김, 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이 발생한다.

중복 송금 자체는 DB UNIQUE 제약이 막는다.
SkipPolicy는 그 예외를 "허용 가능한 중복"으로 보고 다음 건을 처리하게 하는 역할이다.
skipLimit이 3이면 재실행 시 완료된 건이 4개만 넘어도 배치가 멈추므로,
중복 때문에 배치가 멈추면 미처리 송금이 지연된다. 그래서 무제한 스킵 + 신규 건만 처리하는 전략이다.

 

작업 Skip 대상 목적
보험료/정산/운행 이력
10개 이상의 Job
JobInstanceAlreadyCompleteException 재실행 시 흐름 보호
완료된 인스턴스가 전체를 깨뜨리지 않도록
송금 처리
커스텀 SkipPolicy
ConstraintViolationException
skipLimit = Integer.MAX_VALUE
중복 처리로 인한 배치 중단 방지
DB 제약이 중복을 막고, Skip은 배치를 멈추지 않게

 

 


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

 

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

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

 

대신 이렇게 동작한다:

 

1
송금 Job에서 외부 API 호출 실패
2
실패한 건을 오류 테이블에 저장 (RETRY_WAITING, failCount 누적)
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 장애는 즉시 재시도해도 성공을 보장할 수 없다.
송금은 외부 API 호출이다.
타임아웃의 경우 실제 외부 시스템에서는 성공했을 가능성도 있다.
같은 Step 안에서 무작정 재호출하기보다, 실패 상태를 남기고 간격을 두고 재처리하는 편이
추적성과 안정성 면에서 유리하다.

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으로 배치 중단을 막는다

외부 연동 실패 → 에러 테이블에 상태를 남기고 별도 Retry Job이 재처리

21번 재시도해도 안 되면 → CRITICAL_ERROR + 운영자에게 알린다

 

금전 도메인에서는 "빨리 다시 시도"보다

"중복 없이, 추적 가능하게, 운영자가 개입할 수 있게"가 더 중요하기 때문이다.