테넌트 초기화보다 먼저 발행된 Kafka 계약 이벤트
신규 테넌트가 제품 B를 사용하려면 제품 B 서비스에 테넌트를 먼저 만들고, 초기 설정이 끝난 뒤 계약 정보를 반영해야 합니다. 프로비저닝 서비스는 두 작업을 직접 처리하지 않고 Kafka 이벤트로 제품 B 서비스에 요청합니다.
테넌트 초기화와 계약 생성은 하나의 가입 과정에서 일어나지만 실행 조건은 같지 않습니다. 계약을 저장했다는 사실만으로 제품 B가 준비된 것은 아니며, 제품 B가 초기화를 마쳤다는 응답을 받아야 계약을 생성할 수 있습니다.
운영 환경에서는 이 조건이 발행 순서에 반영되지 않아 계약 생성이 간헐적으로 실패했습니다. 처음에는 서로 다른 토픽의 순서 문제로 보였지만, 로그를 따라가 보니 필요한 것은 메시지의 도착 순서가 아니라 선행 작업의 완료 여부였습니다.
제품 B 계약은 테넌트 초기화가 끝난 뒤 생성해야 한다#
프로비저닝 서비스는 tenant-init-request로 초기화를 요청합니다. 제품 B 서비스는 테넌트와 관련 데이터를 준비한 뒤 tenant-init-complete로 결과를 돌려줍니다. 계약 정보는 이 흐름과 별도로 contract-created를 통해 전달합니다.
정상 흐름에서는 tenant-init-complete(result=true) 이후에 contract-created를 발행해야 합니다. 다른 제품 서비스는 처리 대상이 아닌 계약을 추가 조회 없이 넘길 수 있었지만, 제품 B는 자신의 DB에 테넌트 초기화가 끝나 있어야 계약을 반영할 수 있었습니다. 같은 계약 이벤트를 받아도 제품 B에서만 실패가 드러난 이유입니다.
기존 구조에는 두 이벤트를 발행하는 경로만 있었고, 초기화 완료를 계약 발행의 조건으로 연결한 부분은 없었습니다.
계약 저장이 초기화 요청보다 먼저 contract-created를 발행했다#
운영 환경에서 제품 B 계약 생성이 실패했다는 제보를 받고 로그를 확인했습니다. 제품 B Consumer는 테넌트 정보를 찾지 못해 No value present를 기록했고, 같은 offset을 다시 처리하는 retry도 반복해서 실패했습니다.
DB에는 해당 계약이 나중에 생성되어 있었습니다. 최초 처리가 성공한 것은 아니었습니다. 약 1분 뒤 별도로 들어온 update 이벤트가 같은 로직을 다시 실행했고, 그때는 제품 B 초기화가 끝나 있어 계약이 생성됐습니다.
변경 전 14일의 로그를 넓혀 검색했을 때 같은 오류와 retry 패턴은 다음과 같이 확인됐습니다. 이 값은 계약 실패 건수를 집계한 운영 지표가 아니라, 각 환경의 로그에서 동일한 패턴을 찾은 횟수입니다.
| 환경 | 동일 로그 패턴 |
|---|---|
| Staging A | 9회 |
| Staging B | 10회 |
| Production | 4회 |
| 합계 | 23회 |
최근 진행한 EKS 전환도 원인 후보로 검토했지만, Production에서는 전환 전에도 같은 패턴이 있었습니다. 인프라 변경보다 이벤트 발행과 처리 순서를 먼저 확인했습니다.
스테이징에서 증상이 발생한 구간을 시간순으로 정리하면 다음과 같습니다.

| 시각 (KST) | 이벤트 |
|---|---|
| 12:20:17.564 | contract-created 수신 (offset 646) |
| 12:20:17.640 | retry 1 실패 — 테넌트 미존재 (No value present) |
| 12:20:27 / 37 / 47 / 57 | 같은 offset을 다시 소비하며 retry 실패 |
| 12:20:32.122 | tenant-init-request 처리 중 테넌트 row 생성 |
| 12:21:05.779 | tenant-init-complete(result=true) 수신 |
| 12:21:07~08 | 이후 update 이벤트로 계약 처리 성공 |
테넌트 row가 12:20:32에 만들어졌지만 이후 retry도 실패했습니다. 계약 생성에 필요한 조건은 row의 존재가 아니라 제품 B의 초기화 전체가 끝나는 것이었습니다. Consumer retry는 실패한 메시지를 다시 처리하는 기능인데, 이 구조에서는 선행 작업이 끝나기를 기다리는 역할까지 맡고 있었습니다.
다른 발생 건의 Producer 로그에서는 발행 순서가 더 분명했습니다.
11:43:44.477 contract-created 발행
11:43:55.641 tenant-init-request 발행 (+11s)
11:44:18.333 tenant-init-complete 발행 (+34s)계약 저장 트랜잭션이 먼저 커밋되면서 @TransactionalEventListener(phase = AFTER_COMMIT)가 contract-created를 발행했습니다. tenant-init-request는 외부 시스템 동기화를 포함한 별도 흐름을 거친 뒤 발행됐습니다. 따라서 제품 B Consumer가 계약 이벤트를 받을 때 테넌트 초기화가 시작되지 않은 경우도 있었습니다.
서로 다른 토픽의 순서를 맞춰도 초기화 완료는 보장되지 않는다#
Kafka의 순서 보장은 하나의 topic-partition 안에서 적용됩니다. tenant-init-request와 contract-created는 서로 다른 토픽과 발행 경로를 사용하므로 Kafka가 두 이벤트의 전역적인 순서를 정해주지 않습니다.
두 이벤트를 같은 토픽으로 합치는 방법도 검토했습니다. 이 방법으로 순서를 다루려면 같은 key와 partitioning 전략뿐 아니라, 초기화 처리가 끝난 뒤에만 다음 레코드를 처리하는 소비 경로까지 함께 설계해야 합니다. 토픽만 합치거나 tenant-init-request를 먼저 발행하는 것으로는 제품 B의 초기화 완료를 표현할 수 없었습니다.
retry 횟수와 backoff를 늘리는 방법은 선행 작업이 끝날 시간을 추측합니다. 초기화 시간이 retry 예산보다 길면 같은 실패가 반복되고, 그동안 Consumer는 처리할 수 없는 이벤트를 계속 다시 읽습니다.
Kafka transaction과 exactly-once는 Kafka 안의 consume-process-produce 경계를 다루고, Transactional Outbox는 DB 상태 변경과 이벤트 발행 사이의 누락을 줄이는 방식입니다. 이번 문제에서는 이벤트가 유실된 것이 아니라 다른 서비스의 초기화가 끝나기 전에 후속 이벤트를 발행한 것이므로, 두 방법이 직접적인 완료 조건을 제공하지는 않았습니다.
이미 제품 B가 보내고 있던 tenant-init-complete에는 필요한 정보가 있었습니다. 두 토픽의 시간을 맞추는 대신 result=true를 받은 뒤에만 계약 이벤트를 발행하기로 했습니다.
tenant-init-complete(result=true) 뒤에 계약 이벤트를 발행하다#
신규 테넌트의 제품 B 계약은 기존 계약 생성 경로에서 바로 발행하지 않고 보류했습니다. 프로비저닝 서비스가 tenant-init-complete(result=true)를 받으면 계약을 다시 조회해 contract-created를 발행합니다.
새 토픽을 추가하거나 제품 B Consumer의 처리 코드를 바꾸지는 않았습니다. 기존 완료 응답을 프로비저닝 서비스의 발행 조건으로 사용해 후속 이벤트가 나갈 시점을 통제했습니다.
모든 제품 B 계약을 완료 이벤트 뒤로 미룰 수는 없었습니다. 기존 고객이 제품 B를 추가하는 경우에는 이미 테넌트가 있으므로 tenant-init-request와 tenant-init-complete가 발생하지 않습니다. 초기화가 필요한 신규 테넌트에만 발행 대기를 적용하고, 기존 고객의 제품 B 추가는 이전처럼 바로 발행했습니다.
중복 완료와 늦은 실패가 상태를 되돌리지 않게 하다#
기존 tenant-init-complete를 발행 조건으로 사용하려면 완료 이벤트의 중복과 순서도 고려해야 했습니다. Kafka UI에서 확인한 토픽에는 같은 테넌트의 실패 뒤 성공한 결과가 있었고, 여러 partition의 이벤트 사이에는 전역 순서를 가정할 수 없었습니다.
| 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 |
그래서 완료 상태와 계약 발행에 다음 규칙을 적용했습니다.
| 수신 조건 | 상태와 발행 규칙 |
|---|---|
첫 complete(true) | COMPLETE로 전이하고 계약 이벤트 발행 준비 |
중복 complete(true) | 상태와 계약 이벤트를 다시 만들지 않음 |
complete(false) | 실패 상태를 유지하고 계약 이벤트를 발행하지 않음 |
| COMPLETE 이후 늦은 FAILURE | 완료 상태를 되돌리지 않음 |
| 기존 고객의 제품 B 추가 | 초기화 완료 이벤트를 기다리지 않고 즉시 발행 |
현재 상태만 저장해서는 중복 발행을 막을 수 없습니다. 어느 이전 상태에서 COMPLETE로 전이할 수 있는지와 그 전이가 처음 성공했을 때만 계약 발행을 준비한다는 조건을 함께 두었습니다.
상태 전이와 발행 준비를 한 트랜잭션에서 처리하다#
완료 상태를 먼저 커밋하고 계약 조회나 발행 준비가 실패하면 DB에는 COMPLETE만 남습니다. 같은 완료 이벤트가 다시 들어와도 이미 처리된 상태로 판단해 계약 발행을 재시도하지 않을 수 있습니다.
초기화 상태 전이, 계약 재조회와 발행 준비를 하나의 DB 트랜잭션에 넣었습니다. 발행 준비 중 예외가 발생하면 상태 변경도 함께 롤백되므로, COMPLETE만 남고 계약 이벤트가 빠지는 상태를 막을 수 있습니다.
후속 이벤트는 @TransactionalEventListener(phase = AFTER_COMMIT)로 발행하고 있었습니다. Spring의 트랜잭션 연동 이벤트 문서에 따르면 기본 설정의 리스너는 활성 트랜잭션이 없으면 실행되지 않습니다. tenant-init-complete Consumer의 코디네이터 진입점에서 트랜잭션을 열고, 계약 데이터도 그 안에서 다시 조회해 발행 데이터를 구성했습니다.
처음에는 짧은 트랜잭션 안에서 조회한 entity의 LAZY 연관 객체를 트랜잭션 밖에서 사용해 LazyInitializationException이 발생했습니다. 조회와 발행 데이터 구성을 같은 트랜잭션에 두면서 이 경계도 맞췄습니다. 코드 리뷰에서는 진입점과 중복되던 하위 발행 메서드의 @Transactional을 제거하고 외부에서 호출하지 않는 메서드를 private으로 바꿨습니다.
DB 커밋 이후 Kafka 전송까지 보장하려면 다른 설계가 필요하다#
이번 변경이 보장하는 범위는 제품 B의 초기화가 완료되기 전에 계약 이벤트를 준비하지 않는 것까지입니다. 애플리케이션 DB 트랜잭션과 Kafka broker 전송을 하나의 원자적 작업으로 만든 것은 아닙니다.
DB 커밋 뒤 프로세스가 종료되어도 이벤트를 빠뜨리지 않아야 한다면 Transactional Outbox처럼 발행할 데이터를 DB에 영속화하는 구조가 필요합니다. 같은 계약이 재처리될 때 부수효과까지 한 번만 실행해야 한다면 소비 측 멱등성이나 계약 단위의 발행 이력도 함께 필요합니다. 이 요구들은 선행 작업의 완료 조건을 정하는 문제와 구분해 다뤄야 합니다.
중복과 역순을 포함해 계약 발행 조건을 검증하다#
테스트는 정상적인 초기화 완료뿐 아니라 동일한 완료 이벤트의 중복 수신, 초기화 실패, FAILURE 이후 성공, COMPLETE 이후 늦은 FAILURE와 기존 고객의 제품 B 추가를 포함했습니다. 발행 준비 중 예외가 발생하면 완료 상태도 롤백되는지도 확인했습니다.
Embedded Kafka 통합 테스트에서는 이벤트가 한 번만 오거나 예상한 순서로만 온다고 가정하지 않았습니다. 중복과 역순이 발생해도 최종 초기화 상태와 계약 발행 결과가 유지되는지를 검증했습니다.
배포 후 스테이징에서 제품 B 신규 테넌트를 연속으로 생성했습니다. Kafka UI에서는 tenant-init-request → tenant-init-complete → contract-created 순서를 확인했고, Datadog에서는 기존의 No value present와 retry 로그를 함께 관찰했습니다.

제품 B 담당 팀도 신규 테넌트 두 곳에서 tenant-init-complete(result=true) 이후 계약 이벤트가 발행됐고, No value present와 불필요한 retry가 발생하지 않은 것을 확인했습니다. 변경 전 14일 동안 로그에서 23회 확인됐던 패턴은 배포 후 관측 기간에는 다시 나타나지 않았습니다.
토픽의 순서보다 후속 이벤트의 발행 조건을 먼저 정해야 했다#
Kafka에서 같은 partition의 메시지 순서를 보장하는 것과, 다른 서비스에서 수행하는 작업이 끝났음을 확인하는 것은 다른 문제였습니다. 이번 흐름에서 계약 이벤트가 기다려야 했던 것은 tenant-init-request의 발행이나 수신이 아니라 제품 B가 보낸 완료 응답이었습니다.
비동기 작업에 선후 관계가 있다면 요청, 수락과 완료를 각각 구분하고 어떤 상태가 후속 이벤트의 발행 권한을 만드는지 정해야 합니다. 이후에는 토픽과 key로 순서를 맞추기 전에, 실제로 필요한 조건이 메시지의 순서인지 선행 작업의 완료인지부터 확인해야 할 것 같습니다.