시작하며
지금 진행 중인 프로젝트는 WebRTC 기반의 비대면 노래방 서비스이다.
노래방 서비스에서는, 단순하게 HTTP 통신만 해서는 사용자가 온전히 서비스를 즐기기가 어렵다.
누가 가창자가 되었는지, 가창자가 무슨 노래를 골랐는지, 도중에 누가 나가는지, 가창자가 템포나 음정 조절을 하는지 등등,, 실시간으로 클라이언트가 변경할 수 있는 요소가 무궁무진하다.
그렇기 때문에, 지금 진행하는 프로젝트에서 웹소켓으로 클라이언트와 서버가 연결을 맺고, 양방향으로 매시지를 주고 받아야 한다.
프로젝트의 Spring WebSocket과 STOMP 구조
본 프로젝트에서는 Spring WebSocket과 STOMP 프로토콜을 사용했다.
STOMP
메시지를 어디로 보내는지(destination), 어떤 경로를 구독할 것인지 표현할 수 있도록, CONNECT, SEND, SUBSCRIBE등의 명령을 이용해서 소통하는 프로토콜
STOMP의 명령어들
CONNECT
STOMP 통신 시작
SUBSCRIBE
서버 이벤트를 받을 경로 등록
SEND
서버에 메세지 전송
MESSAGE
구독한 경로로 서버 메세지 수신
프로젝트에서 사용하는 기본 destination
WebSocket 연결 endpoint
/ws
클라이언트 -> 서버
/app/**
서버 -> 방 전체
/topic/rooms/{roomId}
서버 -> 특정 사용자
/user/queue/**
클라이언트는, 초기에 "/ws"로 서버와 웹소켓 연결을 맺고,
"/app/~" 경로를 통해 rest-api처럼 서버로 요청을 보낸다.
서버에서는, "/topic/rooms/{roomId}"를 구독하고 있는 모든 클라이언트나, "/user/queue/~" 경로로 특정 사용자에게 이벤트를 보낼 수 있다.
웹소켓 플로우
지금까지 얘기한 걸 바탕으로, 우리 프로젝트에서 채팅을 하는 플로우는 다음과 같다.
1. 클라이언트가 /ws로 웹소켓 연결을 요청한다.
2. 웹소켓 연결이 완료되면 클라이언트가 STOMP CONNECT 프레임을 전송한다.
3. CONNECT 프레임의 Authorization 헤더에 담긴 JWT를 서버가 검증한다.
4. 인증이 성공하면 서버는 해당 웹소켓 세션에 사용자 정보를 Principal로 등록한다.
5. 클라이언트는 서버 이벤트를 받기 위해 /topic/rooms/{roomId}를 SUBSCRIBE한다.
6. 사용자가 채팅을 입력하면 클라이언트는 /app/rooms/{roomId}/chat으로 SEND한다.
7. 서버의 @MessageMapping 컨트롤러가 해당 메시지를 전달받는다.
8. 서버는 사용자 권한, 방 참가 여부, 요청 데이터 등을 검증한다.
9. 처리가 완료되면 서버는 PARTICIPANT_CHAT 이벤트를 생성한다.
10. 서버가 /topic/rooms/{roomId}로 이벤트를 발행한다.
11. 해당 topic을 구독하고 있는 모든 클라이언트가 MESSAGE 프레임으로 이벤트를 전달받는다.
전체 흐름을 한 줄로 표현하면 다음과 같다.
WebSocket 연결
→ STOMP CONNECT
→ JWT 인증
→ SUBSCRIBE
→ SEND
→ 서버 검증 및 처리
→ 이벤트 발행
→ 구독 중인 클라이언트 수신
채팅을 기준으로 보면 다음과 같다.
Client
└─ CONNECT /ws
└─ Authorization: Bearer {accessToken}
Server
└─ JWT 검증 후 사용자 Principal 등록
Client
└─ SUBSCRIBE /topic/rooms/1
Client
└─ SEND /app/rooms/1/chat
{
"message": "안녕하세요"
}
Server
└─ RoomWebSocketController
└─ RoomService
└─ 요청 검증
└─ 채팅 이벤트 생성
└─ /topic/rooms/1 발행
Subscribers
└─ PARTICIPANT_CHAT 이벤트 수신
즉, 클라이언트가 CONNECT로 인증된 연결을 만든 뒤, 서버 이벤트를 받을 경로를 SUBSCRIBE한다.
이후 클라이언트가 /app으로 명령을 SEND하면, 서버는 해당 명령을 처리하고 /topic으로 결과 이벤트를 발행한다.
/user/queue/**만 보고 어떻게 특정 사용자를 구분할까?
결론부터 말하자면, "/user/queue/~" 경로 자체가 사용자를 구분하는 것은 당연히 아니다.
사용자 구분은, WebSocket 세션에 등록된 Principal을 기준으로 이루어진다.
CONNECT 시 사용자 정보를 WebSocket 세션에 등록한다
STOMP CONNECT 과정에서 JWT나 세션 등의 인증 정보를 검증한 뒤, 인증된 사용자 정보를 등록한다.
accessor.setUser(authentication);
*accessor?
STOMP 프로토콜 안의 STOMP 명령(CONNECT, SEND, SUBSCRIBE), 헤더 정보, WebSocket 연결의 세션 ID, 현재 WebSocket 연결에 등록된 principal, 메시지를 보내거나 구독하려는 경로등을 알고 있는 객체이다.
이때 Spring은 내부적으로 다음 관계를 관리한다.
Principal 이름 ↔ WebSocket 세션 ID
예를 들어 authentication.getName()이 "234"라면, Spring은 234번 사용자와 현재 WebSocket 세션의 관계를 기억한다.
2. 서버는 수신할 사용자를 별도로 지정한다
서버가 특정 사용자에게 이벤트를 보낼 때는 convertAndSendToUser()를 사용한다.
messagingTemplate.convertAndSendToUser(
"234",
"/queue/cards",
payload
);
첫 번째 인자인 "234"는 이벤트를 받을 사용자의 Principal.getName() 값이다.
Spring은 "234"라는 Principal이 연결된 WebSocket 세션을 찾고, 해당 세션 전용 목적지로 메시지를 변환한다.
convertAndSendToUser("234", "/queue/cards", payload)
↓
234번 사용자의 WebSocket 세션 조회
↓
해당 세션의 /user/queue/cards 구독으로 전달
3. 클라이언트는 자신의 사용자 ID를 경로에 넣지 않는다
클라이언트는 다음과 같이 모든 사용자가 동일한 경로를 구독한다.
/user/queue/cards
다음처럼 사용자 ID를 직접 넣지 않는다.
/user/234/queue/cards
현재 WebSocket 연결에 등록된 Principal을 기준으로 Spring이 사용자별 목적지를 자동으로 분리하기 때문이다.
WebSocketConfig 파일 작성하기
이제 앞선 지식들을 바탕으로, websocket을 프로젝트에 적용하는 가장 첫 단계인 websocketconfig 파일을 작성해보자.
프로젝트에서는 WebSocketMessageBrokerConfig를 통해 웹소켓 연결 endpoint와 STOMP 메시지 전달 규칙을 설정했다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketMessageBrokerConfig implements WebSocketMessageBrokerConfigurer
@EnableWebSocketMessageBroker
WebSocket 위에서 STOMP 메시지 브로커를 사용할 수 있도록 관련 설정을 활성화한다.
WebSocketMessageBrokerConfigurer
WebSocketMessageBrokerConfigurer를 구현하면 다음과 같은 설정 메서드를 재정의할 수 있다.
registerStompEndpoints()
웹소켓 연결 endpoint 설정
configureMessageBroker()
메시지를 처리할 destination과 broker 설정
configureClientInboundChannel()
클라이언트가 보내는 메시지에 적용할 interceptor 설정
이제부터 이 설정들을 하나하나씩 해보자.
웹소켓 연결 endpoint 설정하기
클라이언트가 서버와 웹소켓 연결을 시작하려면 최초 접속 주소가 필요하다.
프로젝트에서는 다음과 같이 /ws를 연결 endpoint로 등록했다.
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.setErrorHandler(stompErrorHandler);
// WEBSOCKET_ENDPOINT ="/ws"
var endpointRegistration = registry.addEndpoint(WEBSOCKET_ENDPOINT);
List<String> allowedOrigins = corsProperties.getAllowedOrigins();
if (allowedOrigins != null && !allowedOrigins.isEmpty()) {
endpointRegistration.setAllowedOrigins( allowedOrigins.toArray(String[]::new) ); }
}
클라이언트는 /ws로 HTTP Upgrade 요청을 보내 웹소켓 연결을 수립한다.
웹소켓 연결이 만들어진 뒤에는 그 연결 위에서 STOMP CONNECT 프레임을 전송한다.
HTTP 요청
↓ Upgrade
WebSocket 연결 수립
↓
STOMP CONNECT 전송
↓
STOMP 통신 시작
setAllowedOrigins()
setAllowedOrigins() 에서는 웹소켓 연결을 허용할 프론트엔드 origin을 지정한다.
메세지 Broker 설정하기
웹소켓 연결 endpoint를 만들었다고 해서 메시지가 자동으로 구독자에게 전달되는 것은 아니다.
STOMP를 사용하는 클라이언트는 특정 destination을 구독하고, 서버는 해당 destination으로 메시지를 발행한다.
이때 누가 어떤 destination을 구독하고 있는지 관리하고, 발행된 메시지를 알맞은 구독자에게 전달하는 역할을 메시지 broker가 담당한다. 프로젝트에서는 Spring이 제공하는 내장 Simple Broker를 사용했다.
public static final String APPLICATION_DESTINATION_PREFIX = "/app";
public static final String USER_DESTINATION_PREFIX = "/user";
private static final String TOPIC_BROKER_PREFIX = "/topic";
private static final String QUEUE_BROKER_PREFIX = "/queue";
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry .enableSimpleBroker( TOPIC_BROKER_PREFIX, QUEUE_BROKER_PREFIX )
.setHeartbeatValue( new long[] {
HEARTBEAT_INTERVAL_MILLIS, HEARTBEAT_INTERVAL_MILLIS } )
.setTaskScheduler(heartbeatTaskScheduler);
registry.setApplicationDestinationPrefixes( APPLICATION_DESTINATION_PREFIX );
registry.setUserDestinationPrefix( USER_DESTINATION_PREFIX );
registry.setPreservePublishOrder(true);
}
Simple Broker란?
구독 요청을 메모리에 저장하고, destination이 일치하는 연결된 클라이언트에게 메시지를 전달한다.
별도의 메시지 브로커 서버를 설치하지 않고 Spring 애플리케이션 내부에서 사용할 수 있다는 점이 가장 큰 특징이다.
Simple Broker를 사용한 이유
현재 프로젝트처럼 MVP 단계에서 단일 애플리케이션 서버를 우선 운영한다면, 별도의 메시지 브로커 인프라 없이 실시간 이벤트 전달 구조를 빠르게 구성할 수 있다는 장점이 있다.
Simple Broker의 한계
Simple Broker는 Spring 내부 메모리에다가 세션 정보를 기억하기 때문에, 다중 서버에서는 Simple Broker를 사용하면 큰 오류가 발생한다.
만약 클라이언트와 서버A가 서로 웹소켓 연결을 맺은 상태에서, 다음에 클라이언트가 서버 B로 웹소켓 SEND 요청을 보내게 되면, 서버 B 입장에서 클라이언트는 웹소켓 연결이 안되어있기 때문에 오류가 발생한다.
따라서, 다중 서버의 경우에는 rabbitMQ나 Kafka처럼 외부 메시지 브로커를 사용해야한다.
HeartBeat 설정하기
private static final long HEARTBEAT_INTERVAL_MILLIS = 10_000L;
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry
.setHeartbeatValue(
new long[] {
// 서버가 heartbeat를 전송할 주기
HEARTBEAT_INTERVAL_MILLIS,
// 클라이언트가 heartbeat를 전송해야하는 주기
HEARTBEAT_INTERVAL_MILLIS
})
// 서버가 heartbeat를 전송할 주기에 맞게 실행하기 위한 스케줄러 등록
.setTaskScheduler(heartbeatTaskScheduler);
}
Hearbeat란
일정 시간 동안 일반 메시지가 오가지 않을 때도 연결이 정상적으로 유지되고 있는지 확인하기 위해 사용
/app으로 들어온 메시지를 Controller에 전달하기
다음 설정은 클라이언트가 서버에 명령을 보낼 때 사용하는 prefix를 지정한다.
registry.setApplicationDestinationPrefixes("/app");
클라이언트가 다음 destination으로 메시지를 전송했다고 가정해보자.
SEND /app/rooms/1/chat
Spring은 /app을 제거한 나머지 destination을 기준으로 @MessageMapping 메서드를 찾는다.
@MessageMapping("/rooms/{roomId}/chat")
public void chat(...) {
// ...
}
즉, 흐름은 다음과 같다.
SEND /app/rooms/1/chat
↓
/app은 Application Prefix로 인식
↓
@MessageMapping("/rooms/{roomId}/chat")
↓
RoomWebSocketController.chat()
사용자별 destination 설정하기
다음 설정은 사용자별 메시지를 구분하기 위한 prefix를 지정한다.
registry.setUserDestinationPrefix("/user");
클라이언트는 다음과 같이 개인 queue를 구독한다.
SUBSCRIBE /user/queue/cards
클라이언트가 구독하는 논리적 경로
/user/queue/cards
↓
Spring이 Principal과 세션을 기준으로 변환
↓
해당 사용자 세션의 실제 queue
여기서, 이전에 다루었던, "/user/queue/~ 로 특정 사용자를 어떻게 구분할까?"가 한번 더 나온다.
이 문제를 해결하기 위해 프로젝트에서는 STOMP CONNECT 과정에서 JWT를 검증하고 다음 코드를 실행한다.
accessor.setUser(authentication);
이 부분은 다음 목차에서 WebSocketAuthenticationInterceptor를 살펴보며 자세히 설명한다.
이벤트 발행 순서 보존하기
마지막으로 프로젝트에서는 다음 옵션을 활성화했다.
registry.setPreservePublishOrder(true);
서버에서 메시지를 연속해서 발행하더라도, 내부 outbound channel의 비동기 처리로 인해 클라이언트가 실제로 수신하는 순서는 달라질 수 있다.
setPreservePublishOrder(true)를 사용하면 같은 WebSocket 세션에 전달되는 메시지를 발행 순서대로 전송한다.
노래방 프로젝트에서는 이벤트의 순서가 클라이언트 상태에 영향을 줄 수 있다.
PERFORMANCE_PREPARATION_STARTED
↓
PLAYBACK_STARTED
↓
PERFORMANCE_STATE_CHANGED
예를 들어 PLAYBACK_STARTED보다 공연 준비 이벤트가 먼저 처리되어야 한다면, 발행 순서를 보존하는 것이 중요하다.
다만 이 옵션은 서버가 발행한 메시지를 같은 세션에 전달하는 순서를 보존한다.
비즈니스 로직 자체의 동시성이나 서로 다른 요청의 실행 순서까지 자동으로 보장하는 것은 아니다.
클라이언트 메시지에 인터셉터 적용하기
웹소켓 연결과 destination 설정을 마쳤다면, 클라이언트가 보내는 메시지의 인증과 권한도 검사해야 한다.
프로젝트에서는 클라이언트 인바운드 채널에 두 개의 인터셉터를 등록했다.
@Override
public void configureClientInboundChannel(
ChannelRegistration registration
) {
registration.interceptors(
authenticationInterceptor,
authorizationInterceptor
);
}
클라이언트가 보낸 STOMP 메시지는 컨트롤러나 broker로 전달되기 전에 다음 순서로 인터셉터를 통과한다.
Client STOMP Message
↓
WebSocketAuthenticationInterceptor
↓
WebSocketAuthorizationInterceptor
↓
@MessageMapping Controller 또는 Simple Broker
WebSocketAuthenticationInterceptor
STOMP CONNECT 시점에 JWT를 검증하고 현재 연결에 Principal을 등록
이 사용자 누구인가
WebSocketAuthorizationInterceptor
이후의 SEND, SUBSCRIBE 요청에서 인증 여부와 destination 접근 권한을 검사
이 사용자가 특정 destination을 사용해도 되는가
마무리하며
이번 블로그 글에서는, 웹소켓을 프로젝트에 어떻게 적용하는지, 그리고 웹소켓 통신이 클라이언트와 서버 사이에서 어떻게 흘러가는지 다뤄보았다.
다음 글에서는, 웹소켓 통신이 실제로 어떻게 진행되는지 블로그 글로 다뤄보겠다.