[Spring Boot] WebSocket 서버 설정 1 - 연결 설정 및 메세지 매핑
Why WebSocket?
이전 포스팅에 이어 현재 작업중인 개인 프로젝트를 진행하면서 배운 부분에 대한 기록으로 남기는 포스팅이다.
단순한 웹 애플리케이션 개발과 같은 주제로는 개인 프로젝트 규모에서 상용 서비스를 개발하고 운영해보는 과정에서 경험할 수 있는 동시성 문제나 실시간 처리에 대한 성능 개선 등 백엔드 개발자로 성장할 때 필요한 인사이트를 얻기에는 어렵다는 판단이 들었고 적은 수의 유저로도 이러한 경험들을 비슷하게나마 해보며 성장할 수 있는 주제가 무엇이 있을까 고민하는 과정에서 실시간 처리가 필요한 게임이나 채팅을 메인으로 한 서비스를 개발해보면 좋겠다는 생각이 들어 주제를 정하게 되었다.
오픈 이후에는 커뮤니티 기능과 메인 컨텐츠를 분리해 나갈 수 있도록 구조를 잡아 분산 아키텍처나 서비스 분리 등에 대한 경험도 할 수 있으면 좋겠다 생각했는데, 아직 갈 길이 멀다.
이런 고민을 갖고 시작한 프로젝트이다 보니 유저 간 실시간 상호작용이 일어나는 환경을 상정해 백엔드를 개발중이고 이는 Stateless한 HTTP 프로토콜만으로는 원하는 기능들을 모두 구현하기 어렵다는 얘기가 된다. 한 유저가 수행한 작업이 함께 접속해 있는 다른 유저의 클라이언트에 전해지도록 HTTP 프로토콜로 처리하기 위해서는 클라이언트가 상태 변화를 감지할 수 있도록 주기적인 polling을 해야 하므로 클라이언트 입장에서도 비효율적이고 서버 입장에서도 유저가 늘어남에 따라 부하가 급격하게 늘어날 수 있다.
또 서버 입장에서도 컨텐츠 기획에 맞게 무언가 작업을 수행한 다음 클라이언트에게 결과 메시지를를 직접 push할 수 있으면 좋겠지만 클라이언트의 요청에 대해 응답만을 내려줄 수 있는 HTTP 방식에서는 이런 기능도 구현할 수 없다.
그에 반해 WebSocket은 클라이언트와 서버가 연결을 맺은 후 서로 자유롭게 메세지를 주고받을 수 있고, 첫 connect 요청 및 handshake 이후 연결이 유지되어 매 요청마다 커넥션을 새로 맺어야하는 HTTP에 비해 비용이 적게 드는 장점이 있다.
개인적으로 경험해보고자 했던 부분은 아니지만 이 프로젝트의 메인 컨텐츠를 구성하고 유저 경험을 개선할 수 있는 적합한 솔루션이라 생각되어 WebSocket을 도입하게 되었고, 그 과정에서 설정한 코드들을 기록하고 어느 부분에서 헤맸는지 레슨런을 포스팅해보고자 한다.
구조
처음에 구상했던 구조는 대략 이러하다.

1. 클라이언트가 서버에 설정된 웹소켓 엔드포인트인 /ws/canvas 로 connect를 요청한다.
2. 연결 성공
3. 클라이언트가 /canvas 토픽에 대한 구독 요청 메세지를 보낸다.
4. 클라이언트가 draw/show 목적지로 payload와 함께 메세지를 보낸다.
5. 로직 처리 후 시점에 웹소켓 서버와 연결되어있고, canvas 토픽을 구독중인 클라이언트로 메세지를 보낸다.
환경 구성
먼저 환경 구성부터 진행했다.
환경은 기존 SpringBoot로 개발하던 환경에 추가로 WebSocket 설정을 얹어 개발을 진행하려고 한다.
이후에는 REST API로 제공되는 서비스와 분리해 구성하고 앞쪽에 인증과 라우팅을 담당하는 게이트웨이를 붙이는 것까지 목표로 하고 있지만 우선순위를 뒤로 밀기로 했다.
의존성 추가
기존 프로젝트 build.gradle의 dependencies 블록에 SpringBoot에서 제공하는 spring-boot-starter-websocket을 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-websocket'
의존성을 잘 가져왔다면 @Configuration 어노테이션과 @EnableWebSocketMessageBroker 어노테이션을 적용해 웹소켓 관련 스프링 설정을 사용한다.
Config
- 웹소켓 연결 엔드포인트 설정과 그 외의 설정 커스터마이징을 위해 WebSocketMessageBrokerConfigurer 인터페이스를 구현한다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
// ... 설정
}
- 웹소켓 연결을 위한 엔드포인트를 설정하기 위해 registerStompEndpoints를 오버라이딩한다. 클라이언트와 웹소켓서버가 연결되기 위해서는 클라이언트에서 최초 요청시에 HTTP로 handshake 요청을 보내야 하는데, 이 요청을 받아줄 엔드포인트를 설정하는 부분이다. 구조 목차의 1번에 해당하는 설정이다.
즉 이 부분에 설정한 엔드포인트로 웹소켓 연결 요청을 보내야만 클라이언트 - 서버간 연결이 된다.
추가로 setAllowedOriginPatterns("*")를 통해 CORS를 허용하도록 처리해 개발을 진행했다.
오픈 시기에 맞춰 Security 설정과 함께 정리할 예정이다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/**
* WebSocket connect 엔드포인드 설정
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
/**
* canvas 웹 소켓 서버 엔드포인트 지정
*/
registry.addEndpoint("/ws/canvas")
.setAllowedOriginPatterns("*");
}
}
javascript 클라이언트 연결 코드
const socket = new WebSocket("ws://localhost:8080/ws/canvas");
STOMPClient 연결 샘플 코드
/**
* 테스트 클라이언트용 메소드
* 테스트 클라이언트를 생성해 리턴한다.
* @return
*/
private WebSocketStompClient createClient() {
WebSocketStompClient webSocketStompClient = new WebSocketStompClient(new StandardWebSocketClient());
webSocketStompClient.setMessageConverter(new MappingJackson2MessageConverter());
return webSocketStompClient;
}
/**
* 테스트 클라이언트용 메소드
* 테스트 클라이언트로 웹소켓 서버에 연결한 세션을 리턴한다.
* @param client
* @return
* @throws Exception
*/
private StompSession connect(WebSocketStompClient client, String websocketConnectEndpoint) throws Exception {
return client.connectAsync(websocketConnectEndpoint, new WebSocketHttpHeaders(), new StompHeaders(), new StompSessionHandlerAdapter() {}).get();
}
@Test
void test() throws Exception {
// 테스트용 클라이언트 생성
WebSocketStompClient client = createClient();
// 클라이언트로 웹소켓 서버 엔드포인트에 연결을 요청한다.
StompSession session = connect(client, "ws://your.domain.com/ws/canvas");
// ...
}
- canvas와 chat 토픽에 대해 나누어 구독 처리할 수 있도록 configureMessageBroker 메소드를 오버라이딩 해준다.
실제로 브로커를 여러개를 활성화하는 것은 아니고 내장 브로커를 활성화하고 여러 토픽을 처리할 수 있도록 논리적으로 나눈 것이라고 한다.
RabbitMQ와 같은 외부 브로커를 등록하는 방법으로 물리적 다중 브로커를 활성화할 수도 있는데 일단은 메모리 기반 내장 브로커를 사용한다.
구조 목차 중 3번을 처리할 수 있도록 하는 설정
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/**
* 메세지 브로커 설정
* @param registry
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 메시지 브로커 활성화: "/canvas"과 "/chat"로 시작하는 경로에 대해 브로커를 활성화
registry.enableSimpleBroker("/canvas", "/chat");
// 클라이언트에서 메시지를 보낼 때 사용할 prefix 설정 (필요시)
// registry.setApplicationDestinationPrefixes("app");
}
/**
* WebSocket connect 엔드포인드 설정
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
/**
* canvas 웹 소켓 서버 엔드포인트 지정
*/
registry.addEndpoint("/ws/canvas")
.setAllowedOriginPatterns("*");
}
}
Controller
웹소켓을 사용하면 연결 후 클라이언트 -> 서버, 서버 -> 클라이언트로 각각 메세지를 전송할 수 있다. 각각 아래 작업으로 수행 가능하다.
1. 클라이언트 -> 서버 구간 인바운드 메세지의 경로를 매핑
먼저 웹소켓 통신간에는 request body를 사용하지 않으므로 @Controller 어노테이션을 사용해 컨트롤러로 등록한다.
REST API 요청을 @RequestMapping으로 매핑하듯, Spring WebSocket(STOMP)에서는 @MessageMapping을 통해 메시지 핸들러를 등록한다.
WebSocket 메시지는 REST API처럼 HTTP 요청은 아니지만, STOMP 프로토콜에서는 메시지를 destination으로 라우팅하는 구조를 사용하기 때문에, Spring에서는 이 destination을 @MessageMapping 방식으로 처리하여 HTTP의 URI 매핑과 유사하게 개발할 수 있다.
구조 목차의 4번에 해당하는 부분의 등록을 위한 컨트롤러를 만들어보자.
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
@Slf4j
@Controller
public class CanvasWebSocketController {
/**
* 요청 받은 Stroke 이벤트를 요청자가 입장해있는 방에 브로드캐스팅한다.
* @param stroke
*/
@MessageMapping("canvas/{gameRoomId}/draw/stroke")
public void broadcastStrokeOnRoom(@DestinationVariable("gameRoomId") Long gameRoomId, com.packages.to.Stroke stroke) {
log.info("클라이언트로부터 메세지 받음");
}
}
@MessageMapping으로 처음 설계했던 경로를 매핑한다. @PathVariable과 유사하게 @DestinationVariable을 제공하고 있어 동적으로 처음에 생각한대로 클라이언트로부터 입장한 방 ID에 따라 동적으로 보내진 메세지를 핸들러와 매핑할 수 있었다.
이 부분이 헷갈렸는데, HTTP 요청의 경우 @RequestMapping을 통해 매핑한 핸들러는 요청을 보낸 클라이언트에 응답을 보내는 구조라 컨트롤러에서 리턴하게 되면, Spring이 응답을 컨버팅해 내려주는 구조라 크게 신경쓰지 않아도 되었지만 어떤 destination으로 메세지를 보내야 되는지에 대해 명시적으로 지정해줘야 한다. SimpMessagingTemplate을 사용해 수동으로 메시지를 전송하거나 @SendTo 어노테이션을 등록해 처리 결과를 특정 브로커로 push 해주어야 한다.
@SendTo 어노테이션을 사용하는 쪽이 깔끔해보여서 시도해보았지만, 이 부분을 동적으로 처리할 수 있는 방법을 찾지 못해 SimpMessagingTemplate으로 메시지를 전송하기로 했다.
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
@Slf4j
@Controller
@RequiredArgsConstructor
public class CanvasWebSocketController {
private final SimpMessagingTemplate messagingTemplate;
/**
* 요청 받은 Stroke 이벤트를 요청자가 입장해있는 방에 브로드캐스팅한다.
* @param stroke
*/
@MessageMapping("canvas/{gameRoomId}/draw/stroke")
public void broadcastStrokeOnRoom(@DestinationVariable("gameRoomId") Long gameRoomId, com.packages.to.Stroke stroke) {
log.info("클라이언트로부터 메세지 받음");
// destination을 구독하고 있는 유저에게 send 한다.
// 1. 실제 로직에서는 이 부분에서 메세지를 전송해도 되는지 validation 로직을 거친다.
// 2. 실제 로직은 service 레이어로 책임을 분리한다.
String destination = "/canvas/" + gameRoomId;
messagingTemplate.convertAndSend(destination, stroke);
}
}
여기까지 설정을 마치면 구조 목차까지는 구성이 된 듯 하다.
포스팅의 앞쪽에서 작성해두었지만, 이 웹소켓 서버는 이미 개발중이던 SpringBoot 기반의 REST API 서버위에서 함께 동작하도록 구성했고, 초기 connect 요청의 경우 HTTP 요청으로 들어와 기존에 적용해두었던 Spring Security의 영향을 받아 SecurityFilterChain에 진입해 인증 / 인가 로직을 타게된다. 그래서 테스트는 당연하게도 실패했고, 이번 포스팅에서는 테스트 결과로 마무리 짓지 못했다.
WebSocket 연결 요청이 인증을 통과할 수 있도록 별도의 보안 설정이 필요했고, Spring Security를 우회하거나 필터를 통과할 수 있는 구조로 구성해줘야 했다.
이 부분에 대한 자세한 내용은 다음 포스팅에서 이어서 다루겠다.