개요
스포츠 경기 일정 및 결과를 제공하는 서비스인 최종프로젝트에서 스포츠 게임별로 사용자들이 실시간으로 소통할 수 있는 채팅 기능을 제공하고자 하였다. 네이버 스포츠, 아프리카TV 등과 같은 플랫폼에서 제공하는 실시간 채팅 기능을 모티브로하여 개발하였고 그 과정에서 기술 의사결정 및 개발 그리고 느낀점에 대한 회고를 기록하고자 글을 작성한다.
실시간 채팅 서비스 개발을 위한 기술 선정: 폴링과 웹소켓
✅ Polliing 방식 사용 시 발생하는 서버 부하
Polliing 방식은 지정한 인터벌마다 HTTP요청을 보내기 때문에 서버에 많은 부담을 줄 것으로 판단했다. WebScoket도 클라이언트와 연결을 유지하는데 비용이 발생하지만 HTTP도 Keep-Alive로 인해 일정시간 연결을 유지하는 비용이 발생한다. 또한, Polling 방식 사용 시 채팅 데이터가 도착했음을 확인하기 위한 로직과 DB가 필요한데, 해당 로직을 구현하는 비용과 요청마다 DB를 조회해야하기 때문에 DB에도 부하가 함께 발생할 것이라 판단했다.
✅ Polling 방식은 실시간을 모방한 기술
Polling 방식에서 발전한 Long Polling과 Streaming이 있지만 WebSocket과 동작 과정이 유사해보였고 WebSocket을 두고 앞의 두 기술을 사용해야할 이유를 찾지 못했다. 기술적으로 봤을때 실시간을 모방한 Polling 방식에서 실시간성을 보장하며 발전한 기술이 WebSocket이라는 생각을 했다. 제대로된 실시간 채팅 서비스를 제공하고 싶었기에 WebSocket을 사용하고자 했다. 물론 Polling 방식도 상황에 맞게 사용하면 WebSocket보다 나은 선택지가 될 것이라고 생각한다.
✅ 가용 스토리지의 한계
데이터를 저장할 가용 스토리지로 MySQL, Redis가 있었다. RDB인 MySQL을 사용하기에는 Polling 요청마다 데이터 조회와 삽입이 이루어지기 때문에 많은 부하가 발생할 것이라 판단했다. 또한, Redis를 사용하기에는 채팅방, 채팅을 보낸 사용자, 채팅 내역, 채팅 시간 등 저장해야할 정보가 많았기 때문에 key-value 구조가 복잡해져 성능을 보장할 수 없고 정렬 기능도 필요했기 때문에 사용에 무리가 있다고 판단했다. 프로젝트 기간이 약 1-2개월로 러닝커브를 고려했을때 MongoDB와 같은 새로운 기술을 습득하기에도 어려웠기에 이전의 채팅 기록을 보여주지 않는다는 가정하에 별도의 스토리지가 필요하지 않은 WebSocket을 사용하기로 결정했다.
✅ 메시지 발행과 구독을 지원하는 STOMP
STOMP는 메시지 기반 통신을 위한 프로토콜로 WebSocket을 기반으로 동작한다. WebSocket과 같이 HTTP를 WS로 업그레이드하여 양방향 통신을 지원하며 메시지 전송, 토픽 구독 등의 기능을 지원한다. 사용자의 채팅방 입장 시 해당 토픽을 구독하고 채팅방을 구독 중인 사용자들에게 메시지를 발행하는 프로세스가 기획 의도와 부합하며 Polling 방식의 단점을 극복할 수 있는 WebSocket기반의 통신을 제공하기에 해당 프로젝트의 채팅기능 구현을 위해 STOMP를 사용하기로 결정했다. 한가지 유의했던 점은 STOMP는 기본적으로 In-Memory Broker라는 것이다. 해당 프로젝트는 서버의 스케일아웃을 가정하고 있기 때문에 이를 위해서는 추후 Kafka나 RabbitMQ와 같은 메시지큐로 브로커를 대체해야 하는데, 메시지큐에 대한 러닝커브가 매우 클 것으로 예상했기 때문에 개선사항으로 남겨두고 별도의 포스트로 정리할 예정이다.
STOMP를 활용한 실시간 채팅 기능 개발
STOMP는 아래와 같이 동작한다. 아래는 클라이언트와 서버가 WebSocket으로 연결되고 클라이언트에서 특정 토픽을 구독하고 있다는 가정하에 STOMP가 동작하는 방식이다. 먼저, 클라이언트에서 서버에 구독중인 토픽에 발행할 메시지를 전송한다. 서버는 클라이언트에서 보낸 요청의 경로에 따라 내부 메시지 컨트롤러를 경유할지와 그러지 않고 메시지를 바로 발행할지를 결정한다. 아래 예시에서는 요청 경로가 "app"으로 시작한다면 내부 컨트롤러를 경유하고, "topic"으로 시작한다면 메시지를 특정 토픽에 그대로 발행하는 프로세스를 확인할 수 있다. 최종적으로는 STOMP 브로커를 통해 토픽을 구독하고 있는 요청자를 포함한 모든 클라이언트에게 메시지를 발행한다.
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class StompConfig 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");
}
}
실시간 채팅 흐름을 코드로 살펴보자. 첫번째로 클라이언트와 서버간 WebSocket 연결 활성화와 특정 토픽을 구독하고 입장 메시지를 보내는 클라이언트의 리액트 코드이다. 리액트의 STOMP 라이브러리를 통해 클라이언트 객체를 생성한다. 이때 생성자의 파라미터로 brokerURL을 전달받는데 WebSocket 연결 요청을 처리할 서버측 URL을 넣어준다. STOMP의 핵심 브로커는 서버측에서 실행된다는것을 알 수 있다. 이후 onConnect() 함수를 이용해 실제 WebSocket 연결을 시도하는데 연결에 성공한다면 콜백 메서드가 실행된다. 채팅방 입장 시 onConnect()가 바로 실행되기 때문에 연결 성공 후 즉시 subscribe() 함수를 통해 채팅방 아이디를 기준으로 토픽을 구독하고, 사용자가 채팅방에 입장했다는 사실을 알리기 위해 입장 메시지를 발행한다.
import * as StompJs from "@stomp/stompjs";
//1. stompClient 선언
const stompClient = new StompJs.Client({
brokerURL: `${host}/websocket`,
});
//2. 웹소켓 연결 요청
stompClient.onConnect = (frame) => {
console.log('Connected: ' + frame);
//3. 웹소켓 연결 성공 후, /topic/${gameId} 구독
stompClient.subscribe(`/topic/${gameId}`, (message) => {
//4. 구독 토픽에서 메시지 도착 시 처리할 콜백 선언
appendMessage(
JSON.parse(message.body).sender,
JSON.parse(message.body).content,
JSON.parse(message.body).sendAt
);
});
//5. 입장 메시지 발행
stompClient.publish({
destination: `/app/gameChat/${props.gameId}/enter`,
body: JSON.stringify({
'sender': member.memberName
})
});
};
두번째로는 사용자가 채팅 메시지 전송 시 메시지를 발행하는 코드이다. WebSocket 연결 후 입장 메시지를 발행하는 코드와 다르지 않다. 입장 시에는 입장 인원을 식별하기 위한 사용자 정보만 포함해 발행했지만, 아래 코드에는 실제 사용자가 채팅방에 입력한 메시지를 포함해 발행한다. 이후 발행된 메시지는 "app"으로 시작하는 경로로 요청을 보냈기 때문에 서버 내부 컨트롤러를 통한 후 클라이언트들에게 전송될 것이다.
import * as StompJs from "@stomp/stompjs";
const stompClient = new StompJs.Client({
brokerURL: `${host}/websocket`,
});
...
const sendMessage = () => {
stompClient.publish({
destination: `/app/gameChat/${props.gameId}`,
body: JSON.stringify({
'sender': member.memberName,
'message': $("#message").val()
})
});
};
클라이언트에서 요청한 STOMP 관련 요청을 처리하기 위한 컨트롤러 클래스이다. "app"으로 시작하는 경로를 가진 요청에 대해 아래 내부 컨트롤러를 거쳐 메시지가 발행된다. 작동 방식은 HTTP 컨트롤러와 동일하다. 특정 경로에 메서드를 매핑하고 경로 변수, 응답 본문 등을 받아와 처리한 후 특정 토픽으로 메시지를 발행한다.
@Controller
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();
}
}
아래는 해당 서비스에서 개발한 실시간 채팅 서비스의 내부 프로세스를 나타낸 흐름도이다. 공식 문서에서 가져온 흐름도와 크게 다를 것이 없지만, 내부 흐름을 이해하는데 도움을 얻기 위해 작성했다. 보통 이런 서비스 개발 회고를 작성하면 서버쪽 코드를 중심으로 작성하는데 이번 글에서는 프론트엔드 코드에도 어느정도의 비중이 실렸다. WebSocket은 클라이언트와 서버간 양방향 통신이기 때문에 기존 백엔드 서버 뿐만 아니라 클라이언트 측의 코드도 요청을 구독받는 중요한 역할을 하기 때문이라고 생각한다.
서버 스케일아웃을 위한 실시간 채팅 아키텍처 개선
러닝커브의 문제로 STOMP 브로커를 인메모리로 사용했기 때문에 서버의 스케일아웃에 대비할 수 없었다. 채팅 서버의 경우 운영 비용이 매우 크기 때문에 서버의 스케일아웃이 필수일 것이라고 생각한다. 최종 프로젝트를 마무리하고 Kafka, RabbitMQ와 같은 외부 브로커 도입과 채팅 데이터 저장을 위한 DB로 MongoDB를 사용해 서버를 스케일아웃 가능한 구조로 변경하고 채팅 데이터를 적재하고 운영할 수 있는 아키텍처로 개선해보고자 한다. 실시간 채팅 아키텍처 개선에 대한 글은 별도의 게시글로 작성해 정리했다.
참고자료