업무 데이터를 DB에 저장하고 다른 시스템에 메시지를 보내는 일을 따로 하면, 둘 중 하나만 성공하는 순간이 반드시 생긴다. 이 틈 때문에 "신청은 접수됐는데 연계 기관에는 안 갔다", "취소했는데 상대 시스템에는 승인으로 남았다" 같은 정합성 사고가 난다. 이 글은 보내는 쪽의 Outbox, 받는 쪽의 Inbox를 함께 써서 메시지를 잃지도 두 번 처리하지도 않게 만드는 설계와 운영 방법을 정리한 것이다.

한눈에 보기
| 문제 | 해결 | 위치 |
|---|---|---|
| DB 커밋과 메시지 발행 중 하나만 성공 | 업무 데이터와 보낼 메시지를 한 트랜잭션에 저장 | 보내는 쪽 Outbox 테이블 |
| 저장된 메시지를 브로커로 옮겨야 함 | 릴레이가 Outbox를 읽어 발행(폴링 또는 CDC) | 릴레이 프로세스 |
| 재시도 때문에 같은 메시지가 두 번 옴 | 메시지 ID를 먼저 기록하고 이미 있으면 건너뜀 | 받는 쪽 Inbox 테이블 |
| 같은 건의 메시지 순서가 뒤바뀜 | 업무 키로 파티션, 버전 번호 비교 | 브로커 파티션 키, 받는 쪽 |

이중 쓰기(dual write)가 깨지는 방식
가장 흔한 코드는 저장하고 바로 보낸다.
@Transactional
public void apply(ApplyRequest req) {
applicationRepository.save(Application.from(req));
kafkaTemplate.send("application-events", event(req)); // 커밋 전에 나간다
}
이 코드가 깨지는 경우는 두 가지다.
- 발행 후 롤백. 메시지는 나갔는데 뒤이은 검증 오류로 트랜잭션이 롤백된다. 상대 시스템에는 존재하지 않는 신청이 생긴다.
- 커밋 후 발행 실패. 발행을 커밋 뒤로 옮겨도(
@TransactionalEventListener(phase = AFTER_COMMIT)) 브로커 장애나 프로세스 종료가 그 사이에 끼면 메시지가 사라진다. 남는 기록이 없어서 다시 보낼 수도 없다.
두 저장소(DB와 브로커)를 하나의 트랜잭션으로 묶는 분산 트랜잭션(XA)은 지원 범위가 좁고 운영 부담이 커서 잘 쓰지 않는다. Outbox는 저장소를 하나로 줄여 문제를 없앤다.
Outbox 테이블 설계
보낼 메시지를 업무 DB 안의 테이블에 행으로 저장한다.
CREATE TABLE outbox (
id VARCHAR(36) PRIMARY KEY, -- 메시지 ID(UUID), 받는 쪽 중복 판정 키
aggregate_type VARCHAR(50) NOT NULL, -- 예: APPLICATION
aggregate_id VARCHAR(50) NOT NULL, -- 예: 신청번호, 파티션 키로도 쓴다
event_type VARCHAR(50) NOT NULL, -- 예: APPLICATION_SUBMITTED
payload TEXT NOT NULL, -- JSON
status VARCHAR(10) NOT NULL DEFAULT 'NEW', -- NEW / SENT / FAILED
retry_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
sent_at TIMESTAMP
);
CREATE INDEX ix_outbox_status_created ON outbox (status, created_at);
- 메시지 ID는 저장할 때 만든다. 릴레이가 만들면 재시도마다 ID가 달라져 받는 쪽이 중복을 알아보지 못한다.
- payload는 그 시점의 사실을 담는다. 받는 쪽이 다시 조회해야 하는 "ID만 있는 메시지"는 조회 시점에 데이터가 바뀌어 있을 수 있다.
- 스키마 버전을 payload에 넣는다. 필드가 바뀌어도 받는 쪽이 구분할 수 있다.
같은 트랜잭션에서 쓰기
업무 저장과 Outbox 저장이 한 트랜잭션에 들어가는 것이 이 패턴의 전부다.
@Transactional
public void apply(ApplyRequest req) {
Application app = applicationRepository.save(Application.from(req));
outboxRepository.save(OutboxMessage.of(
"APPLICATION", app.getId(), "APPLICATION_SUBMITTED", toJson(app)));
} // 둘 다 커밋되거나 둘 다 롤백된다
이제 "업무는 있는데 보낼 기록이 없는" 상태와 "보낼 기록은 있는데 업무가 없는" 상태가 생기지 않는다. 남은 일은 Outbox의 행을 브로커로 옮기는 것뿐이다.
릴레이: 폴링과 CDC
폴링 방식
릴레이가 주기적으로 NEW 행을 읽어 발행하고, 브로커가 받았다고 응답하면 SENT로 바꾼다.
SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE status = 'NEW'
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
FOR UPDATE SKIP LOCKED는 릴레이를 두 대 이상 띄울 때 같은 행을 동시에 집지 않게 한다. PostgreSQL, MySQL 8.0, Oracle이 지원하며, 쓰는 DBMS의 문법은 매뉴얼에서 확인한다.- 구현이 단순하고 DB만 있으면 된다. 대신 폴링 주기만큼 지연이 생기고 DB에 조회 부하가 더해진다.
CDC 방식
Debezium 같은 CDC 도구가 DB의 변경 로그(MySQL binlog, PostgreSQL WAL)를 읽어 Outbox 행이 들어오는 즉시 발행한다. Debezium에는 Outbox 테이블 형식을 그대로 토픽 메시지로 바꾸는 Outbox Event Router가 있다.
- 지연이 짧고 업무 DB에 조회를 더하지 않는다.
- 커넥터, 변경 로그 보관 설정, 장애 복구를 운영해야 한다. 운영 조직이 이를 감당할 수 있을 때 고른다.
전달 보장은 "최소 한 번"이다
릴레이가 발행에 성공하고 SENT로 바꾸기 직전에 죽으면, 다시 떴을 때 같은 행을 또 보낸다. CDC도 위치 기록 전에 죽으면 같은 변경을 다시 읽는다. 따라서 Outbox가 보장하는 것은 잃지 않는 것이지 한 번만 보내는 것이 아니다.
정확히 한 번 처리되는 효과는 받는 쪽이 만든다. 그것이 Inbox다.
Inbox: 받는 쪽에서 중복 걸러내기
받는 쪽은 처리한 메시지 ID를 테이블에 기록한다. 메시지 ID를 기본 키로 두고, 그 삽입과 업무 처리를 한 트랜잭션에 묶는다.
@Transactional
public void onMessage(EventMessage msg) {
if (inboxRepository.insertIfAbsent(msg.id(), msg.type()) == 0) {
return; // 이미 처리한 메시지: 처리 없이 확인 응답
}
receiptService.register(msg.payload());
} // 커밋 후 브로커에 확인 응답(ack)
insertIfAbsent는INSERT ... ON CONFLICT DO NOTHING(PostgreSQL),INSERT IGNORE(MySQL)처럼 영향받은 행 수를 돌려주는 문장으로 만든다. "조회 후 없으면 삽입"은 동시에 두 건이 오면 둘 다 통과한다.- 확인 응답은 커밋 뒤에 보낸다. 먼저 보내면 처리 중에 죽었을 때 메시지를 잃는다.
- 업무 처리가 원래 멱등이면(예: "상태를 승인으로 덮어쓰기") Inbox 없이도 되지만, 건수를 더하거나 문자를 보내는 처리는 멱등이 아니다.
순서와 운영
순서
- Kafka라면
aggregate_id를 메시지 키로 써서 같은 신청 건이 같은 파티션으로 가게 한다. 파티션 안에서는 순서가 지켜진다. - 다른 건끼리의 순서는 보장하지 않는다. 필요 없도록 설계한다.
- 재처리 때문에 오래된 메시지가 늦게 올 수 있다. payload에 버전 번호를 넣고, 받는 쪽은 가진 버전보다 낮으면 버린다.
운영 지표
- 미발행(
NEW) 건수와 가장 오래된 미발행 행의 나이. 나이가 늘면 릴레이나 브로커가 멈춘 것이다. FAILED건수와 재시도 횟수 분포. 재시도 상한을 넘은 메시지는 별도 큐나 테이블로 옮기고 담당자에게 알린다.- Inbox에서 걸러진 중복 건수. 갑자기 늘면 릴레이가 반복해서 죽고 있다는 신호다.
정리
SENT행과 오래된 Inbox 행은 보관 기간을 정해 지운다. 날짜 파티션을 두면 지우기가 가볍다.- Inbox 보관 기간은 브로커가 메시지를 다시 보낼 수 있는 기간보다 길어야 한다. 더 짧으면 늦게 온 중복을 알아보지 못한다.
공공 SI의 연계 송신 테이블도 같은 구조다. 상대 기관이 메시지 큐 대신 API나 파일을 받는다면, 릴레이가 브로커 대신 API를 호출하거나 파일을 만들 뿐 "업무와 보낼 일을 한 트랜잭션에 적는다"는 원칙은 그대로다.
구성요소를 왜 두는가
| 구성요소 | 주로 보는 사람 | 없으면 생기는 일 |
|---|---|---|
| Outbox 테이블 | 보내는 쪽 개발자 | 커밋과 발행 사이 장애에서 메시지 유실 |
| 저장 시점의 메시지 ID | 양쪽 개발자 | 재시도마다 ID가 달라져 중복을 못 거름 |
| 릴레이(폴링·CDC) | 운영 | Outbox에 쌓이기만 하고 나가지 않음 |
| Inbox 테이블 | 받는 쪽 개발자 | 재전송 때 같은 건이 두 번 등록·과금 |
| 업무 키 파티션과 버전 | 양쪽 개발자 | 취소가 승인보다 먼저 처리되는 역전 |
| 미발행 나이 지표 | 운영 | 연계가 멈춘 것을 민원으로 처음 앎 |
자주 하는 실수
- 커밋 후 이벤트 리스너로 충분하다고 본다. 커밋과 발행 사이에 프로세스가 죽으면 남는 기록이 없다.
- 릴레이에서 메시지 ID를 만든다. 재시도할 때마다 새 ID가 붙어 Inbox가 무력해진다.
- Inbox를 "조회 후 삽입"으로 만든다. 같은 메시지가 동시에 두 번 오면 둘 다 처리된다.
- 확인 응답을 처리 전에 보낸다. 처리 중 장애가 나면 브로커는 이미 끝난 줄 안다.
- Outbox를 지우지 않는다. 몇 달 뒤 테이블이 커져 폴링 조회 자체가 느려진다.
도입 점검표
- 업무 저장과 Outbox 저장이 같은 트랜잭션 안에 있다
- 메시지 ID는 Outbox에 저장할 때 만들어지고 바뀌지 않는다
- payload에 스키마 버전과 업무 버전 번호가 있다
- 릴레이를 두 대 띄워도 같은 행을 동시에 보내지 않는다
- 받는 쪽은 메시지 ID를 기본 키로 한 Inbox를 업무 처리와 같은 트랜잭션에 쓴다
- 확인 응답은 받는 쪽 커밋 뒤에 보낸다
- 같은 업무 건은 같은 파티션(또는 같은 순서 단위)으로 간다
- 미발행 건수와 가장 오래된 미발행 나이를 모니터링한다
- 재시도 상한을 넘은 메시지가 따로 모이고 알림이 간다
- Outbox·Inbox 보관 기간이 정해져 있고 Inbox 기간이 재전송 가능 기간보다 길다
Outbox는 보낼 일을 업무와 같은 트랜잭션에 적어 메시지를 잃지 않게 하고, Inbox는 받은 메시지 ID를 처리와 같은 트랜잭션에 적어 두 번 처리하지 않게 한다. 둘을 함께 써야 연계 데이터가 양쪽에서 같은 상태로 남는다.
댓글 0개
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.