우테코 팀 프로젝트로 진행 중인 핏토링에서는 멘토와 멘티가 실시간 채팅을 통해 운동 상담을 진행할 수 있다.
"실시간 채팅 기능에 이미지, 동영상 등의 파일 전송 기능 추가"를 담당하게 되었다.
본격적인 설계 이전에 우리 프로젝트의 기존 채팅 과정을 단단히 이해하고 가기 위해 정리해봤다.
백엔드 환경 : Java, Spring
- 스프링에서 실시간 채팅을 구현하는 방법 중 가장 널리 알려진 WebSocket + STOMP를 이용했다.
클라이언트와 서버가 양방향 통신을 할 수 있도록 하는 프로토콜, HTTP와 달리 연결이 유지되어 서버가 클라이언트에 먼저 데이터를 보낼 수 있다. 채팅, 온라인 게임, 실시간 알림 등에 활용된다.
키워드는 실시간 통신, 양방향 통신
웹 소켓은 TCP 위에서 동작하는 응용 계층 프로토콜이다. 3-way Handshake를 통해 TCP 연결한 뒤 HTTP 프로토콜로 Upgrade 요청을 보내 WebSocket Handshake를 수행하고 프로토콜을 전환한다.
이후 해당 연결을 유지하며 실시간, 양방향 통신을 지원한다.
웹소켓이 실시간, 양방향 통신을 위한 프로토콜이라면 STOMP는 웹소켓 통신에서 주고 받을 메시지의 양식을 규정하는 프로토콜이다.
웹소켓 위에서 마치 HTTP처럼 정해진 양식으로 통신하도록 한다.
HTTP 메서드(GET, POST 등)비슷한 역할을 하는 COMMAND가 있다.
주요 커맨드는 아래와 같다.
/topic/1)를 구독하여 메시지를 받겠다고 선언/app/1)로 메시지를 보낼 때 사용스프링은 implementation 'org.springframework.boot:spring-boot-starter-websocket' 으로 웹소켓과 STOMP를 간단하게 사용할 수 있도록 지원한다.

WebSocketConfig 클래스에서 정의해 두었다. 이 설정 클래스는 앞으로도 자주 등장한다.@RequiredArgsConstructor
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
...
private final WebSocketAuthHandshakeInterceptor webSocketAuthHandshakeInterceptor;
...
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-chat")
.setAllowedOriginPatterns(
PROD,
DEV,
LOCAL
)
.addInterceptors(webSocketAuthHandshakeInterceptor)
.withSockJS();
}registerStompEndpoints()에서 하는 일은 아래와 같다.우리 서버에 STOMP 형식의 웹소켓 연결을 요청하기 위한 엔드포인트를 설정.
엔드포인트에 HandshakeInterceptor를 설정. 웹소켓 핸드셰이크 시 인터셉터의 동작을 정의할 수 있다.
우리는 여기서 JWT 토큰을 검사하고, 회원정보를 attributes에 저장한다.
public class WebSocketAuthHandshakeInterceptor implements HandshakeInterceptor {
...
@Override
public boolean beforeHandshake(
ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes
) {
if (request instanceof ServletServerHttpRequest servletRequest) {
HttpServletRequest httpServletRequest = servletRequest.getServletRequest();
Cookie[] cookies = httpServletRequest.getCookies();
validateCookie(cookies);
String token = jwtExtractor.extractTokenFromCookie(TOKEN_NAME, cookies);
jwtProvider.validateToken(token);
Long memberId = jwtProvider.getSubjectFromPayloadBy(token);
attributes.put(LOGIN_INFO_KEY, new LoginInfo(memberId));
return true;
}
SockJS를 활성화한다.
웹소켓으로 프로토콜이 전환되면, 클라이언트는 메시지 브로커에 SUBSCRIBE 메시지를 날려 토픽 구독을 요청한다. 메시지 브로커는 해당 토픽 구독자 명단에 클라이언트를 추가한다.
SimpleMessageBroker를 사용한다. 위에서 봤던 WebSocketConfig 클래스에서 경로 등록과 함께 활성화 할 수 있다. @Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}enableSimpleBroker는 SimpleMessageBroker에게 "/topic" 하위의 엔드포인트를 토픽으로 관리하라는 설정이다.setApplicationDestinationPrefixes 애플리케이션 경로를 설정하는 것이다. 기본적으로 STOMP에서는 HTTP의 GET 메서드가 @GetMapping 을 찾는 것처럼 요청이 컨트롤러 메서드를 찾지 않고 바로 메시지브로커에게 전달된다. 위와 같이 애플리케이션 경로를 설정해두면 "/app" 하위의 url은 앱 내부의 @MessageMapping 컨트롤러 메서드를 찾는다.클라이언트가 채팅을 하면 SEND /app/chatroom/{chatRoomId} 로 메시지를 전송한다.
WebSocketConfig 클래스의 configureClientInboundChannel()에 인터셉터를 추가해두면 아까 WebSocket Handshake 때 attributes 에 넣어둔 회원 정보를 꺼내 @MessageMapping컨트롤러 메서드에 파라미터로 주입받을 수 있다.
요런 식으로
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
...
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompAuthChannelInterceptor);
}
}
@RequiredArgsConstructor
@Component
public class StompAuthChannelInterceptor implements ChannelInterceptor {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.SEND.equals(accessor.getCommand())) {
LoginInfo loginInfo = (LoginInfo) Objects.requireNonNull(accessor.getSessionAttributes())
.get(WebSocketAuthHandshakeInterceptor.LOGIN_INFO_KEY);
accessor.setHeader(WebSocketAuthHandshakeInterceptor.LOGIN_INFO_KEY, loginInfo);
}
return message;
}
}
@MessageMapping("/chatroom/{chatRoomId}")
public void chat(
@DestinationVariable("chatRoomId") Long chatRoomId,
@Valid ChatMessageRequest request,
@Header(WebSocketAuthHandshakeInterceptor.LOGIN_INFO_KEY) LoginInfo loginInfo
) {
@MessageMapping 메서드가 메시지를 받는다.
이후 DB에 메시지를 저장하고, 브로커의 토픽에 메시지를 보낸다.
메시지 전송에는 SimpMessageTemplate.convertAndSend()을 활용했다. 응답 DTO를 JSON으로 직렬화 하고 STOMP의 MESSAGE 커맨드를 씌워 보내준다.
메시지 전송에 @SendTo를 활용하는 방법도 있으니 궁금하시면 찾아보시길.
messagingTemplate.convertAndSend("/topic/chatroom/" + chatRoomId, response);
SimpleMessageBroker 가 토픽을 구독하고 있는 구독자들에게 MESSAGE를 전달한다.