
우리 서비스는 SockJS의 요청을 Gateway가 JWT 토큰을 필터링 후 마이크로 서비스로 헤더에 유저 관련 정보를 담아서 넘겨준다. 하지만 기본적으로 Spring Cloud Gateway는 HTTP 요청을 처리하는데 WS 프로토콜로 업그레이드 된 요청에는 커스텀 헤더가 불가능하다. 이 상황에서 어떻게 대처해야 할까?
현재 우리 프론트엔드는 실시간 양방향 통신을 위해서 SockJS와 STOMP를 사용한다. 이 두개가 어떻게 사용되는지 파악해보자
const socket = new SockJS('https://example.com/ws/relay?token=abc123');
이 코드가 실행되면 SockJS는 내부적으로 이런 요청을 보낸다.
GET /ws/relay/info?t=23985678443
Accept: */*
GET /ws/relay/123/xyz/websocket HTTP/1.1
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
서버가 101 Switching Protocols 로 응답하면, 이후부터는 WebSocket 프레임 기반으로 전송이 이루어진다.
이제 WebSocket이 통로를 뚫었으니, 어떤 메시징 규칙으로 데이터를 주고 받을 지를 STOMP (Simple Text Oriented Messaging Protocol) 사용한다.
내부적으로는 다음과 같은 문자열 프레임을 WebSocket으로 보낸다.
// 클라이언트 -> 서버
CONNECT
accept-version:1.1,1.2
heart-beat:10000,10000
token: abc123
^@
// 서버 -> 클라이언트
CONNECTED
version:1.2
heart-beat:0,0
^@
// 구독 요청
SUBSCRIBE
id:sub-0
destination:/topic/rooms/123
// 서버 -> 클라이언트
MESSAGE
subscription:sub-0
message-id:abc-123
destination:/topic/rooms/123
{"type":"USER_JOINED","roomId":"123","data":{...}}
^@
// 등등..
이 모든 프레임들이 WebSocket 프레임(payload) 안에 들어가서 왔다리갔다리 한다.
이제 이걸 처리할 Backend 코드를 살펴보자면

프론트엔드가 /ws/relay 로 WebSocket/SockJS 요청을 보낼 수 있게 해준다.
메시지 브로커를 topic, queue 를 구독 prefix로 설정해두었다.
컨트롤러에서 @MessageMapping("/rooms/{roomId}/chat") 과 @SendTo("/topic/rooms/{roomId}") 를 통해서 브로드 캐스트

사용자가 방에 입장시 서비스 단에서 로직으로 SimpMessagingTemplate 를 사용해서 모든 참여자에게 입장을 알려준다.

/api/ws/relay 요청을 감지하면, /ws/relay로 relay 서버로 보낸다.
게이트웨이에서 인증 절차를 거치기 때문에 Cookie를 마이크로 서비스에 전달하지 않는다.
기본적으로 Gateway는 요청을 전달할 때 Host 헤더를 대상 서버 주소로 바꾼다. 그러나 SockJS 핸드셰이크 과정에서 CORS 및 Host 검증이 필요한 경우, 원본 호스트를 유지하도록 preserveHostHeader를 추가하였다.

처음에 트리거 되는 path를 /ws/relay/** 로 해놨다가 nginx 에서 부터 걸러져서 문제가 있었는데 /api를 추가하고 stripPrefix를 통해 제거해줬다.
(nginx가 /api 만 SCG로 넘김)
1. 프론트엔드 요청
GET /api/ws/relay/info?token=eyJ...&t=1762327856664 (SockJS)
2. Gateway filter
WebSocket 감지 후 쿼리 파라미터 추가
GET /api/ws/relay/info?token=eyJ...&t=...&userId=486&nickname=f23f23fdwf
3. stripPrefix(1) 적용
GET /ws/relay/info?token=eyJ...&t=...&userId=486&nickname=f23f23fdwf
(백엔드에서 /api 요청은 따로 받기 때문에)
4. Relay 서버 WebSocketHandshakeInterceptor
핸드 쉐이크 과정에서 queryParams.get 으로 유저 식별
Spring Cloud Gateway는 HTTP 레벨에서 동작하기 때문에, WebSocket으로 업그레이드된 이후에는 커스텀 헤더를 주입하거나 수정할 수 없다. 따라서 Gateway에서 JWT를 검증한 뒤 유저 정보를 전달하려면 WebSocket 핸드셰이크 직전, 즉 HTTP 단계에서만 정보를 추가할 수 있다.
이 한계를 해결하기 위해 우리는 JWT 토큰을 쿼리 파라미터로 전달하고, Gateway 필터가 이를 검증한 뒤 userId, nickname 등의 유저 정보를 쿼리 파라미터로 추가하는 방식을 택했다. 이렇게 하면 WebSocket 업그레이드 이전의 HTTP 요청(GET /info, GET /{server_id}/{session_id}/websocket) 단계에서 이미 유저 식별 정보가 포함되어 Relay 서버로 전달된다.
Relay 서버에서는 WebSocketHandshakeInterceptor를 통해 이 쿼리 파라미터를 추출하고, 세션에 유저 정보를 저장하여 이후 STOMP 통신에서 인증된 사용자로 인식할 수 있다.
즉, Gateway는 WebSocket 업그레이드 이전 단계에서 JWT를 검증하고 유저 정보를 쿼리 파라미터에 주입,
Relay 서버는 업그레이드 이후 단계에서 해당 정보를 활용하는 구조로 문제를 해결했다.
결과적으로, WebSocket 프로토콜의 제약을 우회하면서도
Gateway 레벨에서 인증과 유저 식별을 안전하게 처리할 수 있게 되었다.