inblog logo
|
p4rksk
    Architecture & Troubleshooting

    WebSocket/STOMP 선택 이유

    박선규's avatar
    박선규
    Aug 07, 2026
    WebSocket/STOMP 선택 이유
    Contents
    HTTP Polling 대신 WebSocket/STOMP를 고른 이유 — ValueHub 실시간 채팅 설계고가 중고·프리미엄 거래 플랫폼 ValueHub에서 1:1 거래 채팅을 설계하면서, “메시지를 얼마나 자주 가져올 것인가”가 아니라 “연결을 어떻게 유지하고 누구에게 분기할 것인가”를 먼저 정했습니다. 이 글은 Chat-Service가 HTTP Polling을 버리고 WebSocket 위에 STOMP를 올린 이유와, Spring Cloud Gateway · X-Member-Uuid · MongoDB last_message가 한 흐름으로 맞물리는 구조를 정리합니다.1. 개요 및 배경 (Context & Problem)2. 왜 이 기술을 선택했는가? (Why This Technology?)3. 핵심 아키텍처 및 구현 흐름 (Implementation Flow)방 생성은 Kafka 없이 REST입니다. 요청 바디의 상품 정보로 chat_product_posts 스냅샷을 INSERT하고, 방 Document에는 productPostUuid만 둡니다. 거래 상태 동기화는 예약 이벤트 글에서 다룹니다.4. 트러블슈팅 및 고려했던 점 (Troubleshooting & Deep Dive)현재는 Spring Simple Broker입니다. 인스턴스가 둘이면 /topic 구독이 프로세스 메모리에 갇힙니다. 지금은 단일 Chat 인스턴스와 Gateway 뒤에서 충분했고, 수평 확장은 Redis 브로커 또는 외부 메시지 브로커로 미뤘습니다. “지금은 필요 없는 인프라를 먼저 올리지 않는다”가 기준이었습니다.5. 마치며 (Summary)

    HTTP Polling 대신 WebSocket/STOMP를 고른 이유 — ValueHub 실시간 채팅 설계

    고가 중고·프리미엄 거래 플랫폼 ValueHub에서 1:1 거래 채팅을 설계하면서, “메시지를 얼마나 자주 가져올 것인가”가 아니라 “연결을 어떻게 유지하고 누구에게 분기할 것인가”를 먼저 정했습니다. 이 글은 Chat-Service가 HTTP Polling을 버리고 WebSocket 위에 STOMP를 올린 이유와, Spring Cloud Gateway · X-Member-Uuid · MongoDB last_message가 한 흐름으로 맞물리는 구조를 정리합니다.

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

    거래 채팅은 일반 메신저와 결이 다릅니다. 텍스트뿐 아니라 위치, 이미지, 예약 확정 말풍선이 같은 방에 들어와야 하고, 목록 화면의 미리보기(last_message, 미읽음)도 수 초 안에 따라가야 합니다. 처음 후보로 올린 구현은 단순했습니다. 클라이언트가 GET /messages?after=를 2~3초마다 두드리는 HTTP Polling입니다. Polling을 그대로 가져가면 문제는 기능이 아니라 트래픽 형태에서 터집니다.
    • 헤더 오버헤드. 매 폴링마다 TLS 핸드셰이크는 재사용하더라도 HTTP 헤더·쿠키·JWT 검증이 반복됩니다. 대화가 빨라질수록 “빈 응답” 비율이 올라가 실질 페이로드 대비 헤더 비용이 커집니다.
    • 서버 부하의 증폭. 활성 방 N개 × 참여자 2명 × 폴링 주기의 역수가 초당 요청 수가 됩니다. 메시지가 없어도 Chat-Service와 Gateway는 살아 있는 대화만큼 일을 합니다.
    • 지연의 하한. 폴링 주기를 줄이면 실시간처럼 보이지만 부하가 선형으로 늘고, 늘리면 “보냈는데 상대 화면에 안 뜨는” 거래 신뢰 문제가 됩니다. 고가 직거래에서 이 지연은 UX가 아니라 거래 리스크입니다.
    • 목록과 방의 이중 폴링. 방 안 메시지와 /chat 목록 미리보기를 각각 폴링하면 같은 사용자가 연결을 두 벌 유지합니다. 미읽음·last_message 동기화가 어긋나기 쉽습니다.
    • 인증 경계의 모호함. REST는 Spring Cloud Gateway가 JWT를 검증한 뒤 X-Member-Uuid를 내려줍니다. 폴링도 REST라서 당장은 편하지만, 나중에 양방향이 필요해지는 순간 인증 모델을 다시 짜야 합니다. 즉 Polling은 “구현이 쉽다”는 이점 외에, 고빈도 1:1 대화와 목록 실시간 패치를 동시에 만족시키기 어려운 구조였습니다.

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

    선택지는 넷이었습니다. HTTP Polling, SSE(Server-Sent Events), 순수 WebSocket, WebSocket + STOMP.
    기준
    HTTP Polling
    SSE
    순수 WebSocket
    WebSocket + STOMP
    방향
    요청-응답 반복
    서버 → 클라이언트
    양방향
    양방향 + 목적지 규격
    연결
    매 주기 신규 HTTP
    장기 GET 1개
    장기 TCP 1개
    장기 TCP 1개
    라우팅
    URL/쿼리로 직접 구현
    스트림 분할을 직접 구현
    프레임 파싱·방 분기를 직접 구현
    /app, /topic, /user/queue
    Spring 연동
    RestController
    WebFlux/SSE
    핸드셰이크만 제공
    Simple Broker + @MessageMapping
    우리 도메인 적합성
    고빈도 대화에 부적합
    전송은 별도 REST 필요
    가능하나 규격 비용 큼
    방 방송 + 개인 목록 패치에 맞음
    WebSocket을 고른 이유는 PDF에 적었던 두 문장 그대로입니다.
    ㅤ
    ㅤ
    ㅤ
    ㅤ
    • 실시간 양방향 통신: HTTP Polling 대비 헤더 오버헤드를 연결 한 번에 상각한다.
    • 서버 부하 감소: 메시지가 있을 때만 프레임이 오가므로, 침묵 구간의 고정 비용이 사라진다. 다만 WebSocket만으로는 부족했습니다. 브라우저가 보내는 것은 “이 방의 텍스트”이고, 서버가 뿌려야 하는 것은 (1) 같은 방 구독자의 말풍선, (2) 상대의 채팅 목록 한 줄, (3) 권한 오류입니다. 이 세 갈래를 소켓 프레임 타입으로 직접 나누면 컨트롤러마다 if (type == LIST_PATCH)가 생깁니다. STOMP를 고른 이유도 라우팅이었습니다.
    • Pub/Sub 기반 메시지 분기 로직을 애플리케이션에서 걷어내고, destination으로 옮긴다.
    • 메시지 규격화와 Spring 브로커 연동이 쉽다. @MessageMapping("/chat.{roomId}") + @SendTo("/topic/chat.{roomId}")면 방 방송이 끝난다.
    • 개인 큐(/user/queue/chat-list, /user/queue/errors)로 목록 패치와 예외를 방 토픽과 분리한다. 의사결정 기준을 한 줄로 줄이면 이렇습니다.
    연결 비용(WebSocket)과 분기 비용(STOMP)을 나누고, 인증은 Gateway의 X-Member-Uuid 한 줄에 맞춘다. SSE는 “받기만 하면 되는 알림”에는 맞지만, 보내기를 REST에 남겨 두면 전송 경로와 수신 경로의 인증·에러 모델이 갈라집니다. 순수 WebSocket은 가능했지만, 팀 규모와 Spring 생태계를 생각하면 STOMP가 생산성 대비 위험이 작았습니다. Redis Pub/Sub + 다중 인스턴스 브로커는 트래픽이 인스턴스 경계를 넘을 때 검토하기로 미뤘습니다. 1차 목표는 프로토콜 선택이었습니다.

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

    브라우저는 Vercel의 Next.js만 봅니다. REST와 STOMP 모두 Spring Cloud Gateway(:8000)를 지나 Chat-Service로 들어갑니다. JWT는 게이트웨이에서 검증하고, 다운스트림에는 회원 식별자만 X-Member-Uuid로 전달합니다. Chat-Service는 토큰을 다시 파지 않습니다.

    저장소 역할 분담

    저장소
    Chat-Service에서의 역할
    MongoDB
    채팅방 Document, 메시지 본문, 방의 last_message(미리보기 스냅샷)
    MySQL chat_db
    채팅 전용 상품 스냅샷 chat_product_posts, 회원 프로필 chat_user_profiles
    Redis
    Chat이 메시지를 넣지 않는다. Gateway의 JWT 블랙리스트 등 인증 보조
    S3
    채팅 이미지 Presigned PUT. 소켓에는 s3Key만 올린다
    Kafka
    채팅 전송 경로에 없다. 예약 확정 시에만 reservation.events를 구독한다
    실시간 전송은 STOMP, 이력·방 생성·미읽음 합계는 REST입니다. 과거 메시지를 소켓으로 재생하지 않습니다.
    ㅤ

    메시지 전송 6단계

    [1] Next.js │ 동일 출처 프록시 /api/chat/ws-chat │ (쿠키 → Authorization: Bearer) ▼ [2] Spring Cloud Gateway │ JWT 검증, Redis 블랙리스트 │ X-Member-Uuid 주입, /ws-chat 업그레이드 ▼ [3] Chat-Service STOMP CONNECT │ StompAuthChannelInterceptor │ X-Member-Uuid → Principal ▼ [4] SEND /app/chat.{roomId} │ TEXT | IMAGE | LOCATION │ RESERVATION 타입은 거절 ▼ [5] MongoDB │ messages INSERT │ rooms.last_message 갱신 │ 참여자 lastRead / unread 계산 ▼ [6] Broker fan-out ├─ /topic/chat.{roomId} → 방 안 말풍선 └─ /user/queue/chat-list → 목록 last_message · unreadCount
    notion image
    1. 핸드셰이크. 브라우저는 WebSocket 헤더에 쿠키를 안정적으로 못 붙입니다. FO가 동일 출처 /api/chat/ws-chat으로 업그레이드하고, 서버가 vh_access_token을 Bearer로 바꿉니다. Gateway 뒤의 실제 엔드포인트는 /ws-chat입니다.
    1. CONNECT 인증. StompAuthChannelInterceptor가 STOMP 헤더 또는 핸드셰이크 세션의 X-Member-Uuid를 Principal로 고정합니다. 이후 SEND의 발신자는 바디가 아니라 Principal입니다. 클라이언트가 senderUuid를 위조할 수 없습니다.
    1. 구독. 방 상세는 /topic/chat.{roomId}, 목록은 /user/queue/chat-list, 실패는 /user/queue/errors입니다.
    1. 전송. @MessageMapping("/chat.{roomId}")이 본문을 받아 SendChatMessageUseCase를 호출합니다. 참여자가 아니면 403에 해당하는 도메인 예외를 개인 에러 큐로 돌립니다.
    1. 영속화와 last_message. 메시지 Document를 넣은 뒤 같은 방 Document의 last_message를 갱신합니다. 목록 REST가 메시지 컬렉션을 매번 집계하지 않도록, 미리보기는 방 문서에 둡니다. 이미지·위치는 목록용 문구로 정규화합니다. (사진이 공유 되었습니다., 위치를 공유했습니다.)
    1. 방송. @SendTo("/topic/chat.{roomId}")로 방 구독자에게 echo하고, PublishChatListPreviewPort가 참여자별 /user/queue/chat-list에 한 줄 패치를 보냅니다. 보고 있는 상대는 unread를 0으로 맞춥니다. 핵심 컨트롤러는 이 형태입니다.
    @MessageMapping("/chat.{roomId}") @SendTo("/topic/chat.{roomId}") public ChatMessagePayloadVo send( @DestinationVariable String roomId, SendChatMessageRequestVo requestVo, Principal principal ) { ChatMessageItemDto saved = sendChatMessageUseCase.send(toCommand(roomId, requestVo, principal)); return toPayload(roomId, saved); }
    저장 유스케이스는 “소켓에 뿌리는 것”과 “목록이 믿는 것”을 한 트랜잭션 경계에서 맞춥니다.
    ChatMessage saved = saveChatMessagePort.save(toNewMessage(room.getId(), senderUuid, command)); LastMessage lastMessage = LastMessage.create(preview(saved), saved.getCreatedAt()); updateChatRoomLastMessagePort.updateLastMessage(room.getId(), lastMessage); publishListPreviews(room, senderUuid, lastMessage);

    방 생성은 Kafka 없이 REST입니다. 요청 바디의 상품 정보로 chat_product_posts 스냅샷을 INSERT하고, 방 Document에는 productPostUuid만 둡니다. 거래 상태 동기화는 예약 이벤트 글에서 다룹니다.

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

    4.1 브라우저 WebSocket은 Gateway JWT를 들고 가지 못한다

    REST 이력은 되고 전송만 안 되는 증상이 있었습니다. 목록은 Next 서버가 쿠키를 읽어 Gateway로 넘기지만, 브라우저는 wss://api.../ws-chat에 쿠키도 Authorization도 못 붙입니다. Gateway는 핸드셰이크를 401로 끊고, 클라이언트는 4초마다 재연결만 반복합니다. 해결은 Chat-Service가 토큰 쿼리를 열어 주는 쪽이 아니라, FO가 동일 출처 프록시로 Bearer를 대신 붙이는 것이었습니다. 인증의 단일 진입점은 여전히 Spring Cloud Gateway입니다. 다운스트림은 X-Member-Uuid만 믿습니다.

    4.2 last_message를 메시지 컬렉션에서 그때그때 집계하지 않는다

    목록 화면은 방 N개의 “마지막 한 줄”만 필요합니다. 메시지 컬렉션에 sort + limit 1을 N번 날리면 대화가 쌓일수록 목록이 느려집니다. 방 Document에 last_message를 비정규화한 이유입니다. 대신 동기화 책임이 생깁니다. 저장은 됐는데 last_message 갱신이 빠지면 방 안과 목록이 갈라집니다. 그래서 전송 유스케이스가 메시지 INSERT와 updateLastMessage를 같은 흐름에서 처리합니다. 예약 말풍선도 클라이언트가 보내지 않고, Kafka 컨슈머가 메시지를 넣은 뒤 같은 포트로 last_message를 갱신합니다.

    4.3 클라이언트가 RESERVATION 타입을 직접 보내면 거절한다

    예약 확정 말풍선은 거래 상태와 묶여 있습니다. STOMP로 아무나 RESERVATION을 보내면 채팅은 예약된 것처럼 보이고 DB는 아직 판매중일 수 있습니다. Chat은 일반 SEND에서 이 타입을 거부하고, reservation.events를 소비한 뒤에만 시스템 메시지를 만듭니다.

    4.4 Presence와 미읽음

    상대가 방을 보고 있는데도 unread가 올라가면 목록이 거짓이 됩니다. CONNECT/SUBSCRIBE 기준으로 방 조회 여부를 추적하고, 보고 있는 참여자는 lastRead를 즉시 맞춥니다. 이 상태는 Redis가 아니라 Chat 프로세스의 presence 트래커입니다. 다중 인스턴스가 되면 공유 저장소로 옮겨야 하는 명시적 부채입니다.

    4.5 단순 브로커의 한계를 알고 넘어갔다

    현재는 Spring Simple Broker입니다. 인스턴스가 둘이면 /topic 구독이 프로세스 메모리에 갇힙니다. 지금은 단일 Chat 인스턴스와 Gateway 뒤에서 충분했고, 수평 확장은 Redis 브로커 또는 외부 메시지 브로커로 미뤘습니다. “지금은 필요 없는 인프라를 먼저 올리지 않는다”가 기준이었습니다.

    5. 마치며 (Summary)

    • HTTP Polling의 고정 부하와 지연 하한을 버리고, 연결 한 번으로 고빈도 1:1 대화를 처리했습니다.
    • STOMP destination으로 방 방송·목록 패치·에러 큐를 나눠, 소켓 분기 로직이 유스케이스를 침범하지 않게 했습니다.
    • Spring Cloud Gateway의 X-Member-Uuid와 MongoDB last_message를 전송 경로의 인증·미리보기 기준으로 고정해, 실시간성과 목록 일관성을 같은 흐름에서 맞췄습니다.
    Share article
    Contents
    HTTP Polling 대신 WebSocket/STOMP를 고른 이유 — ValueHub 실시간 채팅 설계고가 중고·프리미엄 거래 플랫폼 ValueHub에서 1:1 거래 채팅을 설계하면서, “메시지를 얼마나 자주 가져올 것인가”가 아니라 “연결을 어떻게 유지하고 누구에게 분기할 것인가”를 먼저 정했습니다. 이 글은 Chat-Service가 HTTP Polling을 버리고 WebSocket 위에 STOMP를 올린 이유와, Spring Cloud Gateway · X-Member-Uuid · MongoDB last_message가 한 흐름으로 맞물리는 구조를 정리합니다.1. 개요 및 배경 (Context & Problem)2. 왜 이 기술을 선택했는가? (Why This Technology?)3. 핵심 아키텍처 및 구현 흐름 (Implementation Flow)방 생성은 Kafka 없이 REST입니다. 요청 바디의 상품 정보로 chat_product_posts 스냅샷을 INSERT하고, 방 Document에는 productPostUuid만 둡니다. 거래 상태 동기화는 예약 이벤트 글에서 다룹니다.4. 트러블슈팅 및 고려했던 점 (Troubleshooting & Deep Dive)현재는 Spring Simple Broker입니다. 인스턴스가 둘이면 /topic 구독이 프로세스 메모리에 갇힙니다. 지금은 단일 Chat 인스턴스와 Gateway 뒤에서 충분했고, 수평 확장은 Redis 브로커 또는 외부 메시지 브로커로 미뤘습니다. “지금은 필요 없는 인프라를 먼저 올리지 않는다”가 기준이었습니다.5. 마치며 (Summary)

    p4rksk

    RSS·Powered by Inblog