이번 글에서는 FitTrip 프로젝트에 사용한 WebSocket과 STOMP에 대해서 작성하고자 합니다.
websocket과 stomp에 대해 더 자세한 내용을 알고 싶으면 아래 글을 참고 바랍니다.
https://an-jjin.tistory.com/category/%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC/WebSocket
WebSocket 선택 이유
1️⃣ HTTP Polling
HTTP Polling 방식은 WebSocket과 다르게 Connection을 맺고 있지 않아도 돼서, 서버 리소스를 상당히 절약할 수 있었고, API 서버 개발만으로 간단하게 기능 구현이 가능하다는 점에서 고려하게 되었습니다.
그러나 일정 주기로 메시지를 클라이언트에서 가져가는 방식이기 때문에 다른 사용자가 작성한 메시지가 전달되는 데 까지 일정 시간 지연이 발생할 수 있고, 클라이언트 별로 Polling 되는 시점에 따라 서로 보고 있는 메시지가 다르기 때문에 실시간으로 보이는 채팅이 수초가량 불일치하는 문제가 발생할 수 있다는 단점이 있습니다.
2️⃣ HTTP Streaming
HTTP Streaming은 HTTP Polling과 동일하게 HTTP 요청을 보내지만, 변경 사항을 클라이언트에 응답한 이후에도 커넥션을 종료하지 않고 유지합니다. 따라서 매번 새로운 커넥션을 맺고 끊는 것이 아니라 하나의 커넥션을 유지하며, 변경 사항을 응답 메시지로 전달합니다.
HTTP Streaming은 HTTP Polling 방식에 비해서 서버의 부담을 줄일 수 있지만, 여러 건의 변경 사항이 일어난 경우 동시 처리가 어려워집니다. 왜냐하면, 서버가 여러 개의 변경 사항을 처리하는 동안, 클라이언트는 서버가 전달한 모든 데이터를 수신해야 해야하기 때문입니다. 예를들어 다른 사용자가 보낸 메시지가 서버에 도착했지만 클라이언트가 이전 메시지를 처리하고 있는 중이라면, 새로운 메시지가 도착하는 데 지연이 발생할 수 있습니다.
3️⃣ WebSocket
대부분의 채팅 플랫폼에서는 가장 많이 사용되는 메시지 전달 방식으로, 유저와 서버 간의 Connection이 맺어져 있는 상태이기 때문에 메시지가 발생하는 즉시 전달할 수 있다는 장점이 있으나, 서버와 연결이 지속되어야 하므로 서버당 처리할 수 있는 클라이언트의 수의 제한이 있다는 단점이 있습니다. 하지만 HTTP Polling 방식에서 발생하는 단점이 거의 완벽에 가까운 수준으로 발생하지 않습니다.
HTTP Polling, HTTP Streaming, WebSocket 모두 각각의 장단점이 있었지만, 최종적으로 WebSocket을 선택하게 되었습니다. 이유로는 양방향성과 실시간성입니다.
HTTP Polling은 정해진 간격으로 서버에 HTTP 요청을 보내는 방식이기에 실시간성에서 제한이 있으며, 요청 간격에 따라 데이터 전송 지연이 있을 수 있습니다. HTTP Streaming은 실시간성은 있지만 양방향 통신이 불가능하여, 클라이언트가 서버로 데이터를 보내는 것은 어렵습니다. 웹소켓은 이러한 HTTP Polling과 HTTP Streaming 방식의 양방향성과 실시간성에 대한 단점이 발생하지 않습니다.
또한 사용자 입장에서 봤을 때 실시간 채팅 서비스에서 메시지의 전달 지연이 부정적인 사용자 경험을 줄 수 있다고 생각합니다. 따라서 메시지 처리 방식으로 WebSocket을 결정하게 되었습니다.
STOMP 도입
WebSokcet만을 사용해서 충분히 개발을 할 수 있지만 FipTrip 프로젝트에서는 STOMP를 도입했습니다.
이유에 대해서 설명하기 앞서 STOMP에 대해 간단하게 설명하겠습니다.
STOMP 란?
STOMP은 WebSocket 위에서 동작하는 프로토콜로써, 클라이언트와 서버가 전송할 메시지 유형, 형식, 내용들을 정의하는 메커니즘입니다. Spring에서 STOMP 사용 시 스프링 애플리케이션은 연결된 클라이언트를 위한 STOMP Broker가 됩니다. 즉, 클라이언트가 Broker(스프링 애플리케이션)에 메시지를 보내 Broker(스프링 애플리케이션)에 연결된 다른 클라이언트로 메시지를 보낼 수 있는 간단한 Publish-Subscribe 메커니즘을 사용할 수 있습니다.
STOMP는 프레임 기반 프로토콜이며, 그 프레임은 HTTP를 모델로 합니다.
Frame은 아래와 같은 형식을 가지고 있습니다.
COMMAND
header1:value1
header2:value2
Body^@
클라이언트는 SEND 또는 SUBSCRIBE 명령을 사용하여 메시지를 보내거나 구독할 수 있으며, 메시지가 무엇인지, 누가 받아야 하는지를 설명하는 destination 헤더를 포함할 수 있습니다.
만약 클라이언트가 특정 경로에 대해서 아래와 같이 Subscribe한다면, 서버는 원할 때마다 해당 경로를 구독한 클라이언트에게 메시지를 전송할 수 있습니다.
SUBSCRIBE
id:sub-1
destination:/topic/chat/room/1
^@
클라이언트는 서버에 특정 경로로 메시지를 전달할 수 있고 서버는 특정 경로를 Subscribe 한 클라이언트들에게 메시지를 브로드캐스팅할 수 있습니다.
SEND
destination:/topic/chat/room/1
content-type:application/json
content-length:38
{"key1":"value1","key2":"value2", 38}^@
그림을 통해 전체 흐름에 대해 정리하겠습니다.
- 클라이언트는 Broker(스프링 애플리케이션)에 연결하여 커넥션을 수립하고, STOMP 프레임들을 해당 커넥션으로 전송하기 시작합니다. 클라이언트는 /topic/server1 경로의 Destination 헤더를 가지고 SUBSCRIBE 프레임을 전송합니다. Broker(스프링 애플리케이션)는 해당 클라이언트의 구독(Subscription) 정보를 저장하고 관리합니다.
- 이후, 클라이언트는 /app/chat/server/message 경로로 메시지를 전송합니다.
- 메시지 브로커는 매칭된 모든 구독자들(subscribers)을 탐색하고, 각 구독자들에게 연결된 WebSocket 커넥션으로 MESSAGE 프레임을 발행합니다.
도입 이유
STOMP를 사용한 이유는 2가지입니다.
1️⃣
HTTP 경우에는 서버가 URI, Method, Headers 정보로 적절한 핸들러로 라우팅해 처리할 수 있습니다.
하지만 WebSocket은 HTTP와 다르게 메시지 내용에 의미를 두지 않기 때문에, 클라이언트-서버 간에 임의로 메시지에 의미를 부여하지 않으면 처리할 방법이 마땅히 없습니다.
예를 들어 HTTP 경우에는 GET, POST, PATCH, DELETE method를 통해 해당 메시지가 조회, 수정, 삭제인지 알 수 있지만, 웹소켓을 통해 메시지를 보냈을 때는 해당 메시지가 단순 채팅을 보낸 건지 아니면 채팅을 수정하려는 건지 삭제하려는 건지 알 수 없습니다.
이러한 문제를 STOMP 메시징 프로토콜을 통해서 해결할 수 있습니다.
2️⃣
STOMP는 메시지 전송 시 브로드캐스팅에 특화되어 있습니다.
STOMP에는 위에서 설명한 것처럼 특정 경로를 구독한 구독자들에게 연결된 WebSocket 커넥션으로 MESSAGE 프레임을 발행하는 기능이 존재합니다.
FitTrip 프로젝트는 사용자 간 1:1 채팅보다는 한 명의 유저가 보낸 메시지가 같은 서버(채팅방)에 있는 유저들에게 전송되어야 하는 서버(채팅방) 내 채팅이 활발한 서비스입니다.
이러한 서비스의 특징을 고려하여 STOMP의 브로드캐스팅 기능이 FipTrip 서비스에 적합하다고 생각하여 STOMP를 도입하게 되었습니다.
기능 구현 이슈
STOMP를 사용하면서 겪었던 이슈에 대해 작성해 보겠습니다.
STOMP 설정
아래 코드는 STOMP 설정에 관한 코드입니다.
기본적으로 클라이언트와의 커넥션을 위한 STOMP Endpoint를 설정해야만 합니다.
addEndpoiint를 통해 STOMP Endpoint를 설정할 수 있습니다.
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class StompConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketConnectionHandler webSocketConnectionHandler;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue");
config.setApplicationDestinationPrefixes("/ws/chat");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-stomp")
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(webSocketConnectionHandler);
}
}
- /ws-stomp는 WebSocket 또는 SockJS 클라이언트가 WebSocket Handshake로 커넥션을 생성할 경로입니다.
- /ws/chat 경로로 시작하는 STOMP 메시지의 Destination 헤더는 @RestController 객체의 @MessageMapping 메서드로 라우팅 됩니다.
- 내장된 메시지 브로커를 사용하여 클라이언트에게 subscriptions, broadcasting 기능을 지원합니다.
- 또한, /topic 또는 /queue로 시작하는 Destination 헤더를 가진 메시지를 브로커로 라우팅 합니다.
api 설계
평소에 개발해 왔던 rest api 설계에서는 리소스 중심으로 설계하라고 배워왔습니다. 즉 동사가 아닌 명사 중심으로 api를 설계하고 HTTP 메서드를 통해 행위를 구분하는 식으로 개발을 해왔습니다. 하지만 WebSocket은 HTTP 메서드가 존재하지 않아 WebSocket api를 어떻게 설계하면 좋을지 고민했습니다.
WebSocket api는 리소스 중심으로 설계 시 리소스에 대한 행위를 규정할 수 없습니다. 그래서 rest api와는 다르게 리소스 중심이 아닌 행위 중심으로 WebSocket api를 설계했습니다.
아래 이미지와 같이 끝 부분에 send, modify, delete와 같은 동사를 붙여 행위 중심으로 WebSocket api를 설계했습니다.
URL 매핑
HTTP 통신에서는 Spring은 @GetMapping과 @PostMapping 어노테이션을 통해 HTTP 요청을 처리하는 메서드와 URL 매핑을 정의하는 데 사용됩니다
Spring STOMP에서도 위 역할을 하는 어노테이션이 존재합니다.
@MessageMapping
@MessageMapping은 스프링 프레임워크에서 웹소켓(WebSocket) 메시지의 라우팅을 담당하는 어노테이션입니다. 주로 STOMP 프로토콜과 함께 사용되며, 웹소켓 메시지를 특정 경로에 매핑하는 데 사용됩니다. 이는 HTTP 요청을 처리하는 @RequestMapping과 유사하게 동작합니다. 클라이언트가 특정 경로로 메시지를 보낼 때, 해당 메시지를 처리할 메서드를 지정할 수 있습니다.
@Slf4j
@RestController
@RequiredArgsConstructor
public class ServerMessageCommandController {
...
private final ChatEventProducer producerService;
private final AlarmEventProducer alarmEventProducer;
private final ServerMessageCommandService commandService;
@MessageMapping("/server/message/send")
public void save(ServerMessageCreateRequest createRequest) {
ServerMessageDto messageDto = commandService.save(createRequest);
producerService.sendToServerChatTopic(messageDto);
if (!createRequest.getMentionType().equals(MentionType.NO_ALERT)) {
ServerAlarmEventDto serverAlarmEventDto = ServerAlarmEventDto.from(createRequest);
alarmEventProducer.sendToServerAlarmEventTopic(serverAlarmEventDto);
}
}
...
}
앞서 setApplicationDestinationPrefixes("/ws/chat") 에서 설정한 경로와 @MessageMapping("/server/message/send")에서 설정한 경로의 전체적인 흐름에 대해 설명하겠습니다.
- 클라이언트는 http://localhost:8080/ws-stomp에 연결하여 커넥션을 수립하고, STOMP 프레임들을 해당 커넥션으로 전송하기 시작합니다.
- 클라이언트는 /topic/server1 경로의 Destination 헤더를 가지고 SUBSCRIBE 프레임을 전송합니다.
메시지 브로커는 해당 클라이언트의 구독(Subscription) 정보를 저장하고 관리합니다. - 이후, 클라이언트는 /ws/chat/server/message/send 경로로 메시지를 전송합니다.
구체적으로, /ws/chat 접두사가 벗겨진 후에는 /server/message/send 목적지 경로만 남게 되고 ServerMessageCommandController의 @MessageMapping을 가진 save() 메서드로 라우팅 됩니다. - /ws/chat prefix는 해당 메시지가 @MessageMapping 메서드를 가진 컨트롤러로 라우팅 될 수 있도록 도움을 줍니다.
메시지 전송
STOMP에서는 메시지 전송이 간단합니다.
messagingTemplate을 사용하여 WebSocket을 통해 메시지를 전송할 수 있습니다.
여기서 사용되는 메서드는 convertAndSend()입니다. 이 메서드는 Spring Framework에서 제공하는 SimpMessageSendingOperations 인터페이스의 메서드입니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class ServerChatConsumer {
private final SimpMessageSendingOperations messagingTemplate;
@KafkaListener(topics = "${spring.kafka.topic.server-chat}", groupId = "${spring.kafka.consumer.group-id.server-chat}", containerFactory = "serverChatListenerContainerFactory")
public void serverChatListener(ServerMessageDto messageDto) {
Long serverId = messageDto.getServerId();
switch (messageDto.getActionType()) {
case SEND -> {
ServerMessageCreateResponse createResponse = ServerMessageCreateResponse.from(messageDto);
messagingTemplate.convertAndSend("/topic/server/" + serverId, DataResponseDto.of(createResponse));
}
...
}
}
}
convertAndSend(destination, payload): 메시지를 변환하여 지정된 대상(destination)으로 전송하는 메서드입니다.
destination: 메시지가 전송될 대상 경로입니다. 여기서는 /topic/server/{serverId}로 지정했습니다. {serverId}는 동적으로 결정되는 서버 id 값입니다.
payload: 전송할 메시지의 내용입니다. 여기서는 createResponse 객체를 WebSocket 클라이언트에게 전송할 데이터 형식으로 변환한 후 전송합니다.
동작 설명:
- 대상 경로(/topic/server/{serverId}): 클라이언트가 구독한 WebSocket Endpoint인 /topic/server/{serverId}로 메시지를 전송합니다.
- 전송할 데이터: payload 부분에 담긴 데이터를 직렬화하거나 필요한 형식으로 변환하여 전송합니다.
예외 처리
HTTP 같은 경우 Spring에서는 @RestControllerAdvice와 @ExceptionHandler라는 필살기(?)가 존재합니다.
다행히 STOMP에도 이와 비슷한 기능이 있지만 생각하는 방식과 달라 애를 먹었습니다.
생각했던 방식은 우선 여러 클라이언트가 특정 경로를 구독하고 있다고 가정하겠습니다.
특정 클라가 STOMP 메시지 처리 관련해서 예외를 발생시키면(없는 메시지를 수정한다든지 삭제한다든지 등등) 해당 클라에게만 예외 메시지를 전송하고 싶었습니다.
하지만 STOMP는 기본적으로 메시지 전송이 브로드캐스팅이고 STOMP가 자체적으로 메모리에 구독 정보와 WebSokcet 세션에 대한 정보를 저장하고 관리해서 제가 직접 WebSocket 세션에 접근하여 메시지를 보낼 수가 없었습니다.
그래서 해결해야 하는 부분은 아래와 같이 두 가지였습니다.
1. 예외 메시지 전송을 브로드캐스팅하지 않기
2. 예외를 발생시킨 클라에게만 예외 메시지 전송하기
해결 방법을 찾기 위해 열심히 Spring WebSocket 공식 문서를 뒤져봤습니다.
앞서 STOMP에도 @ExceptionHandler와 비슷한 기능을 하는 게 있다고 얘기했는데 바로 @MessageExceptionHandler 입니다.
@MessageExceptionHandler
Spring에서는 @MessageMapping 메서드에서 발생한 Exception을 처리하기 위해서 @MessageExceptionHandler 메서드를 지원합니다. 발생한 예외는 Method Argument을 통해 접근할 수 있습니다. @MessageExceptionHandler 메서드는 전형적으로 선언된 컨트롤러 내부의 예외를 처리합니다. 그래서 좀 더 글로벌하게 예외 처리 메서드를 적용하고 싶다면, @MessageExceptionHandler 메서드를 @RestControllerAdvice가 적용된 클래스에 선언하면 됩니다.
앞서 얘기한 2가지 문제를 아래 코드와 같이 해결했습니다.
1. 따로 예외 메시지를 받을 경로를 구독합니다. 아래 코드처럼 /queue/errors 라는 경로를 설정했습니다.
2. @SendToUser을 이용하면 특정 사용자에게 메시지를 보낼 수 있습니다. 또한 broadcat을 false로 설정하면 하나의 세션에만 메시지를 전송할 수 있습니다.
@Slf4j
@RestControllerAdvice
public class ChatExceptionHandler {
...
@MessageExceptionHandler(ServerChatException.class)
@SendToUser(destinations = "/queue/errors", broadcast = false)
protected ErrorResponseDto serverChatExceptionHandler(ServerChatException e) {
log.error("ServerChatException: {}", e.getErrorCode().getMessage());
return ErrorResponseDto.of(e.getErrorCode(), e.getMessage());
}
}
이제 사용자가 예외 메시지를 경로를 구독하고 있고 @MessageMapping 메서드에서 Exception이 발생한다면 해당 Exception을 발생시킨 사용자에게만 예외 메세지를 전송할 수 있게 되었습니다.
JWT 인증
STOMP에서 JWT 인증 처리에 있어 관련한 내용을 포스팅한 적이 있습니다. 아래 글을 참고해 주시면 감사하겠습니다.
https://an-jjin.tistory.com/23
참고
'프로젝트 > FitTrip' 카테고리의 다른 글
개발 기록 - 분산 시스템에서 데이터를 전달하는 효율적인 방법 (0) | 2024.07.12 |
---|---|
트러블 슈팅 - SockJs를 사용한 웹소켓 연결 시 CORS 이슈 (0) | 2024.07.08 |
개발 기록 - MongoDB auto-incremented sequence 적용 (0) | 2024.07.03 |
트러블 슈팅 - IN 연산자를 활용하여 채팅 목록 조회 92% 성능 최적화 (0) | 2024.07.02 |
트러블 슈팅 - 웹소켓 연결 요청 JWT 검증 문제 (0) | 2024.06.30 |