///
Search
Duplicate
✉️

채팅 서비스 적용기

우리팀은 채팅 서비스의 통신 방식을 [Polling, Websocket] 후보로 생각을 했었다. 이 두개의 통신 방식에 대해서는 많은 레퍼런스가 존재하기에 Websocket 이용해 통신을 하는 방법이 성능적으로 우수하다는 것을 알 수 있었다. 서비스의 Scope 를 작게 기획하여 두개의 기술을 모두 접목해 이 둘의 차이점을 알아보려고 한다.
Polling
클라이언트가 n초 간격으로 request를 서버로 계속 날려서 response를 전달받는 방식이다.
장점 / 단점
Websocket
웹소켓은 HTML5표준 기술로, 사용자의 브라우저와 서버 사이의 동적인 양방향 연결 채널을 구성한다.
Websocket API를 통해 서버로 메세지를 보내고, 요청 없이 응답을 받아오는 것이 가능하다.
장점 / 단점

 그렇다면 어느정도 성능이 우수할까

성능 테스트는 JMeter를 이용해 사용자를 추가해 비교하는 방식으로 진행

Polling

 채팅방에 사용자가 들어가면 채팅을 매초 마다 조회하는 방식

@Transactional(readOnly = true) public ChatRoomDetailGetRes getDetailChatRoom(Long chatRoomId, User user) { isChatRoomMember(chatRoomId, user.getId()); // 채팅방에 존재하는 사용자 인지 Check ChatRoom chatRoom = findById(chatRoomId); List<ChatRes> chatList = chatRepository.findAllByChatRoom_Id(chatRoomId) // 해당 채팅방 내에 있는 채팅을 모두 가져옴 .orElseThrow(() -> new GlobalException(NOT_FOUND_CHAT)) .stream() .map(ChatRes::to) .toList(); return ChatRoomDetailGetRes.builder() .title(chatRoom.getTitle()) .chatResList(chatList) .build(); }
Java
복사

 매초 마다 Query가 나가는 것을 확인

성능 체크

 동시 사용자 1000명

 조회 데이터 (채팅의 개수) 2000개

평균 속도 6280ms TPS(초당 데이터 처리량) 49.3/sec
성능 개선

 커서 기반 페이징 처리 도입

QueryDSL 을 활용하여 Page 처리
@Transactional(readOnly = true) public ChatRoomPaginationDetailGetRes getPaginationDetailChatRoom(Long chatRoomId, Long lastChatId, User user) { isChatRoomMember(chatRoomId, user.getId()); // 채팅방에 속한 사람만 조회 가능 ChatRoom chatRoom = findById(chatRoomId); Slice<ChatRes> chatResList = chatRepository.findChatsByChatRoomId(chatRoomId, lastChatId, LIMIT_SIZE); // 10개 return ChatRoomPaginationDetailGetRes.builder() .title(chatRoom.getTitle()) .chatResList(chatResList) .build(); }
Java
복사
평균 속도 1245ms TPS(초당 데이터 처리량) 189.7/sec

결과

평균 속도 → 약 500% 성능 개선 TPS → 약 380% 성능 개선

 커서 기반 페이징 처리 내부 로직 개선

 동시 사용자 3000명

 조회 데이터 (채팅의 개수) 2000개

Collections.reverse()

private List<ChatRes> queryChats(Long chatRoomId, Long lastChatId, int limitSize) { QChat chat = QChat.chat; QUser user = QUser.user; BooleanExpression queryCondition = createQueryCondition(chat, chatRoomId, lastChatId); List<ChatRes> chats = queryFactory .select(Projections.constructor( ChatRes.class, chat.id, user.id, user.username, user.profileImage, chat.message, chat.isDeleted, chat.createdAt)) .from(chat) .leftJoin(chat.sender, user) .where(queryCondition) .orderBy(chat.id.desc()) .limit(limitSize + 1) .fetch(); Collections.reverse(chats); // <- ( 개선할 부분 ) return chats; }
Java
복사
평균 속도 1176ms TPS(초당 데이터 처리량) 400.5/sec

Collections.reverse() X

Front에서 반환된 List 데이터를 역순으로 조회
private List<ChatRes> queryChats(Long chatRoomId, Long lastChatId, int limitSize) { QChat chat = QChat.chat; QUser user = QUser.user; BooleanExpression queryCondition = createQueryCondition(chat, chatRoomId, lastChatId); List<ChatRes> chats = queryFactory .select(Projections.constructor( ChatRes.class, chat.id, user.id, user.username, user.profileImage, chat.message, chat.isDeleted, chat.createdAt)) .from(chat) .leftJoin(chat.sender, user) .where(queryCondition) .orderBy(chat.id.desc()) .limit(limitSize + 1) .fetch(); return chats; }
Java
복사
평균 속도 650ms TPS(초당 데이터 처리량) 431.7/sec

결과

평균 속도 → 약 190% 성능 개선 TPS → 약 10% 성능 개선
Websocket
WebSocket은 서버와 클라이언트 간에 Socket Connection을 유지해서 언제든 양방향 통신 또는 데이터 전송이 가능하도록 하는 기술입니다.
위 그림은 웹소켓 핸드쉐이크 과정을 그림으로 나타난 것이다.
클라이언트가 ws://localhosy/stomp 로 요청을 보냈을 때 담기는 첫 요청의 헤더 입니다
GET /stomp Host: localhost:8080 Origin: http://localhost:5173 Connection: Upgrade Upgrade: websocket Sec-WebSocket-Key: eZSHFuOwZzTm8h1B1dpY3g== Sec-WebSocket-Version: 13
Java
복사
Connection : 업그레이를 목적으로 하는 요청입니다.
Upgrade: client가 업그레이드 되길 원하는 프로토콜입니다.
Sec-WebSocket-Key : 브라우저에서 생성한 키입니다.
Sec-WebSocket-Version : 웹소켓 프로토콜 버전입니다.

 고민

WebSocket만으로 좋은 Server/Client 소켓 서버를 완성할 수 있으나, WebSocket만을 이용해 채팅을 구현 하면 해당 메세지가 어떤 요청인지 각각의 채팅 메세지 발송 부분을 관리를 해줘야 하는 것인가?? 또한 Security을 사용한 인증 방식에 대해서 어떻게 검증할 것인지에 대한 우려사항이 존재했다.

STMOP (Simple Text Oriented Messaging Protocol)

Websocket 위에서 동작하는 텍스트 기반 메세징 프로토콜입니다.
기본적으로 pub / sub 구조로 되어있어 메세지를 전송하고 메세지를 받아 처리할 수 있다.
메세지의 헤더에 값을 줄 수 있어 헤더 값을 기반으로 통신 시 인증 처리를 구현하는 것도 가능하다.
채팅방 생성: pub / sub 구현을 위한 Topic 생성 채팅방 입장: Topic 구독 채팅방에서 메세지를 송수신: 해당 Topic으로 메세지 송신(pub) / 수신(sub)
Stomp를 사용하는 이유
각각의 채팅방 (커넥션) 마다 WebSocketHandler를 구현하는 방식은 비효율적
STOMPController Annotation이 적용된 객체를 이용해 조직적으로 관리를 할 수 있습니다.
@MessageMapping으로 라우팅시킬 수 있습니다.
Spring Security를 적용해 메세지를 보호할 수 있습니다.
@EnableWebSocketMessageBroker @Configuration public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/stomp").setAllowedOrigins("*"); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.setApplicationDestinationPrefixes("/pub"); registry.enableSimpleBroker("/sub"); } }
Java
복사

registerStompEndpoints

handshake와 통신을 담당할 endpoint를 지정 → ws://localhost:8080/stomp
CORS 통신을 Front origin을 열어줌 → 추후 구체화 예정

configureMessageBroker

setApplicationDestinationPrefixes: Client에 메세지를 송신을 담당한다.
enableSimpleBroker :해당 경로로 SimpleBroker를 등록한다.
 SimpleBroker는 해당하는 경로를 SUBSCRIBE하는 client에게 메시지를 전달하는 간단한 작업을 수행한다.
public class ChatController { private final ChatService chatService; @MessageMapping("chat-rooms/{chatRoomId}") public void sendMessage(@DestinationVariable Long chatRoomId, @Payload ChatCreateReq chatCreateReq) { chatService.sendMessage(chatRoomId, chatCreateReq); } }
Java
복사
@MessageMapping: Client가 SEND를 할 수 있는 경로다. 
config에서 등록한 applicationDestinationPrfixes와 @MessageMapping의 경로가 합쳐진다./pub/chat-rooms/*
@DestinationVariable : 구독 및 발행 url 의 pathparameter HTTP 통신의 @PathVariable과 동일
@Payload: 송신된 메세지의 데이터
@Service @RequiredArgsConstructor public class ChatService { private final SimpMessagingTemplate template; @Transactional public void sendMessage(Long chatRoomId, ChatCreateReq req) { ChatRoomUser chatRoomUser = chatRoomUserRepository.findByChatRoom_IdAndUser_Id(chatRoomId, user.getId()) .orElseThrow(() -> new GlobalException(ACCESS_DENY)); Chat chat = chatRepository.save(Chat.builder() .message(req.getMessage()) .sender(chatRoomUser.getUser()) .chatRoom(chatRoom) .build()); template.convertAndSend("/sub/chat-rooms/" + chatRoomId, chat.getMessage()); } }
Java
복사
SimpleMessagingTemplate : @EnableWebSocketMessageBroker를 통해서 등록되는 bean이다.
converAndSend : /sub/chat-rooms/* 을 구독하는 클라이언트에게 메세지를 송신하는 메서드이다.

STOMP + RabbitMQ

우리팀은 STOMP가 갖고 있는(내장하고 있는) SimpleBroker 라는 것을 사용했다. 사실 이것만으로도 채팅을 구현하는 것에 문제는 없다고 생각 했다. 하지만 이용자 수가 늘어난다면?? 해당 전제가 생긴다면 서버의 문제가 생길 우려가 있다. SimpleBroker는 철저하게 Spring Boot가 실행되는 (정확하게는 채팅 서버) 곳의 메모리를 사용한다. 다른 많은 비즈니스 로직과 채팅에 대한 부담까지 서버가 가지고 가게 된다. 그래서 우리팀은 서버의 부담을 줄이고자 외부 메세지 브로커 RabbitMQ를 접목시켜 서버의 부담을 줄이는 방향으로 개선하고자 한다.
기존 위와 다른점은 오른쪽 하단에 외부 MessageBroker가 생겼다는 점이다.

 Exchange

Client 에게 받은 message를 queue에게 전달해준다.

 Binding

exchange와 queue와의 관계를 의미한다.
binding이 되어있어야 exchange가 queue에게 메세지를 전달 할 수 있다.
Routing Key를 입력하여 구분할 수 있다.

 Queue

exchange는 Client 로부터 전달 받은 메세지를 binding되는 queue들에게 동일하게 전달한다.
이때, queue는 전달받은 메세지들을 consumer들에게 전달한다.
@Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.setPathMatcher(new AntPathMatcher(".")); registry.setApplicationDestinationPrefixes("/pub"); registry.enableStompBrokerRelay("/queue", "/topic", "/exchange", "/amq/queue"); }
Java
복사
기존 enableSimpleBroker 대신 enableStompBrokerRelay 로 입력한 후 메세지 수신 경로를 설정
setPathMatcher(new AntPathMatcher(".") URL을 / -> . 변경 HTTP 통신과 달리 RabbitMQ/ 로 구분자를 짓지 않는다.
@Configuration @EnableRabbit public class RabbitConfig { // Queue 등록 @Bean public Queue queue() { return new Queue(queueName, true); } // Exchange 등록 @Bean public TopicExchange exchange() { return new TopicExchange(exchangeName); } // Exchange와 Queue바인딩 @Bean public Binding binding(Queue queue, TopicExchange exchange) { return BindingBuilder.bind(queue).to(exchange).with(routingKey); } // RabbitMQ와의 메시지 통신을 담당하는 클래스 @Bean public RabbitTemplate rabbitTemplate() { RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory()); rabbitTemplate.setMessageConverter(jackson2JsonMessageConverter()); return rabbitTemplate; } // RabbitMQ와의 연결을 관리하는 클래스 @Bean public ConnectionFactory connectionFactory() { CachingConnectionFactory factory = new CachingConnectionFactory(); factory.setHost(rabbitmqHost); factory.setPort(rabbitmqPort); factory.setUsername(rabbitmqUsername); factory.setPassword(rabbitmqPassword); factory.setVirtualHost("/"); return factory; } }
Java
복사
기존 Controller와 다르게 구분자를 . 으로 변경
public class ChatController { @MessageMapping("chat-rooms.{chatRoomId}") public void sendMessage(@DestinationVariable Long chatRoomId, @Payload ChatCreateReq chatCreateReq) { chatService.sendMessage(chatRoomId, chatCreateReq); } }
Java
복사
exchangeRoutingKey 이용하여 바인딩
@Service @RequiredArgsConstructor public class ChatService { private final RabbitTemplate rabbitTemplate; @Transactional public void sendMessage(Long chatRoomId, ChatCreateReq req) { ChatRoomUser chatRoomUser = chatRoomUserRepository.findByChatRoom_IdAndUser_Id(chatRoomId, user.getId()) .orElseThrow(() -> new GlobalException(ACCESS_DENY)); Chat chat = chatRepository.save(Chat.builder() .message(req.getMessage()) .sender(chatRoomUser.getUser()) .chatRoom(chatRoom) .build()); rabbitTemplate.convertAndSend(exchangeName, "chat-rooms." + chatRoomId, ChatRes.to(chat)); } }
Java
복사
 RabbitMQ 관리자 페이지 Check
Docker 또는 Local에 RabbitMQ를 설치한 후 해당 경로는 yml파일에 입력 후 15672로 들어가면 관리자 페이지를 들어갈 수 있다.
현재 개발 테스트 단계에 있어서 여러 채팅을 보내는 테스트를 하고 있어서 벌써 약 640개가 쌓여 있는 것을 확인할 수 있다.