inblog logo
|
p4rksk
    Architecture & Troubleshooting

    Kafka 비동기 이벤트

    박선규's avatar
    박선규
    Aug 21, 2026
    Kafka 비동기 이벤트
    Contents
    예약은 커밋만 한다 — Kafka Fan-out과 afterCommit으로 서비스 결합을 끊은 이유1. 개요 및 배경 (Context & Problem)2. 왜 이 기술을 선택했는가? (Why This Technology?)3. 핵심 아키텍처 및 구현 흐름 (Implementation Flow)4. 트러블슈팅 및 고려했던 점 (Troubleshooting & Deep Dive)5. 마치며 (Summary)

    예약은 커밋만 한다 — Kafka Fan-out과 afterCommit으로 서비스 결합을 끊은 이유

    ValueHub에서 직거래 예약은 Reservations-Service의 책임입니다. 그런데 예약이 확정되면 채팅창에는 “거래가 예약되었습니다” 말풍선이 떠야 하고, 상품 카드는 RESERVED로 바뀌어야 합니다. 이 글은 그 두 부수 효과를 HTTP로 직접 부르지 않고, reservation.events 한 토픽과 트랜잭션 afterCommit으로 밀어낸 이유를 적습니다.

    1. 개요 및 배경 (Context & Problem)

    판매자가 채팅 패널에서 일정·장소를 확정하면, 사용자 눈에 보이는 결과는 세 갈래입니다.
    A. 예약 원본이 CONFIRMED로 저장된다.
    B. 해당 상품의 거래 상태가 예약중이 된다.
    C. 채팅방에 예약 말풍선이 생기고 목록 last_message가 갱신된다.
    가장 익숙한 구현은 Reservations가 커밋한 뒤 Chat-Service와 Product-Post-Service를 RestTemplate 또는 WebClient로 연속 호출하는 것입니다. 동기 오케스트레이션은 디버깅이 쉽고, “방금 저장했으니 지금 반영됐겠지”라는 직관과도 맞습니다. 다만 MSA에서는 그 직관이 장애 전파 경로가 됩니다.
    • 결합도가 예약 API에 몰린다. Chat의 말풍선 스키마가 바뀌거나 Product의 상태 머신이 바뀌면 예약 서비스가 같이 컴파일됩니다. 예약 도메인이 채팅 문구를 알게 됩니다.
    • 장애가 예약 응답을 삼킨다. 예약 DB는 커밋됐는데 Chat이 4xx나 타임아웃이면, 사용자에게는 실패로 보이고 재시도하면 RESERVATION_ALREADY_CONFIRMED가 납니다. 반대도 위험합니다. Chat을 먼저 부르면 말풍선만 있고 예약이 없는 유령 상태가 됩니다.
    • 타임아웃과 부분 실패. 두 HTTP 호출을 트랜잭션 안에 넣으면 외부 I/O 동안 커넥션을 붙잡고, 밖에 두면 2PC 없는 분산 트랜잭션이 됩니다. 어느 쪽이든 운영 비용이 예약 도메인보다 큽니다.
    • 확장 시마다 예약 코드를 연다. 알림 서비스가 생기면 Reservations에 세 번째 HTTP가 붙습니다. 발행자가 구독자를 아는 구조입니다.
    • 응답 지연. 판매자가 기다리는 것은 “예약이 저장됐는가”입니다. 말풍선 렌더와 상품 카드 갱신은 수백 ms 늦어도 채팅 STOMP로 따라갈 수 있습니다. 그 지연을 예약 HTTP에 포함시킬 이유가 없습니다.
    핵심 문제는 “데이터를 어떻게 맞출까”가 아니라 누구의 트랜잭션에 누구의 실패를 넣을까였습니다.

    2. 왜 이 기술을 선택했는가? (Why This Technology?)

    대안은 네 가지였습니다.
    대안
    요약
    버리는 이유 또는 고른 이유
    동기 HTTP 호출
    예약에서 Chat, 예약에서 Product를 직접 호출
    장애 전파, 스키마 결합, 부분 실패
    분산 트랜잭션 (2PC, Saga 오케스트레이션)
    예약·채팅·상품을 하나의 워크플로로
    팀 규모 대비 복잡도. 보상 트랜잭션이 예약보다 큼
    Outbox 테이블 + CDC
    같은 DB 트랜잭션에 이벤트 행을 넣고 별도 릴레이
    신뢰성은 최고. 1차 트래픽·운영 주기에 CDC가 과함
    Kafka Fan-out + afterCommit
    커밋 후 토픽 1건, 소비자가 각자 처리
    발행자는 구독자를 모름. 예약 응답은 자기 DB만
    Kafka Fan-out을 고른 이유는 PDF의 세 줄입니다.
    • 결합도 최소화: Chat, Product를 직접 호출하지 않고 토픽 하나로 이벤트를 발행한다.
    • 장애 전파 차단: 타 서비스가 내려가도 예약 커밋과 HTTP 응답에 영향이 없다.
    • 독립적 확장: 알림 같은 새 서비스는 reservation.events만 구독하면 된다. 예약 코드를 열지 않는다.
    토픽 키는 productPostUuid입니다. 같은 상품의 예약 이벤트는 같은 파티션에 붙어 순서 기대를 상품 단위로 좁힙니다. 소비자 그룹은 chat-service, listing-service로 나눕니다. 한 그룹이 느려도 다른 그룹의 오프셋은 독립입니다.
    Transactional Event(afterCommit)를 고른 이유도 세 줄입니다.
    • 이벤트 발행 신뢰성: DB 커밋이 끝난 뒤에만 Kafka로 나간다.
    • 트랜잭션 격리: 예약 저장 로직과 말풍선/상태 변경 로직을 분리한다.
    • 복잡도 감소: 무거운 분산 트랜잭션 대신, 커밋 후 비동기 동기화로 일관성을 맞춘다.
    트랜잭션 안에서 kafkaTemplate.send()를 호출하면 브로커에는 갔는데 DB가 롤백되는 순간이 생깁니다. Chat은 없는 예약을 말풍선으로 그립니다. 반대로 커밋 후 애플리케이션 코드가 예외로 죽으면 이벤트는 영영 안 나갈 수 있습니다. 우리는 “없는 예약이 방송되는 것”을 더 위험한 쪽으로 보고, 커밋 이후에만 발행하는 쪽을 택했습니다. 유실 가능성은 로그와 재처리로 다루고, 유령 말풍선은 설계로 막습니다.
    Outbox를 안 쓴 이유도 분명해야 합니다. afterCommit 발행은 프로세스 크래시 구간이 있습니다. 1차 구현의 예약 생성량은 그 위험을 로그 레벨로 감당할 수 있다고 봤고, CDC 파이프라인을 올리는 비용이 더 컸습니다. 발행 실패를 재시도 큐로 올리는 것은 다음 단계입니다.
    의사결정 기준:
    예약의 성공 조건은 reservations_db 커밋뿐이다. 다른 서비스의 성공은 예약의 커밋 조건이 아니다.

    3. 핵심 아키텍처 및 구현 흐름 (Implementation Flow)

    저장소 역할 분담

    저장소
    역할
    MySQL reservations_db
    예약 원본. CONFIRMED / CANCELED. Kafka에 쓰지 않음
    MySQL product_post_db
    Product-Post의 거래 상태(SELLING → RESERVED)
    MySQL chat_db.chat_product_posts
    Chat의 읽기 모델. 헤더/목록의 tradeStatus
    MongoDB
    예약 말풍선 메시지, 방 last_message
    Kafka
    버스만 담당. 토픽 reservation.events, DB 없음
    Redis
    예약 트랜잭션에 참여하지 않음. Gateway JWT 보조
    Kafka는 상태를 저장하지 않습니다. 각 서비스가 자기 DB를 갱신합니다.

    예약 생성 6단계

    [1단계] 판매자 → Next.js Server Action POST /reservations-service/api/v1/reservations [2단계] Spring Cloud Gateway JWT 검증 X-Member-Uuid, X-Role 주입 [3단계] Reservations-Service 판매자만 생성 (memberUuid == sellerUuid) 자기 자신과 예약 금지 방·상품 단위 CONFIRMED 중복 차단 [4단계] MySQL reservations_db COMMIT status = CONFIRMED [5단계] afterCommit KafkaTemplate.send("reservation.events", key=productPostUuid) eventType = CREATED [6단계] Fan-out (서로 모르는 소비자) group listing-service → Product-Post tradeStatus = RESERVED group chat-service → 스냅샷 RESERVED + Mongo 예약 말풍선 + last_message 갱신 + STOMP /topic, /user/queue/chat-list
    flowchart TB classDef kafka fill:#fff7ed,stroke:#ea580c,stroke-width:2px Seller[판매자] --> FE[Next.js] FE -->|REST + Cookie| GW[Spring Cloud Gateway] GW -->|X-Member-Uuid| RSV[Reservations-Service] RSV -->|CONFIRMED 저장| RDB[(MySQL reservations_db)] RSV -.->|afterCommit CREATED| K[Kafka reservation.events] K -.->|group listing-service| PP[Product-Post] K -.->|group chat-service| CH[Chat-Service] PP --> PDB[(MySQL listing tradeStatus)] CH --> SNAP[(MySQL chat_product_posts)] CH --> Mongo[(MongoDB message + last_message)] CH -->|STOMP| FE class K kafka
    1단계. API 진입. 프론트는 채팅방 상세에서 상품 UUID·판매자·구매자를 읽어 예약 바디를 만듭니다. 판매자가 아니면 FO와 서버 모두 거절합니다.
    2단계. 인증. Gateway가 JWT를 검증하고 X-Member-Uuid를 넣습니다. Reservations는 이 헤더가 비면 RESERVATION_AUTH_MISSING입니다. 바디의 sellerUuid와 헤더가 같아야 합니다.
    3단계. 도메인 가드. 구매자와 판매자가 같으면 안 됩니다. 같은 chatRoomId 또는 같은 productPostUuid에 이미 CONFIRMED가 있으면 RESERVATION_ALREADY_CONFIRMED입니다. 한 상품에 예약이 둘이면 채팅방 N개의 헤더가 동시에 거짓이 됩니다.
    4단계. 로컬 커밋. CreateReservationService는 @Transactional 안에서 저장만 합니다. Chat HTTP를 부르지 않습니다.
    5단계. afterCommit 발행. 같은 메서드에서 publishCreated를 호출하지만, 어댑터는 트랜잭션 동기화에 afterCommit을 걸어둡니다. 롤백되면 send는 실행되지 않습니다.
    6단계. 소비자 독립 처리. Chat은 방 Document N개를 돌지 않습니다. 상품 스냅샷 1건을 RESERVED로 바꾸고, 해당 채팅방에 시스템 메시지를 넣습니다. Product-Post는 자기 listing만 갱신합니다.
    발행 어댑터의 핵심은 이 블록입니다.
    // DB 커밋 성공 후에만 reservation.events 1건. 같은 트랜잭션에 send 하지 않음 public void publishCreated(Reservation reservation) { ReservationEventPayload payload = ReservationEventPayload.created(reservation); String key = reservation.getProductPostUuid(); if (TransactionSynchronizationManager.isSynchronizationActive() && TransactionSynchronizationManager.isActualTransactionActive()) { TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { send(key, payload); } }); return; } send(key, payload); }
    페이로드는 소비자가 자기 DB를 갱신하기에 충분한 최소 필드만 담습니다. eventType, productPostUuid, reservationUuid, chatRoomUuid, meetAt, placeName, sellerUuid, buyerUuid, updatedAt. Chat이 Reservations REST를 다시 치며 “상세 조회”하지 않게 하려는 계약입니다.
    Chat 소비자(group-id=chat-service)는 CREATED만 처리합니다.
    reservation.events CREATED → Mongo에 RESERVATION 메시지 ("거래가 예약되었습니다") → rooms.last_message 갱신 → chat_product_posts.tradeStatus = RESERVED → 보고 있는 참여자 lastRead 맞춤 → STOMP 방 토픽 + 목록 큐
    클라이언트가 STOMP로 RESERVATION을 보내는 경로는 닫혀 있습니다. 말풍선의 출처는 오직 이 컨슈머입니다.
    1차 구현에서 취소·수정은 Reservations DB만 바꾸고 토픽에 넣지 않습니다. 이벤트 계약을 좁힌 채 생성 흐름의 신뢰성을 먼저 검증하기 위해서입니다.

    4. 트러블슈팅 및 고려했던 점 (Troubleshooting & Deep Dive)

    4.1 트랜잭션 안에서 send 하면 “유령 예약 방송”이 생긴다

    처음 스케치에는 @Transactional 메서드 끝의 kafkaTemplate.send()가 있었습니다. 통합 테스트에서 의도적으로 커밋 이후 예외를 넣기 전에, 더 단순한 실패를 상상했습니다. 유니크 제약으로 롤백되면 브로커 메시지는 이미 나가 있을 수 있다.
    afterCommit으로 순서를 DB → 버스로 고정했습니다. Chat이 이벤트를 받았을 때 예약이 없는 상태는, 최소한 생성 흐름에서는 나오지 않습니다. 방이 없으면 Chat은 메시지를 만들지 않고 경고 로그만 남깁니다. 없는 방에 말풍선을 만들지 않습니다.

    4.2 afterCommit은 Outbox가 아니다

    커밋과 send 사이에 프로세스가 죽으면 이벤트는 유실됩니다. 이 간격을 숨기지 않았습니다. 발행 실패는 whenComplete에서 에러 로그를 남기고, 예약 HTTP는 이미 200을 준 뒤입니다. 사용자에게는 예약이 성공한 것이 맞고, 말풍선·상품 상태는 잠시 늦을 수 있습니다.
    이 eventual consistency를 제품 제약으로 명시했습니다. 채팅 헤더는 스냅샷을 읽으므로, 소비자가 따라잡으면 별도 보상 API 없이 화면이 수렴합니다. 유실이 운영에서 반복되면 그때 Outbox를 올립니다. 지금은 “예약 생성 실패처럼 보이는 것”을 막는 쪽이 우선입니다.

    4.3 발행 실패가 예약 응답을 되돌리면 안 된다

    send는 try/catch로 감싸고, 예외를 생성 유스케이스로 다시 던지지 않습니다. Kafka가 잠시 내려가도 판매자는 약속을 저장한 것입니다. 버스 장애를 예약 도메인 장애로 승격하지 않는 것이 Fan-out을 고른 이유와 같습니다.

    4.4 소비자의 멱등

    Kafka는 at-least-once입니다. 같은 CREATED가 두 번 오면 Chat은 말풍선이 두 개 생길 수 있습니다. 1차는 reservationUuid 기준의 강한 멱등보다, 생성 API의 중복 CONFIRMED 차단으로 발행 자체를 한 번으로 좁혔습니다. 재처리가 필요해지면 메시지 메타데이터의 reservationId로 중복 INSERT를 막으면 됩니다. 지금 계약에 그 필드가 들어 있는 이유입니다.

    4.5 Fan-out 이후의 화면 동기화

    상품 목록은 Product-Post를, 채팅 헤더는 Chat 스냅샷을 봅니다. 두 소비자의 속도가 달라도 예약 HTTP는 기다리지 않습니다. Chat 쪽 지연은 STOMP로 헤더와 목록을 밀어 사용자 체감 지연을 줄입니다. Product-Post는 다음 조회로 수렴합니다. “실시간”의 정의를 서비스마다 다르게 둔 것입니다.

    5. 마치며 (Summary)

    • 예약의 커밋 조건을 Chat·Product HTTP에서 분리해, 타 서비스 장애가 직거래 확정 응답을 삼키지 않게 했습니다.
    • reservation.events 한 토픽 Fan-out으로 발행자가 구독자를 모르는 구조를 만들고, 새 서비스는 구독만 추가하면 됩니다.
    • Transactional Event afterCommit으로 “커밋되지 않은 예약이 방송되는 불일치”를 막고, 남은 유실 구간은 로그와 eventual consistency로 명시했습니다.
    Share article
    Contents
    예약은 커밋만 한다 — Kafka Fan-out과 afterCommit으로 서비스 결합을 끊은 이유1. 개요 및 배경 (Context & Problem)2. 왜 이 기술을 선택했는가? (Why This Technology?)3. 핵심 아키텍처 및 구현 흐름 (Implementation Flow)4. 트러블슈팅 및 고려했던 점 (Troubleshooting & Deep Dive)5. 마치며 (Summary)

    p4rksk

    RSS·Powered by Inblog