들어가기
최종 프로젝트에서 판매자와 구매자 간 일대일 채팅을 구현하기로 하였다.
처음에는 클라이언트가 서버에게 지속적으로 요청을 해 메시지를 받으면 되지 않을까 생각했다.
하지만, 이런 polling 방식은 매번 HTTP 연결을 생성하고 끊기 때문에 서버에 부담을 준다.
때문에 실제로 채팅, 주식 거래 등 실시간성 데이터를 송수신할 때는 웹 소켓 방식을 활용한다고 한다.
이번 글에서는 웹 소켓과 웹 소켓 위에서 쓰이는 STOMP를 알아보고, 코드에 적용해 볼 것이다.
웹 소켓이란?
웹 소켓은 서버와 클라이언트 간 양방향 통신이 가능한 프로토콜이다.
웹 소켓 과정은 Open Handshake, Data Transfer, Close Handshake로 나눌 수 있다.
[Open Handshake]
클라이언트는 HTTP에게 웹 소켓 업그레이드 요청을 보낸다.
서버는 웹 소켓 프로토콜로 전환하고, 101 응답 코드를 전달한다.
[Data Transfer]
data transfer 단계에서 클라이언트는 별도의 요청 없이, 연결된 소켓으로 상대가 보내는 메시지를 받기만 하면 된다.
이로써 앞서 언급한 polling 방식의 문제점이 해결된다.
[Close Handshake]
서버가 연결을 종료한다는 프레임을 보낸다.
클라이언트도 이에 대한 응답으로 close 프레임을 전송한다.
STOMP
웹 소켓에서 STOMP를 서브 프로토콜로 사용할 수 있다.
STOMP를 사용하면 하위 프로토콜을 따로 정의할 필요 없고, 연결 주소마다 새로 핸들러를 구현할 필요가 없다는 장점이 있다.
STOMP는 텍스트 기반의 메시지 프로토콜로, 프레임을 활용해 메시지 내용과 형식을 정의한다.
프레임 구조는 아래처럼 명령어, 헤더, 본문으로 구성된다.
COMMAND // 명령어
header1:value1 // 헤더
header2:value2
Body^@ // 본문
아래 예시처럼 명령어로는 SEND, SUBSCRIBE, MESSAGE 등이 올 수 있다.
SEND
destination:/queue/chat/rooms/1
content-type:application/json
content-length:44
{"senderId":1, "content": "안녕하세요"}
웹 소켓 연결이 수립되면 위와 같은 STOMP 프레임들을 주고받을 수 있다.
아래 그림은 spring 내장 브로커를 사용하여 프레임을 주고 받는 과정을 나타낸다.
이를 살펴보기 앞서 주요 구성 요소들을 알아보자.
- clientInboundChannel(=request channel)
웹 소켓 클라이언트로부터 받은 메시지를 서버로 전달한다.
- clientOutboundChannel(=response channel)
서버 메시지를 클라이언트에게 전달한다.
- broker channel
서버 애플리케이션에서 가공한 메시지를 메시지 브로커에게 전달한다.
- message
프레임을 request channel로 보내기 전 메시지로 변환한다.
메시지는 header와 payload를 포함하고 있다.
프레임이 어떻게 송수신되는지 자세히 살펴보자.
클라이언트는 SEND 프레임을 목적지 헤더 /app/chat-rooms/{chatRoomId}와 함께 보낸다.
/app prefix는 메시지를 SimpleAnnotationMethod로 라우팅해 서버에서 메시지를 처리하도록 한다.
/app prefix를 제외한 경로는 /chat-rooms/{chatRoomId}가 된다.
메시지는 해당 경로를 가진 @MessageMapping 메서드로 라우팅하는 것이다.
가공 후 메시지의 목적지 헤더는 /queue/chat-rooms/{chatRoomId}로 변환된다.
이후 메시지는 brocker channel를 거쳐 메시지 브로커로 향한다.
메시지 브로커는 해당 경로를 구독하는 클라이언트들을 모두 찾고, 메시지를 response channel로 전송한다.
그렇다면 메시지 브로커는 구독자를 어떻게 찾는 것일까?
클라이언트가 SUBSCRIBE 프레임을 보내면, 메시지 브로커는 id와 목적지 헤더를 참고해 구독정보를 저장한다.
SUBSCRIBE
id:1 // 구독자 아이디
destination:/queue/chat-rooms/{chatRoomId} //목적지(구독 경로)
^@
아래 그림을 통해 알아보자.
해당 프레임의 목적지 헤더는 /queue/chat-rooms/{chatRoomId} 이다.
메시지는 request channel을 거쳐 메시지 브로커로 라우팅 된다.
메시지 브로커는 메시지를 받아 클라이언트의 구독 정보를 저장한다.
다이어그램만 봐서는 이해가 안될 수 있다
아래 예제 코드를 통해 반복해서 설명하겠다.
웹 소켓 + STOMP 예제
웹 소켓과 STOMP를 활용하여 간단한 채팅 애플리케이션을 만들 것이다.
이를 위해선 설정 파일과 컨트롤러 파일을 작성해야 한다.
WebSocketConfig
@Configuration
@EnableWebSocketMessageBroker // STOMP 활성화
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-stomp"); //[1]
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/queue"); //[2]
registry.setApplicationDestinationPrefixes("/app"); //[3]
}
}
registerStompEndpoints()는 웹 소켓 연결 주소를 설정한다.
[1] 웹 소켓 클라이언트가 처음에 handshake를 위해 연결하는 경로이다.
configureStompEndpoints()는 메시지 브로커를 설정한다.
[2] 스프링에 내장된 브로커를 사용한다.
목적지 헤더가 /queue로 시작하는 메시지들은 메시지 브로커로 라우팅되어 구독자들에게 바로 전달된다.
[3] 목적지 헤더가 /app으로 시작하는 메시지들은 @MessageMapping이 붙은 함수로 라우팅 된다.
Controller
@MessageMapping("/chat/rooms/{chatRoomId}") //[1]
public ChatMessageResponse chatMessage(
@DestinationVariable Long chatRoomId,
@Payload ChatMessageRequest request /*[2]*/
){
return chatMessageService.registerChatMessage(
chatRoomId, request.senderId(), request.content() //[3]
);
}
[1] 클라이언트가 요청한 경로에서 /app prefix를 제외한 경로이다.
[2] 스프링 메시지의 payload를 body 객체로 매핑한다.
[3] 요청값들을 DB에 저장하고, 응답 객체를 생성해 반환하는 서비스 함수이다.
메시지의 흐름을 살펴보자.
/app을 목적지 경로 prefix로 가지면, @MessageMapping에 매핑된 함수에서 메시지 가공을 하고 SimpleBroker로 전달된다.
메시지 가공 단계에서 request값을 DB에 저장한다.
설정 파일에서 enableSimpleBroker("/queue")로 설정했기 때문에 가공 후 /queue 경로가 붙는다.
따라서 응답 경로는 queue/chat/rooms/{chatRoomId}가 되고, 해당 경로로 응답이 반환된다.
시연
웹 소켓 연결을 생성한 후 채팅을 보내면, 새로 고침을 하지 않아도 실시간으로 전송되는 채팅을 볼 수 있다.
이때 채팅방 아이디에 따라 구독 경로가 달라지므로 다른 채팅방 id의 채팅을 볼 수 없다.
마무리
위 예제에서 생각해봐야 할 점들이 있다.
먼저 내장된 브로커는 구현이 쉽지만 인메모리이기 때문에 메시지가 유실될 가능성이 높다.
STOMP와 함께 사용 가능한 외부 브로커(ex:RabbitMQ, ActiveMQ)를 활용하여 해당 문제점을 해결할 수 있다.
이상 웹 소켓과 STOMP를 활용해 간단한 채팅 애플리케이션을 만들어보았다.
HTTP 통신하는 코드만 작성했어서 웹 소켓 개념이 낯설었는데 공부를 해보며 친숙해지는 계기가 되었다.
Reference
https://www.youtube.com/watch?v=rvss-_t6gzg
https://spring.io/guides/gs/messaging-stomp-websocket