DLQ 에 쌓인 메시지, 원래 토픽에 다시 넣으면 끝일까요?

Kafka 소비자가 어떤 메시지를 계속 처리하지 못하면 그 메시지를 다른 곳으로 옮겨 두어야 뒤의 메시지가 처리됩니다. 옮겨 두는 토픽을 DLQ(Dead Letter Queue)라고 부릅니다. PeekCart 에서는 소비자가 처음 실패한 뒤 1초, 5초, 30초를 기다리며 세 번 더 시도하고, 그래도 실패하면 Spring Kafka 의 DeadLetterPublishingRecoverer 가 그 레코드를 원래 토픽 이름에 .dlq 를 붙인 토픽으로 보냅니다. payment.completed 에서 실패한 메시지는 payment.completed.dlq 로 갑니다.

PeekCart 는 주문(Order), 상품(Product), 결제(Payment), 알림(Notification) 네 서비스가 Kafka 토픽으로만 메시지를 주고받습니다. 같은 토픽을 여러 서비스가 읽을 때는 서비스마다 consumer group 을 따로 둡니다. Kafka 는 group 마다 어디까지 읽었는지(offset)를 따로 기억하므로 한 group 이 실패해도 다른 group 은 계속 읽어 나갑니다. 예를 들어 payment.completed 는 Order, Product, Notification 이 각자의 group 으로 읽고 셋 중 누가 실패하든 실패한 메시지는 같은 payment.completed.dlq 로 모입니다.

DLQ 로 옮겼다고 일이 끝나지는 않습니다. Product 가 payment.completed 처리에 실패하면 주문의 결제는 끝났는데 재고 예약은 확정되지 않은 채로 남습니다. 각 서비스는 이런 실패를 실패 기록 테이블(dead_letter_records, 이하 원장)에 한 행씩 남기고, 한 행은 “어느 토픽, 파티션, offset 의 레코드가 어느 consumer group 에서 실패했나”로 구분합니다. DLQ 토픽은 여러 서비스가 함께 쓰므로, 각 서비스는 Spring Kafka 가 레코드 헤더에 남긴 실패 group 을 보고 자기 group 의 실패만 자기 원장에 적재합니다. 실패 group 을 읽지 못한 레코드는 원래 토픽을 발행한 서비스가 따로 격리해 둡니다. 이 글에서는 이 실패가 원장에 #1 로 남았다고 보고 끝까지 따라가겠습니다. #1 은 Product 의 재고 확정 소비자 코드 경로를 근거로 만든 예시이고 실제로 겪은 장애는 없습니다.

DLQ 로 옮겨지기까지

결제 완료 메시지 하나를 세 서비스가 각자 읽습니다. Product 만 실패할 때 메시지가 어디로 가고, 각 group 의 읽은 위치(offset)가 어떻게 바뀌는지 따라가 보세요.

흐름을 설명하려고 만든 모형입니다. offset 값과 메시지 두 건은 예시이고, 재시도 간격은 시간을 압축해 보여 줍니다. DLQ 를 읽어 원장에 적재하는 소비자의 세부(어느 서비스가 어떤 행을 가져가는지)와 Payment 의 발행 과정은 그리지 않았습니다.

원장에는 실패가 기록될 뿐이었고 #1 의 메시지를 다시 보내는 기능은 없었습니다. 원인을 고친 뒤 다시 처리하는 방법으로는 이것이 가장 먼저 떠오릅니다.

DLQ 에 쌓인 메시지를 원래 토픽에 다시 넣는다.

한 줄이면 될 것 같았지만 실제로 만들려고 하니 미리 답해야 할 질문이 네 개 나왔습니다. 몇 개는 기능을 연 뒤에는 되돌리기 어려웠습니다. 결론부터 적으면 이렇습니다.

  • 어디로 넣나: 원래 토픽에 넣되 중복 발행은 받아들이고(at-least-once), 처리가 두 번 일어나는 것은 소비자의 처리 이력으로 막습니다.
  • 원본이 남아 있나: 보존 기간을 토픽 선언에 적고 재발행 직전마다 원본이 있는지 확인합니다.
  • 다시 실패하면: 재실패를 원래 사건에 연결하는 경로를 재발행 API 보다 먼저 만듭니다.
  • 연 뒤 되돌릴 수 있나: 스위치를 끈 채 배포하고 롤백 전에는 스크립트로 남은 재발행이 없는지 확인합니다.

여기서 다루는 재발행(replay) 기능은 실제 클러스터에서 한 번도 열린 적이 없습니다. 운영 트래픽이 없는 포트폴리오 프로젝트이고 기능 스위치는 지금도 네 서비스 모두 꺼져 있습니다. 아래에서 “동작한다”고 쓴 내용은 두 가지 방법으로 확인했습니다. Testcontainers 로 실제 MySQL 과 Kafka 를 띄운 통합테스트, 그리고 PR 마다 핵심 조건을 몇 개씩 골라 손으로 지우거나 뒤집었을 때 테스트가 실패하는지 본 변이 검사입니다. 변이 도구로 모든 조건을 검사한 것은 아닙니다.

어떻게 접근할 수 있을까요?

처음에는 네 질문에 이렇게 답했습니다.

어디로 넣을까요? 원래 토픽에, 한 번만

Kafka 토픽에 넣은 메시지는 그 토픽을 구독하는 모든 consumer group 이 받습니다. 원래 토픽에 다시 넣는 방식으로는 실패한 group 하나에만 보낼 수 없습니다. #1 의 메시지를 payment.completed 에 다시 넣으면 실패했던 Product 뿐 아니라 이미 처리를 끝낸 Order 와 Notification 도 같은 메시지를 다시 받습니다.

이 경우에 대비한 장치는 이미 있었습니다. 각 서비스의 소비자는 메시지를 처리하기 전에 처리 이력 테이블(processed_events)에서 (event_id, consumer_group) 쌍을 확인하고 처음 보는 쌍일 때만 이력을 남긴 뒤 비즈니스 로직을 실행합니다. 이력 기록과 비즈니스 로직은 같은 트랜잭션에서 실행되므로, 처리에 실패하면 이력도 함께 롤백됩니다. 그래서 #1 을 처리하지 못한 Product 에는 이력이 남지 않습니다. 다시 넣은 메시지의 eventId 가 원래 것과 같다면 Order 와 Notification 은 이미 처리한 이벤트로 보고 건너뛰고, 이력이 없는 Product 만 실제로 처리합니다.

남은 걱정은 같은 메시지를 두 번 보내는 일이었습니다. 기존 계획에서는 원장 행에 상태를 두고 REPLAY_REQUESTED 로 바꾸고, 발행한 뒤 REPLAY_PUBLISHED 로 바꿔서 재발행이 한 번만 일어나게 하기로 했습니다.

원본이 남아 있을까요? 원본 토픽에서 읽는다

무엇을 다시 보낼지도 정해야 했습니다. 원장에도 본문 컬럼이 있지만 진단용이라 8000자를 넘으면 잘려서 저장됩니다. 다시 보낼 원본은 원장에 기록된 위치(토픽, 파티션, offset)에 남아 있는 원래 레코드로 정했습니다. 원장의 사본은 쓰지 않습니다.

그러려면 그 레코드가 Kafka 에 아직 남아 있어야 합니다. Kafka 는 누가 읽었는지와 상관없이 보존 기간(retention)이 지난 레코드를 지웁니다. 서비스 설정에는 “Kafka 토픽 보존 7일”과 “DLQ replay window 7일”이 있었고 둘 다 처리 이력을 며칠 보관할지 정하는 근거로 쓰이던 값입니다. replay window 는 재발행을 허용하는 기간입니다. 이미 처리한 group 이 재발행분을 건너뛰려면 처리 이력이 남아 있어야 하므로, 처리 이력 보관 기간은 replay window 보다 길어야 합니다. 처음에는 브로커의 보존 기간이 어디에도 설정돼 있지 않다고 보고 토픽마다 보존 기간을 설정하려 했습니다.

다시 실패하면 어떻게 될까요? 진입점부터, 연결은 나중에

원인을 고쳤다고 생각했는데 재발행한 메시지가 또 실패할 수 있습니다. 그러면 그 메시지도 DLQ 로 가고 원장에는 새 위치로 #2 가 생깁니다. #1 과 #2 를 연결하지 않으면 #1 은 재발행했으니 끝난 것처럼 보이고 #2 는 별개의 실패처럼 보입니다.

재실패를 원래 사건에 연결하는 경로(correlation)가 필요했습니다. 기존 계획은 재발행을 시작하는 진입점(관리자가 호출하는 API)을 먼저 열고 연결 경로는 다음 PR 에서 붙이는 순서였습니다.

연 뒤에 되돌릴 수 있을까요? 앞 단계로 되돌리면 된다

작업은 여러 PR 로 나눠 배포할 예정이었습니다. 새 컬럼은 전부 비워 둘 수 있게(nullable) 추가하고 구버전 코드가 그 컬럼을 몰라도 동작하도록 했습니다. 그래서 어느 단계에서 문제가 생기든 앞 단계 이미지로 되돌리면 된다고 보았습니다.

네 답은 각각 그럴듯해 보였지만 코드와 하나씩 맞춰 보니 넷 모두 그대로는 쓸 수 없었습니다.

해 보려니 이런 문제가 있었습니다

어디로: “한 번만 보낸다”는 지킬 수 없었습니다

상태를 두 단계로 바꾸는 방식에는 crash window 가 있습니다. 원장을 REPLAY_REQUESTED 로 바꾸고 메시지를 보낸 뒤, REPLAY_PUBLISHED 로 바꾸기 전에 프로세스가 죽는 경우입니다. 다시 뜬 프로세스는 REPLAY_REQUESTED 만 보고는 메시지가 나갔는지 알 수 없습니다. 다시 보내면 중복이고 보내지 않으면 유실일 수 있습니다.

다음으로 이미 쓰고 있던 transactional outbox 를 떠올렸습니다. 보낼 메시지를 DB 의 outbox 테이블에 먼저 쓰고 별도 poller 가 그 행을 읽어 Kafka 로 보내는 방식입니다. poller 코드에도 같은 crash window 가 있었습니다.

kafkaTemplate.send(buildRecord(event)).get(publishTimeout.toMillis(), TimeUnit.MILLISECONDS);
publishSuccessTimer.record(System.nanoTime() - publishStartNanos, TimeUnit.NANOSECONDS);
event.markPublished();
outboxEventRepository.save(event);

브로커의 ack 를 받은 뒤에 DB 에 PUBLISHED 를 따로 저장합니다. 그 사이에 프로세스가 죽으면 행은 PENDING 으로 남고 다음 주기에 같은 메시지가 다시 나갑니다. 이 구성에는 DB 커밋과 브로커 ack 를 원자적으로 묶을 방법이 없었습니다. 발행한 뒤 상태를 따로 저장하는 outbox poller 라면 어디서든 같은 crash window 가 생깁니다. 설계를 바꿔도 중복 발행을 0으로 만들 수는 없었습니다.

중복이 생겨도 멱등 처리가 막아 준다고 기대할 수 있지만 여기에도 조건이 두 개 있었습니다.

  • 처리 이력은 영원히 남지 않습니다. 보관 기간 7일이 지나면 하루 한 번 도는 정리 작업이 지웁니다. 그 뒤에 다시 보내면 이미 처리했던 Order 와 Notification 도 처음 보는 이벤트로 처리합니다.
  • 멱등 검사는 eventId 가 같을 때만 걸립니다. 다시 보내면서 새 eventId 를 붙이면 모든 group 이 처음부터 다시 처리합니다.

발행 규칙도 걸렸습니다. PeekCart 에는 “토픽 하나에는 발행하는 서비스가 하나”라는 규칙이 있어서 payment.completed 에 쓰는 서비스는 Payment 하나뿐입니다. 그런데 #1 을 다시 보낼 근거는 그 메시지를 읽다가 실패한 Product 의 원장에 있습니다. 소비하다 실패한 행의 원래 토픽은 언제나 다른 서비스가 발행한 토픽이고, Product 원장에 남는 자기 토픽의 행은 실패 group 을 읽지 못해 격리한 행뿐이라 애초에 재발행 대상이 아닙니다. 규칙과 재발행을 함께 지키면 다시 보낼 수 있는 행이 하나도 없었습니다. 남은 선택지는 Product 에 규칙의 예외를 주거나, 원래 발행 서비스인 Payment 에 재발행을 맡기는 것이었습니다.

원본: “보존 기간이 설정돼 있지 않다”는 절반만 맞았습니다

코드와 배포 설정을 찾아보면 토픽 선언(NewTopic)에도, 브로커 환경 변수에도 보존 기간 설정이 하나도 없었습니다. 선언만 보면 설정이 없는 것이 맞습니다.

레코드가 실제로 며칠 남는지는 다른 질문이었습니다. Kafka 는 보존 기간을 따로 정하지 않으면 기본값 log.retention.hours=168, 즉 7일을 쓰므로 레코드는 이미 최소 7일 동안 남고 있었습니다. 그러니 “보존 기간을 7일로 설정한다”는 작업은 동작을 하나도 바꾸지 않습니다. 선언값과 실효값을 나눠 보지 않으면 아무것도 바뀌지 않은 작업이 “보존 기간 설정 완료”로 보고됩니다. 실제로 고칠 것은 아무도 선언하지 않은 Kafka 기본값에 기대고 있다는 점이었습니다.

그 기본값이 어디서 오는지도 따로 확인했습니다. 프로젝트와 같은 환경 변수로 브로커를 띄우고 kafka-configs.sh --describe --all 로 조회하면 log.retention.hours=168 의 출처는 Kafka 에 내장된 기본값(DEFAULT_CONFIG)으로 나옵니다. 이미지 안의 설정 파일에도 같은 168이 들어 있지만 이 이미지는 환경 변수로 브로커를 구성하면 그 파일을 쓰지 않습니다. 환경 변수 없이 띄운 브로커에서만 출처가 설정 파일(STATIC_BROKER_CONFIG)로 나왔습니다. 이번에는 두 값이 같아서 결과도 같았지만 이미지의 설정 파일만 보고 보존 기간을 알아내려 하면 두 값이 다른 이미지에서는 틀린 답을 얻습니다. 실효값과 출처는 브로커에 직접 조회해야 확실합니다.

# 프로젝트와 같은 환경 변수로 띄운 브로커
log.retention.hours=168 sensitive=false synonyms={DEFAULT_CONFIG:log.retention.hours=168}
log.segment.bytes=1073741824 sensitive=false synonyms={DEFAULT_CONFIG:log.segment.bytes=1073741824}

# 환경 변수 없이 띄운 브로커
log.retention.hours=168 sensitive=false synonyms={STATIC_BROKER_CONFIG:log.retention.hours=168, DEFAULT_CONFIG:log.retention.hours=168}

디스크 용량을 계산하다가 함정이 하나 더 나왔습니다. Kafka 브로커의 디스크(PVC)는 1 GiB 였고 파티션마다 크기 상한 retention.bytes 를 두면 될 것처럼 보였습니다. 하지만 Kafka 는 파티션 로그를 세그먼트 파일로 나눠 쓰고 크기 기준으로 지울 때도 닫힌 세그먼트만 지웁니다. 지금 쓰고 있는 활성 세그먼트는 상한과 상관없이 계속 커지고 세그먼트 크기 기본값(log.segment.bytes)은 1 GiB 입니다. 시간 기준 삭제도 같은 단위로 일어납니다. 세그먼트 안의 가장 늦은 timestamp 가 보존 기간을 넘어야 그 세그먼트가 지워지므로, 세그먼트를 닫는 주기(segment.ms, 기본 7일)만큼 레코드가 더 남을 수 있습니다. 세그먼트 크기를 함께 정하지 않으면 retention.bytes 를 아무리 작게 정해도 파티션 하나가 디스크 전체보다 커질 수 있었습니다.

retention.bytes 를 두어도 파티션이 커지는 이유

한 파티션에 1 MiB 씩 메시지를 씁니다. 세그먼트 크기를 바꿔 가며 파티션 크기가 retention.bytes(8 MiB) 근처에 머무는지 보세요.

업무 토픽 한 파티션을 1 MiB 단위로 단순화한 모형이고, 쓰기 속도는 예시입니다. 실제 브로커는 삭제 검사를 주기적으로(기본 5분) 하고 지운 세그먼트도 잠시 뒤에 실제로 삭제하지만, 장면에서는 쓰기 직후 바로 검사합니다. 시간 기준 삭제(7일)도 같은 세그먼트 단위로 일어나지만 그리지 않았고, 인덱스 파일도 생략했습니다. segment.bytes 1 GiB 쪽은 20 MiB 이후를 그리지 않습니다.

여유도 부족했습니다. 기동할 때 하는 설정 검증은 “처리 이력 보관 기간이 각 근거 기간 이상인가”였고 보관 7일과 replay window 7일은 등호로 통과했습니다. 서비스마다 시계가 조금씩 다르고 이력 정리 작업은 하루 한 번 돕니다. 등호인 상태에서는 그만큼 window 의 끝이 먼저 잘려 나가므로 7일 replay window 는 실제로는 보장되지 않았습니다.

다시 실패하면: 순서, 대조할 값, 그리고 JPA

먼저 순서입니다. 진입점을 먼저 열고 연결 경로를 나중에 붙이면 그 사이에 재발행한 #1 이 또 실패할 수 있습니다. 그 실패는 #1 과 연결되지 않은 #2 가 됩니다. 사건은 하나인데 원장에는 미결이 두 건으로 보이고 #1 은 재발행했으니 해결된 것처럼 보입니다. 재실패가 날 때마다 미결 행이 하나씩 늘어납니다.

진입점만 먼저 열면

연결 경로 없이 재발행을 열었다고 가정합니다. 사건은 하나인데 원장의 미결 건수가 어떻게 바뀌는지 보세요.

원장 행의 식별자는 위치와 consumer group 등 여러 컬럼이지만 장면에서는 행 번호로 줄였고, 발행 상태는 그리지 않았습니다. 재실패를 연결할 때 실제로는 아홉 가지 값을 대조하지만 "대조 통과" 한 번으로 표시했습니다. 숫자는 예시 모형이고, 중복분은 at-least-once 로 같은 재발행이 두 번 나간 경우를 예로 든 것입니다. 이 순서는 계획 단계에서 바뀌어 실제로 배포된 적이 없는 가정 경로입니다. 연결 경로를 먼저 만든 결과는 해결책을 다루는 절에서 같은 장면으로 다시 봅니다.

이 순서 문제는 계획 단계에서 드러나 바로 바뀌었고 진입점만 먼저 열린 상태로 배포된 적은 없습니다. 무엇을 기준으로 #1 과 #2 를 연결할지 정하는 데 더 오래 걸렸습니다. 재발행할 때 메시지 헤더에 원래 사건 번호와 시도 번호를 실어 보내면 재실패가 DLQ 에 도착했을 때 그 헤더로 #1 을 찾을 수 있습니다. 다만 헤더는 누구나 쓸 수 있는 값이라 DB 에 남은 값과 대조해야 했습니다.

outbox 행은 발행 뒤 보관 기간이 지나면 지워지는데 미결 사건은 몇 주라도 남을 수 있으므로, 기존 계획에서는 대조 기준을 원장에 두었습니다. 그런데 같은 계획의 대조 목록에 “이 레코드가 재발행용인가”(record_kind=REPLAY)라는, outbox 에만 있는 값이 들어 있었습니다. 원장을 기준으로 한다는 원칙과 맞지 않는 값이었습니다.

그 값을 빼자 다른 문제가 보였습니다. 남은 대조 값(시도 번호, 서비스, group, 원래 사건 id, 토픽, eventId, key, timestamp)에는 메시지 본문을 확인하는 값이 하나도 없었습니다. 같은 eventId 와 key 를 달고 본문만 다른 메시지가 그 토픽에서 실패하면 모든 대조를 통과해 다른 사건에 붙고 이미 종결된 사건을 다시 열 수 있었습니다. 원장의 본문은 8000자에서 잘려 저장되므로 비교 대상으로도 쓸 수 없었습니다.

구현에서는 JPA 함정 두 개가 나왔습니다. 둘 다 DB 행 잠금은 얻었는데 자바 객체는 잠금 이전 상태로 남아 있는 경우입니다.

첫 번째는 사건을 종결하는 코드였습니다. 처음 구현은 #1 을 엔티티로 읽은 뒤 SELECT ... FOR UPDATE 로 잠갔습니다. Hibernate 는 이미 영속성 컨텍스트에 있는 엔티티를 잠금 조회로 다시 읽어도 DB 잠금만 얻고 객체 상태는 새로 채우지 않습니다. 잠금을 기다리는 동안 다른 트랜잭션이 #1 을 먼저 종결해도 이 트랜잭션은 영속성 컨텍스트에 있던 이전 상태를 보고 상태를 바꿔 앞선 종결을 덮어썼습니다. 머지 전에 고친 실제 버그였습니다.

잠금은 얻었는데 객체는 이전 상태

왼쪽은 트랜잭션 A 의 영속성 컨텍스트, 오른쪽은 DB 입니다. 두 쪽의 #1 상태가 언제 달라지는지 보고, 순서를 바꾸면 어떻게 되는지 비교해 보세요.

본문의 재현 실험을 옮긴 모형입니다. 재현과 같이 잠금을 기다리는 경합은 만들지 않았고, "먼저 읽기, 다른 트랜잭션 커밋, 잠금 조회" 순서로 같은 상태를 보여 줍니다. 사건 상태 외의 컬럼과 SQL 의 세부는 그리지 않았습니다.

두 번째는 재실패를 원래 사건에 연결하는 코드였습니다. 처음에는 #1 을 잠그고 대조한 다음 #2 를 INSERT 하고 #1 을 다시 열었는데, INSERT 에 쓴 리포지토리 메서드가 이렇게 선언돼 있었습니다.

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = """
        INSERT IGNORE INTO dead_letter_records
        ...

clearAutomatically = true 는 쿼리 직후 영속성 컨텍스트를 통째로 비웁니다. 앞에서 잠가 둔 #1 엔티티도 그 순간 detach 되고 그 뒤에 reopen() 으로 상태를 바꿔도 UPDATE 가 나가지 않습니다. 예외도 나지 않습니다. #2 가 #1 에 연결됐는지만 확인하는 테스트라면 #1 이 다시 열리지 않았는데도 통과합니다.

INSERT 뒤에 잠가 둔 객체가 빠져나간다

왼쪽은 트랜잭션 A 의 영속성 컨텍스트, 오른쪽은 DB 입니다. #2 를 INSERT 한 뒤 #1 객체가 어디에 있는지, 커밋 뒤 DB 값이 무엇인지 보세요.

본문의 재현 실험을 옮긴 모형입니다. 실제 코드는 INSERT 전에 아홉 가지를 대조하지만 장면에서는 생략했고, 사건 상태 외의 컬럼과 #2 를 #1 에 연결하는 값은 그리지 않았습니다.

두 함정은 다시 재현해 확인했습니다.

  • 첫 번째 함정: 같은 트랜잭션 안에서 네이티브 쿼리 SELECT status ... FOR UPDATE 를 함께 실행해 원인을 나눴습니다. DB 는 다른 트랜잭션이 커밋한 RESOLVED 를 돌려줬지만 잠금 조회로 받은 엔티티에는 먼저 읽어 둔 OPEN 이 남아 있었고, 폐기(discard) 처리가 적용돼 커밋 후 DB 값은 DISCARDED 였습니다. 비교를 위해 엔티티 대신 id 만 먼저 읽은 경우에는 잠금 조회한 엔티티가 RESOLVED 를 보고 아무것도 바꾸지 않았습니다.
  • 두 번째 함정: INSERT 직후 entityManager.contains(root) 가 false 였습니다. reopen() 은 true 를 돌려주고 객체 상태도 OPEN 이 됐지만 커밋 후 DB 값은 RESOLVED 그대로였습니다. 비교를 위해 INSERT 뒤에 원래 사건을 다시 잠금 조회해 reopen() 한 경우에는 OPEN 이 저장됐습니다.

연 뒤에: 되돌리는 순간 망가지는 것

재발행 데이터가 하나라도 생기면 “앞 단계로 되돌리면 된다”는 계획은 쓸 수 없습니다. 재발행할 메시지도 outbox 테이블에 행으로 들어가는데 구버전 poller 는 행의 event_type 값을 그대로 목적지 토픽 이름으로 씁니다. 재발행 행에는 실제 토픽 이름 대신 __replay__ 라는 sentinel 값(실제 토픽 이름 대신 넣어 두는 특수 값)을 넣어 두었기 때문에 이전 이미지로 내려간 poller 는 그 행을 엉뚱한 토픽으로 보내려다 실패합니다. sentinel 대신 실제 토픽 이름을 넣었다면 구버전 poller 가 원래 key 와 파티션을 맞추는 검사 없이 메시지를 보냈을 테니 더 나빴을 것입니다. 재발행 행처럼 새 버전이 만든 데이터를 구버전 코드가 읽게 되면 이미지만 되돌려서는 이전 상태로 돌아가지 않습니다.

다음에는 “되돌리기 전에 남은 재발행 행이 없는지 확인한다”는 롤백 절차를 운영 문서에 넣으려 했습니다. 하지만 문서로는 프로세스가 뜨는 것을 막을 수 없습니다. 확인을 건너뛰고 이전 이미지를 배포해도 아무것도 실패하지 않습니다.

확인할 조건을 정하자 이번에는 영원히 끝나지 않는 상태가 나왔습니다. 원장의 재발행 요청은 REQUESTED 로 시작하고, 별도 작업(reconciler)이 outbox 행의 발행 결과를 보고 바꿉니다. outbox 행이 어떤 이유로 사라지면 발행이 됐는지 알 수 없습니다. 행이 없다는 것만으로는 실패했다고 할 수 없으니 그 요청을 실패로 바꾸지 않고 그대로 둡니다. 그러면 “진행 중인 재발행이 없어야 되돌릴 수 있다”는 조건이 영원히 충족되지 않고, 발행이 진행 중인 사건은 종결할 수 없게 해 두었으므로 사건 종결도 함께 막힙니다.

마지막은 기능을 열어도 운영자가 호출할 수 없다는 것이었습니다. 진입점은 각 서비스의 관리용 엔드포인트(Spring Boot Actuator, /actuator/deadletter)에 있었고 관리자 권한(ROLE_ADMIN)을 확인했습니다. 하지만 외부 요청은 게이트웨이를 거치고 게이트웨이는 /api/v1/** 만 서비스로 넘겼습니다. 서비스는 게이트웨이가 서명한 내부 토큰으로만 요청자를 확인하므로 클러스터 안에서 직접 호출하면 요청자 정보가 비어 권한을 보기도 전에 인증 단계에서 거절됩니다. 권한 검사는 정확했지만 운영자의 요청이 그 검사까지 갈 경로가 없었습니다.

좀 더 적절한 해결책은 무엇일까요?

네 질문 모두 보장 범위를 줄이고 줄인 범위는 코드에서 확인하도록 바꿨습니다. 코드로 막지 못한 부분은 한계로 남겼습니다.

어디로: 여러 번 나가도 처리는 한 번

앞에서 본 대로 crash window 때문에 “한 번만 보낸다”는 지킬 수 없었습니다. 멱등 처리에도 조건이 붙고 발행 규칙을 지키면 보낼 행이 없다는 문제도 남아 있었습니다.

먼저 보장 수준을 낮췄습니다. 재발행은 at-least-once, 즉 적어도 한 번은 나가고 두 번 이상 나갈 수도 있다고 정했습니다. 처리가 한 번만 일어나는 것은 소비자 쪽에서 보장합니다. 원본의 eventId 를 그대로 싣고 두 번째부터는 각 group 의 처리 이력이 걸러 냅니다.

그러려면 재발행 메시지가 원본과 같아야 했습니다. 기존 outbox 가 보내는 메시지는 토픽이 이벤트 타입으로 정해지고, 파티션은 비워 두고, key 는 집계 id 이고, timestamp 는 지정하지 않았습니다. 그래서 outbox 테이블에 컬럼을 추가하고 행의 종류(record_kind 의 DOMAIN 또는 REPLAY)에 따라 poller 가 메시지를 다르게 조립하게 했습니다. 재발행 행은 원본의 토픽, 파티션, key, 본문, eventId, timestamp 를 그대로 싣습니다. outbox 행 자체의 id 는 재발행 시도마다 새로 만들고 원본의 eventId 는 별도 컬럼에 둡니다. 둘을 한 컬럼에 담으면 unique 제약 때문에 같은 메시지를 두 번째로 재발행할 수 없게 됩니다.

원본 timestamp 를 유지하는 이유가 하나 더 있습니다. 처리 이력이 7일 뒤 지워지므로 재발행은 원본 시각에서 replay window(7일) 안에만 허용합니다. 이 마감 시각은 #1 에서 한 번만 계산하고, #1 에 연결된 #2 같은 재실패 행은 그 값을 물려받습니다. 하지만 대조에 실패해 별도 사건으로 적재된 행은 물려받을 마감이 없어서, DLQ 에 기록된 timestamp 로 마감을 새로 계산합니다. 재발행 메시지에 원본 timestamp 를 싣지 않으면 그 값이 재발행 시각이 되어 실패할 때마다 window 가 늘어납니다. timestamp 는 재실패를 연결할 때 대조하는 값이기도 합니다. 원본 timestamp 가 없는 행은 마감을 정할 수 없으므로 재발행하지 않습니다.

“토픽 하나에 발행 서비스 하나” 규칙에는 예외를 두고 예외가 넓어지지 않도록 조건을 코드로 막았습니다.

  • 원장을 가진 서비스만, 자기 원장의 행에 대해서만 보낼 수 있습니다.
  • 목적지 토픽과 파티션은 원본과 같아야 합니다.
  • key, 본문, eventId 는 원본과 바이트 단위로 같아야 합니다.
  • 새 이벤트를 만들지 않습니다. 하나라도 어긋나면 발행을 거부합니다.

고르지 않은 안도 있습니다.

  • Kafka 트랜잭션으로 exactly-once: Kafka 안의 쓰기와 offset 커밋만 원자적으로 묶이고 DB 저장과 브로커 ack 사이의 crash window 는 그대로 남습니다. 네 서비스 소비자에 isolation.level 설정을 맞추는 비용도 들었습니다.
  • CDC(DB 변경 로그를 읽어 발행하는 방식, 예: Debezium): poller 가 ack 뒤에 상태를 따로 저장하는 단계 자체가 사라지므로, crash window 를 실제로 줄일 수 있는 안은 이것뿐이었습니다. 다만 커넥터도 기본은 at-least-once 라서 중복을 0으로 만들려면 Kafka Connect 의 exactly-once source 설정까지 갖춰야 합니다. 무엇보다 가끔 사람이 실행하는 재발행 때문에 Kafka Connect, 커넥터, binlog 권한을 운영하는 비용이, 중복 한두 건을 처리 이력이 걸러 내는 비용보다 훨씬 컸습니다. 도메인 outbox 자체가 병목이 되면 다시 고려할 안입니다.
  • 재발행 전용 outbox 테이블: poller, 스케줄러, 메트릭, 정리 작업이 네 서비스에서 모두 두 벌이 됩니다.
  • 원래 발행 서비스에 재발행 위임: 서비스 사이에 요청, 회신, 멱등 처리를 갖춘 명령 채널을 새로 만들어야 했습니다.

원본: 기대던 값을 선언하고, 재발행 직전마다 확인한다

되짚어 보면 보존 기간은 선언 없이 Kafka 기본값 7일에 기대고 있었습니다. 크기 상한은 세그먼트 크기를 함께 정하지 않으면 효과가 없었고 보관 기간과 replay window 는 둘 다 7일이라 여유가 없었습니다.

보존 기간은 모든 업무 토픽과 .dlq 토픽의 선언(NewTopic)에 직접 적었습니다.

설정업무 토픽.dlq 토픽동작이 바뀌나
retention.ms7일7일아니요 (기본값과 같음)
cleanup.policydeletedelete아니요
message.timestamp.typeCreateTimeCreateTime아니요
retention.bytes파티션당 8 MiB4 MiB예 (무제한에서 상한으로)
segment.bytes4 MiB2 MiB예 (1 GiB 에서 4 MiB 로)
segment.ms1일1일예 (7일에서 1일로)

이 표에서 중요한 것은 마지막 열입니다. 위 세 줄은 실제 동작을 바꾸지 않고 이 값에 기대고 있다는 것을 코드에 드러냅니다. CreateTime 을 적은 것도 같은 이유입니다. 브로커가 수신 시각으로 timestamp 를 덮어쓰면 재발행이 원본 timestamp 를 싣는 의미가 없어집니다.

아래 세 줄은 실제 동작을 바꿔서 7일이 지나기 전에 크기 때문에 지워지는 경우가 새로 생깁니다. 세그먼트를 하루마다 닫으므로 시간 기준 실효 보존도 최대 약 8일로 짧아집니다. 세그먼트 크기를 함께 줄였기 때문에 파티션 하나가 차지하는 최대 용량은 retention.bytes + segment.bytes 로 계산할 수 있게 됐습니다. 업무 토픽은 10개에 파티션이 3개씩이고 파티션마다 12 MiB, .dlq 토픽은 10개에 파티션이 1개씩이고 파티션마다 6 MiB 이므로 합치면 약 420 MiB 입니다. 삭제 검사 주기, 삭제 지연, 인덱스 파일, 내부 토픽이 계산에 들어 있지 않아서 이 값은 정상 상태의 목표치일 뿐 상한으로 보장하지는 않습니다.

선언만으로는 이미 있는 토픽이 바뀌지 않습니다. Spring Kafka 의 KafkaAdmin 은 기본적으로 새 토픽만 만들고 기존 토픽의 설정은 고치지 않으므로 spring.kafka.admin.modify-topic-configs: true 를 함께 켰습니다.

그리고 “7일 안이면 원본을 반드시 읽을 수 있다”는 보장하지 않기로 했습니다. 트래픽을 측정한 적이 없어서 크기 상한이 7일보다 먼저 차지 않는다고 증명할 수 없었습니다. 그 대신 재발행 직전에 원본이 있는지 매번 확인합니다. 요청한 offset 이 토픽의 현재 범위 안에 있는지 보고 읽어 온 레코드의 offset 이 요청한 offset 과 같은지도 확인합니다. 압축(compaction)으로 중간이 빈 토픽에서는 요청한 위치를 찾으면 그다음 레코드가 돌아오기 때문입니다. 확인에 실패하면 재발행할 수 없다고 기록하고 끝냅니다.

여유 부족은 기동 검사 식을 바꿔 막았습니다. 처리 이력 보관 기간이 replay window 에 시계 오차 여유(5분)와 정리 작업 지연 여유(1일)를 더한 값 이상이어야 서비스가 뜨도록 하고 네 서비스의 보관 기간을 7일에서 9일로 늘렸습니다. 여유 값이 0이면 다시 7일과 7일이 등호로 통과하므로 여유 값은 양수여야 한다는 검사도 추가했습니다.

이 설정을 검증하는 테스트에서도 함정이 하나 나왔습니다. 처음 테스트는 기대값을 검증 대상인 설정 클래스의 상수에서 그대로 읽었기 때문에 대상이 바뀌면 기대값도 같이 바뀌었습니다. 업무 토픽 8 MiB 와 .dlq 4 MiB 를 각각 10 MiB 와 2 MiB 로 바꿔도 합계와 비율 검사가 모두 통과했습니다. 설계에서 정한 숫자를 테스트에 직접 적고 나서야 값을 바꾸는 변이에서 테스트가 실패했습니다.

고르지 않은 안은 하나였습니다.

  • 원장의 본문이 잘렸으면 재발행 금지: 다시 보낼 원본은 원장에 기록된 위치의 레코드이므로, 원장 사본이 잘렸는지는 재발행과 관계가 없습니다.

다시 실패하면: 연결 경로를 먼저 만들고, 원장을 기준으로 연결한다

여기서 풀 문제는 세 가지였습니다.

  • 진입점이 먼저 열리면 재실패가 별개의 사건으로 쌓일 수 있었습니다.
  • 대조 목록이 원장 기준과 맞지 않았고 본문을 확인하지 않았습니다.
  • JPA 가 잠금 이전 상태를 쓰는 곳이 두 군데 있었습니다.

배포 순서

연결 경로를 먼저 만들고 진입점을 나중에 여는 쪽으로 바꿨습니다. 연결 경로가 먼저 있으면 재발행을 처음 여는 순간부터 재실패가 원래 사건에 연결됩니다.

순서배포이 시점에 가능한 것
1원장에 발행 상태 컬럼 추가, 미결 집계 수정상태 분리, 사건 단위 집계 (재발행은 아직 불가)
2재발행 행을 보내는 poller재발행 행을 보낼 수 있음 (그런 행을 만드는 진입점은 없음)
3재실패 연결과 재개방재실패가 원래 사건으로 모임 (재발행은 아직 불가)
4진입점재발행을 시작할 수 있음 (스위치는 꺼져 있음)
5롤백 절차와 게이트웨이 경로안전하게 되돌릴 수 있게 된 뒤에야 스위치를 켤 수 있음

어느 단계에서 멈춰도 그때까지 배포된 것만으로 안전하도록 나눴습니다. 앞 단계가 만든 컬럼은 비어 있어도 되고 진입점이 없으면 재발행 행 자체가 생기지 않습니다. 실제 클러스터에서 단계별로 배포해 확인하지는 않았습니다.

무엇과 대조하나

대조 기준은 끝까지 원장에 두었습니다. #1 을 재발행하는 순간 #1 행에 이번 시도 번호, 대상 consumer group, 원본 본문 전체의 SHA-256 세 값을 같은 UPDATE 로 기록합니다.

재실패가 도착하면 헤더의 시도 번호로 #1 을 찾고, 아래 아홉 가지가 모두 맞을 때만 연결합니다. 하나라도 다르면 연결하지 않고 별도 사건으로 적재합니다.

  1. 시도 번호: 헤더 값과 #1 에 기록한 값
  2. 원장을 가진 서비스: 헤더 값과 지금 적재하는 서비스
  3. 대상 group: 실제로 실패한 group, 헤더 값, #1 에 기록한 값 세 개가 모두 같아야 합니다
  4. 원래 사건 id: 헤더 값과 #1 의 id
  5. 토픽
  6. eventId
  7. key
  8. 원본 timestamp
  9. 본문 해시: 실패한 메시지 본문 전체의 SHA-256 과 #1 에 기록한 값

5번부터 8번까지는 재실패 행과 #1 이 원장에 남긴 값을 비교합니다. 해시는 원장의 잘린 사본 대신 원본 토픽에서 읽어 온 전체 본문으로 계산합니다.

이 해시에는 서명이 없으므로 같은 토픽에 쓸 수 있는 누군가가 값을 맞춰 붙일 수도 있습니다. 막으려는 것은 같은 eventId 를 가진 다른 본문이 한 사건으로 잘못 합쳐지는 사고이고 공격 방어는 범위 밖에 두었습니다.

헤더는 쓸 때와 읽을 때 기준을 다르게 뒀습니다. 헤더가 일부만 실린 재발행이 나가면 재실패 때 대조할 값이 없어 별도 사건으로 갈라집니다. 그래서 보내는 쪽은 연결용 헤더 네 개(시도 번호, 원장을 가진 서비스, 대상 group, 원래 사건 id)가 빠짐없이 있고 값이 모두 유효할 때만 발행합니다. Spring Kafka 의 표준 DLT_* 헤더는 싣지 않습니다. 실으면 재실패 때 원본 위치 정보가 덮이기 때문입니다. 받는 쪽은 헤더가 깨져 있어도 예외를 던지지 않고 연결만 하지 않습니다. 조작된 헤더 하나 때문에 DLQ 적재가 실패하면 실패 기록 자체가 사라지기 때문입니다.

JPA 실행 순서

JPA 함정은 순서를 바꿔 피했습니다. 앞의 두 장면에서 “바꾼 순서”를 누르면 아래 순서가 어떻게 동작하는지 볼 수 있습니다.

  • 잠그기 전에는 엔티티 대신 id 만 읽습니다(id projection). 잠금 조회가 객체를 새로 채우지 않는다면 잠금 전에 객체를 영속성 컨텍스트에 올리지 않으면 됩니다. 엔티티를 이미 읽어 둔 상황이라면 entityManager.refresh(entity, LockModeType.PESSIMISTIC_WRITE) 처럼 잠그면서 다시 읽는 방법도 있습니다.

  • 함께 바꿀 하위 행도 잠금 조회로 읽습니다. MySQL 의 기본 격리 수준(REPEATABLE READ)에서 잠금 없는 조회는 트랜잭션이 처음 만든 snapshot 을 보므로 원래 사건은 최신인데 하위 행만 과거 상태로 보이는 경우가 생길 수 있었습니다.

  • 재실패를 연결하는 순서는 이렇게 바꿨습니다.

    1. id 로 #1 을 찾는다.
    2. #1 을 FOR UPDATE 로 잠근다.
    3. 아홉 가지를 대조한다.
    4. #2 를 INSERT 한다.
    5. #1 을 다시 FOR UPDATE 로 읽는다.
    6. #2 를 #1 에 연결하고 #1 을 다시 연다.

    5단계를 일반 조회로 하면 1단계에서 만들어진 snapshot 을 다시 읽으므로, 그 사이 종결된 #1 을 아직 열려 있는 것으로 보고 다시 열기를 건너뜁니다. 행 잠금은 이미 이 트랜잭션이 갖고 있으므로 5단계에서 기다리지는 않습니다.

사건은 누가 종결하나

원장의 상태 컬럼을 둘로 나눴습니다.

  • 발행 상태: 요청 없음, REQUESTED, PUBLISHED, PUBLISH_FAILED
  • 사건 상태: OPEN, ACKED, RESOLVED, DISCARDED

PUBLISHED 는 브로커가 메시지를 받았다는 뜻이고 Product 의 처리 결과와는 관계가 없습니다. 그래서 미결 집계에서 빠지지 않습니다. 사건은 운영자가 근거를 적어 RESOLVED 나 DISCARDED 로 바꿀 때만 종결됩니다.

종결한 뒤에 늦게 도착한 재실패도 아직 해결되지 않았다는 뜻이므로 #1 을 다시 엽니다. 사람이 종결한 것을 시스템이 되돌리는 경로는 이것 하나뿐이라서 다시 열리면 알림을 보냅니다.

미결 건수는 사건 단위로 셉니다. #2 와 #3 이 생겨도 사건 하나는 미결 하나입니다. 여기에도 함정이 있었습니다. 원래 사건을 가리키는 컬럼(root_record_id)은 새로 추가한 컬럼이라, 이미 쌓여 있던 행은 이 값이 비어 있습니다. 조건을 root_record_id = id 로만 걸면 기존 미결이 모두 집계에서 빠져 미결이 0으로 보입니다. 미결이 부풀던 문제를 고치려다 미결이 사라지는 문제를 새로 만들 수 있었습니다. 전환 기간에는 root_record_id IS NULL OR root_record_id = id 로 세고 배포 전후 미결 건수가 같은지 테스트로 확인했습니다.

같은 재실패 두 번을 두 순서로 비교하면 이렇습니다.

같은 재실패, 두 순서

같은 다섯 단계를 두 순서로 비교합니다. 재실패 행이 원래 사건 아래로 모이는지, 미결 건수가 실제 사건 수와 같은지 보세요.

원장 행의 식별자는 위치와 consumer group 등 여러 컬럼이지만 장면에서는 행 번호로 줄였고, 발행 상태는 그리지 않았습니다. 재실패를 연결할 때 실제로는 아홉 가지 값을 대조하지만 "대조 통과" 한 번으로 표시했습니다. 숫자는 예시 모형이고, 중복분은 at-least-once 로 같은 재발행이 두 번 나간 경우를 예로 든 것입니다. "진입점 먼저" 순서는 계획 단계에서 바뀌어 실제로 배포된 적이 없는 가정 경로입니다.

고르지 않은 안은 이렇습니다.

  • 진입점을 먼저 배포하되 기능 플래그로 꺼 두기: “아직 켜지 않는다”를 사람이 지켜야 합니다.
  • 서명(HMAC 등)으로 헤더 검증: 공격까지 막을 수 있지만 지금 목적에는 키 관리 비용만 늘어납니다.
  • 해시를 헤더에만 싣기: 보내는 쪽이 스스로를 증명하는 구조가 됩니다.
  • record_kind 를 원장에도 복사: 그 값을 채울 근거가 결국 조작할 수 있는 헤더뿐입니다.
  • 소비자가 처리 성공을 기록해 자동 종결: 네 서비스의 모든 정상 소비 경로에 재발행용 코드를 넣어야 합니다. 재발행 빈도가 늘면 다시 고려할 안입니다.
  • 종결 뒤 도착한 재실패를 거부하거나 종결 상태를 물려받기: 거부하면 실패 기록을 버리게 되고 물려받으면 미결인데 종결된 것처럼 보입니다.
  • 재실패 행을 링크만 하고 행마다 미결로 세기: 원래 사건을 종결할 때 어디까지 함께 끝나는지 정해지지 않아 미결이 계속 늘기만 합니다.

연 뒤에: 스위치를 끈 채 배포하고, 되돌리기 전에 스크립트로 확인한다

여기서 풀 문제는 넷이었습니다. 구버전 poller 가 재발행 행에서 실패하고, 문서로는 배포를 막지 못하고, REQUESTED 가 끝나지 않을 수 있고, 운영자가 진입점까지 갈 경로가 없었습니다.

구버전 코드와 함께 돌아야 하는 기간을 위해 record_kind 컬럼을 비워 둘 수 있게(nullable) 만들고 기본값(DEFAULT 'DOMAIN')은 두지 않았습니다. 두 결정은 이유가 다릅니다.

  • nullable: 롤링 배포 중에 남아 있는 구버전 서비스는 이 컬럼을 모른 채 INSERT 합니다. NOT NULL 이면 그 INSERT 가 실패합니다.
  • 기본값 없음: 기본값을 두면 신버전 코드가 종류를 빠뜨려도 DB 가 조용히 DOMAIN 으로 채우므로 누락을 오류로 드러낼 수 없습니다.

그래서 읽을 때는 NULL 을 DOMAIN 으로 해석하고 쓸 때는 신버전이 항상 값을 넣도록 코드에서 강제했습니다. 재발행 행의 event_type 에 실제 토픽 이름 대신 __replay__ 를 넣은 것도 이 기간을 위한 결정입니다.

진입점은 배포해도 바로 열리지 않게 했습니다. 스위치의 기본값은 false 이고 각 서비스의 기본 설정 파일에 이 값이 들어 있습니다. 기본값이 true 면 새 Pod 가 뜨는 순간 데이터 정리, 측정, 리허설보다 API 가 먼저 열립니다. 켜고 끄려면 Kubernetes ConfigMap 값을 바꾸고 롤링 재기동을 해야 합니다. 그래서 즉시 끌 수 없고, 끄는 재기동이 끝나기 전에 들어오는 요청은 운영자가 막아야 합니다. 이 부분은 한계로 받아들였습니다.

되돌려도 되는지는 스크립트가 확인합니다. 네 서비스의 DB 마다 네 조건을 모두 봅니다.

조건확인 방법
아직 안 나간 재발행이 없다outbox 에 REPLAY 종류이면서 PENDING 인 행이 0
결과를 모르는 요청이 없다원장에 발행 상태가 REQUESTED 인 행이 0
재발행분을 다 소비했다대상 group 과 DLQ 적재 group 의 lag 가 0. 간격을 두고 두 번 연속 관측
재시도가 다 끝났다마지막 발행 결과가 정해진 시각 + 재시도 상한 < 지금

재시도 상한은 소비자의 재시도(1초, 5초, 30초 대기와 그 사이 handler 실행 시간)에 DLQ 적재 지연과 시계 오차 여유를 더한 값입니다. 마지막 조건의 기준 시각을 정하는 데 가장 오래 걸렸습니다. 이미 있던 컬럼들은 뜻이 맞지 않아서 원장에 이 확인에만 쓰는 컬럼을 새로 두고 발행 결과가 정해질 때 DB 시각을 기록하기로 했습니다.

결과가 PUBLISH_FAILED 일 때도 시각을 기록합니다. outbox poller 는 발행이나 저장에서 예외가 나면 행을 PENDING 으로 둔 채 재시도 횟수만 올리고, 상한(기본 5회)에 닿으면 FAILED 로 바꿉니다. reconciler 는 이것을 원장의 PUBLISH_FAILED 로 옮깁니다. ack 를 받은 뒤 저장에서 예외가 나도 같은 경로를 타므로 PUBLISH_FAILED 인 메시지도 실제로는 나갔을 수 있습니다. 같은 이유로 소비 완료는 시간 계산만으로 확인하지 않고 실제 lag 를 봅니다. 소비자가 멈춰 있으면 시간만 흐르고 소비는 일어나지 않기 때문입니다. DB 나 브로커에 접속하지 못하면 배포를 막습니다(fail-closed).

이 확인은 배포 래퍼 스크립트가 kubectl apply -k 앞에서 실행합니다. 래퍼를 건너뛰고 kubectl apply -k 를 직접 실행하면 확인 없이 배포되는데 이 우회 가능성은 한계로 받아들였습니다.

REQUESTED 가 끝나지 않는 문제에는 단방향 상태 전이를 추가했습니다. 이 전이는 운영자만 실행할 수 있습니다. 새 상태 PUBLISH_UNKNOWN 은 다음 규칙을 따릅니다.

  • REQUESTED 에서만 바꿀 수 있습니다.
  • outbox 행이 실제로 없을 때만 바꿀 수 있습니다. 행이 남아 있으면 정상 경로가 결과를 정하므로 그때 이 전이를 허용하면 결과를 정하는 경로가 둘이 됩니다.
  • 이 상태에서는 다시 재발행할 수 없습니다. 재발행 요청 조건이 “요청 없음 또는 PUBLISH_FAILED”만 허용하므로 새 값은 자동으로 막힙니다.
  • 사건 종결은 허용합니다.
  • 누가 어떤 근거로 바꿨는지는 원장 컬럼에 기록합니다.

운영자가 진입점까지 갈 수 있도록 게이트웨이에 관리자 경로를 추가했습니다. /api/v1/admin/deadletter/{서비스} 와 /api/v1/admin/deadletter/{서비스}/{id} 를 각 서비스의 /actuator/deadletter 로 바꿔 넘깁니다. {id} 는 숫자만 받게 해서 경로를 조작해 다른 관리 엔드포인트로 가는 요청을 경로 매칭 단계에서 막았습니다. 권한 확인은 게이트웨이로 옮기지 않고 서비스의 기존 ROLE_ADMIN 검사에 맡겼습니다.

여기서도 고르지 않은 안을 모아 둡니다.

  • 롤백 절차를 운영 문서에만 두기: 프로세스가 뜨는 것을 막지 못합니다.
  • 기준 시각으로 기존 재발행 마감 시각 컬럼을 쓰기: 7일 멱등 window 를 뜻하는 값이라, 덮어쓰면 그 window 가 초 단위로 줄어듭니다.
  • 기준 시각으로 outbox 행의 생성 시각을 쓰기: 행이 지워지면 “없으니 끝났다”로 읽히고(fail-open), 행을 만든 시각일 뿐 발행한 시각도 아닙니다.
  • 소비 완료를 시간 계산 하나로 대신하기: 소비자가 멈춰 있어도 시간이 지나면 통과합니다.
  • 클러스터 안의 initContainer 로 강제: 실제로 막을 수는 있지만 정상 배포 때마다 네 서비스 모두 DB 자격 증명과 mysql 클라이언트 이미지를 들고 있어야 합니다.
  • outbox 행이 없으면 자동으로 실패 처리: 발행됐을 수도 있는 건을 실패로 분류하고 재요청까지 허용하면, 같은 메시지를 모르는 사이에 두 번 보낼 수 있습니다.
  • 동시 요청을 버전 컬럼(@Version)으로 막기: 네 DB 에 컬럼과 마이그레이션이 필요하고 조건부 UPDATE 로 충분했습니다.
  • 관리 경로에만 서비스 쪽 인증 방식 추가: 요청자를 확인하는 곳이 두 군데로 늘고 게이트웨이의 rate limit, 헤더 제거, 감사 로그를 거치지 않는 경로가 생깁니다.
  • 경로를 만들지 않고 한계로만 남기기: 기능은 있는데 쓸 수 없는 상태가 됩니다.

문제와 해결

질문처음 답문제해결
어디로 넣나원래 토픽에, 한 번만crash window 때문에 “한 번만”은 불가능at-least-once + 같은 eventId + 소비자 처리 이력
처리 이력은 7일 뒤 지워짐원본 timestamp 유지, 마감은 한 번만 계산해 물려받음, timestamp 없으면 금지
발행 규칙을 지키면 보낼 행이 없음예외를 두고, 조건을 코드로 강제
원본이 남아 있나원본 토픽에서 읽는다. 보존 기간을 설정한다이미 7일이었음(선언 없는 기본값)토픽 선언에 명시, 동작이 바뀌는 설정을 따로 구분, 기존 토픽에도 반영
크기 기준 삭제는 닫힌 세그먼트만 지움segment.bytes 와 segment.ms 선언, 7일 보장 대신 재발행 직전 확인
보관 기간과 replay window 가 같아 여유 0여유를 넣은 기동 검사, 9일, 여유 값 양수 검사
다시 실패하면진입점부터, 연결은 나중에진입점이 먼저면 사건이 여러 행으로 나뉨연결 경로 먼저, 진입점 나중
대조 기준이 원칙과 맞지 않음, 본문 확인 없음원장에 기록한 세 값 + 전체 본문 해시, 아홉 가지 대조
잠금 조회가 객체를 새로 채우지 않음잠금 전에는 id 만 읽기, 하위 행도 잠금 조회
INSERT 가 잠근 객체를 detach6단계 실행 순서, 5단계는 잠금 재조회
연 뒤 되돌릴 수 있나앞 단계로 되돌린다. 절차는 문서로구버전 poller 가 재발행 행에서 실패__replay__ sentinel, nullable, 기본값 없음, 스위치 기본 꺼짐
문서로는 배포를 막지 못함배포 래퍼와 확인 스크립트(우회 가능)
REQUESTED 가 끝나지 않음PUBLISH_UNKNOWN 단방향 전이
진입점까지 갈 경로 없음게이트웨이 관리자 경로, 권한 확인은 서비스에서

최종적으로 이렇게 됐습니다

설계 결정을 먼저 정하고 약 2주 동안 일곱 개 PR 로 나눠 반영했습니다. 마지막 PR 이 머지됐을 때도 재발행 스위치는 꺼져 있었고 지금도 꺼져 있습니다.

재발행 한 건이 지나가는 길

#1 을 재발행하면 이런 순서로 처리됩니다.

  1. 운영자가 게이트웨이 관리자 경로로 #1 의 id 를 지정해 재발행을 요청합니다. 요청자와 사유를 함께 적어야 하고 권한은 서비스가 ROLE_ADMIN 으로 확인합니다.
  2. 서비스는 스위치가 꺼져 있으면 바로 거절합니다.
  3. #1 을 FOR UPDATE 로 잠그고 재발행 조건 여덟 가지를 확인합니다. eventId, 실패한 group, 원본 위치가 있는지, 위치가 유효한지, 토픽마다 재발행 허용 여부를 정한 정책에 걸리지 않는지, 원본 timestamp 가 있는지, 7일 마감 전인지, 원본 토픽에서 읽은 레코드가 원장 기록과 같은지입니다. 원본은 offset 을 커밋하지 않는 별도 consumer 로 그 위치만 읽고, 하나라도 어긋나면 거절합니다. 3단계부터 6단계까지는 한 트랜잭션이라 Kafka 에서 원본을 읽는 동안에도 #1 의 잠금이 유지됩니다(관리 조회와 poll 에 각 10초 timeout).
  4. 허용이든 거절이든 결과를 원장에 남깁니다. 거절은 여기서 끝나므로, 이 단계에서 남기지 않으면 거절 이력이 사라집니다.
  5. 발행 상태를 기록할 행을 claim(먼저 차지해 표시)합니다. 대상은 #1 에 연결된 재실패 행 중 가장 최근의 열린 행이고, 그런 행이 없으면 #1 자신입니다. “요청 없음 또는 발행 실패”인 행만 REQUESTED 로 바꾸는 조건부 UPDATE 라서, 이미 요청 중이거나 종결된 사건이면 0행이 되어 거절됩니다. 같은 사건에 요청이 동시에 와도 하나만 통과합니다.
  6. #1 에 시도 번호, 대상 group, 본문 해시를 기록하고 REPLAY 종류의 outbox 행을 만듭니다.
  7. poller 가 원본 토픽, 파티션, key, 본문, timestamp 와 연결용 헤더 네 개를 실어 보냅니다. 별도 작업(reconciler)이 outbox 행의 결과를 보고 claim 한 행의 발행 상태를 PUBLISHED 나 PUBLISH_FAILED 로 바꾸고 그 시각을 기록합니다.
  8. 같은 eventId 를 이미 처리한 Order 와 Notification 은 건너뛰고 Product 만 다시 처리합니다.
  9. Product 가 또 실패하면 그 메시지는 DLQ 를 거쳐 원장에 #2 로 들어옵니다. 아홉 가지가 모두 맞으면 #1 아래에 붙고 #1 이 종결돼 있었다면 다시 열립니다. 미결 건수는 그대로 하나입니다. 이 사건을 다시 재발행하면 5단계의 claim 대상은 #2 가 됩니다.
  10. 운영자가 도메인 상태를 확인하고 근거를 적어 #1 을 종결합니다.

이미지를 되돌려야 할 때는 배포 래퍼가 먼저 네 서비스의 DB 와 브로커를 조회해 네 조건을 확인하고 하나라도 맞지 않으면 배포를 멈춥니다.

아래 장면은 이 흐름을 한 건으로 따라갑니다. 위쪽 세 버튼으로 저장 실패, 재처리 결과, outbox 행 삭제를 바꿔 보면 원장의 두 상태와 미결 건수, 롤백 확인 결과가 어떻게 달라지는지 볼 수 있습니다.

재발행 한 건이 지나가는 길

스위치를 바꾸면 같은 일곱 단계를 다른 조건으로 다시 봅니다. 원장의 발행 상태와 사건 상태, 아래의 숫자와 로그를 함께 보세요.

    실제 운영에서 실행된 적 없는 흐름을 옮긴 모형이고, 숫자는 예시입니다. 여덟 가지 재발행 조건 확인과 아홉 가지 재실패 대조는 "허용", "대조 통과" 한 줄로 줄였습니다. 재시도 간격, lag 관측 간격, 재시도 상한 시간은 표시만 하고 실제 시간 흐름은 압축했습니다. 저장 실패는 한 번 일어난 뒤 다음 주기에 다시 발행되는 경우만, outbox 행 삭제는 발행 뒤 결과가 정해지기 전에 사라지는 경우만 그렸습니다.

    테스트가 틀렸는데도 통과한 경우

    동작은 Testcontainers 로 실제 MySQL 과 Kafka 를 띄운 통합테스트로 확인했고 그 테스트가 실제로 조건을 검사하는지는 변이 검사로 확인했습니다. 변이 검사는 코드의 조건 하나를 일부러 지우거나 뒤집은 뒤 테스트가 실패하는지 보는 방법입니다. 실패하지 않으면 그 테스트는 그 조건을 검사하지 않는 것입니다. 이 과정에서 찾은 사례 가운데 다른 프로젝트에서도 생길 만한 것들을 정리합니다.

    • 기본값을 확인하는 테스트가 없었습니다. 스위치 기본값을 true 로 뒤집는 변이를 넣어도 모든 테스트가 통과했습니다. 통합테스트가 @BeforeEach 에서 스위치 값을 직접 넣고 있었기 때문입니다. 코드의 기본값을 읽는 테스트와, 네 서비스의 설정 파일 값을 검사하는 lint 를 따로 추가했습니다.
    • 거절만 확인하는 테스트로는 판별 함수가 고장 났는지 알 수 없습니다. 게이트웨이 관리자 경로 테스트에서 경로 일치 여부를 보는 보조 함수가 비동기 결과(Publisher<Boolean>)를 Boolean 과 바로 비교하고 있어서 항상 false 를 돌려주었습니다. 그 상태로 “다른 관리 엔드포인트로 가지 않는다”는 테스트 6개가 모두 통과했고 같은 함수를 쓰는 “이 경로는 일치해야 한다”는 테스트 8개가 실패하면서 드러났습니다.
    • 계약에 없는 것을 테스트가 assert 했습니다. at-least-once 를 확인하는 테스트가 “정확히 2번 발행”을 assert 했는데 테스트가 직접 부른 poller 말고 백그라운드 스케줄러도 같은 행을 가져가 3번이 나왔습니다. 계약은 중복 횟수에 상한을 두지 않으므로 assert 를 “적어도 2번 발행, 처리는 1번”으로 바꿨습니다.
    • 문자열 변환이 바이트 단위 비교를 깨고 있었습니다. 원본 토픽에서 레코드를 읽을 때 StringDeserializer 를 쓰면 올바르지 않은 UTF-8 바이트가 조용히 대체 문자로 바뀌어 바이트 비교와 본문 해시가 둘 다 틀린 결과를 냅니다. byte[] 로 읽고 UTF-8 로 엄격하게 디코딩한 뒤 다시 인코딩해도 같은 바이트가 나올 때만 문자열로 바꾸도록 했습니다.
    • 변경하지 않은 모듈이 깨졌습니다. 원본을 읽는 컴포넌트를 공통 모듈에 @Component 로 두었더니, Kafka 를 쓰지 않는 사용자 서비스의 애플리케이션 컨텍스트가 뜨지 않았습니다. 변경한 파일만 봐서는 알 수 없었고 전체 모듈 테스트를 다시 돌려서 찾았습니다. Kafka 를 쓰는 네 서비스의 설정에서만 빈으로 등록하도록 바꿨습니다.

    남은 것

    • 실제 클러스터에서 열어 본 적이 없습니다. 꺼진 채로 배포하고, 측정하고, 게이트웨이 경로를 배포한 뒤 스위치를 켜고 리허설하는 순서는 정해 두었지만 실행하지 않았습니다.
    • 두 구간은 처음부터 끝까지 이어서 확인하지 못했습니다.
      • 관리자 토큰으로 게이트웨이를 거쳐 진입점까지 가는 구간: 경로 규칙과 서비스의 권한 검사를 각각 확인했을 뿐, 실제 스택에서 이어서 호출해 보지 않았습니다.
      • 롤백 확인 스크립트가 클러스터에서 DB 와 브로커 값을 모으는 부분: 확인 로직만 가짜 입력으로 테스트했습니다.
    • 재실패는 일부만 실제 경로로 확인했습니다. 재발행분이 업무 소비자에서 실패하는 순간은 DLQ 에 직접 넣는 방식으로 대신했습니다. 그 뒤의 DLQ 헤더 읽기, 적재, 연결, 재개방은 실제 코드 경로로 실행됩니다.
    • 원본 timestamp 가 없는 원장 행의 비율은 측정하지 못했습니다. 그런 행은 재발행할 수 없으므로 그 비율만큼 재발행이 불가능한데, 운영 원장이 없어 표본이 0개였습니다. 0개를 “0%“로 쓰지 않고 “미측정”으로 남겼습니다. 비율이 10% 미만이면 받아들이고 10% 이상이면 원인부터 찾는다는 기준은 미리 정했습니다.
    • record_kind 와 root_record_id 는 아직 nullable 입니다. 기존 행을 채우는 작업까지는 했지만 NOT NULL 로 바꾸는 마지막 단계는 하지 않았습니다. 그때까지 “신버전은 항상 값을 넣는다”는 DB 제약 없이 코드만 지킵니다.
    • 롤백 확인의 “lag 0” 조건은 트래픽이 없는 환경에서만 확인했습니다. 트래픽이 꾸준한 토픽에서는 lag 가 정확히 0인 순간이 드물 수 있습니다. 그런 환경이라면 재발행 레코드의 offset 을 대상 group 의 커밋 offset 이 넘었는지로 보는 식으로 조건을 바꿔야 할 수 있습니다.
    • 재발행 조건을 확인하는 동안 #1 의 행 잠금이 유지됩니다. 원본을 Kafka 에서 읽는 시간만큼 같은 사건의 종결이나 재실패 연결이 기다립니다. 저빈도 수동 작업이라 받아들였지만, 브로커가 느릴 때 얼마나 기다리는지는 측정하지 않았습니다.
    • tombstone(본문이 null 인 레코드)은 재발행 대상이 아닙니다. 의도적으로 범위 밖에 두었습니다.
    • 알고 받아들인 한계도 그대로입니다.
      • 배포 래퍼는 우회할 수 있습니다.
      • 스위치는 재기동 없이 꺼지지 않습니다.
      • Kafka 디스크 용량 상한은 업무 토픽에만 있고 내부 토픽까지 포함한 전체 용량은 확인하지 못했습니다.

    이후 네 서비스에 복제돼 있던 outbox, 멱등 처리, DLQ 원장 코드를 공유 모듈 하나로 옮겼지만 재발행 동작은 바뀌지 않았습니다.

    더 좋은 방법은 없었을까요?

    아래는 작업을 마친 뒤 기록을 다시 보며 정리한 사후 분석입니다. 작업 중에는 따져 보지 않았고 이렇게 했다면 실제로 더 나았을지도 확인하지 않았습니다.

    원래 토픽에 다시 넣는 것 말고 다른 방식과 비교했다면

    이번 작업은 “원장을 가진 서비스가 원래 토픽에 다시 넣는다”를 전제로 시작했고, 이 전제를 다른 재처리 방식과 비교하지 않았습니다. 이 글의 복잡함 가운데 상당 부분이 이 전제에서 나왔습니다. 모든 group 이 다시 받는 문제, 발행 규칙의 예외, 구버전 poller 를 위한 sentinel 이 그렇습니다. 비교했어야 할 방식은 세 가지쯤입니다.

    • 실패한 서비스가 원본을 읽어 자기 핸들러로 다시 실행: 다른 group 에 메시지가 가지 않고 발행 규칙도 그대로 지킵니다. 대신 Kafka 소비 경로 밖에서 같은 핸들러를 부르는 별도 경로를 만들고, 처리 이력과 트랜잭션을 그 경로에서도 똑같이 지켜야 합니다.
    • group 별 재시도 토픽이나 DLQ: 다시 받는 대상을 한 group 으로 좁힐 수 있습니다. 대신 토픽과 구독이 group 수만큼 늘어납니다.
    • 실패한 group 의 offset 되감기: 가장 단순합니다. 대신 그 위치 뒤의 메시지까지 모두 다시 처리하고, group 의 소비자를 멈춘 상태에서만 할 수 있습니다.

    이 비교를 먼저 했다면 이번 설계를 고르더라도 그 이유를 글에 적을 수 있었을 것입니다.

    기준으로 삼을 값이 얼마나 오래 남는지 먼저 확인했다면

    이번 작업에서 가장 여러 번 바뀐 결정은 무엇을 기준 값으로 삼을지였습니다. 같은 문제가 세 번 나왔습니다.

    • 재실패 대조 목록의 record_kind: outbox 행에만 있는 값이었고 그 행은 미결 사건보다 먼저 지워집니다.
    • 롤백 확인의 첫 기준 시각, 재발행 마감 시각: 7일 멱등 window 를 뜻하는 값이었습니다.
    • 두 번째 후보, outbox 행의 생성 시각: 행이 지워지면 사라지고 발행 시각도 아니었습니다.

    원장의 본문을 대조에 쓸 수 없었던 것도 같은 이유입니다. 진단용이라 잘려서 저장되는 값이었습니다.

    네 값 모두 “확인하려는 대상만큼 오래 남는가, 그리고 정말 그 뜻의 값인가”를 먼저 물었다면 후보에서 빠졌을 것입니다. 결국 원장에 새로 둔 컬럼들을 기준으로 썼습니다. 원장은 미결 사건이 남아 있는 동안 함께 남고 새 컬럼들은 그 확인에만 쓰입니다.

    되돌릴 수 없게 되는 것의 목록을 먼저 만들었다면

    처음 계획에서는 문제가 생기면 앞 단계로 되돌리기로 했습니다. 되돌릴 수 없게 되는 부분은 약 2주에 걸쳐 하나씩 드러났습니다.

    • 진입점을 먼저 열면 사건이 여러 행으로 나뉜다
    • 구버전 poller 가 재발행 행에서 실패한다
    • 문서로는 롤백을 막지 못한다
    • REQUESTED 가 끝나지 않는다
    • 진입점까지 갈 경로가 없다

    다섯 가지 모두 “기능을 연 뒤에 무엇이 남는가”라는 질문 하나로 찾을 수 있었습니다. 처음부터 다음 질문들을 목록으로 만들어 두었다면 한 번에 볼 수 있었을 것입니다.

    • 새 데이터를 구버전 코드가 읽으면 어떻게 되나
    • 스스로 끝나지 않는 상태가 있나
    • 사람이 그 기능을 실제로 호출할 수 있나
    • 켜고 끄는 데 무엇이 필요한가

    운영자가 호출할 경로로 한 번 먼저 보내 봤다면

    재발행 기능은 끝까지 한 번도 실제로 열리지 않았습니다. 진입점까지 갈 경로가 없다는 사실도 진입점을 만든 뒤에야 알았고 게이트웨이를 거쳐 진입점까지 가는 구간은 마지막까지 실제 스택에서 호출해 보지 않았습니다.

    단서가 하나 있습니다. E2E 스택에는 게이트웨이가 없었고 서비스는 게이트웨이 도입 전환기에 쓰던 평문 헤더(서명 없이 요청자 정보를 담은 헤더)로도 요청자를 확인할 수 있었습니다. 코드를 따라가 보면 그 스택에서 스위치를 켜고 리허설했다면 재발행은 성공했을 것입니다(실행해 보지는 않았습니다). 그러면 경로가 없다는 문제는 오히려 가려졌을 수 있습니다. 그래서 리허설을 하더라도 운영자가 실제로 호출할 경로로 해야 했습니다. 게이트웨이와 관리자 토큰을 거쳐, 스위치가 꺼져 있는지 확인하고, 켠 뒤 한 건을 보내는 경로입니다.

    정리하며

    “DLQ 메시지를 원래 토픽에 다시 넣는다”는 한 줄을 만들려면 보장 수준, 원본 보존, 재실패 연결, 되돌리기의 네 가지를 정해야 했습니다. 그중 재실패를 원래 사건에 연결하는 경로와 되돌리기 전에 확인하는 장치는 기능을 열기 전에 코드에 있어야 했습니다. 이 결론의 근거는 실제 MySQL 과 Kafka 를 띄운 통합테스트, 손으로 고른 변이 검사, 구현과 별도로 다시 해 본 두 재현이고, 재발행 기능은 아직 실제 클러스터에서 열린 적이 없습니다. 운영자가 호출할 경로로 게이트웨이를 거쳐 한 건을 보내 보는 일과 원본 timestamp 가 없는 행의 비율을 재는 일이 남아 있습니다.