← 블로그로 돌아가기

대용량 배치와 외부 연동을 안전하게 만들기: Outbox 패턴 적용기

RabbitMQ 컨슈머 타임아웃을 넘겨 43시간 동안 약 50회 재실행되던 월 배치를, 컨슈머 타임아웃 상향 대신 Transactional Outbox 구조로 바꿔 해결한 과정을 정리했습니다.

월 단위 만료일 배치가 수만 건의 계약을 처리하면서 외부 시스템에 고객 동의정보를 동기 호출하던 구조를 Transactional Outbox 패턴으로 바꾼 과정을 정리했습니다.


1. 배경

보험 영업 시스템에는 개인정보 활용 동의 만료일 배치가 있습니다.

보험 계약을 맺으면 피보험자의 개인정보 활용 동의가 함께 생기고, 계약 만료일이 바뀌면 동의 만료일도 갱신해 새 영업지원 시스템인 Phase2에 알려야 합니다.

  • 수수료 시스템(CMSN)이 매월 계약 상태(해지·취소·실효·부활)를 판단해 계약별 만료일을 계산합니다.
  • CMSN은 계산한 계약 목록을 Redis에 저장하고, 그 Redis 키를 메시지 큐(MQ)인 RabbitMQ로 영업지원 시스템(AIMS)에 전달합니다. 메시지 큐는 두 시스템이 동시에 작업을 끝내지 않아도 되도록, 처리할 내용을 메시지로 보관했다가 수신 시스템에 전달하는 역할을 합니다.
  • AIMS의 MQ 컨슈머는 Redis 키로 계약 목록을 읽어 한 건씩 처리하며, 계약에 연결된 피보험자 동의 이력의 만료일을 새 만료일로 갱신합니다.
  • 갱신된 동의정보는 Phase2에 API로 전송해 동기화합니다.

평소에는 건당 한두 번의 호출이면 충분했습니다. 하지만 월 배치는 한 번에 수만 건을 처리합니다.


2. 문제 상황

기존 구조는 단순했습니다. 계약 하나를 처리할 때마다 DB 갱신 직후 Phase2 API를 동기 호출했고, 메시지 하나에 담긴 전체 처리가 하나의 트랜잭션으로 묶여 있었습니다.

MQ 메시지 수신 → [하나의 트랜잭션: 계약 루프 → DB 갱신 → Phase2 HTTP 호출 → 다음 계약] → ACK

2-1. 컨슈머 타임아웃을 넘긴 배치가 끝없이 다시 실행됐다

이 구조에서 배치 1회 실행에는 약 50분이 걸렸습니다. 문제는 RabbitMQ의 컨슈머 타임아웃이 30분이라는 점이었습니다.

컨슈머 타임아웃은 메시지를 가져간 컨슈머가 정해진 시간 안에 처리 완료 응답(ACK)을 보내지 않으면, 브로커가 그 컨슈머에 장애가 생겼다고 보고 메시지를 회수하는 제한 시간입니다. 회수된 메시지는 다시 전달됩니다.

그 결과 다음 일이 반복됐습니다.

메시지 수신 → 배치 처리 시작 (약 50분 필요)
  → 30분 경과: ACK 없음 → 브로커가 메시지 회수
  → 메시지 재전달 → 같은 배치를 처음부터 다시 실행
  → 30분 경과: 다시 회수 → ...

결국 배치는 정상 완료되지 못한 채 43시간 동안 약 50회 재실행됐습니다.

2-2. 함께 드러난 문제들

문제증상
반복 재실행1회 처리 시간(약 50분)이 컨슈머 타임아웃(30분)보다 길어, 처리 도중 메시지가 회수되고 처음부터 다시 실행됨
같은 큐의 다른 업무 정체배치가 컨슈머를 계속 점유하는 동안 같은 큐의 다른 업무 메시지도 처리되지 못함
장애 전파계약마다 Phase2 응답을 기다리므로 Phase2 지연·실패가 배치 전체의 지연·실패로 번짐
트랜잭션 결합전체 처리가 하나의 트랜잭션이라, 중간에 오류가 나거나 처리가 끊기면 내부 DB 변경까지 함께 취소될 수 있음
부분 실패 추적 불가어떤 계약이 실패했는지 남지 않아 수동 복구가 어려움

핵심은 내부 DB 갱신이 외부 API의 응답을 기다려야 하고, 외부 통신의 실패가 내부 처리에도 영향을 주는 구조라는 것이었습니다. 배치의 성공 여부가 우리 시스템이 아닌 외부 시스템의 상태에 좌우되는 구조였습니다.


3. 대안 검토: 컨슈머 타임아웃을 늘리면 되지 않을까?

가장 빠른 방법은 컨슈머 타임아웃을 50분보다 길게 늘리는 것이었습니다. 설정 하나만 바꾸면 당장의 재실행은 멈출 수 있습니다.

하지만 컨슈머 타임아웃은 응답하지 않는 컨슈머를 찾아내기 위한 안전장치이기도 합니다. 타임아웃을 늘리면 다음 문제가 생깁니다.

  • 장애 감지·복구 지연: 컨슈머에 실제 장애가 생겨도, 늘린 시간만큼 메시지가 묶여 있다가 회수됩니다. 장애를 알아차리고 다른 컨슈머가 이어받는 시점이 그만큼 늦어집니다.
  • 브로커 메모리 부담: ACK되지 않은 메시지는 브로커가 계속 보관해야 하므로, 처리가 길어질수록 브로커 메모리에 쌓입니다.
  • 배포·재기동 시 재전달 지연: 서버를 배포하거나 재기동할 때도 처리 중이던 메시지를 다시 보낼지 판단하는 시점이 늦어집니다.

그래서 타임아웃 상향 대신, MQ 처리 구간 자체를 타임아웃 안에 끝나도록 구조를 바꾸는 방향을 선택했습니다.


4. 해결 전략

4-1. 내부 작업과 외부 연동의 분리 — Transactional Outbox

여기서 트랜잭션은 여러 DB 변경을 하나의 처리 단위로 묶어, 성공하면 함께 확정하고 실패하면 함께 취소하는 범위입니다. 변경을 확정하는 것을 커밋, 취소하는 것을 롤백이라고 합니다.

외부 API를 이 처리 단위 안에서 직접 호출하는 대신, 내부 DB 갱신과 “외부에 보내야 할 내용”의 저장을 함께 확정하도록 바꿨습니다. 전송할 내용을 임시로 보관하는 DB 테이블이 Outbox(아웃박스)입니다. 별도 전송 작업이 이 테이블을 읽어 외부 API를 호출하므로, 내부 배치가 매번 외부 응답을 기다리지 않아도 됩니다.

[배치 트랜잭션]
DB 갱신 + 아웃박스 INSERT  →  커밋 (외부 호출 없음)

[백그라운드 전송기]
아웃박스에서 대기 건을 꺼내 → Phase2 HTTP 호출 → 상태 갱신

아웃박스에는 각 건이 대기 중인지, 전송 중인지, 완료됐는지 기록합니다. 성공·실패 결과에 따라 상태를 바꾸는 규칙을 정해 관리하며, 이를 상태 머신이라고 부릅니다.

P(대기) → R(처리중) → D(완료)
                   ↘ F(1차 실패) → 재시도 → D 또는 X(최종 실패)
  • 첫 전송에 실패하면 ’1차 실패(F)’로 기록하고 한 번 더 시도합니다. 다시 실패하면 ’최종 실패(X)’로 남겨 수동 확인 대상으로 분리합니다.
  • 각 건에는 시도 횟수와 마지막 오류 메시지를 함께 저장해, 최종 실패 건을 확인할 때 원인을 바로 볼 수 있게 했습니다.

  • 같은 고객과 동의이력의 조합을 두 번 저장하지 못하도록 UNIQUE(CSTMR_NO, AGRMN_SEQ) 제약을 둡니다. 중복 요청이 들어와도 DB가 같은 조합의 추가 저장을 막습니다.
  • 전송 상태와 아웃박스 번호를 함께 사용하는 복합 인덱스 (SYNC_STATE, OUTBOX_SEQ)를 만듭니다. 전송 대상을 찾을 때 테이블 전체를 반복해서 확인하는 부담을 줄이기 위한 설정입니다.

이렇게 하면 외부 시스템이 응답하지 않더라도 내부 DB 갱신과 전송 요청 저장을 먼저 끝낼 수 있습니다. 전송하지 못한 건은 아웃박스에 남기고 이후 다시 시도합니다.

4-2. 계약 단위 독립 트랜잭션 — 부분 실패 격리

수만 건을 하나의 트랜잭션으로 처리하면, 1건의 오류로 전체가 롤백됩니다. 이를 막기 위해 계약 1건마다 REQUIRES_NEW를 적용했습니다. 바깥 처리와 별개로 DB 변경을 확정하거나 취소하는 트랜잭션을 새로 시작하므로, 한 계약의 실패가 다른 계약의 DB 변경까지 취소하지 않도록 처리 범위를 나눌 수 있습니다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOne(CntrcVO cntrc, boolean useOutbox) { ... }
  • 특정 계약에서 예외가 나도 그 계약만 롤백, 나머지는 커밋 유지
  • 실패 건은 별도 실패 이력 테이블(TB_EXPRT_BATCH_FAIL)에 계약번호·오류 메시지와 함께 기록 → 수동 복구 대상이 명확해짐

4-3. 멱등성 확보 — 재실행해도 안전하게

멱등성은 같은 요청을 여러 번 처리해도 결과가 한 번 처리했을 때와 같도록 만드는 성질입니다. 배치가 중단되어 재실행되거나 메시지 큐에서 같은 메시지를 다시 받을 수 있으므로, 이미 처리한 계약인지와 동의이력이 있는지를 각각 확인했습니다.

  1. 계약 확인: 계약의 만료일이 이미 변경하려는 날짜와 같으면 다시 갱신하지 않고 다음 계약으로 넘어갑니다.
  2. 동의이력 확인: 해당 계약·고객·목표 만료일에 해당하는 유효한 이력이 이미 있으면 같은 이력을 다시 만들지 않습니다.

실제 운영에서 같은 메시지가 다시 처리됐을 때, 이미 반영된 건은 Skip... 로그를 남기고 넘어가는 것을 확인했습니다. 여기서 Skip은 처리를 빠뜨렸다는 뜻이 아니라, 중복 반영을 피하기 위해 의도적으로 건너뛰었다는 뜻입니다.

4-4. MQ 리스너와 전송 루프의 분리 — @Async

MQ 리스너는 메시지를 받아 처리하는 코드이고, 컨슈머 스레드는 그 코드를 실행하는 작업 흐름입니다. ACK(Acknowledgement)는 메시지 큐에 “이 메시지의 처리를 마쳤다”고 알리는 응답입니다.

이 작업 흐름에서 외부 전송까지 모두 기다리면 ACK도 늦어집니다. 전송기에 @Async를 적용해 외부 API 호출을 별도 작업으로 실행하고, 메시지를 받은 쪽은 내부 배치와 아웃박스 저장까지 마친 뒤 완료를 알리도록 나눴습니다.

  • 메시지를 받은 쪽은 내부 DB 처리와 아웃박스 저장이 끝나면 외부 전송 완료를 기다리지 않고 ACK를 보냅니다. 메시지별 처리 결과는 MQ 로그 테이블에 기록합니다.
  • 외부 전송은 별도 스레드에서 수행합니다. 아웃박스에 남은 전송 대상을 100건씩 읽어 처리하고, 대상이 없어질 때까지 반복합니다.
  • AtomicBoolean으로 실행 상태를 관리해, 같은 서버 안에서 전송기가 중복 실행되지 않도록 했습니다.
  • 서버가 비정상 종료되어 ‘처리 중(R)’으로 남은 건은, 다음 전송기 실행 때 30분이 넘게 ‘처리 중’으로 남아 있는지 확인한 뒤 ‘대기(P)’ 상태로 되돌려 재처리하도록 했습니다.

5. 적용 결과

월 배치에 적용해 확인한 결과입니다.

  • 반복 재실행 해결: 43시간 동안 약 50회 반복되던 재실행 문제가 사라졌습니다.
  • MQ 처리 구간: 만료일 갱신·동의 이력 등록·Outbox 적재까지를 10분 이내에 마쳐, 컨슈머 타임아웃(30분) 안에 완료됩니다.
  • Phase2 전송: 백그라운드 전송기가 약 1시간 20분 안에 모두 전송합니다. 이 시간은 MQ 처리와 분리되어 컨슈머를 점유하지 않습니다.
  • 메시지 처리 완료 응답(ACK): 내부 배치가 끝나면 외부 전송을 기다리지 않고 완료를 알립니다. 같은 메시지가 다시 오더라도 이미 반영한 건인지 확인하고 중복 처리를 건너뜁니다.
  • 전송 현황 확인: 아웃박스의 상태별 건수를 조회하면 완료·실패·미처리 건이 각각 얼마나 남았는지 확인할 수 있습니다.
SELECT SYNC_STATE, COUNT(*) FROM TB_AGRMN_SYNC_OUTBOX GROUP BY SYNC_STATE;
-- D: 완료, X: 최종 실패(관리자 확인 대상), P/R/F 잔존 없음 → 정상 종료
  • 부분 실패 복구: 실패 이력에서 계약번호와 오류 메시지를 확인해 복구할 대상을 찾습니다. 해당 계약을 수동으로 연동 처리했는지 나타내는 값(MANL_SYNC_YN)도 함께 확인해 후속 처리 상황을 추적합니다.

6. 회고

잘한 점

  • 컨슈머 타임아웃을 늘려 증상을 덮는 대신, MQ 처리 구간이 타임아웃 안에 끝나도록 구조를 바꾼 판단이 맞았다고 생각합니다. 브로커의 장애 감지 기능은 그대로 유지하면서 재실행 문제를 해결할 수 있었습니다.
  • 외부 시스템이 정상적으로 응답하는지와 내부 배치가 완료되는지를 분리한 것이 가장 큰 수확이었습니다. 내부 DB 처리와 외부 전송을 따로 확인할 수 있어, 장애가 발생했을 때 어느 단계부터 다시 처리해야 하는지 판단하기 쉬워졌습니다.
  • 계약과 동의이력을 각각 확인한 덕분에, 실제로 같은 MQ 메시지가 두 번 처리됐을 때도 데이터가 중복 반영되지 않았습니다. 대용량 배치에서 “재실행 가능함”은 선택이 아니라 필수라는 걸 체감했습니다.

아쉬운 점과 남은 과제

  • 현재 전송기를 시작하는 시점은 배치가 끝났을 때뿐입니다. 전송 작업이 예외로 중단되거나 서버가 재시작되면, 남은 건은 다음 배치가 전송기를 다시 시작할 때까지 기다리게 됩니다. 이후에는 @Scheduled로 일정 시간마다 미전송 건을 확인해 처리하거나, 서버가 켜질 때 남은 건을 찾아 전송을 재개하는 기능을 추가할 수 있습니다. 앞에서 설명한 상태 복구는 전송기가 다시 실행돼야 동작하며, 전송기를 스스로 재시작하는 기능과는 다릅니다.
  • AtomicBoolean은 같은 서버 안에서 전송기가 중복 실행되는 것을 막습니다. 여러 서버에서 전송기를 실행하는 경우에는 같은 건을 동시에 선택할 수 있으므로, 전송 대상을 한 작업만 가져가도록 추가 제어가 필요합니다. 현재는 전송할 건을 ‘처리 중’으로 표시하는 UPDATE와 내용을 읽는 SELECT가 분리되어 있어, 여러 전송기가 각각 어떤 건을 맡았는지 구분하는 장치가 부족합니다. 이후에는 전송할 건을 맡을 때 전송기의 고유 식별값을 함께 기록하고, 자신이 맡은 건만 읽도록 구성할 수 있습니다. 또는 처리할 행에는 DB 잠금을 걸고, 다른 작업이 이미 잠근 행은 건너뛰는 FOR UPDATE SKIP LOCKED 방식으로 보완할 수 있습니다.

배운 것

대용량 배치에서 외부 연동을 다룰 때의 원칙을 정리하면 이렇습니다.

  1. 내부 DB 처리와 외부 전송을 분리할 것 — DB 갱신과 전송 요청을 함께 저장하고, 외부 API는 별도 작업에서 호출합니다.
  2. 한 건의 실패가 전체를 취소하지 않게 할 것 — 계약마다 DB 처리 범위를 나누고 실패한 건을 따로 기록합니다.
  3. 같은 요청을 다시 처리해도 안전하게 만들 것 — 이미 반영한 데이터인지 확인해 중복 변경을 막습니다.
  4. 현재 처리 상황을 DB에 남길 것 — 상태별 건수를 조회해 완료 여부와 복구 대상을 확인할 수 있게 합니다.