개요
최종프로젝트는 스포츠 경기 일정 및 결과를 제공하는 서비스인데 게임별 사용자가 소통할 수 있는 실시간 채팅 기능을 제공하고자 하였다. 네이버 스포츠, 아프리카TV 등과 같은 플랫폼에서 제공하는 실시간 채팅 기능을 모티브로하여 개발하였고 그 과정에서의 기술 의사결정과 개발과정 및 느낀점에 대한 회고를 진행하고자한다.
Polling vs WebSocket
개발전 실시간 채팅에 사용할 수 있는 기술로 Polling 방식, WebSocket 중 어떤 기술을 채택해야할지 고민하는 시간을 가졌다. 결과적으로 WebSocket으로 개발하였고 WebSocket을 채택한 이유는 아래와 같다.
첫 번째로, Polling 방식은 지정한 interval마다 http요청을 보내기 때문에 서버에 많은 부담을 줄 것으로 판단했다. 물론 WebSocket 또한 클라이언트와 Connection을 유지하는데 비용이 발생하지만 MEM 사용률에 대한 매트릭을 잘 파악하고 서버 분리, 스케일아웃으로 대응할 수 있을 것이라고 판단했다.
두 번째로, Polling 방식은 실시간을 모방한 기술이지 실시간을 보장하는 기술이 아니다. Polling 방식에서 발전한 Long Polling, Streaming이 있지만 WebSocket과 동작 과정이 유사해보였고 WebSocket이 있는데 굳이 앞의 두 기술을 사용해야할 이유를 찾지 못했다. 기술적으로 봤을때, Polling 방식은 실시간을 모방한 무식한 방법이고, Polling에서 실시간성을 보장하며 발전한 기술이 WebSocket이라는 느낌을 받았다. 제대로된 실시간 채팅 서비스를 제공하고 싶었기에 WebSocket을 사용하고자 했다.
세 번째로, 데이터를 저장할 스토리지가 없었다. 가용 스토리지로 MySQL, Redis가 있었는데 MySQL에 데이터를 올리기에는 interval 마다 데이터 조회와 삽입이 이루어지기 때문에 사실상 불가능할 것으로 판단했고 Redis에 데이터를 올리기에는 채팅방, 보낸 사용자, 채팅 내용, 채팅 시간 등 저장해야할 정보가 많았기 때문에 key-value 구조가 복잡해져 성능이 안좋아지고 정렬 기능도 필요했기 때문에 부적합하다고 판단했다. 또한, 프로젝트 기간이 약 1개월로 짧았기에 러닝커브를 고려했을때 MongoDB와 같은 새로운 기술을 익히기에 무리가 있다고 판단했다.
이와 같은 이유로 Spring에서 제공하는 STOMP WebSocket을 사용하기로 결정했다. STOMP라는 in-memory broker로 sub/pub 기능을 제공하기 때문에 이전 채팅 기록을 보여주지 않는다는 가정하에 별도의 스토리지가 필요하지 않아 당장 적용할 수 있고, 추후 Kafka와 같은 메세지큐로 브로커를 대체하고 MongoDB를 사용해 scale-out 가능한 Spring-Kafka-MongoDB 아키텍처로 채팅 기능을 확장할 수 있을 것이라고 생각했다.
WebSocket 실시간 채팅 기능 개발
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
//wss://{host}:{port}/websocket 경로로 handshake 및 connection 연결
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry
.addEndpoint("/websocket")
.setAllowedOrigins("*");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
//broker에게 바로 전달
config.enableSimpleBroker("/topic");
//controller로 전달 -> 내부 로직으로 전처리 후 broker에게 전달
config.setApplicationDestinationPrefixes("/app");
}
}
game_id로 채팅방 subscribe -> 클라이언트에서 game_id와 함께 사용자의 publish 요청 전송
@Controller
@Slf4j(topic = "MessageController")
@RequiredArgsConstructor
public class MessageController {
@MessageMapping("/gameChat/{gameId}/enter")
@SendTo("/topic/{gameId}")
public GameChatResponseDto subscribe(
@DestinationVariable("gameId") Long gameId,
GameChatRequestDto request
) {
return GameChatResponseDto.builder()
.content(request.getSender() + "님이 입장하셨습니다.")
.build();
}
@MessageMapping("/gameChat/{gameId}")
@SendTo("/topic/{gameId}")
public GameChatResponseDto publish(
@DestinationVariable("gameId") Long gameId,
GameChatRequestDto request
) {
LocalDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime();
String formattedTime = now.format(DateTimeFormatter.ofPattern("HH:mm"));
return GameChatResponseDto.builder()
.sender(request.getSender())
.content(request.getMessage())
.sendAt(formattedTime)
.build();
}
}
실시간 채팅 아키텍처 개선 Spring+Kafka+MongoDB
급한불은 껐지만, STOMP 또한 default로 in-memory broker를 사용하기 때문에 서버의 scale-out에 대비할 수 없다. 최종 프로젝트 기간이 끝나고 러닝커브의 문제로 적용하지 못했던 kafka 외부 브로커 도입과 채팅 데이터 스토리지로 MongoDB를 이용해 실시간 채팅 아키텍처를 스케일아웃 가능한 구조로 개선해보고자하였다.
[ 참고자료 ]