화상 채팅 프로젝트를 진행하면서 채팅 기능을 구현중에 웹 소켓 관련하여 정리가 필요하여 글을 쓰게 되었다.
먼저 웹소켓에 대해서 알아보자!
웹소켓 프로토콜은 웹 애플리케이션을 위한 새로운 기능으로써 클라이언트 양방향 통신의 오랜 역사를 가지고 있다. HTTP와는 다른 TCP 프로토콜이지만 HTTP에서 동작가능하게 디자인 되었고 80, 443 포트(ws 프로토콜)를 사용하며 방화벽규칙을 재사용할 수 있도록 되어있다.
일반 HTTP 요청에 Upgrade 헤더를 포함한 reqeust를 전송하면 WebSocket protocol로 변환되며 WebSocket interaction이 시작된다.
[요청]
GET /spring-websocket-portfolio/portfolio HTTP/1.1
Host: localhost:8080
Upgrade: websocket ---- 1
Connection: Upgrade ---- 2
Sec-WebSocket-Key: Uc9l9TMkWGbHFD2qnFHltg==
Sec-WebSocket-Protocol: v10.stomp, v11.stomp
Sec-WebSocket-Version: 13
Origin: http://localhost:8080
위와 같은 요청을 받은 웹 소켓을 지원하는 서버는 일반적으로 200 상태 코드 대신에 101 상태코드를 반환한다
[응답]
HTTP/1.1 101 Switching Protocols ---- 1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 1qVdfYHU9hPOl4JYYNXF623Gzn0=
Sec-WebSocket-Protocol: v10.stomp
프로토콜 스위치
http upgrade 요청을 받아 성공적인 핸드쉐이크가 성공한 이후에 tcp 소켓은 클라이언트와 계속 열어두고 메시지를 주고 받는다.
만약 websocket이 웹서버(nginx)에서 실행될 경우 websocket upgrade request를 웹 소켓 서버로 전달할 수 있도록 설정해줘야한다. 마찬가지로 만약에 어플리케이션이 클라우드 환경에서 동작할 때 클라우드가 웹소켓을 지원하는지 여부를 확인해야한다.
웹소켓이 비록 Http와 비슷하게 설게되었고 Http request로 시작했을지라도 두개의 시스템은 서로 다른 아키텍쳐를 가지고 있고 다른 애플리케이션 프로그래밍 모델을 따르고 있다.
Http 그리고 Rest에서 애플리케이션은 많은 url을 가지고 있다. 애플리케이션과 클라이언트가 통신하기 위해서는 url에 요청을 보내야하고 request-response 형태를 띄고 있다. 서버는 Http Url, 메서드 및 헤더를 기반으로 요청을 적절한 handler에 라우팅 시킨다.
반대로 웹소켓은 초기 연결을 위한 하나의 연결만 사용한다. 그후에 모든 애플리케이션 메시지는 같은 tcp connection을 가지고 주고 받게 된다. 이것은 완전히 다른 비동기식 이벤트 기반 메시징 아키텍처이다.
웹소켓은 또한 http와 다르게 메시지 내용의 규정을 정의하지 않는 low level 전송 프로토콜입니다. 그래서 클라이언트와 서버가 미리 정의한 메시지 규약없이는 메시지가 라우팅 되거나 처리되지 못한다.
웹소켓 클라이언트와 서버는 Http 핸드쉐이크 요청에서 Sec-WebSocket-Protocol을 사용해서 STOMP와 같은 높은 레벨의 메시징 프로토콜을 사용할 수 있다. 이런 방법이 아니라면 별도의 다른 규약이 필요하다.
WebSocket을 먼저 사용하고 여러 인터넷 등의 상황으로 upgrade 헤더 요청을 보내지 못할 경우등에 사용할 fallback으로 Http 기반의 기술로 WebSocket interaction을 에뮬레이트하고 같은 애플리케이션 api를 노출하는 방식
Servlet 스택에서 Spring Framework는 SockJS 프로토콜에 대한 서버(및 클라이언트) 지원을 모두 제공한다.
주요 목적은 서버가 웹소켓 사용이 불가능할 경우에 사용하기 위함
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(myHandler(), "/myHandler")
// interceptor
.addInterceptors(new HttpSessionHandshakeInterceptor())
// 허용 도메인
.setAllowedOrigins("https://mydomain.com");
// fallback
.withSockJS();
}
@Bean
public WebSocketHandler myHandler() {
return new MyHandler();
}
// websocket 관련 설정
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(8192);
container.setMaxBinaryMessageBufferSize(8192);
return container;
}
}
SockJs 프로토콜은 서버에 heartbeats 메시지를 보내서 서버가 hang이 걸리는것을 방지한다. Spring SockJS에는 hartbeatTime이라는 속성이 있는데 이 속성을 이용해서 빈도수를 custom 할 수 있다. 기본적으로 heartbeats의 기본은 어떠한 메시지가 연결에 보내지지 않았다는 가정하에 25초이다.
WebSocket 그리고 SockJs를 사용하여 STOMP를 사용할 때 만약 STOMP 클라이언트와 서버의 heartbeats 협약이 변경될 경우 SockJS heartbeats는 무시된다.
- websocket 위에서 동작하는 문자 기반 메세징 프로토콜로써 클라이언트와 서버가 전송할 메세지의 유형, 형식, 내용들을 정의하는 매커니즘이다.
- TCP와 웹소켓과 같은 신뢰할 수 있는 양방향 스트리밍 네트워크 프로토콜에서 사용할 수 있다.
- 기본적으로 pub / sub 구조로 되어있어, 메세지를 전송하고 받아 처리하는 부분이 확실히 정해져있다.
- http와 마찬가지로 frame을 사용해 전송하는 프로토콜이다.
메세징 프로토콜과 메세징 형식을 개발할 필요가 없다.
STOMP 클라이언트는 Java 클라이언트를 포함해서 사용할 수 있다.
메세지 브로커를 사용하면 구독을 관리하고 메세지를 broadcast하는데 사용할 수 있다.
🔔 유저가 접속할 때 이를 모든 클라이언트에 알리고, 서버에도 로깅한다.
이때, 모든 클라이언트에 메세지를 보내는 것을 브로드캐스트 (Broadcast) 한다고 말한다.
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
@Configuration
@EnableWebSocketMessageBroker -- 1
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/portfolio").withSockJS(); -- 2
}
// stotmp
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.setApplicationDestinationPrefixes("/app"); -- 3
config.enableSimpleBroker("/topic", "/queue"); -- 4
}
}
메시지를 클라이언트에게 전달받으면 stomp frame으로 디코딩 하고 spring Message형태로 변환한 후 clientInboundChannel로 다음 프로세스를 진행하기 위해 전달한다.
예를들어 STOMP 메시지 /app으로 시작하는 destination header를 가지고 들어오는 요청은 Controller 애노테이션 되어있는 클래스 내부에 @MessageMapping메소드가 할당곳으로 매핑되고 반면에 /topic, /queue의 요청을 가지고 들어오게 되면 바로 message broker에게 전달 된다.
클라이언트에게 전달받은 stomp 메시지를 다루는 @Controller 애노테이션은 메시지를 broker channel을 통해서 메시지 브로커에게 메시지를 전달하고 브로커는 clientOutboundChannel을 통해 메시지를 매칭되는 subscriber에게 값을 전달한다.
동일한 컨트롤러 내에서 http 요청을 받을 수 있기 때문에 요청을 받고 브로커에게 메시지 응답을 보내서 가입된 클라이언트에 브로드 캐스트 할 수 있다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/portfolio");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/app");
registry.enableSimpleBroker("/topic")
.setTaskScheduler(taskScheduler())
// 안쓰면 그냥 10s 씩 (inbound, outbound)
.setHeartbeatValue(new long[] {3000L, 3000L});
}
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.initialize();
return taskScheduler;
}
}
@Controller
public class GreetingController {
@MessageMapping("/greeting") {
public String handle(String greeting) {
return "[" + getTimestamp() + ": " + greeting;
}
}
localhost:8080/portfolio를 클라이언트에서 연동하여 client와 websocket이 연결되면 stomp frame은 그 커넥션 위에서 동작하게 된다.
클라이언트가 /topic/greeting header 목적지에 SUBSCRIBE frame을 전송한다. 수신되고 decode된 메시지는 clientInboundChannel에 전송되고 메시지 브로커에게 route되고 client subscription에 저장된다.
client가 SEND frame을 /app/greeting에 보낸다. /app prefix는 controller애노테이션된곳에 요청을 라우트 하고 /app prefix없이 @MessageMapping에 /greeting이 걸려있는 곳에 메시지 요청이 매핑된다.
Gretting Controller로 부터 반환된 값은 반환값과 /topic/greeting의 기본 대상 헤더를 기반으로 하는 페이로드가 포함된 spring message로 전환된다.
메시지 브로커는 매칭된 모든 subscriber에게 MESSAGE frame을 clientOutboundChannel을 통해서 전송하고 STOMP frame에 인코딩 된 상태로 client에게 전송된다.
클라이언트로 부터 받은 메시지를 다루기 위해 classes에 @Controller 애노테이션을 달수 있다. 각각의 클래스들은 @MessageMapping, @SubscribeMapping, @ExceptionHandler 메소드를 선언할 수 있다.
목적지를 기반으로 메시지를 라우팅하는 메소드에 annotaion을 달 수 있다. 메소드와 type level에서 annotation 할 수 있다.
여러 /app/**, @DestinationVariable /app/{id} 등을 사용할 수 있다.
여러 Method arguement 등을 사용할 수 있다.
@Controller
public class PortfolioController {
@MessageMapping("/trade")
@SendToUser("/queue/position-updates")
public TradeResult executeTrade(Trade trade, Principal principal) {
// ...
return tradeResult;
}
}
@Controller
public class MyController {
@MessageMapping("/action")
public void handleAction() throws Exception{
// raise MyBusinessException here
}
@MessageExceptionHandler
@SendToUser(destinations="/queue/errors", broadcast=false)
public ApplicationError handleException(MyBusinessException exception) {
// ...
return appError;
}
}
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
// 시간과 보내는 버퍼 사이즈 제한
registration.setSendTimeLimit(15 * 1000).setSendBufferSizeLimit(512 * 1024);
// 전송받고 보낼 메시지 사이즈 제한
registration.setMessageSizeLimit(128 * 1024);
}
// ...
}
양질의 글 감사합니다