들어가며
최근 Secondhand 프로젝트를 정리하며 그동안의 학습을 돌아보고 디버깅과 성능개선을 하고 있습니다.
그러다보니 거의 반년동안 계속 유지보수하고 있는 프로젝트가 되어서 이정도면 반려 프로젝트(...)라고 할 수 있을 정도로 애증이 담긴 프로젝트가 되어버렸는데요. web site
웹소켓 통신을 구현했지만, STOMP 프로토콜을 테스트 하기 쉽지 않아서 난항을 겪었던 때가 있었습니다.
평소에는 `Postman`이나 간단한 API라면`curl`로 테스트를 하는데 Postman에서 `STOMP`를 지원하지 않았거든요.
그래서 인터넷을 찾다가 `APIC`이라는 api테스트 툴을 애용하고 있었습니다. 근데 언제부턴가 이 사이트에 접속할 수 없게 되었어요 🥲
그래서 팀원과 논의 끝에 계속 꾸준히 쓰고 있던 `Postman`으로 테스트를 작성해두기로 했습니다.
`STOMP`도 웹소켓 통신 위에 있는 프로토콜이니 직접 테스트를 작성하면 되지 않을까? 해서요.
Postman으로 Websocket 테스트를 하는 법
포스트맨에서 요청을 생성할 때, 기본으로 생성하면 `HTTP` Rest API 생성을 하는데요. 저 위에 New를 누르고 `WebSocket` 요청을 생성해주고 이를 저장할때 새로운 콜렉션을 만들어주면 다른 HTTP 프로토콜 외 프로토콜 요청을 위한 콜렉션을 따로 만들 수 있었습니다.
지정한 엔드포인트로 connect를 요청하니 잘 연결이 됩니다 👍
STOMP의 이해와 메시지 보내기
STOMP를 사용했던 이유
프로젝트에 STOMP를 도입했던 이유는 1Spring에서 적용하기 쉽기 때문이었습니다. 더불어 간결하고 쉬운 프로토콜이라 메시지 기반으로 통신하기 용이할 것 같다는 판단이었습니다.
송수신 처리하는 부분이 정의되어 있어 해당 프로토콜을 잘 지키면 별도로 메시지 데이터를 추출하기 위해 파서 등을 구현해야하는 품을 많이 줄일 수 있다는 장점이 있었습니다.
메시지를 보면 헤더와 바디를 전달할 때 HTTP 와 비슷한 형태라 직접 읽기도 쉽습니다.
SEND // command
destination:/queue/a // headers
receipt:message-12345
hello queue a^@ //body,null octect
또한 스케일 아웃과 서버 안정성을 위해 다른 웹소켓 통신 브로커(RabbitMQ 등)와 함께 작동하도록 구성하기도 용이합니다.
당시 저희는 Redis cache를 이용했기 때문에, Redis의 pub/sub을 활용해서 외부 브로커로 사용하기로 했습니다. 별도의 인프라 구축을 하지 않아도 되며 Pub/sub을 지원하기 때문입니다.
단, Redis는 STOMP를 지원하지 않기 때문에 위 다이어그램에서처럼 STOMP TCP로 메시지를 주고받지는 않았습니다.
사용 예시
Spring에서는 간편하게`spring-boot-starter-websocket` 의존성을 추가한 후, `config`와 `controller`를 정의하여 사용을 시작할 수 있었습니다.
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final StompMessageProcessor stompMessageProcessor;
private final StompErrorHandler stompErrorHandler;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/chat", "/notification")
.setAllowedOrigins("*");
registry.setErrorHandler(stompErrorHandler);
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub"); // 구독 요청
registry.setApplicationDestinationPrefixes("/pub"); // 발행
// registry.setPathMatcher(new AntPathMatcher("."));
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompMessageProcessor);
}
}
@RestController
@RequiredArgsConstructor
class ChatController {
private final RedisMessagePublisher redisMessagePublisher;
private final ChannelTopic chatTopic;
@MessageMapping("/message")
public void message(ChatbubbleRequest message) {
redisMessagePublisher.publish(chatTopic.getTopic(), message.toDomain());
}
}
특히 이번 프로젝트를 하면서 유용하게 썼던 것은 STOMP의 커맨드를 기준으로 시행되어야하는 비즈니스 로직을 나눈 것이었습니다. STOMP를 사용함으로써 로직 분기점을 위해 별도 규칙을 만들지 않아도 되어서 편리했습니다. 단순히 프로토콜 형식에 따라 로직을 구현함으로써 구조상으로 단순해질 수 있기 때문입니다.
[참고 : 2023.10.16 - [🌱 Spring] - 안 읽은 채팅 구현기 : STOMP의 ChannelInterceptor 를 사용하여]
SessionConnectEvent: Published when a new STOMP CONNECT is received to indicate the start of a new client session. The event contains the message that represents the connect, including the session ID, user information (if any), and any custom headers the client sent. This is useful for tracking client sessions. Components subscribed to this event can wrap the contained message with SimpMessageHeaderAccessor or StompMessageHeaderAccessor.
- 출처 : Spring docs
Postman으로 테스트하기
Postman으로 테스트를 하기 위해서는 웹소켓 통신을 `connect`하고 STOMP 의 형식에 맞게 메시지만 보내주면 되었는데요.
이 테스트는 같은 팀원이 전담해서 짜주었고, 생기는 트러블 슈팅을 같이 열심히 했습니다 😎
Postman에서 message를 저장할 수가 있어서 한번 써두면 반복해서 테스트하기 편했어요.
SEND // command
destination:/queue/a // headers
receipt:message-12345
hello queue a^@ //body,null octect
프로토콜에 맞추어 커맨드, 헤드, 한줄 공백 후 바디(optional), 그리고 메시지가 끝났다는 것을 표시하는 Null octect까지 보내주면 됩니다.
주의사항 & Trouble shooting
트러블 슈팅하며 알아낸 주의할 점이 몇가지 있었습니다.
- `null octetct`
- null octect을 아스키코드나 `^@`로 넣으면 잘 먹히지 않습니다. (아마 텍스트 그 자체로 인식하는 듯) 그래서 실제로 웹서비스에서 주고받는 메시지에서 null octect을 복사해와서 사용했습니다 🥲 뭔가 잘못해서 그런가...
- body가 있는 경우, `content-length`, `content-type`을 정확하게 넣어주어야합니다.
- 공식 문서에 각 커맨드마다 필수 헤더들이 있으니 빠지지 않게 작성해주고, 경우에 따라 optional 헤더도 활용해줍니다.
- `CONNECT` or `STOMP`
- REQUIRED: accept-version, host
- OPTIONAL: login, passcode, heart-beat
- `CONNECTED` response
- REQUIRED: version
- OPTIONAL: session, server, heart-beat
- `SEND`
- REQUIRED: destination
- OPTIONAL: transaction
- `SUBSCRIBE`
- REQUIRED: destination, id
- OPTIONAL: ack
- `UNSUBSCRIBE`
- REQUIRED: id
- OPTIONAL: none
- `DISCONNECT`
- REQUIRED: none
- OPTIONAL: receipt
- `ERROR`
- REQUIRED: none
- OPTIONAL: message
- `CONNECT` or `STOMP`
- 서버사이드에서 메시지 크기 제한을 통해 클라이언트의 악의적인 요청을 막을 수 있습니다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
registration.setMessageSizeLimit(128 * 1024);
}
// ...
}
- `heart-beat` 헤더를 통해 네트워크 연결이 살아있는지, 확인하는 heartbeat 주기(interval)를 설정할 수 있습니다. 서버가 죽었거나, 연결 종료를 빠르게 감지하기 위해 사용합니다. `CONNECT` 시에 헤더로 같이 설정해줍니다. 그렇지 않으면 기본적으로 0,0 으로 설정됩니다.
CONNECT // 클라이언트쪽 요청
heart-beat:<cx>,<cy>
CONNECTED // 서버쪽 응답
heart-beat:<sx>,<sy>
마무리하며
위와 같은 규칙적인 프로토콜을 통해 클라이언트와 정해진 규칙대로 메시지를 주고 받으며 채팅 로직을 수행할 수 있었습니다.
(`CONNECT` -> `SUBSCRIBE` -> `SEND` -> `UNSUBSCRIBE` -> `DISCONNECT`)
직접 STOMP 테스트를 짜보려고하니 해당 프로토콜에 대해 더 깊게 이해하게 되어 오히려 APIC 등 다른 STOMP 테스트 도구에 의존하는 것보다 배울 수 있는 것들이 많았어서 유익했습니다.
이것으로 더 궁금해진 것들을 아래 적어두고 추후에 조금씩 해결해보려고 합니다.
- 웹소켓 프로토콜로 통신시 bean scope는 어떻게 될까? 그리고 이를 어떻게 활용할 수 있을까?
- STOMP 외에 다른 메시징 프로토콜은 뭐가 있을까? Spring 프레임워크에서는 STOMP를 사용하는 것이 가장 편한 것일까?
- 클라이언트 메시지 사이즈, heart-beat 외에 서버사이드에서 설정해주어 최적화할 것들은 뭐가 있을까?