티스토리 뷰
이전 글에서는 Spring Batch 애플리케이션 내부에서 처리 실패가 발생했을 때, Chunk 트랜잭션과 Retry/Skip 정책을 어떻게 설계해야 하는지 정리해 보았습니다. 그 글에서는 비교적 단일 애플리케이션 경계 안에서의 재시도 전략에 초점을 맞추었습니다.
이번 글에서는 그 연장선에서, 분산 환경으로 확장했을 때 재처리 전략을 어떻게 바라보아야 하는지 정리해 보려고 합니다. 특히 Saga 패턴, Transactional Outbox, 그리고 DLQ 기반 재처리를 중심으로 살펴보겠습니다.
Spring Batch 설정의 문제가 아니라, 실패를 전제로 한 시스템 설계 관점에서 접근해 보겠습니다.
단일 애플리케이션 Retry의 한계
Spring Batch 공식 문서에 따르면 Step은 Chunk 단위 트랜잭션으로 동작하며, 예외 발생 시 해당 Chunk 전체가 롤백됩니다. 또한 Spring Retry를 통해 특정 예외에 대해 재시도를 설정할 수 있습니다.
이 구조는 단일 데이터소스, 단일 애플리케이션 경계 안에서는 비교적 명확하게 동작합니다. 그러나 분산 환경에서는 문제가 달라집니다.
예를 들어 다음과 같은 상황을 생각해 볼 수 있습니다.
- Batch에서 주문 상태를 DB에 업데이트합니다.
- 이어서 Kafka로 이벤트를 발행합니다.
- 또는 외부 서비스 API를 호출합니다.
이때 DB 커밋은 성공했지만 메시지 발행이 실패하거나, 외부 호출이 타임아웃으로 종료되는 경우가 발생할 수 있습니다. Spring Batch의 Retry는 애플리케이션 내부 로직을 다시 실행해 줄 수는 있지만, 이미 커밋된 DB 상태를 자동으로 되돌려주지는 않습니다.
Martin Kleppmann은 『Designing Data-Intensive Applications』에서 분산 시스템에서는 부분 실패(partial failure)가 기본 전제라고 설명합니다. 네트워크는 언제든 실패할 수 있고, 프로세스는 중간에 종료될 수 있습니다. 이러한 환경에서는 단일 트랜잭션 경계로 모든 것을 묶는 접근이 현실적으로 어렵습니다.
결국 단순 Retry 설정만으로는 분산 환경의 정합성 문제를 해결하기 어렵다는 점을 인정하는 것이 출발점이라고 생각합니다.
DB 커밋과 메시지 발행 사이의 원자성 문제
분산 환경에서 자주 마주하는 문제는 “DB에는 반영되었는데 메시지는 발행되지 않은 상태”입니다. 이 문제는 단순 재시도로 해결되지 않습니다.
Spring의 트랜잭션은 기본적으로 단일 리소스 또는 JTA 기반 글로벌 트랜잭션 범위 안에서 원자성을 보장합니다. 그러나 Kafka와 같은 메시지 브로커까지 하나의 글로벌 트랜잭션으로 묶는 것은 운영 복잡도가 매우 높습니다. 2PC(2 Phase Commit)는 강한 일관성을 제공하지만, 가용성과 확장성을 희생할 수 있습니다.
이 지점에서 등장하는 것이 Transactional Outbox 패턴입니다.
Transactional Outbox 패턴의 필요성
Transactional Outbox는 비즈니스 데이터와 이벤트 레코드를 동일한 DB 트랜잭션 안에 함께 기록하는 방식입니다.
예를 들어 Batch에서 주문 상태를 변경할 때, 다음과 같이 처리합니다.
- 주문 테이블 업데이트
- outbox 테이블에 이벤트 레코드 insert
- 하나의 DB 트랜잭션으로 커밋
이렇게 하면 최소한 “DB 상태와 이벤트 기록” 사이의 원자성은 보장됩니다. 이후 별도의 프로세스가 Outbox 테이블을 읽어 Kafka에 메시지를 발행합니다. 이를 Polling Publisher 방식이라고 하며, CDC(Change Data Capture)를 활용하는 구조도 있습니다.
이 접근은 DB와 메시지 브로커 사이의 직접적인 분산 트랜잭션을 피하면서도, 이벤트 유실을 방지하려는 현실적인 타협이라고 볼 수 있습니다.
다만 여기서 또 하나의 문제가 등장합니다. 메시지가 중복 발행될 수 있다는 점입니다.
멱등성과 Idempotent Consumer
Kleppmann은 분산 시스템에서 at-least-once delivery는 흔한 모델이라고 설명합니다. 메시지는 중복 전달될 수 있으며, 이를 전제로 설계해야 합니다.
Outbox 패턴을 적용하더라도, 메시지 발행 중 네트워크 재시도나 프로세스 재시작으로 인해 동일 이벤트가 두 번 이상 발행될 수 있습니다. 이때 소비자(Consumer)가 멱등하게 설계되어 있지 않다면, 상태가 두 번 변경될 수 있습니다.
따라서 다음과 같은 전략이 필요합니다.
- 이벤트에 고유 ID 포함
- 소비자 측에서 처리 이력 테이블 관리
- Unique 제약 기반 중복 방지
- 상태 전이 검증 로직 추가
이러한 설계는 Batch 재시도 전략과도 연결됩니다. 애플리케이션 레벨에서 멱등성을 확보하지 않으면, 분산 환경에서는 완전한 exactly-once를 기대하기 어렵습니다.
Kafka 공식 문서에서도 exactly-once semantics는 특정 조건 하에서 가능하다고 설명하지만, 이는 프로듀서·브로커·컨슈머 설정이 함께 맞물려야 합니다. 애플리케이션 단에서 멱등성을 고려하지 않으면 의미가 약해질 수 있습니다.
Saga 패턴과 분산 트랜잭션 관리
Retry와 Outbox가 “이벤트 전달” 문제를 다룬다면, Saga 패턴은 “여러 로컬 트랜잭션 간의 일관성 유지”를 다룹니다.
2PC는 모든 참여자가 준비 완료를 선언해야 커밋되는 구조입니다. 그러나 네트워크 장애나 참여자 중단이 발생하면 전체 시스템이 블로킹될 수 있습니다. 이러한 이유로 마이크로서비스 환경에서는 Saga 패턴이 대안으로 자주 언급됩니다.
Saga는 여러 개의 로컬 트랜잭션을 순차적으로 실행하고, 중간에 실패가 발생하면 이미 커밋된 트랜잭션에 대해 보상 트랜잭션(Compensation)을 실행합니다.
Batch가 Saga 환경에서 수행할 수 있는 역할은 다음과 같이 생각해 볼 수 있습니다.
- Saga 시작 이벤트를 발행하는 역할
- 특정 상태의 Saga를 주기적으로 점검하고 복구하는 역할
- 실패한 Saga를 재처리하는 관리 Job 역할
Orchestration 방식에서는 중앙 오케스트레이터가 상태를 관리합니다. Choreography 방식에서는 각 서비스가 이벤트를 수신하며 자율적으로 상태를 전이합니다. 어느 쪽을 선택하든, 단순 Retry만으로는 해결되지 않는 “부분 성공 상태”를 다루어야 합니다.
이 지점에서 Retry는 “아직 커밋되지 않은 작업”에 대한 전략이고, Saga는 “이미 일부 커밋된 분산 작업”을 다루는 전략이라는 차이를 인식하게 됩니다.
DLQ 기반 재처리 설계
Dead Letter Queue 또는 Dead Letter Topic은 일정 횟수 이상 실패한 메시지를 격리하기 위한 구조입니다.
동기 Batch Retry는 동일 실행 흐름 안에서 재시도를 수행합니다. 반면 메시지 기반 아키텍처에서는 소비자가 메시지 처리를 반복 실패하면, 이를 DLQ로 이동시켜 메인 처리 흐름을 보호합니다.
DLQ의 목적은 실패 메시지를 무한히 재시도하는 것을 방지하고, 운영자가 분석 후 재처리할 수 있도록 분리하는 것입니다.
운영 관점에서는 다음을 고려하게 됩니다.
- 몇 회까지 재시도할 것인가
- Backoff 전략은 어떻게 설정할 것인가
- DLQ에 쌓인 메시지는 누가, 언제 재처리할 것인가
- 자동 재처리 Batch를 둘 것인가, 수동 복구로 둘 것인가
이러한 설계는 기술 설정의 문제가 아니라, 조직의 운영 프로세스와도 연결됩니다.
Exactly-once의 현실적 한계
분산 환경에서 exactly-once를 보장하고 싶다는 요구는 자주 등장합니다. 그러나 Kleppmann이 설명하듯, 네트워크 재전송과 장애 복구 과정에서는 중복 요청이 발생할 수 있습니다.
Kafka의 exactly-once semantics는 특정 범위 안에서 유효하지만, 애플리케이션 레벨의 부작용까지 완전히 통제해 주는 것은 아닙니다.
결국 현실적인 접근은 다음과 같은 방향에 가깝습니다.
- at-least-once를 전제로 한다.
- 멱등성을 설계한다.
- 중복을 흡수하는 구조를 만든다.
- 부분 실패를 복구할 수 있는 운영 절차를 마련한다.
이러한 사고 전환이 분산 환경 재처리 전략의 출발점이라고 느끼게 됩니다.
마무리하며
이전 글에서는 Spring Batch 내부의 Retry 전략을 정리했습니다. 이번 글에서는 그 한계를 인정하고, 분산 환경으로 확장했을 때 필요한 설계 요소를 정리해 보았습니다.
Retry는 여전히 중요합니다. 그러나 Retry는 어디까지나 한 단계의 도구일 뿐입니다. DB와 메시지 사이의 원자성 문제는 Outbox로, 분산 상태 전이는 Saga로, 반복 실패 격리는 DLQ로 접근해야 할 수 있습니다.
실무에서는 “어디까지를 하나의 일관성 경계로 볼 것인가”를 계속 고민하게 됩니다. 재처리 전략은 설정 값 몇 개의 문제가 아니라, 시스템이 실패를 어떻게 받아들이고 복구할 것인지에 대한 설계 선택이라고 생각합니다.
'STUDY' 카테고리의 다른 글
- Total
- Today
- Yesterday
- Double-Checked Locking
- DB 인덱스 성능
- Redis 캐시 전략
- spring batch 5
- mybatis
- Cache Aside
- InterruptedException
- Enum 기반 싱글톤
- Eager Initialization
- 캐시 장애
- 스레드 생명주기
- Cache Avalanche
- Spring Batch
- 백엔드 아키텍처
- Java Performance
- 백엔드 성능 튜닝
- 동시성처리
- 트래픽 처리
- Redis 성능 개선
- 트랜잭션 관리
- Redis vs DB
- 캐시와 인덱스
- TTL 설계
- DB 트랜잭션
- Hot Key 문제
- 캐시 성능 비교
- Initialization-on-Demand Holder Idiom
- Cache Penetration
- 백엔드 성능
- 백엔드 성능 설계
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |

