Contents
채팅방 100개에 쓰지 않는다 — CQRS 스냅샷으로 거래 상태 읽기 병목을 줄인 이유ValueHub는 상품 DB와 채팅 DB가 처음부터 분리되어 있습니다. 예약이 확정되면 그 상품과 연결된 모든 채팅 헤더에 예약중이 보여야 합니다. 이 글은 방 Document마다 상태를 복사하지 않고, Chat 전용 읽기 모델(chat_product_posts) 한 줄로 수렴시킨 CQRS 선택과 동기화 전략을 정리합니다.1. 개요 및 배경 (Context & Problem)2. 왜 이 기술을 선택했는가? (Why This Technology?)3. 핵심 아키텍처 및 구현 흐름 (Implementation Flow)같은 유스케이스에서 예약 말풍선과 last_message를 갱신하므로, 헤더가 RESERVED인데 목록 미리보기는 이전 대화인 상태를 줄입니다. 읽기 모델이 두 개(스냅샷 테이블 + 방 last_message)라도, 한 소비자 안에서 같이 맞춥니다.
프론트는 방 상세 REST로 받은 productPost.tradeStatus를 헤더에 그리고, 이후 패치는 STOMP /user/queue/chat-list의 productPost 필드로 덮습니다. 읽기 모델의 변경이 다음 전체 새로고침을 기다리지 않습니다.4. 트러블슈팅 및 고려했던 점 (Troubleshooting & Deep Dive)대화량은 상태 전이보다 두 자릿수 이상 많습니다. 메시지 INSERT가 많은 컬렉션에 상품 상태 필드를 두면, 헤더 갱신이 채팅 I/O와 락을 공유합니다. 헤더 PK 갱신을 MySQL로 분리한 것은 CQRS라기보다 워크로드 분리입니다. 둘을 같이 했을 때 이득이 분명했습니다.5. 마치며 (Summary)채팅방 100개에 쓰지 않는다 — CQRS 스냅샷으로 거래 상태 읽기 병목을 줄인 이유
ValueHub는 상품 DB와 채팅 DB가 처음부터 분리되어 있습니다. 예약이 확정되면 그 상품과 연결된 모든 채팅 헤더에 예약중이 보여야 합니다. 이 글은 방 Document마다 상태를 복사하지 않고, Chat 전용 읽기 모델(chat_product_posts) 한 줄로 수렴시킨 CQRS 선택과 동기화 전략을 정리합니다.
1. 개요 및 배경 (Context & Problem)
중고 거래에서 채팅방은 상품 1개당 여러 개입니다. 인기 매물은 구매 희망 대화가 수십, 많으면 백 개까지 열립니다. 각 방 상단에는 같은 상품의 사진·가격·거래 상태가 있습니다. 사용자는 방 안이든 목록이든 “이 물건이 아직 살 수 있는지”를 그 헤더로 판단합니다.
MSA에서 이 헤더를 구현하는 가장 단순한 방법은 두 가지입니다.
A. 채팅이 필요할 때마다 Product-Post REST를 친다.
방 목록 30줄을 그리면 상품 서비스에 30번 물어보거나, 벌크 API를 새로 엽니다. 채팅 트래픽이 상품 읽기 트래픽이 됩니다. Product-Post가 점검 중이면 채팅 목록이 같이 내려갑니다. 장애가 도메인 경계를 넘습니다.
B. 채팅방 Document에
tradeStatus를 비정규화한다.방 생성 때 상태를 복사해 두면 목록은 빠릅니다. 문제는 쓰기입니다. 예약 한 번에 상태 전이가 한 번인데, 방이 100개면 MongoDB 업데이트가 100번입니다. 인기 상품일수록 1:N 쓰기 증폭(Write Amplification)이 커지고, 방 단위 락과 I/O가 채팅 전송 경로를 밀어냅니다. 일부 방만 갱신에 실패하면 같은 상품이 어떤 방에선 예약중, 어떤 방에선 판매중으로 보입니다.
단일 RDB에 상품·채팅·예약을 몰아넣으면 조인으로 헤더를 만들 수 있습니다. 그 순간 Chat-Service는 독립 배포 단위가 아닙니다. 채팅 인덱스가 상품 트랜잭션에 섞이고, 실시간 STOMP 부하가 거래 원본 테이블을 위협합니다.
정리하면 기존 방식의 한계는 이렇습니다.
- 동기 REST 조회: 읽기 결합 + 장애 전파.
- 방마다 상태 복제: 쓰기 증폭 + 부분 갱신 불일치.
- 모놀리식 단일 DB: 배포·확장 경계 소실, 실시간 워크로드와 거래 원본의 경합. 필요한 것은 “항상 원본과 강한 일관성”이 아니라, 채팅 화면이 빠르게 읽는 로컬 모델과 원본 변경을 한 번의 쓰기로 반영하는 경로였습니다.
2. 왜 이 기술을 선택했는가? (Why This Technology?)
CQRS를 교과서대로 Command DB / Query DB 이중 클러스터로 올리지 않았습니다. ValueHub의 CQRS는 서비스 경계 위의 읽기 모델입니다. 쓰기의 원본은 Reservations와 Product-Post의 MySQL이고, 채팅이 화면을 그릴 때 믿는 것은 Chat이 가진 스냅샷입니다.
대안 | 읽기 | 쓰기(상태 변경 1회) | 장애 전파 |
채팅 → Product REST | 느림, 결합 | Product 1회 + 채팅 N회 조회 | Product 장애 = 채팅 목록 장애 |
방 Document에 상태 복제 | 빠름 | 방 N회 UPDATE | Chat 내부 불일치 |
공유 단일 DB 조인 | 조인 비용 | 원본 1회 | 배포·락 경합 |
CQRS 로컬 읽기 모델 | 스냅샷 1건 조인 | 스냅샷 1회 UPDATE | 원본 장애와 채팅 조회 분리 |
읽기/쓰기 모델을 나눈 이유는 PDF의 두 문장입니다. | ㅤ | ㅤ | ㅤ |
- 쓰기 증폭 방지: 1:N I/O 병목을 1:1 업데이트로 줄인다.
- 장애 전파 차단: 로컬 읽기 모델로 채팅 조회 성능을 원본 서비스 SLA에서 분리한다.
방에는
productPostUuid만 둡니다. 이름·가격·이미지·tradeStatus는chat_product_posts(PK =productPostUuid) 한 행입니다. 같은 상품의 채팅방 100개가 그 한 행을 공유합니다. 예약 이벤트가 오면 방 100개가 아니라 스냅샷 1건만RESERVED가 됩니다. 100방 기준 상태 쓰기는 1/100입니다. 왜 MongoDB 방 문서가 아니라 MySQL 스냅샷인가. 거래 상태는 메시지처럼 무한히 쌓이지 않고, PK 조회·갱신 패턴이 관계형에 잘 맞습니다. 메시지 본문과 미리보기(last_message)는 MongoDB에 남깁니다. 문서형 워크로드와 상품 헤더 워크로드를 같은 컬렉션에 넣지 않았습니다. 왜 Kafka로 동기화하는가. Chat이 Product-Post를 구독하는 것이 아니라, 예약이라는 도메인 이벤트를 구독합니다. 거래 상태를 바꾸는 실제 커맨드는 예약 확정입니다. Product-Post도 같은 토픽을 소비해 자기 원본 listing을 갱신합니다. Chat의 스냅샷은 그 커맨드의 투영(projection)입니다. 의사결정 기준:
화면이 같은 값을 100번 읽더라도, 상태 전이는 한 번만 쓴다. 그 한 번은 예약 커밋 이후의 이벤트여야 한다.
3. 핵심 아키텍처 및 구현 흐름 (Implementation Flow)
Command와 Query를 서비스와 저장소 기준으로 나누면 아래와 같습니다.
구분 | 담당 | 저장소 | 하는 일 |
Command (예약) | Reservations-Service | MySQL reservations_db | CONFIRMED 커밋, afterCommit 이벤트 |
Command (상품 원본) | Product-Post-Service | MySQL product_post_db | listing tradeStatus 갱신 |
Query (채팅 헤더) | Chat-Service | MySQL chat_product_posts | 방 목록/상세 헤더의 상품 뷰 |
Query (대화) | Chat-Service | MongoDB rooms/messages | 말풍선, last_message, unread |
Query (회원 표시) | Chat-Service | MySQL chat_user_profiles | 닉네임·프로필. Member REST 비의존 |
Redis는 이 읽기 모델의 소스가 아닙니다. Gateway 인증(JWT 블랙리스트)에 쓰입니다. 채팅 헤더 캐시를 Redis에 한 벌 더 두지 않은 이유는, 스냅샷 테이블이 이미 “채팅용 캐시”이기 때문입니다. 캐시 레이어를 중첩하면 무효화 경로만 늘었습니다. | ㅤ | ㅤ | ㅤ |
동기화 흐름 6단계
[1] 방 생성 (REST, Kafka 없음)
Mongo rooms INSERT — productPostUuid만 저장
MySQL chat_product_posts INSERT (없을 때만)
MySQL chat_user_profiles UPSERT
[2] 채팅 읽기 (Query)
방 목록/상세 = rooms ⋈ chat_product_posts ⋈ profiles
Product-Post HTTP 없음
[3] 예약 Command
Gateway → X-Member-Uuid → Reservations
reservations_db COMMIT
[4] afterCommit
reservation.events (key = productPostUuid, type = CREATED)
[5] 투영 (Chat Consumer)
chat_product_posts 1건 → RESERVED
Mongo 예약 메시지 + last_message
STOMP 헤더/목록 패치
[6] 투영 (Product Consumer)
listing tradeStatus 1건 → RESERVED
채팅 조회 경로와 무관flowchart TB
classDef kafka fill:#fff7ed,stroke:#ea580c,stroke-width:2px
subgraph command [Write / Command]
RSV[Reservations] --> RDB[(reservations_db)]
PP[Product-Post] --> PDB[(product_post_db)]
end
subgraph query [Read / Chat Query]
CH[Chat-Service] --> SNAP[(chat_product_posts)]
CH --> Mongo[(rooms / messages / last_message)]
CH --> PROF[(chat_user_profiles)]
end
FE[Next.js] -->|REST / STOMP| GW[Spring Cloud Gateway]
GW -->|X-Member-Uuid| RSV
GW --> CH
GW --> PP
RSV -.->|afterCommit| K[Kafka reservation.events]
K -.->|스냅샷 1건| CH
K -.->|listing 1건| PP
CH -->|STOMP 헤더 패치| FE
class K kafka방 생성 때 Kafka가 없는 것은 의도입니다. 생성 요청 바디에 이미 상품 이름·가격·상태가 있고, 그 시점의 헤더를 보여 주면 됩니다. 원본 조회로 방 생성을 막지 않습니다. 이후 상태 전이는 이벤트가 스냅샷을 덮어씁니다.
Chat 소비자의 상태 갱신은 방 루프가 아닙니다.
private ChatProductPost markHeaderReserved(ChatProductPost productPost) {
if (productPost == null) {
log.warn("reservation.events CREATED: product snapshot missing, header skipped");
return null;
}
return saveChatProductPostPort.save(productPost.markReserved());
}같은 유스케이스에서 예약 말풍선과 last_message를 갱신하므로, 헤더가 RESERVED인데 목록 미리보기는 이전 대화인 상태를 줄입니다. 읽기 모델이 두 개(스냅샷 테이블 + 방 last_message)라도, 한 소비자 안에서 같이 맞춥니다.
프론트는 방 상세 REST로 받은 productPost.tradeStatus를 헤더에 그리고, 이후 패치는 STOMP /user/queue/chat-list의 productPost 필드로 덮습니다. 읽기 모델의 변경이 다음 전체 새로고침을 기다리지 않습니다.
4. 트러블슈팅 및 고려했던 점 (Troubleshooting & Deep Dive)
4.1 스냅샷이 없는 방을 어떻게 할 것인가
이벤트는 왔는데
chat_product_posts가 없으면 헤더를 만들 수 없습니다. 이 경우 방 Document를 부분 업데이트하지 않고, 헤더 갱신을 건너뛰고 경고를 남깁니다. 없는 스냅샷을 이벤트만으로 INSERT하면, 이미지·가격이 빈 상품 행이 생깁니다. 빈 읽기 모델은 잘못된 읽기 모델보다 낫지 않습니다. 스냅샷의 출처는 방 생성입니다.4.2 읽기 지연을 사용자에게 숨기는 지점
afterCommit 이후 Chat 소비자가 돌기 전까지 헤더는 옛 상태일 수 있습니다. 예약 API를 호출한 판매자 화면은 예약 패널의
CONFIRMED를 이미 알고 있습니다. 구매자 화면은 STOMP 말풍선과 목록 패치가 도착하는 시점에 수렴합니다. “모든 서비스가 같은 밀리초에 RESERVED여야 한다”를 요구하지 않았습니다. 요구한 것은 같은 상품의 모든 방이 같은 스냅샷을 본다는 것입니다. 그 조건은 PK 1건이면 성립합니다.4.3 방 문서에 상태를 남겨 두면 안 되는 이유
마이그레이션 중에 “일단 방에도
tradeStatus를 넣어 두자”는 유혹이 있습니다. 그 필드는 반드시 다시 어긋납니다. 이벤트는 스냅샷만 갱신하고 방 필드 갱신이 빠지는 순간, 목록(방 필드)과 상세(스냅샷)가 갈라집니다. 단일 소스를 강제하는 편이 코드가 적습니다.4.4 취소·수정은 아직 투영하지 않는다
1차 이벤트는
CREATED뿐입니다. 취소를 스냅샷에 반영하지 않으면 헤더는 RESERVED로 남을 수 있습니다. 이는 알려진 갭입니다. 생성 흐름에서 쓰기 증폭과 장애 전파를 먼저 제거한 뒤, 상태 머신(RESERVED → SELLING)을 같은 토픽의 다른 eventType으로 확장하면 됩니다. 읽기 모델 스키마를 바꾸지 않아도 됩니다. CQRS를 좁게 시작한 이유입니다.4.5 Gateway와 인증은 읽기 모델과 별개다
스냅샷은 공개 캐시가 아닙니다. 방 목록 REST와 STOMP 모두 Spring Cloud Gateway를 지나며, Chat은
X-Member-Uuid 참여자만 해당 방을 읽습니다. 읽기 모델을 로컬에 둔다고 인가를 느슨하게 하지 않습니다. 상품 원본이 내려가도 채팅 헤더는 읽히지만, 남의 방 헤더는 읽히지 않습니다.4.6 메시지 쓰기와 헤더 쓰기를 같은 Mongo 컬렉션에 넣지 않은 이유
대화량은 상태 전이보다 두 자릿수 이상 많습니다. 메시지 INSERT가 많은 컬렉션에 상품 상태 필드를 두면, 헤더 갱신이 채팅 I/O와 락을 공유합니다. 헤더 PK 갱신을 MySQL로 분리한 것은 CQRS라기보다 워크로드 분리입니다. 둘을 같이 했을 때 이득이 분명했습니다.
5. 마치며 (Summary)
- 방 N개에 거래 상태를 복제하지 않고
chat_product_posts1건을 읽기 모델로 두어, 예약 1회의 헤더 쓰기를 1/N로 줄였습니다.
- 채팅 조회가 Product-Post REST에 묶이지 않아, 원본 서비스 장애와 실시간 채팅 읽기 경로를 분리했습니다.
- 동기화는 예약
afterCommit의reservation.events투영으로만 수행해, 커맨드 원본과 채팅 화면이 같은 이벤트를 기준으로 수렴하게 했습니다.
Share article