티스토리 뷰

Spring Batch를 운영하다 보면 “실패를 어떻게 다시 처리할 것인가”라는 질문을 반복해서 마주하게 됩니다. 단순히 retry 옵션을 몇 번으로 설정할지의 문제가 아니라, 트랜잭션 경계와 데이터 정합성, 외부 시스템 의존성까지 함께 고려해야 하는 설계의 문제라고 느끼게 됩니다.

 

이번 글에서는 Spring Batch 공식 문서와 Spring Retry, 그리고 Martin Kleppmann의 『Designing Data-Intensive Applications』에서 설명하는 분산 시스템의 실패 모델을 바탕으로, 처리 실패 시 재시도 전략을 정리해 보려고 합니다. 특히 다음 두 가지 상황을 구분해 살펴보겠습니다.

  • 트랜잭션에 DB 쓰기 작업이 포함되는 경우
  • 트랜잭션에 DB 쓰기 작업이 포함되지 않는 경우 (내부 API 호출 포함)

 


 

Spring Batch에서 실패가 발생하면 기본적으로 어떻게 동작하는가

 

Spring Batch는 Chunk 기반 처리 모델을 사용합니다. ItemReader, ItemProcessor, ItemWriter가 하나의 Chunk 단위로 묶여 트랜잭션 안에서 실행됩니다. 공식 문서에 따르면 Chunk 처리 중 예외가 발생하면 해당 Chunk 전체가 롤백됩니다.

 

여기서 중요한 점은 Rollback과 Retry는 서로 다른 개념이라는 점입니다.

 

Rollback은 데이터베이스 트랜잭션을 이전 상태로 되돌리는 것이고, Retry는 동일한 아이템을 다시 시도하도록 허용하는 정책입니다. Retry가 있다고 해서 트랜잭션이 자동으로 안전해지는 것은 아닙니다. 재시도는 동일 로직을 다시 실행하는 것일 뿐이며, 그 과정에서 중복 처리나 부작용이 발생할 가능성은 여전히 존재합니다.

 

이 차이를 명확히 이해하는 것이 재시도 전략 설계의 출발점이라고 생각합니다.

 


 

트랜잭션에 DB 쓰기 작업이 포함되는 경우

 

Chunk 트랜잭션과 재처리의 관계

 

DB 쓰기가 포함된 Step에서는 Writer에서 예외가 발생하면 해당 Chunk 전체가 롤백됩니다. 이후 Retry 정책이 설정되어 있다면 동일 아이템이 다시 처리됩니다.

 

문제는 “다시 처리된다”는 사실 자체입니다. 이미 일부 로직이 외부에 영향을 주었거나, Writer 내부에서 멱등하지 않은 로직이 있었다면 중복 insert, 잘못된 상태 전이, Lost Update와 같은 문제가 발생할 수 있습니다.

 

Spring Batch 공식 문서에서는 RetryPolicy, SkipPolicy, BackOffPolicy를 통해 세밀한 제어가 가능하다고 설명합니다. 그러나 이러한 설정은 어디까지나 재시도 동작을 조정하는 도구일 뿐, 데이터 정합성을 보장해 주는 장치는 아닙니다.

 


 

멱등성(Idempotency)의 중요성

 

Kleppmann은 분산 시스템에서 at-least-once 재시도는 기본 전제이며, 중복 처리를 흡수하기 위해 멱등성이 필요하다고 설명합니다. 이 관점은 Batch에서도 동일하게 적용됩니다.

 

DB 쓰기가 포함된 경우에는 다음과 같은 전략을 고려할 수 있습니다.

 

  • Unique Key 제약을 통해 중복 insert 방지
  • Optimistic Lock(version 컬럼)을 통한 충돌 감지
  • UPSERT 전략을 활용한 상태 기반 업데이트
  • 상태 전이 검증 로직 추가

 

예를 들어, 이미 “완료” 상태인 데이터를 다시 “완료”로 업데이트하는 것은 논리적으로 멱등합니다. 반면 “처리 횟수 +1”과 같은 로직은 멱등하지 않습니다. 재시도를 전제로 설계할 때는 이러한 차이를 항상 고려해야 한다고 느낍니다.

 


 

Retry와 Skip의 조합 전략

 

모든 예외를 무조건 재시도하는 것은 바람직하지 않습니다. 일시적인 Deadlock, 네트워크 단절 등은 Retry 대상이 될 수 있지만, 데이터 포맷 오류나 명백한 비즈니스 예외는 재시도해도 성공하지 않을 가능성이 큽니다.

 

Spring Batch는 Retry와 Skip을 함께 설정할 수 있습니다. 일정 횟수 재시도 후 Skip하도록 설계하면, 무한 재시도에 빠지는 것을 방지할 수 있습니다.

 

이때 BackOffPolicy를 함께 적용하지 않으면, 짧은 시간에 반복 재시도로 DB 부하를 증가시킬 수 있습니다. 특히 장애 상황에서 재시도가 오히려 장애를 증폭시키는 사례는 실제 운영 환경에서도 자주 관찰됩니다.

 


 

재시도 폭주와 트레이드오프

 

DB 장애가 발생한 상황에서 무제한 재시도는 커넥션 풀 고갈이나 CPU 스파이크를 유발할 수 있습니다. 따라서 Retry 횟수 제한과 Exponential Backoff는 사실상 필수에 가깝습니다.

 

여기서 고민하게 되는 지점은 데이터 정합성과 가용성 사이의 균형입니다. 강한 정합성을 유지하기 위해 모든 실패를 재시도하면 가용성이 저하될 수 있고, 일부 실패를 Skip하면 정합성 보장이 약해질 수 있습니다.

 

어느 쪽을 선택할지는 도메인 요구사항에 따라 달라지며, 일괄적인 정답은 없다고 생각합니다.

 


 

트랜잭션에 DB 쓰기 작업이 포함되지 않는 경우

 

이 경우는 상황이 조금 다릅니다. 예를 들어, Batch에서 내부 API를 호출해 다른 서비스에 상태 변경을 요청하는 경우를 생각해 볼 수 있습니다.

 

이때는 트랜잭션 롤백으로 외부 시스템을 되돌릴 수 없습니다. 따라서 Retry 전략은 더욱 신중하게 설계해야 합니다.

 


 

네트워크 오류와 5xx 재시도

 

Spring Retry는 특정 예외에 대해서만 재시도하도록 설정할 수 있습니다. 예를 들어 Timeout, 5xx 응답은 일시적 오류로 간주하고 재시도 대상에 포함할 수 있습니다.

 

이 경우에도 Exponential Backoff를 적용해 점진적으로 재시도 간격을 늘리는 것이 일반적입니다. 그렇지 않으면 장애 중인 시스템에 계속 요청을 보내는 결과가 될 수 있습니다.

 


 

멱등성 없는 API 호출의 위험성

 

외부 API가 멱등하지 않다면, 재시도는 심각한 부작용을 초래할 수 있습니다. 예를 들어 “결제 승인 요청”을 두 번 보내면 이중 승인으로 이어질 수 있습니다.

 

Kleppmann은 네트워크 재전송과 프로세스 재시작이 언제든 발생할 수 있다고 설명합니다. 따라서 외부 호출은 idempotency key를 포함하거나, 서버 측에서 중복 요청을 감지할 수 있도록 설계하는 것이 필요합니다.

 

Batch에서 재시도를 설정하기 전에, 해당 API가 재시도 가능한 설계인지 먼저 검토해야 한다고 생각합니다.

 


 

메시지 기반 재처리와의 비교

 

동기 방식 Retry는 동일 실행 흐름 안에서 즉시 재시도합니다. 반면 Kafka와 같은 메시지 기반 아키텍처에서는 실패 메시지를 재처리하거나, Dead Letter Topic으로 이동시킨 후 별도 재처리를 수행할 수 있습니다.

 

DLQ는 반복 실패 메시지를 격리하는 역할을 합니다. 일정 횟수 이상 실패한 메시지를 DLQ로 이동시키면, 메인 처리 흐름을 보호할 수 있습니다. 이후 운영자가 분석 후 재처리하거나, 별도의 Batch를 통해 복구 작업을 수행할 수 있습니다.

 

이 구조는 동기 Retry보다 장애 격리에 유리한 측면이 있습니다.

 


 

분산 환경에서의 확장 전략

 

Outbox 패턴의 필요성

 

DB 업데이트와 메시지 발행을 동시에 수행하는 경우, 둘 사이의 원자성을 보장하기 어렵습니다. DB는 커밋되었지만 메시지 발행이 실패하는 상황이 발생할 수 있습니다.

 

Transactional Outbox 패턴은 비즈니스 데이터와 이벤트를 동일 트랜잭션 안에 기록하고, 이후 별도 프로세스가 Outbox 테이블을 읽어 메시지를 발행하도록 설계합니다. 이를 통해 DB와 이벤트 기록의 원자성을 확보할 수 있습니다.

 


 

Saga와 단순 Retry의 차이

 

단순 Retry는 아직 성공하지 않은 작업을 다시 시도하는 전략입니다. Saga는 이미 일부 성공한 로컬 트랜잭션을 보상 트랜잭션으로 되돌리는 분산 트랜잭션 관리 전략입니다.

 

2PC 대신 Saga를 선택하는 이유는 가용성과 확장성 때문입니다. Batch가 여러 서비스와 상호작용하는 경우에는 단일 트랜잭션으로 전체를 묶는 대신, Saga로 상태를 관리하는 것이 현실적인 접근일 수 있습니다.

 


 

Exactly-once의 한계

 

Kafka는 exactly-once semantics를 지원한다고 설명하지만, 이는 프로듀서·브로커·컨슈머 설정이 함께 맞물려야 합니다. 애플리케이션 레벨에서 완전한 exactly-once를 보장하는 것은 매우 어렵습니다.

 

따라서 현실적으로는 at-least-once를 전제로 하고, 멱등성 설계를 통해 중복을 흡수하는 방식이 일반적입니다. 이 점은 Batch 재시도 전략에서도 동일하게 적용된다고 생각합니다.

 


 

마무리하며

 

Spring Batch의 재시도 전략은 단순히 retryLimit 값을 정하는 문제가 아니라, 트랜잭션 경계, 멱등성, 외부 시스템 의존성, 분산 환경까지 고려해야 하는 설계의 문제라고 느낍니다.

 

DB 쓰기가 포함된 경우에는 Chunk 트랜잭션과 멱등성 설계가 핵심이며, DB 쓰기가 없는 외부 호출에서는 재시도 가능성 자체를 먼저 검토해야 합니다. 분산 환경으로 확장되면 Outbox, Saga, DLQ 같은 패턴을 함께 고려해야 합니다.

 

실무에서는 “어디까지를 하나의 일관성 경계로 볼 것인가”를 끊임없이 고민하게 됩니다. 재시도 전략은 설정이 아니라, 시스템이 실패를 어떻게 받아들이고 복구할 것인지에 대한 설계 선택이라는 점을 다시 정리하게 되었습니다.