스프링 채팅 — 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 프레임으로 양방향 통신

핵심 포인트


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란?

SockJSWebSocket의 브라우저 호환성 문제를 해결하는 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}

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 외부 브로커:

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()     // 채팅 페이지는 로그인 필수

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 등)