실시간 전사 전송은 raw WebSocket이 아니라 STOMP로 한다
맥락
원안은 TranscriptionWebSocketHandler 하나가 프레임을 직접 받아 메시지 타입을 손으로 분기하는
구조였다. 라우팅도 구독도 우리가 짠 코드였다. 나흘 전(2026-07-13) 전사 MVP 설계는 "세션당 브라우저
WebSocket은 하나뿐이며 STOMP·SockJS·Socket.IO·broker·viewer broadcast를 추가하지 않는다"로 브로커를
명시적으로 금지해 둔 상태였다.
뒤집은 이유는 커밋에 없다. 메시지는 한 줄이고 설계 문서도 이슈도 붙지 않았다. 확인되는 것은 이
커밋이 전송 계층을 바꾸면서 asyncapi.yml 171줄을 함께 고쳤다는 사실, 그리고 그 뒤에 붙은 노트 토픽
팬아웃·구독 인터셉터·사용자 목적지가 전부 브로커를 전제한다는 사실뿐이다. raw 핸들러였다면 구독자
관리·팬아웃·순서 보장을 전부 직접 짜야 했을 자리다. 다만 그것이 이 시점의 동기였는지는 미확인이다
(노트 토픽은 12일 뒤인 2026-07-29에 들어왔다).
결정
- 전사 구간의 전송을 STOMP 1.2 over WebSocket으로 한다. endpoint는
/ws/transcriptions하나이고 SockJS fallback은 두지 않는다. - broker는 Spring simple broker다.
/queue·/topic을 켜고 application prefix/app, user destination prefix/user. 전사가 단일 서버를 전제하므로 외부 브로커는 붙이지 않는다. 대신 "다중 인스턴스에서는 broker relay로 교체해야 한다"는 조건을 설정 주석으로 남긴다. - 순서는 프로토콜이 지키게 한다.
setPreservePublishOrder(true)+setPreserveReceiveOrder(true). final segment의sequence는 서버가 매기지만, 전송 순서까지 뒤집히면 화면이 그것을 되돌릴 수 없다. - heartbeat 10s/10s를 전용 단일 스레드 스케줄러가 돌린다. 유휴 연결을 중간 장비가 조용히 버리는 것을 프로토콜 층에서 막는다.
- 인증은
access_token쿠키 +Origin검증을 그대로 쓴다. STOMP CONNECT 프레임에 토큰을 싣지 않는다. - 프레임 상한을 명시한다: binary 1MB(PCM 업로드), text 16KB, send buffer 4MB, send time limit 10s.
- 계약은 AsyncAPI가 소유하고
*WebSocketDocsTest가 실제 DTO 직렬화로 검증한다.
대안
대안 1 — raw WebSocket 유지. 설계가 명시적으로 정한 원안이고 의존성이 늘지 않는다. 기각한 이유: 메시지 타입 분기와 라우팅을 계속 손으로 들고 가야 한다. 구독 개념이 생기는 순간 브로커를 우리 손으로 다시 만들게 된다.
대안 2 — SockJS·Socket.IO. 구형 브라우저 fallback과 재연결을 라이브러리가 준다. 기각한 이유: 원안이 함께 금지했고 되살리지 않았다. 브라우저가 최신 WebSocket을 지원하는데 fallback 계층을 유지할 이유가 없다.
대안 3 — 외부 broker relay(Redis·RabbitMQ). 다중 인스턴스에서도 팬아웃이 성립한다. 기각한 이유: 단일 서버 전제에서는 운영 컴포넌트만 하나 는다. 대신 "다중 인스턴스가 되면 여기로 온다"는 조건을 설정 주석에 남겼다.
결과
- 구독자 관리·팬아웃·순서 보장을 직접 짜지 않는다. 12일 뒤 노트 단위 실시간 채널이 이 브로커 위에 그대로 얹혔다.
- 새로 운영할 것은 없다. 브로커가 애플리케이션 프로세스 안에 있다.
- simple broker는 JVM 하나 안에서만 산다. 서버를 두 대로 늘리는 순간 구독자가 인스턴스별로 갈리고 팬아웃이 깨진다. 전사가 단일 서버를 전제한다는 제약이 이제 전송 계층에도 박혔다.
- 프로토콜이 하나 늘어 브라우저와 서버 양쪽이 STOMP 프레이밍을 알아야 한다. 디버깅할 때도 소켓 프레임과 STOMP 프레임 두 층을 본다.
- Spring 메시징 스택에 묶였다. 채널 인터셉터·목적지 규칙·executor가 전부 Spring 것이라 이 층을 떠나려면 계약부터 다시 쓴다.
- 실시간 경로가 결국 둘이 됐다. 전사는 STOMP, agent 채팅은 SSE다 (ADR-0012). 재연결·하트비트·백프레셔를 두 번 푼다.
- heartbeat·outbound executor·구독 상한 같은 손잡이가 늘었다. 실제로 그 자리에서 사고가 났다 (APP-406 — DISCONNECT 리스너 예외가 노트 구독 맵을 영구히 오염시켰다).