“그래서 채팅, 진짜로 어떻게 만들죠?”
1편(WebSocket vs SSE vs Polling)에서 ‘양방향이 본질이면 WebSocket’이라는 결론까지 왔다. 그러면 자연스러운 다음 질문이 따라온다.
“실제 코드는 어떻게 짜나요?”
“STOMP는 뭐고 native WebSocket이랑 뭐가 다르죠?”
“SockJS는 꼭 끼워야 하나요?”
“사용자별로 메시지 보내는 건 어떻게 하나요?”
이 글은 그 질문에 코드로 답한다. Realtime & Messaging 시리즈의 2편이다. Spring Boot 환경에서 WebSocket + STOMP + SockJS를 묶어 ‘MVP 수준의 실시간 채팅’을 만드는 데까지 가고, 그 뒤에 운영에서 부딪히는 함정을 별도 체크리스트로 따로 짚는다.
전제: Spring Boot 3.x 이상. 코드는 자바 기준. 클라이언트는 브라우저(JS) 기준. Spring으로 REST API는 한 번 만들어 본 주니어 개발자를 가정한다.

결론 먼저: 이 글에서 만들 것
‘한 줄 요약: Spring + STOMP로 메시지 라우팅을 얹고, 브라우저는 SockJS + StompJS로 붙는다. 토픽 브로드캐스트 + 사용자별 메시지까지 MVP로 만든다.’
세 줄 그림:
[브라우저 A] ──(WebSocket+STOMP)──┐
├──▶ Spring (STOMP broker, in-memory)
[브라우저 B] ──(WebSocket+STOMP)──┘ ├─ /topic/chat/{roomId} (브로드캐스트)
└─ /user/queue/notice (사용자별)
핵심 구성 요소:
- ‘서버 (Spring)’:
spring-boot-starter-websocket,@EnableWebSocketMessageBroker,@MessageMapping,SimpMessagingTemplate - ‘브로커(broker, 메시지를 받아 구독자에게 나눠주는 중간 라우터)’: 시작은
enableSimpleBroker(인메모리). 운영 클러스터링 시점에 RabbitMQ/ActiveMQ relay로 교체 - ‘클라’: SockJS + StompJS (브라우저 라이브러리)
- ‘인증 두 갈래’: ‘기본 — Spring Security 세션·쿠키 기반’ / ‘고급 — STOMP CONNECT 단계에서 JWT 검증’
이 글은 ‘MVP까지’다. 클러스터링·외부 broker relay·메시지 영속화·재전송 같은 운영 주제는 본문 뒤쪽 ‘MVP 이후 — 운영 체크리스트’에서 따로 다룬다. 즉 이 글의 코드는 ”동작하는 채팅을 만드는 코드’이지, ‘운영 가능한 대규모 채팅 시스템의 완성본’은 아니다.”
STOMP와 SockJS, 왜 같이 쓰나
‘한 줄 요약: STOMP는 메시지 규칙, SockJS는 연결 폴백. 둘은 같은 층이 아니다.’
STOMP — 메시지 라우팅 규칙
‘WebSocket’은 ‘양방향 메시지를 흘리는 파이프’다. 그 파이프 위에 어떤 형식으로, 어떤 라우팅 규칙으로 메시지를 흘릴지는 정해져 있지 않다.
‘STOMP(Streaming Text Oriented Messaging Protocol)’는 그 파이프 위에 얹는 ‘간단한 메시지 프로토콜’이다. 토픽 구독, 메시지 전송, 인증 같은 패턴을 표준 명령(CONNECT, SUBSCRIBE, SEND, MESSAGE)으로 정의한다.
Spring은 STOMP 명령을 알아서 해석해 주고, /topic/..., /queue/..., /user/... 같은 ‘destination(STOMP에서 메시지를 보내거나 구독하는 주소)’으로 메시지를 라우팅해 준다. native WebSocket으로 직접 짜면 이 라우팅을 손으로 만들어야 하는데, STOMP를 쓰면 라이브러리가 거의 다 풀어준다.
SockJS — 연결 폴백
‘SockJS’는 다른 결의 도구다. WebSocket이 차단된 환경(일부 사내망·낡은 프록시·구식 모바일 망)에서 ‘HTTP streaming이나 long polling으로 자동 폴백’해 주는 라이브러리다. STOMP가 메시지 규칙이라면 SockJS는 연결 자체를 책임지는 층이다.
정리:
- ‘단순 양방향만 필요하면 native WebSocket’으로 충분하고, ‘구독·브로드캐스트·사용자별 메시지가 필요하면 STOMP’를 얹는다. 채팅은 보통 후자.
- ‘통제된 인프라 + 최신 브라우저면 SockJS는 빼도 된다.’ 사내망·고객사 환경까지 지원해야 한다면 보험으로 끼운다.
주소 치트시트: /ws, /app, /topic, /user/queue
‘한 줄 요약: 채팅 입문에서 가장 헷갈리는 건 다섯 개 prefix. 각각이 무엇이고 누가 쓰는지부터 외운다.’
| 이름 | 누가 쓰나 | 의미 |
|---|---|---|
/ws | 브라우저가 처음 연결할 때 | WebSocket 핸드셰이크 엔드포인트 (HTTP URL) |
/app/... | 클라이언트 → 서버 | @MessageMapping 컨트롤러로 가는 STOMP destination |
/topic/... | 서버 → 여러 구독자 | 채팅방 전체 ‘topic'(여러 사람이 같이 받는 방송 채널) |
/user/queue/... | 서버 → 특정 사용자 | 내 알림만 받는 구독 주소. Spring이 세션별 destination으로 자동 변환 |
/queue/... | 서버 내부·브로커 관용 | 1:1처럼 쓰지만 simple broker에서는 이름만으로 보안이 보장되지 않음 |
”중요: /app/...은 HTTP URL이 아니라 STOMP destination prefix다.” 브라우저 주소창에 치는 게 아니라 STOMP SEND 프레임의 destination 헤더에 들어가는 값이다. /ws는 HTTP 핸드셰이크 엔드포인트라서 진짜 URL.
전체 메시지 흐름
‘한 줄 요약: 연결 → 구독 → 발송 → 브로드캐스트의 4단계.’
[1. 연결]
브라우저 ── HTTP GET /ws (Upgrade) ──▶ Spring
(STOMP CONNECT 프레임 + 인증 정보)
[2. 구독]
브라우저 ── STOMP SUBSCRIBE /topic/chat/42 ──▶ Spring
(앞으로 이 destination 메시지 받겠다고 등록)
[3. 발송]
브라우저 ── STOMP SEND /app/chat/42/send ──▶ Spring
(서버 컨트롤러로 가는 메시지)
[4. 브로드캐스트]
Spring(@MessageMapping) ── 처리 ──▶ /topic/chat/42
broker가 그 destination 구독자 모두에게 MESSAGE 프레임 전송
이 흐름을 머릿속에 두고 이후 코드를 보면 ‘서버의 어느 메서드와 클라의 어느 줄이 연결되는지’가 분명해진다.
1단계 — 서버 설정
‘한 줄 요약: spring-boot-starter-websocket 한 줄 + WebSocketMessageBrokerConfigurer 구현이 시작점.’
의존성
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-websocket")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation") // @Valid 사용 시
implementation("org.springframework.boot:spring-boot-starter-security") // 인증 기본 경로
}
spring-boot-starter-websocket을 추가하면 Spring WebSocket과 STOMP 메시징에 필요한 기본 모듈이 들어온다. @Valid @Payload로 메시지 검증을 쓸 거라면 validation starter도 같이 받는다.
Broker 설정
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("https://example.com", "https://*.example.com")
.withSockJS(); // SockJS 폴백이 필요할 때만
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 인메모리 simple broker — MVP까지만. 클러스터링은 외부 broker relay로 교체
config.enableSimpleBroker("/topic", "/queue")
.setHeartbeatValue(new long[] { 10_000, 10_000 })
.setTaskScheduler(heartbeatScheduler());
config.setApplicationDestinationPrefixes("/app");
config.setUserDestinationPrefix("/user");
}
// 가장 단순한 형태. bean lifecycle에서 자동 초기화된다.
// 순환 참조 우려가 있으면 @Lazy TaskScheduler 주입 패턴을 공식 문서에서 참고.
@Bean
public TaskScheduler heartbeatScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(1);
scheduler.setThreadNamePrefix("ws-heartbeat-");
return scheduler;
}
}
설정의 의미:
- ‘
/ws‘: 클라가 처음 붙는 핸드셰이크 엔드포인트(HTTP URL). - ‘
setAllowedOriginPatterns(...)‘: ‘CORS·Origin 검증의 첫 관문’. 운영에서는 와일드카드*를 절대 두지 말 것. - ‘
withSockJS()‘: WebSocket이 막힌 환경에서 long polling/streaming 폴백. - ‘
enableSimpleBroker("/topic", "/queue")‘: 인메모리 broker./topic/...은 브로드캐스트,/queue/...은 1:1 관용. - ‘
setApplicationDestinationPrefixes("/app")‘: 클라가 서버 컨트롤러로 보낼 메시지의 prefix. - ‘
setUserDestinationPrefix("/user")‘: 사용자별 메시지에 쓰는 prefix. - ‘STOMP heartbeat(연결이 살아있는지 확인하는 주기적 신호)’:
setHeartbeatValue([send, expect]). idle timeout 끊김을 막는 첫 방어선.
2단계 — 클라이언트 연결
‘한 줄 요약: SockJS + StompJS 두 라이브러리. 토큰은 connectHeaders로.’
서버 설정만 보면 추상적으로 느껴진다. 클라이언트가 실제로 어떻게 연결·구독·발송하는지 먼저 보면, 다음 절의 서버 컨트롤러 코드가 더 또렷해진다.
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@7/bundles/stomp.umd.min.js"></script>
const client = new StompJs.Client({
webSocketFactory: () => new SockJS('/ws'),
connectHeaders: {
// STOMP CONNECT 프레임에 붙는 헤더 (HTTP handshake 헤더가 아니다).
// JWT 인증 시 서버 ChannelInterceptor가 이 값을 검증한다.
Authorization: `Bearer ${accessToken}`,
},
reconnectDelay: 5000,
heartbeatIncoming: 10_000,
heartbeatOutgoing: 10_000,
debug: (msg) => console.debug('[stomp]', msg),
});
client.onConnect = () => {
// 토픽 구독 (서버는 /topic/chat/42 destination으로 broadcast)
client.subscribe('/topic/chat/42', (frame) => {
const msg = JSON.parse(frame.body);
appendChat(msg);
});
// 사용자별 알림 구독 (Spring이 세션별 destination으로 자동 라우팅)
client.subscribe('/user/queue/notice', (frame) => {
const notice = JSON.parse(frame.body);
toast(notice.text);
});
};
client.onStompError = (frame) => {
console.error('STOMP error', frame.headers['message'], frame.body);
};
client.activate();
// 메시지 보내기 — /app/... destination이 서버 @MessageMapping으로 들어간다
function sendChat(text) {
client.publish({
destination: '/app/chat/42/send',
body: JSON.stringify({ text }),
});
}
핵심:
- ‘
connectHeaders.Authorization‘: STOMP CONNECT 프레임에 토큰을 실어 보낸다(HTTP 헤더가 아님). 서버ChannelInterceptor가 검증해 인증 실패 시 즉시 거부·종료. - ‘
reconnectDelay‘: 연결 끊김 후 자동 재연결까지의 지연. 너무 짧으면 thundering herd 위험. - ‘
heartbeatIncoming/Outgoing‘: STOMP heartbeat. 서버 설정과 짝이 맞아야 한다. - ‘
webSocketFactory: () => new SockJS('/ws')‘: SockJS 사용. native WebSocket만 쓰려면brokerURL: 'wss://example.com/ws'로 대체. - ‘
activate()/deactivate()‘: 라이프사이클. SPA에서는 라우팅 변경 시 정리 필요.
운영 재연결: MVP는 reconnectDelay: 5000으로 충분하다. 다만 운영에서는 ‘exponential backoff + jitter’를 붙여 장애 복구 시 재접속 폭주를 줄이는 편이 안전하다(StompJS의 ReconnectionTimeMode.EXPONENTIAL, maxReconnectDelay).
3단계 — 브로드캐스트 메시지
‘한 줄 요약: 가장 단순한 케이스는 @SendTo. 실무 케이스는 SimpMessagingTemplate. destination은 slash로 간다.’
가장 먼저 실행해볼 최소 예제 — @SendTo
@Controller
public class ChatController {
@MessageMapping("/chat/{roomId}/send")
@SendTo("/topic/chat/{roomId}")
public ChatMessage onSend(
@DestinationVariable String roomId,
@Payload ChatPayload payload,
Principal principal) {
return ChatMessage.of(
roomId,
principal.getName(),
payload.text(),
Instant.now()
);
}
}
public record ChatPayload(String text) {}
public record ChatMessage(String roomId, String sender, String text, Instant sentAt) {
public static ChatMessage of(String roomId, String sender, String text, Instant sentAt) {
return new ChatMessage(roomId, sender, text, sentAt);
}
}
흐름: 클라가 /app/chat/42/send로 보내면 컨트롤러가 받고, 반환값이 /topic/chat/42로 자동 브로드캐스트된다. ”이 예제 하나만 돌려보면 양방향 메시지가 동작’한다는 감각이 잡힌다.”
‘destination 표기 주의’: Spring STOMP는 기본적으로
/chat/{roomId}/send같은 ‘slash 기반 mapping’을 기대한다./chat.42.send같은 dot 기반 convention을 쓰려면registry.setPathMatcher(new AntPathMatcher("."))설정이 별도로 필요하다. 이 글은 slash로 간다.
실무 확장 — SimpMessagingTemplate
채팅 실무에서는 메시지 저장, 권한 검사, 금칙어 처리, 발신자 제외, 읽음 처리, 알림 분기 같은 로직이 금방 붙는다. 이때는 @SendTo 반환보다 SimpMessagingTemplate로 직접 보내는 구조가 더 자연스럽다.
아래 코드의
RoomAuthorizer,ChatMessageService는 ‘구조를 보여주기 위한 예시 인터페이스’다. 실제 구현은 직접 만든다.
@Controller
@RequiredArgsConstructor
public class ChatController {
private final SimpMessagingTemplate messaging;
private final RoomAuthorizer roomAuthorizer; // 예시 인터페이스
private final ChatMessageService chatMessageService; // 예시 인터페이스
@MessageMapping("/chat/{roomId}/send")
public void onSend(
@DestinationVariable String roomId,
@Valid @Payload ChatPayload payload,
Principal principal) {
roomAuthorizer.assertCanSend(principal, roomId);
ChatMessage message = chatMessageService.save(
roomId,
principal.getName(),
payload.text()
);
messaging.convertAndSend("/topic/chat/" + roomId, message);
}
}
”@SendTo는 가장 단순한 브로드캐스트에 좋고, 실무에서는 권한 검사·저장·후처리·조건부 라우팅이 붙으니 SimpMessagingTemplate을 더 자주 쓴다.”
4단계 — 사용자별 메시지
‘한 줄 요약: /user/{username}/queue/...로 보내면 그 사용자에게만 간다. 단 “큐”라는 이름이 자동 1:1을 보장하지는 않는다.’
@Service
@RequiredArgsConstructor
public class NoticeService {
private final SimpMessagingTemplate messaging;
public void sendNotice(String username, String text) {
messaging.convertAndSendToUser(
username, // 보통 Principal.getName()과 같은 값
"/queue/notice",
new Notice(text, Instant.now())
);
}
}
public record Notice(String text, Instant sentAt) {}
”convertAndSendToUser의 첫 인자는 보통 Principal.getName()과 매칭된다.” 임의 문자열을 넣어도 항상 되는 게 아니라, Spring이 그 이름으로 등록된 세션을 찾아 라우팅한다. 클라는 /user/queue/notice로 구독하면 자동으로 자기 메시지만 받는다.
서버 코드에서는 /user/{username}/queue/...로 보내고 클라는 /user/queue/...로 구독하는 비대칭이 헷갈리는 자리다. Spring이 사용자별로 고유 세션 ID를 들고 있다가, /user/... 메시지를 그 세션의 실제 큐로 자동 변환해 라우팅해 준다.
주의할 두 가지
”첫째, /queue 자체가 ‘진짜 1:1 큐’를 의미하진 않는다.” Spring simple broker에서는 결국 destination을 구독한 세션에게 메시지를 보낸다. 사용자별 메시지를 안전하게 보내려면 항상 /user/queue/...를 쓰고, 서버에서는 convertAndSendToUser(...)를 쓴다.
”둘째, 같은 사용자가 여러 탭·여러 기기에서 접속할 수 있다.” convertAndSendToUser는 기본적으로 ‘그 사용자의 모든 세션’으로 보낸다. “방금 요청한 세션 하나에만” 보내야 한다면 @SendToUser(broadcast = false) 같은 패턴을 별도로 봐야 한다.
잠깐 — 사용자별 메시지가 되려면 Principal이 있어야 한다
convertAndSendToUser(username, ...)가 동작하려면 ‘Spring이 “이 WebSocket 세션의 사용자가 누구인가”를 알고 있어야’ 한다. 즉 ‘Principal(현재 연결된 사용자를 나타내는 Spring의 사용자 객체)’이 세션에 세팅돼 있어야 한다.‘경로는 두 가지’다.
– ‘Spring Security 기반’: HTTP 인증이 이미 통과한 상태라면 그 Principal이 WebSocket 세션으로 자연스럽게 이어진다. 추가 작업이 거의 없다. — ‘5단계 기본 경로’
– ‘직접 JWT 기반’: STOMP CONNECT 프레임 또는 custom handshake handler 단계에서 Principal을 ‘직접 세팅’해야 한다. handshake interceptor의
attributes에 단순히 사용자 객체를 넣어두는 것만으로는 부족하다. — ‘5단계 고급 경로’즉 이 박스를 무시하면
principal.getName()에서null이 나오거나convertAndSendToUser가 안 가는 사고가 난다.
5단계 — 인증과 권한
‘한 줄 요약: 기본 경로는 Spring Security 세션. 토큰 기반이면 STOMP CONNECT JWT(고급 박스). 권한은 CONNECT·SUBSCRIBE·SEND 세 곳에서.’
기본 경로 — Spring Security 세션·쿠키 기반
가장 단순한 경로다. HTTP 요청이 이미 Spring Security로 인증돼 있으면, ‘핸드셰이크 시점의 인증 정보가 WebSocket 세션의 Principal로 그대로 이어진다.’ @MessageMapping 메서드에서 Principal principal을 그냥 받을 수 있다. 이 경로면 추가 작업이 거의 없다.
”Server-side 렌더링·세션 쿠키 기반 웹앱이라면 이 경로를 우선” 검토한다. SPA·모바일·외부 API 토큰 기반이면 아래 고급 박스로 간다.
권한 검사: CONNECT · SUBSCRIBE · SEND 세 곳
”인증이 끝나도 “이 사용자가 이 방에 들어올 권한이 있는가”는 별도 검사”다. 채팅에서는 최소 세 번 확인한다.
- 'CONNECT': 이 사용자가 누구인가? (인증)
- 'SUBSCRIBE': 이 사용자가 이 방을 구독할 수 있는가? (구독 권한)
- 'SEND': 이 사용자가 이 방에 메시지를 보낼 수 있는가? (발송 권한)
SUBSCRIBE만 막고 SEND를 안 막으면, 권한 없는 사용자가 /app/chat/42/send로 메시지를 직접 던지는 시나리오가 그대로 통과한다. 둘 다 검사해야 한다.
고급: JWT를 STOMP CONNECT에서 검증할 때
SPA·모바일·외부 API처럼 토큰 기반 인증이면 이 경로를 본다. 브라우저 WebSocket 생성자는 custom HTTP header를 마음대로 붙이지 못해서, ‘Authorization 헤더를 HTTP 핸드셰이크에 붙이는’ 방식은 브라우저 클라이언트에선 곤란하다. 대신 ‘StompJS의
connectHeaders로 Bearer 토큰을 넘기고, 서버의ChannelInterceptor(STOMP 메시지가 컨트롤러·broker로 가기 전에 가로채는 훅)가 CONNECT 프레임에서 검증해 Principal을 직접 세팅’하는 패턴이 실무 표준이다.“`java
@Configuration
@RequiredArgsConstructor
public class StompSecurityConfig implements WebSocketMessageBrokerConfigurer {
private final JwtService jwtService; // 예시
private final RoomAuthorizer roomAuthorizer; // 예시
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor =
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor == null || accessor.getCommand() == null) return message;
StompCommand cmd = accessor.getCommand();
if (StompCommand.CONNECT.equals(cmd)) {
String bearer = accessor.getFirstNativeHeader(“Authorization”);
UserPrincipal principal = jwtService.verifyBearer(bearer);
if (principal == null) {
throw new MessageDeliveryException(“auth failed”); // 즉시 거부
}
accessor.setUser(principal);
}
if (StompCommand.SUBSCRIBE.equals(cmd)) {
Principal user = accessor.getUser();
String dest = accessor.getDestination();
// null user/destination은 fail-closed: 거부
if (user == null || dest == null
|| !roomAuthorizer.canSubscribe(user, dest)) {
throw new MessageDeliveryException(“forbidden subscribe”);
}
}
if (StompCommand.SEND.equals(cmd)) {
Principal user = accessor.getUser();
String dest = accessor.getDestination();
if (user == null || dest == null
|| !roomAuthorizer.canSend(user, dest)) {
throw new MessageDeliveryException(“forbidden send”);
}
}
return message;
}
});
}
}
`인증 실패 시
MessageDeliveryException을 던지면 STOMP 클라이언트는ERROR프레임을 받고 연결이 종료된다. ”null user / null destination은 항상 fail-closed로 거부”하는 게 안전한 기본값이다.
디버깅 팁
‘한 줄 요약: 첫 디버깅은 DevTools WS 탭 + Spring 로그 두 줄로 90% 풀린다.’
- ‘Chrome DevTools Network → WS 탭’에서 STOMP frame을 직접 볼 수 있다.
CONNECT,SUBSCRIBE,MESSAGE,SEND흐름이 그대로 보인다. - 서버 쪽은
logging.level.org.springframework.web.socket=DEBUG+logging.level.org.springframework.messaging=DEBUG로 frame 흐름 확인. - 클라
debug: (msg) => console.debug(msg)도 켜두면 첫 디버깅이 빨라진다. - ‘메시지가 안 갈 때 가장 흔한 원인 셋’: (1)
principal이 null — 인증 단계 점검, (2) destination prefix 오타(/appvs/topic), (3) Origin·CORS 차단.
여기까지가 ‘MVP 구현’이다. 아래부터는 ‘운영에서 부딪히는 것들’이다. 한 번에 다 할 필요는 없다. ‘MVP를 먼저 띄우고 트래픽이 늘기 시작하면 차례로’ 본다.
MVP 이후 — 운영 체크리스트
‘한 줄 요약: MVP를 띄운 다음 단계의 함정 6가지. 각자 “왜 문제가 되는지”부터 짚는다.’
1. 세션 stickiness — 배포 전에 반드시 봐야 함
”왜 문제가 되나”: SimpMessagingTemplate이 메시지를 보내려면 ‘그 사용자가 붙어 있는 인스턴스’에서 보내야 한다. 로드밸런서가 매 요청마다 다른 인스턴스로 보내면 그 사용자에게 push가 못 가서 메시지가 사라진다.
선택지:
- ‘sticky session(같은 사용자를 항상 같은 서버 인스턴스로 라우팅하는 LB 설정)’ 활성화 — 가장 단순
- ‘Redis pub/sub 가운데 두기’ — 어느 인스턴스에서 push하든 모두 받아 자기 사용자에게 전달
- ‘외부 STOMP broker relay (RabbitMQ/ActiveMQ)’ — 다음 항목
2. SimpleBroker → 외부 broker relay — 인스턴스 2대 넘는 순간
”왜 문제가 되나”: enableSimpleBroker는 인메모리. 인스턴스 A에서 /topic/chat/42에 보낸 메시지는 인스턴스 B의 구독자에게 닿지 않는다. 인스턴스가 두 대 이상이 되는 순간 토픽이 분리된다.
config.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("rabbitmq.internal")
.setRelayPort(61613)
.setSystemLogin("relay")
.setSystemPasscode("...")
.setClientLogin("clients")
.setClientPasscode("...");
”주의: ‘broker relay(Spring 내부 broker 대신 외부 broker로 메시지를 넘기는 방식)’의 TCP 연결 관리를 위해 io.projectreactor.netty:reactor-netty와 io.netty:netty-all 의존성이 필요할 수 있다.” Spring 버전·Boot 자동 구성에 따라 달라지므로 같이 확인.
3. /user 클러스터링 — relay만으로는 자동 해결 X
”왜 문제가 되나”: /topic 브로드캐스트는 외부 broker relay로 자연스럽게 확장된다. 하지만 ‘/user/{username}/... 메시지는 한 가지를 더 봐야 한다.’ 메시지를 보내려는 인스턴스와 ‘사용자가 실제로 붙어 있는 인스턴스’가 다를 수 있어서 unresolved 상태가 될 수 있다.
이 경우 Spring의 ‘userDestinationBroadcast’, ‘userRegistryBroadcast’ 같은 다중 서버 설정을 검토해야 한다. ”외부 broker relay만 붙이면 사용자별 메시지도 자동”이라고 단순화하면 안 된다.
4. Heartbeat 두 층 — 헷갈리는 자리
”왜 문제가 되나”: SockJS와 STOMP는 다른 층의 heartbeat를 따로 보낸다. 서로 같은 것으로 착각하면 idle timeout 끊김 진단이 어긋난다.
- ‘SockJS heartbeat’: Spring 문서 기준 기본 25초.
- ‘STOMP heartbeat’: 협상되면 SockJS heartbeat은 비활성화된다.
- LB idle timeout이 60초라면 STOMP heartbeat 10~25초가 안전. 클라·서버 양쪽 값이 짝을 맞춰야 함.
5. 메시지 저장·재전송 — 채팅 서비스라면 거의 항상
”왜 문제가 되나”: enableSimpleBroker는 메시지를 저장하지 않는다. 클라가 잠깐 끊긴 사이의 메시지는 사라진다.
- ‘DB에 메시지 저장 + 입장 시 최근 N개 로드’ 패턴이 가장 흔함.
- ‘unread count, read receipt’는 별도 모델.
- ‘fan-out 디자인'(쓰기 시 분기 vs 읽기 시 조회)은 트래픽 패턴에 따라 결정.
6. 관측성 — 사고 후 원인 분석의 베이스
”왜 문제가 되나”: 동시 연결 수, 끊김 빈도, broker 큐 depth 같은 지표가 없으면 사고가 났을 때 원인이 인프라인지 코드인지 가르기 어렵다.
Spring은 ‘WebSocketMessageBrokerStats’로 세션 수, STOMP frame 수, inbound/outbound channel 상태 같은 기본 통계를 모은다. Actuator/Micrometer와 같이 쓰면 JVM·HTTP·애플리케이션 메트릭까지 묶인다.
다만 채팅 서비스에서 정말 필요한 지표 — 방별 구독 수, 메시지 저장 latency, publish → broadcast 도달 시간, 재연결 성공률 — 같은 값은 ‘Micrometer counter/timer로 직접 심는’ 편이 안전하다.
보안 최종 체크
‘WebSocket 인증에서 가장 흔한 실수는 “연결만 인증하면 끝”이라고 생각하는 것이다.’ 채팅에서는 최소 세 번 확인한다.
1. ‘CONNECT’: 이 사용자가 누구인가?
2. ‘SUBSCRIBE’: 이 사용자가 이 방을 구독할 수 있는가?
3. ‘SEND’: 이 사용자가 이 방에 메시지를 보낼 수 있는가?
추가 원칙:
– 운영에서는 ‘
ws://가 아니라wss://‘를 쓴다.– 쿠키 기반 인증을 쓴다면 ‘Origin 검사’를 반드시 한다.
– ‘브라우저 WebSocket은 custom HTTP header를 붙이기 어렵다.’ query string에 토큰을 넣는 예제가 많지만 URL은 access log·proxy log·browser history에 남을 수 있다. 가능하면 ‘쿠키/세션 기반 또는 STOMP CONNECT header 기반’을 우선.
– ‘null user / null destination은 fail-closed로 거부.’ 토큰 만료, 방 권한 변경, 메시지별 인가는 별도 설계.
마치며: 시리즈 2편으로
이 글은 ‘Realtime & Messaging’ 시리즈 2편이다. 1편이 ‘WebSocket을 골랐다’까지였다면, 이 글은 ‘Spring + STOMP + SockJS로 MVP 채팅까지 만든다’였다.
요약하면:
- ‘STOMP는 메시지 라우팅 규칙, SockJS는 연결 폴백.’ 둘은 다른 층의 도구다.
- ‘주소 다섯 개'(
/ws,/app,/topic,/user/queue,/queue)와 ‘메시지 흐름 4단계'(연결·구독·발송·브로드캐스트)를 먼저 머릿속에 둔다. - ‘인증은 두 갈래’. 기본은 Spring Security 세션, 토큰 기반은 STOMP CONNECT JWT. 권한은 CONNECT·SUBSCRIBE·SEND 모두 검사.
- ‘MVP까지의 코드와 운영 체크리스트는 다른 글이다.’ MVP를 먼저 띄우고 트래픽이 늘면 sticky session → broker relay →
/user클러스터링 순으로 본다.
다음 글은 ‘RabbitMQ와 메시지 큐 입문’이다. 이번 글의 ‘STOMP broker relay’ 자리에서 자연스럽게 이어진다.
📦 추가 메모
메시지 크기와 백프레셔
- STOMP frame 크기 제한이 있다.
WebSocketMessageBrokerConfigurer.configureWebSocketTransport(...)에서setMessageSizeLimit,setSendBufferSizeLimit,setSendTimeLimit으로 조정. - 큰 메시지(이미지·파일)는 채팅 채널이 아니라 별도 업로드 + URL 전송 패턴이 안전하다.
SockJS의 추가 비용
SockJS를 붙이면 /ws 하나만 열리는 게 아니라, 내부적으로 /ws/{server-id}/{session-id}/{transport} 형태의 여러 HTTP 요청이 생긴다. ‘WebSocket fallback이 HTTP streaming/long polling으로 떨어지면 요청 수가 늘고, 로드밸런서 sticky session과 CORS 설정도 같이’ 봐야 한다.
메시지 순서 보장 — 두 층으로 나눠 본다
- ‘연결 하나 안의 frame 순서’는 TCP·STOMP가 보장한다.
- ‘채팅방 전체의 메시지 순서’는 별개다. 여러 사용자·여러 인스턴스·외부 broker·DB 저장이 섞이면 자연스럽게 보장되지 않는다. 운영에서는 서버 생성
messageId,sequence,sentAt을 두고, 클라가 이 값으로 정렬·중복 제거하는 패턴이 일반적.
채팅에서 흔히 빠뜨리는 것
- ‘Typing indicator'(상대가 타이핑 중)는 사용자별 토픽이 아니라 방 토픽으로 가볍게 보내는 편이 단순.
- ‘읽음 표시(read receipts)’는 메시지 ID 기반 별도 endpoint로 받고 broadcast.
- ‘오프라인 메시지’는 push 알림(FCM/APNs)과 결합. 1편 ’30초 선택 트리’의 5번 항목.