카프카는 예외도 로그도 없이 조용히 잃는다 — 정산 시스템이 실패를 시끄럽게 만드는 여섯 지점
HTTP 는 실패할 때 시끄럽다. 500 이 뜨고, 스택트레이스가 남고, 호출한 쪽이 즉시 안다.
카프카는 그렇지 않다. 프로듀서가 필드 이름을 하나 바꾸면 컨슈머는 그 필드를 null 로 받고 계속 돈다. 두 서비스가 같은 group.id 를 들면 각자 메시지의 절반씩만 받고 둘 다 “정상 동작 중” 이다. 리스너가 예외를 던지고 재시도를 소진하면 그 메시지는 그냥 사라진다. 어느 경우에도 에러 로그는 없다. 대시보드는 초록불이고, 며칠 뒤 정산 금액이 안 맞는다.
정산 시스템에서 이건 그냥 버그가 아니라 돈이 틀리는 일이다. 그래서 이 시스템의 카프카 관련 장치들은 전부 같은 목적 하나를 공유한다 — 조용히 지나갈 실패를 어딘가에서 시끄럽게 만드는 것. 이 글은 그 지점 여섯 곳을 정리한 것이다.
범위에 대해 먼저: 이 글은 시스템의 설계 의도와 메커니즘 을 정리한다. 스키마 56종·컨슈머 파일 50개·PG 4사 같은 수치는 시스템 작성자 기준이며, 이 글에서 리포지토리를 다시 실측한 결과가 아니다. 코드에 장치가 있다는 것과 그 장치가 지금 돌고 있다는 것은 다른 문제이고, 그 차이는 이전에 브로커를 직접 실측하면서 이미 겪었다. 마지막 절에서 다시 다룬다.
1. 계약 — 스키마가 없으면 깨진 걸 아무도 모른다
토픽별로 JSON-Schema 를 두고, 프로듀서와 컨슈머 양쪽에 계약 테스트를 붙였다.
왜 필요한가는 이 장치가 없을 때를 그려보면 분명하다. 주문 서비스가 orderAmount 를 amount 로 바꿔 배포한다. 정산 컨슈머는 orderAmount 를 찾다 못 찾고 null 을 얻는다. 역직렬화는 성공한다 — JSON 은 없는 키에 관대하다. 컨슈머는 예외를 던지지 않고, 오프셋을 커밋하고, 다음 메시지로 넘어간다. 정산 금액이 0 으로 쌓인다.
동기 호출이었다면 컴파일이 깨지거나 최소한 4xx 가 났을 자리다. 비동기에서는 그 자리가 비어 있으므로 따로 만들어야 한다. JSON Schema 는 그 목적을 위한 IETF 규격이다 — “JSON 문서가 어떤 모양이어야 하는지를 단언한다”는 것이 명세의 첫 문장이다.1
중요한 건 스키마 파일을 두는 것 자체가 아니라 양쪽에 테스트를 붙이는 것 이다. 스키마만 있으면 그건 문서다. 문서는 코드와 따로 논다. 프로듀서 테스트가 “내가 실제로 만드는 메시지가 이 스키마를 만족하는가” 를 확인하고, 컨슈머 테스트가 “이 스키마를 만족하는 메시지를 내가 실제로 처리할 수 있는가” 를 확인할 때에야, 스키마는 CI 에서 깨질 수 있는 물건이 된다. 깨질 수 있어야 지켜진다.
2. 발행 — DB 는 커밋됐는데 메시지는 안 나갔다
발행은 Transactional Outbox 로 한다.
이 패턴이 푸는 문제는 한 문장으로 정리된다. “DB 를 갱신하는 것과 메시지를 보내는 것을 원자적으로 하려면?”2 트랜잭션 안에서 kafkaTemplate.send() 를 호출하면 두 가지 방식으로 틀린다. 트랜잭션이 롤백돼도 메시지는 이미 나갔거나, 커밋 직후 프로세스가 죽어 메시지가 영영 안 나간다. 2PC 는 브로커가 지원하지 않거나, 지원해도 서비스를 DB 와 브로커 양쪽에 묶어버려서 쓰고 싶지 않다.
Outbox 의 해법은 우회다. 비즈니스 엔티티를 갱신하는 바로 그 트랜잭션 안에서 메시지를 outbox 테이블에 INSERT 한다. 별도 프로세스(message relay)가 그 테이블을 읽어 브로커로 보낸다.
[비즈니스 트랜잭션]
UPDATE settlement SET ...
INSERT INTO outbox (event_id, topic, payload, status='PENDING')
COMMIT ← 여기서 둘은 같이 성공하거나 같이 실패한다
[별도 폴러]
SELECT ... WHERE status='PENDING'
→ 브로커로 발행
→ UPDATE status='PUBLISHED'
트랜잭션이 커밋되면 메시지는 반드시 나간다. 롤백되면 반드시 안 나간다. 원자성이 DB 하나 안에서 끝나므로 2PC 가 필요 없다.
대신 이 패턴은 값을 지불한다. Richardson 이 명시하는 그대로다 — “message relay 가 메시지를 한 번 이상 발행할 수 있다.” 발행한 뒤 그 사실을 기록하기 전에 죽으면, 재시작 후 같은 메시지를 다시 보낸다. 그래서 “메시지 컨슈머는 반드시 멱등해야 한다.”2
즉 Outbox 를 쓰기로 한 순간 다음 절은 선택이 아니라 의무가 된다.
3. 수신 — 중복은 버그가 아니라 명세다
중복은 세 겹으로 받는다.
| 겹 | 위치 | 막는 것 |
|---|---|---|
| L1 | outbox event_id UNIQUE |
발행 측 중복 적재 — 같은 이벤트가 두 번 outbox 에 들어가는 것 |
| L2 | processed_events PK |
수신 측 재처리 — 이미 처리한 event_id 를 다시 처리하는 것 |
| L3 | 비즈니스 UNIQUE 제약 | 위 둘을 모두 통과한 결과의 중복 — 같은 정산 건이 두 줄 생기는 것 |
세 겹인 이유는 각각이 다른 지점을 막기 때문이다. 하나로 줄이면 그 겹이 없는 구간이 그대로 뚫린다.
- L1 만 있으면 — 발행은 한 번인데 relay 가 브로커에 두 번 보낸 경우(at-least-once) 를 못 막는다.
- L2 만 있으면 —
processed_eventsINSERT 와 비즈니스 INSERT 가 다른 트랜잭션이거나 순서가 어긋나는 경계에서 샌다. - L3 만 있으면 — 중복을 막긴 하지만
DataIntegrityViolationException이 리스너 밖으로 튀어나가 정상 흐름이 예외로 처리된다. 중복은 예외 상황이 아니라 예상된 입력 인데도.
마지막 항목이 핵심이다. at-least-once 전달에서 중복은 사고가 아니라 계약이다. 계약대로 들어온 입력을 예외로 처리하는 시스템은 언젠가 그 예외를 삼키게 되고, 삼키는 순간 진짜 오류와 구분되지 않는다.
실패한 건은 DLQ 로 빼서 다시 태운다. 이게 세 겹만큼 중요하다. 재시도를 다 소진한 메시지가 갈 곳이 없으면 그냥 사라지기 때문이다. Spring for Apache Kafka 는 @RetryableTopic 으로 재시도 토픽과 DLT 배선을 만들어 준다 — 블로킹하지 않는 재시도라서 뒤 메시지가 막히지 않는다.3 DLQ 의 의미는 “실패를 보관한다” 가 아니라 “실패를 잃지 않고 눈에 보이는 곳에 쌓는다” 는 것이다. 쌓여야 알람을 걸 수 있고, 알람이 걸려야 조용한 실패가 아니게 된다.
4. 배선 — 사람 눈은 50개 파일에서 스케일하지 않는다
컨슈머가 50개 파일에 흩어져 있다. 그래서 두 가지를 커밋 단계에서 막는다.
- 서로 다른 두 서비스가 같은
group-id를 드는 것 - DLT 배선이 빠진 리스너
첫 번째가 왜 위험한지는 카프카의 컨슈머 그룹 의미론을 보면 바로 나온다. 카프카는 group.id 를 공유하는 컨슈머들을 하나의 논리적 구독자 로 취급하고, 파티션을 그들 사이에 나눠 준다.4 이건 병렬 처리를 위한 정상 기능이다 — 같은 서비스의 인스턴스 3개가 파티션을 나눠 갖는 것.
문제는 서로 다른 두 서비스 가 실수로 같은 group-id 를 들었을 때다. 카프카 입장에서 둘은 같은 구독자의 두 인스턴스이므로, 파티션을 절반씩 나눠 준다. 정산 서비스는 주문 이벤트의 절반만 받고, 알림 서비스도 절반만 받는다. 양쪽 다 에러 없이, 로그 없이, 정상으로 보이면서 각자 절반을 잃는다. 이보다 조용한 실패는 찾기 어렵다.
두 번째, DLT 배선이 빠진 리스너는 재시도 소진 시점에 메시지를 버린다. 앞 절에서 말한 그대로다.
여기서 진짜 결정은 “이걸 막자” 가 아니라 “어디서 막느냐” 다. 코드 리뷰로 잡겠다는 선택지가 있고, 실제로 대부분 그렇게 한다. 하지만 50개 파일에 흩어진 문자열 상수를, 새 리스너가 추가될 때마다, 사람이 매번 교차 대조하는 일은 스케일하지 않는다. 리뷰어가 열 번 중 아홉 번 잡아도 남은 한 번이 파티션 절반을 가져간다.
그래서 커밋 단계에 넣는다. 이건 취향이 아니라 실패 비용에 대한 계산 이다. 규칙이 기계적으로 검사 가능하고(문자열 중복 검사, 애노테이션 속성 존재 검사), 놓쳤을 때의 대가가 조용한 데이터 유실이라면, 그 검사는 사람이 아니라 도구가 해야 한다.
다만 이런 게이트에는 알려진 함정이 하나 있다. 검사가 0건일 때도 초록불이 뜬다는 것. 스캔 경로가 바뀌거나 매처가 아무것도 못 잡으면 규칙 전체가 공허하게 통과한다. 게이트를 만들 때는 “위반을 잡는가” 만큼 “실제로 몇 개를 검사했는가” 를 함께 단언해야 한다. 이건 안전 게이트를 실행 파일로 옮기면서 똑같이 겪은 문제다.
5. 격리 — 토스 장애가 KCP 결제를 죽이면 안 된다
PG 사(toss / kcp / nice / inicis)별로 서킷 브레이커를 따로 뒀다.
서킷 브레이커 자체는 익숙한 패턴이다. Resilience4j 의 구현을 보면 CLOSED / OPEN / HALF_OPEN 세 상태의 유한 상태 기계이고, 슬라이딩 윈도로 호출 결과를 집계해 실패율이 임계치를 넘으면 CLOSED → OPEN 으로 넘어간다.5 열린 회로는 호출을 즉시 거절해서, 죽은 상대를 계속 때리며 스레드를 소진하는 일을 막는다.
여기서 설계 결정은 “서킷 브레이커를 쓴다” 가 아니라 “몇 개를 두느냐” 다.
하나로 묶으면 이렇게 된다. 토스가 장애를 내고 응답이 느려진다. 전체 실패율이 임계치를 넘는다. 회로가 열린다. KCP 로 결제하려던 사용자도 같이 막힌다. 토스는 멀쩡한 KCP 의 가용성까지 끌고 내려간 셈이고, 이건 서킷 브레이커가 막으려던 바로 그 전파를 서킷 브레이커가 일으킨 경우다.
PG 사는 서로 독립적으로 장애 난다. 독립적으로 장애 나는 대상은 독립적으로 차단해야 한다. 브레이커의 경계는 “외부 호출” 이 아니라 “함께 죽는 단위” 에 맞춰야 한다는 뜻이다.
6. 조회 — 남의 장애를 내 장애로 만들지 않기
정산 서비스는 주문·결제·상품·유저를 카프카로 자기 DB 에 투영 하고, 조회는 QueryDSL 로 자기 DB 에서 한다.
이건 CQRS 의 read model 이다. Richardson 의 정의 그대로 — 데이터를 소유한 서비스가 발행하는 도메인 이벤트를 구독해서 읽기 전용 복제본을 유지하고, 그 조회에 최적화된 스키마로 둔다.6
대안은 조회 때마다 주문 서비스와 결제 서비스를 동기 호출하는 것(API Composition)이다. 정산 화면 하나가 네 서비스를 부르면 그 화면의 가용성은 네 서비스 가용성의 곱 이 된다. 각각 99.9% 여도 곱하면 99.6% 다. 게다가 정산은 대량 집계 조회다 — 수만 건을 매번 네 곳에서 끌어오는 것은 성능 이전에 구조가 성립하지 않는다.
투영하면 조회는 로컬 조인 한 번이다. 주문 서비스가 죽어도 정산 조회는 뜬다.
대신 정직하게 지불하는 값이 있다. Richardson 이 이 패턴의 단점으로 명시한 그대로다 — 복잡도 증가, 코드 중복, 그리고 복제 지연 / 최종적 일관성.6 방금 들어온 주문이 정산 화면에 아직 안 보이는 구간이 존재한다. 정산이라는 도메인에서 이걸 받아들일 수 있는 이유는, 정산이 원래 실시간 조회가 아니라 마감 기준 집계이기 때문이다. 도메인이 달랐다면 같은 선택이 틀렸을 수 있다.
남은 것 — 코드에 있는 장치와 돌고 있는 장치
여섯 지점을 정리했지만, 이 글이 증명하지 않는 것을 분명히 해 둔다.
첫째, 이 글은 설계 의도를 정리한 것이지 실측 보고가 아니다. 앞서 브로커에 직접 붙어 kafka-topics.sh --describe 를 때려봤을 때 배운 것이 정확히 이거였다 — 코드에는 컨슈머가 있는데 브로커에는 그 컨슈머 그룹이 없었다. 설계 문서와 실행 중인 시스템은 다른 물건이다. 장치가 리포지토리에 존재한다는 사실은 그 장치가 지금 프로덕션에서 동작한다는 증거가 아니다.
둘째, 수치는 시스템 작성자 기준이다. 스키마 56종, 컨슈머 50개 파일, PG 4사 — 이 글을 쓰면서 리포지토리를 다시 세어 확인하지 않았다.
셋째, 가장 중요한 검증은 아직 남아 있다. 각 장치가 정말 막는지는 “위반을 넣었을 때 빨개지는가” 로만 증명된다. 계약 테스트에 어긋난 페이로드를 넣어보고, 같은 group-id 를 가진 리스너를 커밋해보고, DLT 없는 리스너를 추가해봐야 한다. 통과하는 초록불은 아무것도 증명하지 않는다 — 검사가 0건이어도 초록불은 뜨기 때문이다.
조용한 실패를 막는 장치를 만들었는데 그 장치 자체가 조용히 실패하고 있다면, 그건 없느니만 못하다. 안심시키는 초록불이 하나 더 늘었을 뿐이니까.
References
-
Wright, A., Andrews, H., Hutton, B., Dennis, G. JSON Schema: A Media Type for Describing JSON Documents, draft-bhutton-json-schema-01, IETF, 2022-06-16. https://json-schema.org/draft/2020-12/json-schema-core ↩
-
Richardson, C. Pattern: Transactional outbox, microservices.io. 문제 정의, 해법, 그리고 “message relay 가 메시지를 한 번 이상 발행할 수 있으므로 컨슈머는 멱등해야 한다”는 결과 맥락. https://microservices.io/patterns/data/transactional-outbox.html ↩ ↩2
-
Non-Blocking Retries, Spring for Apache Kafka Reference.
@RetryableTopic/RetryTopicConfiguration을 통한 재시도 토픽·DLT 부트스트랩. https://docs.spring.io/spring-kafka/reference/retrytopic.html ↩ -
KafkaConsumer (kafka 4.0.2 API) — “Consumer Groups and Topic Subscriptions”. 컨슈머 그룹을 통한 부하 분산과 파티션 할당 의미론. https://kafka.apache.org/40/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html ↩
-
CircuitBreaker, Resilience4j 공식 문서. CLOSED / OPEN / HALF_OPEN 상태 기계와 슬라이딩 윈도 기반 실패율 임계치. https://resilience4j.readme.io/docs/circuitbreaker ↩
-
Richardson, C. Pattern: Command Query Responsibility Segregation (CQRS), microservices.io. 도메인 이벤트 구독을 통한 view database 유지, 그리고 단점으로 명시된 “replication lag / eventually consistent views”. https://microservices.io/patterns/data/cqrs.html ↩ ↩2