스프링 채팅 — WebSocket, STOMP, SockJS 개념 정리
1. 왜 채팅에 WebSocket이 필요한가?
HTTP의 한계
일반적인 웹 통신은 HTTP 기반이다.
HTTP는 요청-응답(Request-Response) 모델로, 클라이언트가 요청해야만 서버가 응답한다.
[클라이언트] --요청--> [서버]
[클라이언트] <--응답-- [서버]
채팅처럼 서버가 먼저 클라이언트에게 메시지를 보내야 하는 경우에는 HTTP가 비효율적이다.
HTTP로 채팅을 구현하려면 폴링(Polling) 방식을 써야 한다:
| 방식 | 설명 | 단점 |
|---|---|---|
| Polling | 클라이언트가 주기적으로 서버에 “새 메시지 있어?” 요청 | 불필요한 요청 반복, 서버 부하, 실시간성 떨어짐 |
| Long Polling | 서버가 새 메시지가 올 때까지 응답을 보류(대기) | 연결 유지 비용, 타임아웃 관리 복잡 |
| SSE (Server-Sent Events) | 서버 → 클라이언트 단방향 스트리밍 | 단방향이라 클라이언트 → 서버 전송 불가 |
WebSocket이란?
WebSocket은 HTTP와 다른 양방향(Full-Duplex) 통신 프로토콜이다.
한번 연결하면 클라이언트와 서버가 자유롭게 양방향으로 메시지를 주고받을 수 있다.
[클라이언트] <==양방향==> [서버]
(하나의 연결 유지)
HTTP vs WebSocket 비교:
| 구분 | HTTP | WebSocket |
|---|---|---|
| 통신 방향 | 단방향 (요청 → 응답) | 양방향 (Full-Duplex) |
| 연결 방식 | 매 요청마다 연결/해제 | 한번 연결 후 유지 |
| 프로토콜 | http://, https:// |
ws://, wss:// |
| 헤더 오버헤드 | 매 요청마다 HTTP 헤더 전송 | 최초 핸드셰이크 이후 프레임 단위 (헤더 최소) |
| 적합한 용도 | REST API, 페이지 요청 | 채팅, 실시간 알림, 게임, 주식 시세 |
2. WebSocket 핸드셰이크 (연결 수립 과정)
WebSocket 연결은 HTTP 핸드셰이크로 시작된다.
처음에는 HTTP로 요청하고, 서버가 동의하면 프로토콜이 WebSocket으로 업그레이드된다.
핸드셰이크 과정
1단계: 클라이언트 → 서버 (HTTP Upgrade 요청)
─────────────────────────────────────────────
GET /ws-stomp HTTP/1.1
Host: localhost:8080
Upgrade: websocket ← "WebSocket으로 바꿔줘"
Connection: Upgrade ← "연결을 업그레이드할 거야"
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== ← 랜덤 키 (보안용)
Sec-WebSocket-Version: 13 ← WebSocket 버전
2단계: 서버 → 클라이언트 (101 Switching Protocols 응답)
─────────────────────────────────────────────
HTTP/1.1 101 Switching Protocols ← "좋아, WebSocket으로 바꾸자"
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ← Key 기반 응답 (검증용)
3단계: WebSocket 연결 수립 완료!
─────────────────────────────────────────────
이제부터 HTTP가 아닌 WebSocket 프레임으로 양방향 통신
핵심 포인트
- 최초 연결만 HTTP → 이후는 TCP 위에서 WebSocket 프레임 교환
101 Switching Protocols응답이 오면 프로토콜 전환 완료Sec-WebSocket-Key/Sec-WebSocket-Accept교환으로 연결 검증 (CSRF 방어 등)- WebSocket 연결이 수립되면 서버와 클라이언트 양쪽 모두 언제든 메시지 전송 가능
3. 순수 WebSocket의 문제점 → 왜 STOMP를 쓰는가?
순수 WebSocket만 쓰면?
Spring은 @EnableWebSocket으로 순수 WebSocket을 지원한다.
하지만 순수 WebSocket은 저수준(Low-Level) 프로토콜이라:
// 순수 WebSocket — 모든 것을 직접 구현해야 함
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) {
String payload = message.getPayload();
// JSON 파싱은? 어떤 방에 보내는 건지? 누구한테?
// 메시지 타입 구분은? 에러 처리는?
// → 전부 직접 구현해야 함
}
순수 WebSocket의 한계:
| 문제 | 설명 |
|---|---|
| 메시지 라우팅 없음 | 어떤 채팅방에 보낼지, 누구에게 보낼지 직접 관리 |
| 메시지 형식 없음 | JSON이든 텍스트든 자유 → 파싱/포맷 직접 구현 |
| 구독(Pub/Sub) 없음 | 특정 주제를 구독하는 개념이 없음 → 직접 세션 관리 |
| 세션 관리 직접 | 접속자 목록, 방 별 세션, 연결/해제 이벤트 모두 직접 구현 |
| 헤더/인증 처리 없음 | 메시지에 인증 정보 넣는 것도 직접 |
STOMP란?
STOMP (Simple Text Oriented Messaging Protocol)는 WebSocket 위에서 동작하는 서브 프로토콜이다.
WebSocket이 TCP 위의 양방향 파이프라면, STOMP는 그 파이프 위에서 메시지 형식과 라우팅 규칙을 정해주는 프로토콜이다.
┌─────────────────────────────────┐
│ STOMP 프로토콜 │ ← 메시지 형식, 라우팅, Pub/Sub
├─────────────────────────────────┤
│ WebSocket 연결 │ ← 양방향 통신 파이프
├─────────────────────────────────┤
│ TCP/IP │ ← 네트워크 전송
└─────────────────────────────────┘
STOMP 프레임 구조
STOMP 메시지는 프레임(Frame) 단위로 교환된다:
COMMAND ← CONNECT, SUBSCRIBE, SEND, MESSAGE 등
header1:value1 ← 헤더 (destination, content-type 등)
header2:value2
← 빈 줄
Body (본문) ← JSON 등 실제 데이터
^@ ← NULL 문자 (프레임 종료 표시)
주요 STOMP 프레임:
| 프레임 | 방향 | 설명 |
|---|---|---|
CONNECT |
클라이언트 → 서버 | STOMP 세션 연결 |
CONNECTED |
서버 → 클라이언트 | 연결 성공 응답 |
SUBSCRIBE |
클라이언트 → 서버 | 특정 destination 구독 |
SEND |
클라이언트 → 서버 | 메시지 전송 |
MESSAGE |
서버 → 클라이언트 | 구독자에게 메시지 전달 |
DISCONNECT |
클라이언트 → 서버 | 연결 해제 |
순수 WebSocket vs STOMP 비교
| 구분 | 순수 WebSocket | STOMP |
|---|---|---|
| 메시지 라우팅 | 직접 구현 | destination 기반 자동 라우팅 |
| Pub/Sub | 직접 구현 | 내장 (SUBSCRIBE/MESSAGE) |
| 메시지 형식 | 자유 (규칙 없음) | 프레임 구조 (COMMAND + headers + body) |
| Spring 지원 | WebSocketHandler 직접 구현 |
@MessageMapping 어노테이션 (MVC와 유사!) |
| 세션 관리 | WebSocketSession 직접 관리 |
프레임워크가 관리 |
| 메시지 브로커 | 없음 | SimpleBroker 또는 외부 브로커(RabbitMQ 등) |
STOMP를 쓰면 Spring MVC의 @RequestMapping처럼 @MessageMapping으로 메시지를 처리할 수 있다!
4. SockJS — 왜 필요한가?
WebSocket을 지원하지 않는 환경
WebSocket은 모든 환경에서 사용 가능한 것이 아니다:
| 문제 상황 | 설명 |
|---|---|
| 구형 브라우저 | IE 9 이하 등 WebSocket 미지원 |
| 프록시/방화벽 | 기업 네트워크의 프록시가 WebSocket 연결을 차단 |
| 로드 밸런서 | 일부 L4/L7 로드 밸런서가 WebSocket 업그레이드를 지원하지 않음 |
| CDN | CloudFlare 등 일부 CDN이 WebSocket을 제한 |
SockJS란?
SockJS는 WebSocket의 브라우저 호환성 문제를 해결하는 JavaScript 라이브러리이다.
WebSocket이 안 되면 자동으로 대체 전송(Fallback Transport)을 사용한다.
SockJS 연결 시도 순서:
1. WebSocket ← 최우선 (ws://)
2. XHR Streaming ← WebSocket 안 되면 HTTP 스트리밍
3. XHR Polling ← 그것도 안 되면 HTTP 폴링
SockJS 동작 흐름
1. 클라이언트가 /ws-stomp/info 에 GET 요청 (SockJS info endpoint)
→ 서버가 지원하는 전송 방식, WebSocket 가능 여부 등 응답
2. SockJS가 최적의 전송 방식 결정
→ WebSocket 가능하면 ws://로 연결
→ 불가능하면 xhr-streaming 또는 xhr-polling으로 fallback
3. 어떤 방식이든 SockJS가 동일한 API를 제공
→ 개발자는 WebSocket이든 폴링이든 코드 변경 불필요
SockJS의 URL 패턴
SockJS를 활성화하면 자동으로 여러 URL이 생성된다:
/ws-stomp/info ← SockJS 정보 엔드포인트
/ws-stomp/{server-id}/{session-id}/websocket ← 실제 WebSocket 연결
/ws-stomp/{server-id}/{session-id}/xhr ← XHR 폴링 fallback
/ws-stomp/{server-id}/{session-id}/xhr_streaming ← XHR 스트리밍 fallback
이 때문에 SecurityConfig에서 /ws-stomp/**로 permitAll 설정이 필요하다.
코드에서의 사용
// SockJS 없이 (순수 WebSocket)
const socket = new WebSocket('ws://localhost:8080/ws-stomp');
// SockJS 사용 (자동 fallback)
const socket = new SockJS('/ws-stomp'); // http:// 사용 (SockJS가 프로토콜 결정)
개발자가 신경 쓸 것은 없다 — SockJS가 알아서 최적의 전송 방식을 선택한다.
5. Spring에서의 STOMP 아키텍처 (전체 동작 흐름)
아키텍처 그림
┌──────────────────────────────────────────────────────────────┐
│ Spring 서버 │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ SockJS │ │ STOMP │ │ SimpleBroker │ │
│ │ Endpoint │───→│ Protocol │───→│ (/sub prefix) │ │
│ │/ws-stomp │ │ Handler │ │ │ │
│ └──────────┘ └──────┬───────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ 구독자에게 전달 │
│ │@MessageMapping│ │
│ │ Controller │ │
│ │ (/pub prefix)│ │
│ └──────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
메시지 흐름 상세
[클라이언트A] [Spring 서버] [클라이언트B]
│ │ │
│ 1. SockJS 연결 (/ws-stomp) │ │
│ ─────────────────────────────→│ │
│ │ │
│ 2. STOMP CONNECT │ │
│ ─────────────────────────────→│ │
│ 3. STOMP CONNECTED │ │
│ ←─────────────────────────────│ │
│ │ │
│ 4. SUBSCRIBE │ 5. SUBSCRIBE │
│ /sub/chat/room/1 │ /sub/chat/room/1 │
│ ─────────────────────────────→│ ←─────────────────────────────│
│ │ │
│ 6. SEND │ │
│ /pub/chat/message │ │
│ {"type":"TALK", │ │
│ "roomId":1, │ │
│ "sender":"홍길동", │ │
│ "message":"안녕!"} │ │
│ ─────────────────────────────→│ │
│ │ │
│ 7. @MessageMapping("/chat/message") 실행 │
│ 8. messageSendingOperations.convertAndSend( │
│ "/sub/chat/room/1", message) │
│ │ │
│ 9. MESSAGE │ 9. MESSAGE │
│ /sub/chat/room/1 │ /sub/chat/room/1 │
│ ←─────────────────────────────│─────────────────────────────→ │
│ {"type":"TALK", │ {"type":"TALK", │
│ "sender":"홍길동", │ "sender":"홍길동", │
│ "message":"안녕!"} │ "message":"안녕!"} │
이 프로젝트의 Prefix 규칙
| Prefix | 방향 | 역할 | 예시 |
|---|---|---|---|
/pub |
클라이언트 → 서버 | 메시지 전송 (applicationDestinationPrefixes) | /pub/chat/message |
/sub |
서버 → 클라이언트 | 구독/브로드캐스트 (enableSimpleBroker) | /sub/chat/room/{roomId} |
- 클라이언트가
/pub/chat/message로 SEND →@MessageMapping("/chat/message")실행 - 서버가
/sub/chat/room/1로 convertAndSend → 해당 destination을 SUBSCRIBE한 모든 클라이언트가 수신
6. Spring 설정 코드 설명
WebSocketConfig
@Configuration
@EnableWebSocketMessageBroker // STOMP 메시지 브로커 기능 활성화
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// SimpleBroker: 메모리 기반 메시지 브로커
// /sub로 시작하는 destination을 구독한 클라이언트에게 메시지 전달
config.enableSimpleBroker("/sub");
// /pub로 시작하는 메시지 → @MessageMapping 메서드로 라우팅
config.setApplicationDestinationPrefixes("/pub");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// /ws-stomp: WebSocket 핸드셰이크를 수행할 HTTP URL
// withSockJS(): WebSocket 불가 시 자동 fallback
registry.addEndpoint("/ws-stomp")
.withSockJS();
}
}
SimpleBroker vs 외부 브로커:
enableSimpleBroker(): Spring 내장 메모리 브로커 (소규모 서비스 적합)enableStompBrokerRelay(): 외부 메시지 브로커(RabbitMQ, ActiveMQ) 연동 (대규모 서비스)- 이 프로젝트는 학습용이므로 SimpleBroker 사용
ChatMessageController (STOMP 메시지 처리)
@Controller
@RequiredArgsConstructor
public class ChatMessageController {
// 서버에서 특정 destination으로 메시지를 보내는 인터페이스
private final SimpMessageSendingOperations messageSendingOperations;
// 클라이언트가 /pub/chat/message로 SEND한 메시지가 여기로 옴
// (prefix "/pub" + @MessageMapping("/chat/message"))
@MessageMapping("/chat/message")
public void message(ChatMessageDTO message) {
// 타입별 메시지 가공
if (ENTER) → message.setMessage(sender + "님이 입장했습니다.");
if (LEAVE) → message.setMessage(sender + "님이 퇴장했습니다.");
// 해당 채팅방 구독자 전체에게 브로드캐스트
messageSendingOperations.convertAndSend(
"/sub/chat/room/" + message.getRoomId(), message);
}
}
@MessageMapping vs @RequestMapping 비교:
| 구분 | @RequestMapping | @MessageMapping |
|---|---|---|
| 프로토콜 | HTTP | STOMP (over WebSocket) |
| 트리거 | HTTP 요청 (GET/POST 등) | STOMP SEND 프레임 |
| prefix | 없음 (URL 그대로) | applicationDestinationPrefixes 적용 |
| 응답 방식 | HTTP 응답 | convertAndSend로 구독자에게 브로드캐스트 |
7. 클라이언트 코드 설명 (JavaScript)
연결 순서
// 1. SockJS 객체 생성 — /ws-stomp로 HTTP 핸드셰이크
const socket = new SockJS('/ws-stomp');
// 2. STOMP 클라이언트 생성 — SockJS 위에 STOMP 프로토콜 얹기
const stompClient = Stomp.over(socket);
// 3. STOMP 연결 (CONNECT 프레임 전송)
stompClient.connect({}, function(frame) {
// 연결 성공 콜백
// 4. 채팅방 구독 (SUBSCRIBE 프레임 전송)
stompClient.subscribe('/sub/chat/room/' + roomId, function(msg) {
// 메시지 수신 콜백 — 서버에서 MESSAGE 프레임이 올 때 실행
const chatMessage = JSON.parse(msg.body);
showMessage(chatMessage);
});
// 5. 입장 메시지 전송 (SEND 프레임)
stompClient.send('/pub/chat/message', {}, JSON.stringify({
type: 'ENTER',
roomId: roomId,
sender: nickname,
message: ''
}));
});
메시지 전송
stompClient.send(
'/pub/chat/message', // destination (서버의 @MessageMapping과 매핑)
{}, // headers (추가 헤더, 보통 빈 객체)
JSON.stringify({ // body (ChatMessageDTO JSON)
type: 'TALK',
roomId: roomId,
sender: nickname,
message: '안녕하세요!'
})
);
Thymeleaf → JavaScript 데이터 전달
<script th:inline="javascript">
const roomId = [[${room.id}]]; // Long → JS number (예: 1)
const nickname = [[${user.nickname}]]; // String → JS string (예: "홍길동")
</script>
th:inline="javascript" + [[${...}]] 문법으로 서버의 model 데이터를
JavaScript 변수에 안전하게 바인딩한다 (XSS 방어 자동 적용).
8. 채팅 동작 시나리오 (A, B 두 사용자 기준)
시나리오: A가 채팅방 생성 → A, B 입장 → 대화 → B 퇴장
1. [A] GET /chat/rooms (채팅방 목록 페이지)
- Security가 인증 확인 → 로그인 안 했으면 /login으로 리다이렉트
2. [A] POST /chat/rooms (roomName="자유채팅방")
- ChatController → ChatService.createRoom() → DB에 채팅방 저장
- redirect:/chat/rooms
3. [A] GET /chat/rooms/1 (채팅방 입장 페이지)
- room.html 렌더링 (roomId=1, nickname="A닉네임" JS에 바인딩)
- SockJS 연결 → STOMP CONNECT → SUBSCRIBE /sub/chat/room/1
- SEND /pub/chat/message {type:ENTER, roomId:1, sender:"A닉네임"}
- → 서버가 "A닉네임님이 입장했습니다" /sub/chat/room/1로 브로드캐스트
- → A가 시스템 메시지 수신
4. [B] GET /chat/rooms/1 (B도 입장)
- 같은 과정 → "B닉네임님이 입장했습니다" 브로드캐스트
- → A, B 모두 수신
5. [A] SEND /pub/chat/message {type:TALK, roomId:1, sender:"A닉네임", message:"안녕!"}
- 서버가 /sub/chat/room/1로 브로드캐스트
- → A(내 메시지, 오른쪽), B(상대 메시지, 왼쪽) 모두 표시
6. [B] 나가기 버튼 클릭
- SEND {type:LEAVE, roomId:1, sender:"B닉네임"} → "B닉네임님이 퇴장했습니다" 브로드캐스트
- disconnect → /chat/rooms로 이동
- → A가 퇴장 시스템 메시지 수신
9. 보안 — 이 프로젝트에서의 접근 방식
보안 적용 범위
이 프로젝트에서는 HTTP 요청(RequestMapping) 수준에서만 보안을 적용한다.
// SecurityConfig
.requestMatchers("/ws-stomp/**").permitAll() // SockJS 핸드셰이크 허용
.requestMatchers("/chat/**").authenticated() // 채팅 페이지는 로그인 필수
/chat/rooms(목록),/chat/rooms/{id}(입장 페이지): 로그인 필수/ws-stomp/**(WebSocket 핸드셰이크): permitAll (SockJS 동작을 위해)
WebSocket 보안을 적용하지 않는 이유
채팅방 페이지 자체가 로그인 필수이므로, 해당 페이지에 접근할 수 있다면 이미 인증된 사용자이다.
WebSocket 레벨 보안(ChannelInterceptor 등)을 추가하면 복잡도가 크게 올라가므로,
학습용 프로젝트에서는 HTTP 수준 보안으로 충분하다.
운영 환경에서 WebSocket 보안이 필요하다면:
ChannelInterceptor를 등록하여 STOMP CONNECT 시 인증 토큰 검증@MessageMapping메서드에서SimpMessageHeaderAccessor로 사용자 정보 추출- Spring Security의
AbstractSecurityWebSocketMessageBrokerConfigurer사용
닉네임(sender) 전달 방식
현재는 Thymeleaf에서 서버 세션의 nickname을 JavaScript 변수로 바인딩한 뒤,
STOMP 메시지의 sender 필드로 전송한다.
<script th:inline="javascript">
const nickname = [[${user.nickname}]]; // 서버 세션에서 가져온 닉네임
</script>
채팅방 페이지 자체가 로그인 필수이므로, 이 닉네임은 인증된 사용자의 실제 닉네임이다.
10. 용어 정리
| 용어 | 설명 |
|---|---|
| WebSocket | HTTP 핸드셰이크 후 양방향 통신을 유지하는 프로토콜 (ws://, wss://) |
| STOMP | WebSocket 위에서 동작하는 메시징 프로토콜 (프레임 기반, Pub/Sub 지원) |
| SockJS | WebSocket 불가 시 자동 fallback을 제공하는 JavaScript 라이브러리 |
| 핸드셰이크 | HTTP → WebSocket 프로토콜 업그레이드 과정 (101 Switching Protocols) |
| Pub/Sub | 발행(Publish)/구독(Subscribe) 패턴 — 발행자가 메시지를 보내면 구독자 전원이 수신 |
| SimpleBroker | Spring 내장 메모리 기반 메시지 브로커 |
| destination | STOMP 메시지의 목적지 경로 (예: /sub/chat/room/1) |
| 프레임(Frame) | STOMP 메시지의 단위 (COMMAND + headers + body) |
| SUBSCRIBE | 특정 destination을 구독하는 STOMP 프레임 |
| SEND | 서버로 메시지를 보내는 STOMP 프레임 |
| MESSAGE | 서버가 구독자에게 보내는 STOMP 프레임 |
| convertAndSend | 서버에서 특정 destination으로 메시지를 브로드캐스트하는 Spring 메서드 |
| @MessageMapping | STOMP SEND 프레임을 처리하는 Spring 어노테이션 (HTTP의 @RequestMapping에 대응) |
| SimpMessageSendingOperations | 서버에서 STOMP 메시지를 전송하는 인터페이스 |
| Full-Duplex | 동시에 양방향 통신이 가능한 방식 (WebSocket의 특징) |
| Fallback Transport | WebSocket 불가 시 SockJS가 사용하는 대체 전송 방식 (xhr-streaming, xhr-polling 등) |