들어가면서
실시간 채팅 서비스와 같이 클라이언트와 서버 간 양방향 소통이 필요한 서비스는 어떤 기술을 사용해 개발할까?
가장 많이 사용되는 기술은 WebSocket이다. WebSocket을 사용하면 HTTP보다 효율적으로 데이터를 주고받을 수 있다. 따라서 Spring의 WebSocket 지원 기능을 활용해 실시간 채팅 서비스를 개발할 수 있다.
하지만 WebSocket을 직접 이용해 채팅 서비스를 만들기 위해서는 번거로운 작업이 필요하다. 그리고 Spring에서도 WebSocket을 직접 이용하는 게 아니라, 상위 프로토콜을 사용하는 걸 권장한다.
As explained in the introduction, direct use of a WebSocket API is too low level for applications — until assumptions are made about the format of a message there is little a framework can do to interpret messages or route them via annotations. This is why applications should consider using a sub-protocol and Spring’s STOMP over WebSocket support.
When using a higher level protocol, the details of the WebSocket API become less relevant, much like the details of TCP communication are not exposed to applications when using HTTP. Nevertheless this section covers the details of using WebSocket directly.
이번 글에서는 WebSocket을 직접 사용했을 때의 불편함과 Spring의 STOMP 지원에 대해 알아볼 것이다.
WebSocket의 불편함
Spring의 WebSocket 지원을 이용하면 간단하게 WebSocket을 지원하는 애플리케이션을 만들 수 있다. 자세한 내용은 글을 참고하자.
하지만 채팅 서버를 개발하려면 추가적인 작업이 필요하다.
1. 클라이언트와 메시지 명세를 정해야 한다. (WebSocket은 메시지에 text와 binary 타입이 있다는 것 외에 정해둔 것이 없다.)
2. 서버에서는 WebSocket에 연결된 클라이언트를 채팅방 기준으로 그룹 지어줘야 한다.
3. 클라이언트가 메시지를 보내면 해당 메시지를 같은 채팅방에 있는 클라이언트들에게 전송해줘야 한다.
위 작업을 직접 구현할 수는 있겠지만 번거롭다..
Spring의 STOMP 지원을 사용하면 위 작업을 따로 구현해줄 필요가 없다! 이미 Spring이 구현해 두었기 때문이다.
그럼 Spring STOMP에 대해 좀 더 자세히 알아보자.
그전에 잠시 STOMP를 정리하고 시작하자.
STOMP
STOMP는 Simple Text-Oriented Messaging Protocol의 약어이다. STOMP는 Ruby, Python과 Perl 같은 스크립트 언어에서 기업용 메시지 브로커에 접속하기 위해 개발된 프로토콜이다.
STOMP는 TCP와 WebSocket 같은 신뢰할 수 있는 양방향 스트리밍 네트워크 프로토콜 위에서 사용된다.
STOMP의 frame 구조
COMMAND
header1:value1
header2:value2
Body^@
보통의 Use-Case
구독
SUBSCRIBE
id:sub-1
destination:/topic/price.stock.*
^@
- destination을 구독한다.
메시지 전송
SEND
destination:/queue/trade
content-type:application/json
content-length:44
{"action":"BUY","ticker":"MMM","shares",44}^@
- destination에 메시지를 보낸다.
메시지 브로커가 구독자들에게 메시지 전송
MESSAGE
message-id:nxahklf6-1
subscription:sub-1
destination:/topic/price.stock.MMM
{"ticker":"MMM","price":129.45}^@
좀 더 자세한 내용은 공식문서를 참고하길 추천한다.
Spring's STOMP support
서론이 길었다. 그럼 본격적으로 Spring의 STOMP 지원에 대해 알아보자.
Spring의 STOMP 지원 기능을 사용하면, 애플리케이션이 STOMP 브로커 역할을 하게 된다. 이를 통해 WebSocket만을 이용했을 때 추가로 해야 하는 작업을 최소화할 수 있다.
1. 클라이언트와 메시지 명세를 정해야 한다. -> STOMP는 메시지 포맷이 정해져 있다.
2. 서버에서는 WebSocket에 연결된 클라이언트를 채팅방 기준으로 그룹 지어줘야 한다. -> 메시지 브로커 구독 기능을 이용하면 된다.
3. 클라이언트가 메시지를 보내면 해당 메시지를 같은 채팅방에 있는 클라이언트들에게 전송해줘야 한다. -> 메시지 브로커의 broadcast 기능을 이용하면 된다.
Spring STOMP 구조
Spring의 STOMP 기능을 활성화하면 아래와 같은 구성으로 컴포넌트가 컨텍스트에 등록된다.
플로우는 다음과 같다.
1. 클라이언트가 보낸 메시지가 request channel에 들어간다.
2. 메시지는 목적지에 따라 SimpAnnotationMethodMessageHandler 또는 SimpleBrokerMessageHandler를 통해 처리된다. SimpAnnotationMethodMessageHandler로 처리될 때는 처리된 메시지가 brokerchannel을 통해 SimpleBrokerMessageHandler에게 전달된다.
3. SimpleBrokerMessageHandler는 메시지 타입에 맞게 메시지를 처리한다.
4. 클라이언트에게 메시지를 보내야 한다면 response channel을 통해 전달한다.
간략한 이론을 이해했으니 코드를 통해 더 깊게 파헤쳐보자!
Spring STOMP 직접 파헤치기
예시 작성
1. Spring STOMP 기능 활성화 & 엔드포인트 활성화
2. 메시지 핸들러 구현
Spring STOMP 기능 활성화 & 엔드포인트 활성화
@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig: WebSocketMessageBrokerConfigurer {
override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry.addEndpoint("/ws-stomp").setAllowedOriginPatterns("*")
.withSockJS()
}
override fun configureMessageBroker(config: MessageBrokerRegistry) {
config.enableSimpleBroker("/sub")
config.setApplicationDestinationPrefixes("/pub")
}
}
- @EnableWebSocketMessageBroker를 통해 STOMP 브로커 역할을 수행하는 빈들을 컨택스트에 등록한다.
- registerStompEndpoints 함수를 오버라이드해 클라이언트가 "/ws-stomp" 엔드포인트로 WebSocket 서버에 연결할 수 있게 한다.
- configureMessageBroker 함수를 오버라이드해 메시지를 처리할 엔드포인트를 설정해줬다.
- enableSimpleBroker를 통해 "/sub"을 Prefix로 요청하는 메시지들은 SimpleBrokerMessageHandler에서 처리되게 해주었다.
- setApplicationDestinationProfefixes를 통해 "/pub"을 Prefix로 요청하는 메시지들은 SimpleAnnotationMethodMessageHandler에서 처리되게 해주었다.
메시지 핸들러 구현
@Controller
class ChatController(
private val messagingTemplate: SimpMessageSendingOperations,
) {
@MessageMapping("/chat/message")
fun message(message: ChatMessage) {
when (message.type) {
ChatMessage.MessageType.ENTER -> {
val enterMessage =
ChatMessage(message.type, message.roomId, message.sender, "${message.sender}님이 입장했습니다.")
messagingTemplate.convertAndSend("/sub/chat/room/${enterMessage.roomId}", enterMessage)
}
ChatMessage.MessageType.TALK -> {
messagingTemplate.convertAndSend("/sub/chat/room/${message.roomId}", message)
}
}
}
}
- "/pub/chat/message"로 전송되는 메시지를 처리하는 핸들러를 만들었다.
- 핸들러에서 메시지를 "/sub/chat/room/${roomId}" 엔드포인트로 전송한다.
디버깅
직접 디버깅을 통해 플로우를 눈으로 확인해보자. (클라이언트에서 "/ws-stomp" 연결 요청 & 구독 요청은 생략하겠다.)
클라이언트에서 서버로 메시지 전송
ws.send("/pub/chat/message", {}, JSON.stringify({type:'TALK', roomId:this.roomId, sender:this.sender, message:this.message}));
서버 플로우
전체적인 플로우는 스프링 공식문서에 있는 순서와 같다. 이를 좀 더 자세히 분석해보자.
1. 메시지가 clientInboundChannel(request Channel)에 전송되고 해당 메시지를 처리할 핸들러를 찾는다.
- 메시지의 목적지 prefix가 "/pub"이라 WebSocketAnnotationMethodMessageHandler가 선택되었다.
- WebSocketAnnotationMethodHandler에게 메시지 처리를 요청한다.
2. 메시지 핸들러(WebSocketAnnotationMethodMessageHandler)에서 메시지를 처리한다.
- 메시지를 처리한 후 메시지를 brokerChannel에 전송한다.
3. brokerChannel에서 메시지를 받고, 처리할 메시지 핸들러를 찾는다.
- 메시지 핸들러로 SimpleBrokerMessageHandler가 선택됐다.
- SimpleBrokerMessageHandler에게 메시지 처리를 요청한다.
4. 메시지 핸들러(SimpleBrokerMessageHandler)에서 메시지를 처리한다.
- 메시지 타입이 MESSAGE라서 destination을 구독한 클라이언트들에게 메시지를 broadcast 한다.
- 구독자에게 발송할 메시지는 clientOutboundChannel에 전송한다.
5. ClientOutBoundChannel에서 메시지를 클라이언트에게 전송
- clientOutboundChannel에서 SubProtocolWebSocketHandler를 통해 메시지를 클라이언트에게 전송한다.
지금까지 클라이언트가 메시지를 전송하고, 해당 메시지가 구독자에게 broadcast되는 과정을 자세하게 살펴봤다. 다른 메시지 타입도 위와 비슷하게 진행된다.
예시에 대한 전체 코드는 Github을 참고하기 바란다.
마무리
이번 글을 통해 Spring의 STOMP 지원을 사용하면 좋은 점과 동작 원리를 파헤쳤다. 글의 내용이 너무 길어질 수 있어 추상적으로 설명한 부분도 있다. 이 부분들은 스프링 공식문서와 코드를 통해 공부해보길 추천한다.
reference
'Spring' 카테고리의 다른 글
Spring MVC에서 MappingJacksonValue 활용하기 (0) | 2024.03.01 |
---|---|
Spring Data Redis Pub/Sub 파헤치기 (0) | 2023.06.28 |
Spring Websocket Trobleshooting!! (feat. Decorator Pattern) (0) | 2023.05.21 |
Spring Cloud GateWay의 non-blocking server (0) | 2022.12.31 |
@SpringBootTest의 비밀! (0) | 2022.12.11 |