간헐적으로만 실패하는 계약 생성 — Kafka 크로스 토픽 순서 race 포렌식
retry가 우연히 맞아떨어지고 있었다#
운영 환경에서 한 테넌트의 제품 B 계약 생성이 실패했다는 제보가 들어왔습니다. 로그를 확인해 보니, 계약 이벤트를 받은 하위 제품 서비스가 테넌트 정보가 없다며 No value present 예외를 던지고 있었습니다. 이 예외로 인해 컨슈머의 retry가 1회부터 5회까지 전부 소진되었고, 그대로 실패로 끝났어야 하는 상황이었습니다.
그런데 조사를 시작해 보니 해당 테넌트의 계약이 데이터베이스에 멀쩡히 만들어져 있었습니다. 어떻게 된 일인지 추적해 본 결과, 약 1분 뒤에 별개로 유입된 단순 update 이벤트가 같은 처리 로직을 다시 태우면서 우발적으로 계약이 복구된 것이었습니다.
고쳐져서 정상적으로 돌아간 게 아니라, 타이밍이 우연히 맞아서 돌아간 거라 오히려 이쪽이 더 무섭게 느껴졌습니다. 이게 한 번뿐인 우연인지 확인하기 위해 로그를 14일 치 거슬러 올라가 봤습니다. 그 결과 스테이징 두 환경에서 각각 9건과 10건, 심지어 운영 환경에서도 4건(5/28)이나 동일한 패턴이 발생한 것을 찾아냈습니다. 처음에는 최근 진행된 EKS 인프라 전환 작업을 의심했지만, 운영 환경 발생 시점을 확인하면서 인프라와는 무관하게 처음부터 존재했던 구조적 문제라는 확신이 들었습니다.
배경 구조는 이렇습니다. 프로비저닝 서비스가 새로운 계약을 생성하면 contract-created 토픽으로 이벤트를 발행하고, 하위 제품 서비스들이 이를 구독해 각자 자신의 DB에 계약을 반영합니다. 만약 신규 테넌트라면, 이와 별도로 tenant-init-request 토픽으로 초기화 요청이 먼저 나갑니다. 제품 B 서비스가 이 요청을 받아 테넌트를 생성한 뒤, tenant-init-complete 토픽으로 완료 응답을 프로비저닝 서비스로 돌려주는 흐름입니다.
즉, 계약 정보와 테넌트 초기화 정보가 서로 다른 토픽으로 흐르고 있었는데, 모든 문제는 바로 여기서 시작되었습니다.
로그 포렌식 — 타임라인 재구성#
도대체 어떤 순서로 이벤트가 처리되고 있길래 이런 일이 벌어지는지, 스테이징 환경에서 동일한 증상이 잡힌 구간의 로그를 분 단위로 쪼개어 재구성해 봤습니다.

| 시각 (KST) | 이벤트 |
|---|---|
| 12:20:17.564 | contract-created 수신 (offset 646) |
| 12:20:17.640 | retry 1 실패 — 테넌트 미존재 (No value present) |
| 12:20:27 / 37 / 47 / 57 | retry 2~5 — 에러 핸들러가 매번 offset 646으로 seek해서 재소비 |
| 12:20:32.122 | (그 사이) tenant-init-request 처리 → 테넌트 row 생성 |
| 12:21:05.779 | tenant-init-complete 수신 (result: true) |
| 12:21:07~08 | retry 성공 — 계약 생성 완료 |
우선 retry의 양상을 보면, 앞으로 나아가는 게 아니라 에러 핸들러가 매번 같은 offset(646)으로 seek을 수행하며 제자리걸음을 하고 있었습니다. 선행 조건인 테넌트 생성이 외부에서 완료되기를 기다리는 일종의 폴링 작업을, 애꿎은 컨슈머 에러 핸들러가 10초 간격으로 대신 수행하고 있던 셈입니다.
더 이상했던 건 타임라인의 중간 부분입니다. 12:20:32에 이미 DB에 테넌트 row가 생성되었는데도, 그 이후에 발생한 12:20:37, 47, 57의 retry는 계속해서 실패했습니다. 그리고 전체 초기화 과정이 끝나고 tenant-init-complete 응답이 수신된 12:21:05 이후에야 비로소 retry가 성공했습니다.
처음 로그를 볼 때는 "row가 있는데 왜 조회를 못 하지?" 하고 이상하게만 여겼습니다. 하지만 이 데이터는 나중에 해결책을 설계할 때 기준점을 어디에 두어야 하는지 알려주는 가장 중요한 단서가 되었습니다.
Kafka가 보장하는 것과 보장하지 않는 것#
수신 로그만으로는 부족해서, 다른 재현 건을 찾아 이번에는 발행 시점의 타임라인만 따로 뽑아봤습니다.
11:43:44.477 contract-created 발행 (기준)
11:43:55.641 tenant-init-request 발행 (+11s)
11:44:18.333 tenant-init-complete 발행 (+34s)계약 이벤트가 초기화 요청보다 무려 11초나 먼저 나갑니다. 보통 'race condition(경쟁 상태)'이라고 하면 0.1초 차이로 승패가 갈리는 아슬아슬한 타이밍 싸움을 떠올립니다. 하지만 이 경우는 구조적으로 거의 항상 계약 이벤트가 먼저 도착하게끔 기울어져 있었습니다.
코드를 따라가 원인을 찾아보니, 계약을 생성하는 로직에서 DB에 계약을 저장하는 트랜잭션이 테넌트 도메인 생성 로직보다 앞에 위치해 있었습니다. 트랜잭션이 커밋되는 순간 @TransactionalEventListener(phase = AFTER_COMMIT) 리스너가 동작해 즉시 Kafka로 이벤트를 쏴버립니다. 반면 테넌트 초기화 요청은 외부 시스템과의 동기화가 필요한 무거운 작업이라, 선행 로직들이 모두 끝나고 Kafka 왕복까지 마친 뒤에야 비로소 발행되는 구조였습니다.
여기서 짚고 넘어가야 할 Kafka의 특성이 있습니다. Kafka가 순서를 보장하는 범위는 같은 토픽의 같은 파티션 안까지입니다. 파티션 하나 안에서는 큐에 들어간 순서대로 정확히 소비되지만, 토픽이 다르다면 순서 보장은 완벽한 0입니다. 브로커가 잘못한 게 아니라 애초에 설계상 약속한 적이 없는 영역인 셈이죠.
우리는 순서 보장이 전혀 없는 그 공간 위에, 순서에 절대적으로 의존하는 두 개의 메시지를 흘려보내고 있었습니다. 심지어 의존하는 쪽(계약)을 선행 자원(테넌트 초기화)보다 먼저 발행하고 있었으니, race가 항상 나쁜 쪽으로 결론 날 수밖에 없는 안타까운 배치였습니다.
왜 한 서비스만 터졌나#
또 하나 궁금했던 점은, contract-created 이벤트가 특정 key 없이 발행되어 컨슈머 그룹 3개가 fan-out 형태로 똑같이 받아가는데 유독 제품 B 서비스에서만 에러가 났다는 점입니다. race 조건 자체는 세 소비자 모두에게 완전히 동일하게 주어졌는데 말이죠.
차이는 바로 선행 조건의 유무였습니다. 다른 소비자들은 이벤트 내용이 제품 B의 계약이면 그냥 무시하고 return 해버리는 로직을 갖고 있었습니다. 테넌트가 존재하든 말든 상관없이 통과한 겁니다. 반면 제품 B 서비스는 자신의 계약을 반영하려면 무조건 테넌트 row가 사전에 존재해야 하는 강한 선행 조건(hard precondition)을 갖고 있었습니다. 로그에 남은 No value present는 바로 그 필수 정보를 조회하다가 Optional이 비어 있어서 터진 예외였습니다.
크로스 토픽 race라는 근본적인 문제는 시스템 전 구간에 깔려 있었지만, 그로 인한 파열음은 선행 조건이 엄격한 단 한 곳에서만 발생했습니다. 간헐적인 실패가 꽤 오랜 시간 동안 운영 환경에서 살아남을 수 있었던 이유가 대체로 이런 모양인 것 같습니다. 대부분의 소비자에게는 무해하게 넘어가다 보니, 마치 깨지는 그 한 곳만의 로컬 문제인 것처럼 시야가 좁아지기 때문입니다.
토픽을 합치면 되지 않나#
원인을 명확히 파악한 뒤 가장 먼저 떠올린 해결책은 단순했습니다. "두 이벤트를 같은 토픽에 넣어서 Kafka의 파티션 순서 보장을 받자"는 것이었습니다. 하지만 조금만 깊이 검토해 보니 이 아이디어는 세 군데서 무너졌습니다.
첫째, 같은 토픽이라도 같은 파티션에 들어가야만 순서가 보장됩니다. 현재 우리는 이벤트를 key 없이 발행하고 있어 메시지가 파티션들에 라운드로빈 방식으로 무작위 분산됩니다. 토픽을 하나로 합치더라도 두 메시지가 서로 다른 파티션에 배정되면 순서는 여전히 꼬이게 되고, 결국 라우팅 key 설계부터 바닥부터 다시 해야 합니다.
둘째, 도착 순서를 완벽하게 맞춰도 문제가 해결되지 않습니다. tenant-init-request는 어디까지나 "테넌트를 만들어라"라는 지시일 뿐, 실제 생성 작업은 컨슈머 쪽에서 비동기로 진행됩니다. 아무리 요청 메시지가 계약 메시지보다 0.1초 먼저 도착하게 만든다고 해도, 계약을 처리하는 시점에 테넌트 생성이 끝났다는 보장은 어디에도 없습니다.
앞서 타임라인 재구성 파트에서 발견했던 사실이 여기서 결정타로 작용합니다. 테넌트 row가 DB에 생긴 12:20:32 이후에도 retry는 계속 실패했고, 초기화 로직 전체가 끝난 12:21:05의 완료 응답 뒤에야 성공했습니다. 우리가 기다려야 할 진짜 기준은 'DB row 존재 여부'가 아니라 **'초기화 로직의 완전한 종료'**였던 겁니다. 메시지 도착 순서 조정만으로는 애초에 풀리지 않는 문제였습니다.
셋째, 소비 구조의 제약입니다. 계약 토픽은 3개 그룹이 함께 소비하고 초기화 토픽은 제품 B 전용으로 사용 중입니다. 이 둘을 합치는 순간 기존의 메시지 스키마와 소비자들의 구독 구조를 전부 뜯어고쳐야 하는 대공사가 됩니다.
토픽 병합 외에 흔히 거론되는 카드들도 함께 검토했지만 결론은 다르지 않았습니다. 컨슈머 쪽 retry를 늘리는 방법(backoff 연장)은 지금 구조의 연장선일 뿐입니다. 선행 조건이 채워지길 기다리는 폴링을 에러 핸들러에 계속 떠넘기는 것이고, 초기화가 오래 걸리는 테넌트는 여전히 실패합니다. Kafka 트랜잭션(exactly-once)은 한 애플리케이션 안에서 consume-process-produce를 원자적으로 묶는 장치라, 서로 다른 서비스가 각자 발행하는 두 토픽 사이의 순서와는 애초에 층위가 다릅니다. Transactional Outbox 패턴도 마찬가지였습니다. outbox가 푸는 문제는 "DB 커밋과 발행의 원자성"인데 우리가 겪는 건 "상대 서비스의 처리 완료를 모른다"는 문제입니다. 지금 쓰고 있는 AFTER_COMMIT 리스너가 사실상 outbox의 경량 사촌 격인데, 그게 문제의 원인이 아니었던 겁니다.
결국 지금 우리에게 필요한 건 메시지가 도착하는 순서가 아니라, 선행 작업이 끝났음을 확실히 알려주는 **완료 확인(handshake)**이었습니다.
발행자 게이트 — 새 토픽 0개, 소비자 무변경#
수신 쪽에서 순서를 맞추는 대신, 아예 이벤트를 발행하는 쪽에서 완료를 확인한 뒤에 발행하도록 구조를 바꿨습니다. 이른바 '발행자 게이트(Publisher Gate)' 패턴입니다.
① 프로비저닝 ──[tenant-init-request]──▶ 제품 B (만들어라)
② 제품 B가 테넌트 생성
③ 제품 B ──[tenant-init-complete]──▶ 프로비저닝 (완료 handshake)
④ 프로비저닝이 ③ 수신 → 그제서야 ──[contract-created]──▶ 제품 B이 그림에서 ①번부터 ③번까지의 흐름은 놀랍게도 새로 만든 게 아닙니다. 시스템에 이미 오가고 있던 메시지 흐름을 그대로 재사용한 겁니다. 프로비저닝 서비스에 완료 응답을 받는 컨슈머가 이미 존재하긴 했는데, 상태값만 업데이트하고 후속 작업(다운스트림)이 전혀 없는 빈 껍데기였습니다.
저는 이 비어 있던 hook 지점에 '계약 발행 코디네이터'를 새롭게 연결했습니다. 그리고 기존 계약 생성 경로에서는 제품 B에 해당하는 계약 이벤트만 즉시 발행되지 않도록 게이트 뒤로 보류시켰습니다.
결과적으로 새로운 토픽을 1개도 추가하지 않고, 소비자(제품 B 서비스) 측의 코드는 단 한 줄도 건드리지 않은 채, 프로비저닝 서비스 쪽 배포 한 번으로 흐름을 통제할 수 있게 되었습니다.
// 기존 tenant-init-complete 컨슈머
// 단순히 상태만 갱신하던 자리에 코디네이터를 연결해 게이트 역할 부여
@KafkaListener(topics = "tenant-init-complete", groupId = "provisioning-group")
public void consume(TenantInitCompleteMessage message) {
// 상태 변경과 계약 발행을 반드시 하나의 트랜잭션으로 묶어 처리
// (발행에 실패하면 COMPLETE 상태도 롤백되어 영구 유실 방지)
contractPublishCoordinator.handleTenantInitComplete(
message.tenant(), message.serviceType(), message.result());
}// 기존 계약 생성 경로
// 제품 B의 계약만 발행을 보류하고, 나머지는 기존과 동일하게 즉시 발행
contracts.stream()
.filter(contract -> !contract.isOfProductB())
.forEach(contractEventPublisher::publish);물론 이 기존의 완료 응답 메시지를 트리거로 전적으로 믿어도 될지 검증이 필요했습니다. Kafka UI를 통해 tenant-init-complete 토픽의 실데이터를 확인해 보았습니다.
| offset | partition | timestamp | value |
|---|---|---|---|
| 195 | 1 | 6/8 12:21:05.779 | {"tenant":"tenant-a","serviceType":"PRODUCT_B","result":true} |
| 194 | 0 | 6/8 11:12:50.982 | tenant-b · true |
| 193 | 0 | 6/4 20:32:07.886 | tenant-c · true |
| 194 | 1 | 6/4 14:44:59.868 | tenant-d · true |
| 192 | 0 | 6/4 11:36:04.661 | tenant-d · false |
맨 위 offset 195번이 타임라인 12:21:05.779에 찍힌 바로 그 메시지입니다. 데이터를 보니 성공(true)이든 실패(false)든 완료 응답이 누락 없이 안정적으로 들어오고 있었습니다. 트리거로 쓰기엔 충분히 신뢰할 만했습니다.
다만 표 아래쪽의 tenant-d를 보면, false(6/4 11:36)가 들어온 이후 다시 true(6/4 14:44)가 들어오는 케이스가 실제로 존재했습니다. 이는 동일한 테넌트에 대해 메시지가 여러 번 들어올 수 있음을 의미하므로 멱등성 처리가 필수적입니다. 또한, 파티션이 0과 1로 나뉘어 있어 완료/실패 메시지의 순서가 뒤바뀌어 수신될 가능성도 안고 있었습니다. 이러한 특징들은 고스란히 다음 단계에서 고려해야 할 엣지 케이스가 되었습니다.
경계상황 5종과 트랜잭션 설계의 디테일#
발행을 통제하는 게이트의 아이디어 자체는 단순하지만, 발행 시점을 강제로 늦추면서 파생되는 새로운 경계상황(Edge Case)들이 꽤 많았습니다. PR을 올릴 때 리뷰어들이 안심할 수 있도록 대응 방안을 표로 정리했습니다.
| 상황 | 미처리 시 영향 | 처리 방안 |
|---|---|---|
| 동일 complete(true) 중복 수신 (at-least-once) | 계약 중복 발행 | 상태 전이 메서드가 실제 변경 여부(boolean)를 반환하게 하여 최초 1회만 발행되도록 차단 |
| 초기화 실패 (result: false) | 없는 테넌트에 계약 발행 강행 | 즉시 미발행 처리. 추후 재실행 API로 초기화에 성공하면 그때 자동 발행 |
| COMPLETE 후 FAILURE 지연 도착 (key=null) | 완료된 테넌트가 FAILURE 상태로 회귀 | 현재 상태가 이미 COMPLETE라면 늦게 도착한 stale FAILURE는 무시 |
| 기존 고객이 제품 B 상품 추가 | 초기화 이벤트 자체가 발생 안함 → 트리거 부재로 영구 미발행 | 신규가 아닌 상품 추가 케이스만 별도로 판별해 게이트를 거치지 않고 즉시 발행 |
| 발행 과정 중 DB/이벤트 등록 실패 | COMPLETE 상태만 남고 발행 실패 → 재시도 불가로 영구 유실 | 상태 변경과 발행 준비를 단일 트랜잭션으로 결합. 실패 시 롤백하여 기존 복구 프로세스 태움 |
이 중 넷째 행은 게이트 설계 시 가장 놓치기 쉬운 치명적인 함정입니다. 이미 테넌트가 존재하는 기존 고객이 뒤늦게 제품 B 상품을 추가하는 경우에는 애초에 테넌트 생성 과정이 생략되므로 초기화 요청(init-request) 자체가 나가지 않습니다. 당연히 완료 응답(init-complete) 트리거도 영원히 돌아오지 않죠. 만약 아무 생각 없이 제품 B 계약이라는 이유만으로 몽땅 게이트 뒤에 가둬뒀다면, 기존 고객의 계약 이벤트는 블랙홀에 빠져 영원히 발행되지 않았을 겁니다. 이를 방지하기 위해 기존 고객 상품 추가 케이스(productBNewlyAdded)를 정확히 판별하여 즉시 발행 경로를 태우는 로직이 반드시 필요했습니다.
다섯째 행은 코드 리뷰 과정에서 팀원과 가장 밀도 있는 기술적 논의가 오간 지점입니다. 동료 리뷰어는 이렇게 질문했습니다. "상태를 COMPLETE로 업데이트하는 로직과 계약 정보를 다시 조회해 이벤트를 쏘는 로직을 굳이 별도의 트랜잭션으로 빼서 하나로 묶으신 이유가 궁금합니다."
만약 두 로직을 분리해 순차적으로 실행한다고 가정해 봅시다. 앞의 상태 변경은 성공해서 DB에 COMPLETE가 커밋되었는데, 직후에 네트워크 지연 등으로 계약 발행 로직이 예외를 던진다면 어떻게 될까요? DB에는 COMPLETE 상태가 남지만 이벤트는 발행되지 않은 상태(Inconsistent State)가 됩니다. 더 무서운 건 그 이후입니다. 동일한 완료 응답 메시지가 재처리되더라도, 첫 번째 행의 방어 로직(멱등성 체크)이 "이미 COMPLETE 상태네? 변경된 게 없으니 발행도 하지 마"라고 판단해 skip 해버립니다. 영구 누락이 발생하는 것이죠.
그래서 이 둘을 하나의 트랜잭션으로 강력하게 묶었습니다. 발행 준비가 실패하면 COMPLETE 상태 변경도 함께 롤백되도록 만든 겁니다. 이렇게 되면 상태가 여전히 대기 중으로 남기 때문에, 기존에 존재하던 재실행 API를 호출하는 것만으로 자연스럽게 전체 복구 경로에 올라탈 수 있게 됩니다. 시스템을 보호하기 위한 멱등성 장치가 자칫 치명적인 유실 장치로 표변할 수 있기 때문에, 트랜잭션의 경계를 멱등성 판정의 논리적 단위와 일치시킨 것입니다.
이 과정에서 기술적인 암초도 두어 번 만났습니다. 하나는 트랜잭션 경계 자체를 잡는 문제였습니다. @TransactionalEventListener(phase = AFTER_COMMIT)는 현재 진행 중인 활성 트랜잭션에 바인딩되어 동작하는데, Kafka 컨슈머 스레드는 진입 시점에 기본적으로 트랜잭션을 물고 있지 않습니다. 트랜잭션 없이 일반 이벤트만 덜렁 publish 해버리면, 기껏 등록한 애프터 커밋 리스너가 호출조차 되지 않고 조용히 증발할 수 있는 구조였죠. 코디네이터가 굳이 명시적으로 트랜잭션을 열어야 했던 이유가 여기 있습니다. 그렇게 트랜잭션을 열고 나니 이번엔 그 스코프를 어디까지 넓혀야 하느냐가 문제였습니다. 스코프를 좁게 잡았더니 LazyInitializationException이 터졌는데, 트랜잭션 밖에서 detached 된 계약 엔티티로 발행 페이로드를 만들려다 LAZY 연관 객체에 접근하는 순간 영속성 컨텍스트가 닫혀 있어 나는 에러였습니다. 결국 트랜잭션 안에서 엔티티를 한 번 더 재조회해 페이로드를 구성하도록 정리했습니다.
코드 리뷰에서는 제가 작성한 코디네이터 클래스 내부의 세부 publish 메서드에 붙은 @Transactional이 중복이라는 예리한 지적도 받았습니다. 진입점에서 이미 트랜잭션을 열어두고 있었기 때문이죠. 타당한 지적이라 기분 좋게 수용하고, 해당 어노테이션을 제거한 뒤 메서드 접근자를 private으로 내리는 리팩토링으로 마무리했습니다. 혼자서는 놓칠 뻔한 디테일을 동료의 리뷰 덕분에 단단하게 다질 수 있었습니다.
게이트가 막지 못하는 것#
솔직하게 적어두자면, 이 게이트로 모든 유실 가능성이 사라진 건 아닙니다. 남는 구멍도 PR에 그대로 기록해 뒀습니다.
우선 producer의 전송 자체는 fire-and-forget입니다. 트랜잭션이 커밋되어 COMPLETE가 확정된 다음에 브로커 전송이 실패하면 그 발행은 유실됩니다. 발행 "준비" 단계의 실패는 롤백 덕분에 재실행 경로로 복구되지만, 전송 자체의 실패는 이 코드베이스의 프로듀서 전반이 공유하는 한계라, 별도의 재발행 수단을 후속 과제로 남겼습니다.
재-COMPLETE 전이 때는 테넌트 단위로 계약을 다시 발행하기 때문에 좁은 중복 창도 존재합니다. 트리거 경로를 좁혀 위험 자체는 줄였지만, 완전히 닫으려면 소비 측 계약 반영의 멱등성 확인과 계약 단위 발행 이력이 필요해서 이것도 후속으로 분리했습니다. 마지막으로 완료 토픽이 partition 0과 1로 나뉘어 있으니, 완료 컨슈머가 두 파티션을 모두 소비하고 있는지를 배포 전 점검 항목에 넣었습니다. 한쪽만 물고 있었다면 완료 응답의 절반이 증발했을 테니까요.
검증 — 임베디드 Kafka 테스트와 배포 후 0건#
경계상황들이 전부 미세한 타이밍과 메시지 도달 순서에 얽혀 있다 보니, mock을 활용한 단순 유닛 테스트만으로는 도무지 마음이 놓이지 않았습니다.
그래서 실제 카프카 환경과 동일하게 동작하는 임베디드 Kafka 통합 테스트를 111줄가량 작성했습니다. 메시지 도착 순서 역전, 중복 수신, 초기화 실패, 기존 고객의 상품 추가, 발행 도중 실패 등 앞서 정의한 모든 시나리오를 실제 컨슈머 경로에 그대로 태워 꼼꼼히 검증했습니다. 기존의 유닛 테스트들까지 모두 합치면 약 522줄에 달하는 테스트 코드가 PR에 포함되었고, CI 파이프라인 빌드를 무사히 통과했습니다.
PR 본문에는 코드뿐만 아니라 배포 후 인프라 상에서의 검증 계획을 구체적으로 남겼습니다.
- 스테이징 환경에서 제품 B가 포함된 테넌트 약 30개를 연속 생성하며 전후 발생 건수를 비교하고 (목표 0건)
- Kafka UI를 통해 3개의 관련 토픽이 '요청 → 완료 → 계약 발행'의 올바른 순서로 적재되는지 직접 눈으로 확인하며
- 운영 환경 Datadog에는
No value present키워드를 탐지하는 알람을 걸어두겠다는 계획이었습니다.

운영 배포가 끝나고 얼마 뒤, 제품 B를 담당하는 소비 측 팀에서 반가운 메시지가 도착했습니다.
"테스트 결과 수정 완료 확인되었습니다. 수정 후 신규 테넌트 두 곳 모두 tenant-init-complete(true) 수신 후 계약 이벤트가 발행되는 순서로 정상 처리되었습니다. Datadog 로그상으로도 배포 시점 이후 No value present 에러나 불필요한 retry가 단 1건도 발생하지 않았습니다."
이전 14일 동안 23건이나 끈질기게 발생하던 간헐적 증상이, 소비 측 팀의 확인 시점까지 완벽하게 0건으로 줄어들었습니다. 메시지 도착 타이밍이 우연히 맞아떨어지기만을 막연히 기다리던 불안한 구조가, 작업 완료를 확실하게 인지한 뒤 발행하는 구조로 바뀌면서 나온 결과였습니다.
순서가 필요하면 완료를 확인하라#
이번 포렌식과 트러블슈팅을 통해 얻은 뼈저린 교훈은 하나입니다. 비동기 이벤트 환경에서 논리적인 순서가 보장되어야 한다면, 메시지의 발송 타이밍에 기댈 게 아니라 선행 작업이 끝났음을 발행 전에 확실히 확인해야 한다는 것입니다.
Kafka가 자랑하는 강력한 순서 보장은 어디까지나 '같은 토픽, 같은 파티션 내의 도착 순서'에 국한됩니다. 백번 양보해서 도착 순서를 완벽하게 통제했다고 해도, 상대편 서비스가 그 메시지를 받아 처리를 완료하는 것은 전혀 별개의 타임라인입니다. 실제로 이번 케이스에서도 DB에 테넌트 row가 생성되고 나서 전체 초기화 로직이 끝나기까지 33초나 더 걸렸고, 그 사이 컨슈머가 시도한 retry는 전부 실패로 돌아갔습니다.
다행히 우리 시스템에는 이미 오가고 있는 완료 응답 메시지가 존재했습니다. 그것이 가장 저렴하고 확실한 게이트 재료가 되어주었습니다. 새로운 토픽을 늘리지도, 소비자의 코드를 변경하지도 않고, 오직 발행자 쪽의 배포 한 번으로 복잡한 분산 환경의 타이밍 이슈를 잠재울 수 있었습니다.
마지막으로 작은 예고를 하나 남기며 글을 맺으려 합니다. 이번 건을 조사하려고 로그를 뒤지다 우연히 Datadog에서 리밸런싱 generation 숫자가 1만 6천을 넘어선 괴상한 컨슈머 그룹 하나를 발견했습니다. 여러 독립된 서비스들이 동일한 group id 리터럴을 하드코딩해서 공유하는 바람에, 90개 가까운 컨슈머가 거대한 단일 그룹으로 묶여 있었습니다. 이는 메시지 순서가 아니라 특정 멤버의 배포나 타임아웃이 그룹 전체의 리밸런싱으로 번지는 '폭발 반경(Blast Radius)'이라는 완전히 다른 축의 문제입니다. 원인은 규명해 두었지만 group id 분리는 아직 계획 단계라, 그 과정과 원리를 다음 글에서 따로 풀어보려 합니다.