SNUXI 개발일지 - 4 [WebSocket 인증과 메시지 흐름]
서비스를 개발하며 WebSocket의 인증 방식과 데이터 흐름에 대해 정리하고, 왜 그러한 설계로 이루어졌는지 정리해보고자 한다.
1. WebSocket 데이터 흐름
우리 서비스 코드 기준으로, 다음과 같은 예시 상황에서 WebSocket의 데이터 흐름을 정리해보자.
(상황) 웹소켓 연결 -> 채팅방 구독 -> 메시지 송수신 -> 채팅방 연결종료
- 클라이언트가 /ws로 WebSocket 업그레이드 요청(HTTP 요청, 응답으로 101 수신)
- WebSocketConfig.kt의 registerStompEndPoints() 함수를 통해, 프론트가 연결할 주소와 신뢰하는 프론트 도메인을 추가 => HttpSessionHandshakeInterceptor()를 통해 HTTP 세션의 정보를 웹소켓 세션으로 전달할 수 있고 허용하는 origin일 경우 WebSocket 연결 성공(인증 정보는 이후 interceptor 부분에서 불러와서 메시지 검증 수행)
- 클라이언트가 STOMP SUBSCRIBE 전송(= 채팅방 구독) => WebSocketConfig.kt의 configureClientInboundChannel() 함수에 등록한 interceptor가 로직에 따라 수행됨(구독 성공)
- 클라이언트가 /pub/…..로 메시지 전송 => 등록한 interceptor의 preSend() 함수가 로직에 따라 수행되어, 실제 참여자인지 확인 후 통과하여 컨트롤러로 이동
- 컨트롤러 -> 서비스 -> DB 저장 수행 후, SimpMessagingTemplate을 이용하여 해당 방 전체에 메시지 브로드캐스트
- 클라이언트가 연결 종료시(DISCONNECT), WebSocket 세션 종료 및 구독 정보 삭제(서버가 STOMP DISCONNECT를 수신하면 Spring에 의해 구독 정리됨, 방 나가기 api와 다른 것임)
이때 클라이언트->서버 방향으로 들어오는 데이터 흐름을 Inbound, 서버->클라이언트 방향으로 나가는 데이터 흐름을 Outbound라고 한다.
검증에 사용된 registerStompEndPoints(), configureClientInboundChannel(), preSend() 함수에 대해 정리해보자.
1. fun registerStompEndPoints()
WebSocketMessageBrokerConfigurer 인터페이스에 정의된 void 함수이며, 클라이언트가 WebSocket 연결을 시도할 엔드포인트를 등록하는 함수이다. Handshake 요청을 보낼 수 있도록 하며, 서버는 신뢰하는 프론트 도메인을 따로 지정해줄 수 있다.
2. fun configureClientInboundChannel()
1번과 동일한 인터페이스에 정의된 void 함수이며, Inbound 메시지가 @MessageMapping으로 전달되기 이전 과정을 제어하는 ChannelInterceptor를 등록할 수 있게 하는 함수이다. 즉, 메시지가 서버로 전송되기 전 해당 함수를 통해 등록한 interceptor에 의해 보안 로직이 수행된다.
3. fun preSend()
ChannelInterceptor 인터페이스에 정의된 함수이며, 메시지가 서버로 전송되기 전에 메시지를 가로채어 내부의 로직을 수행하게 하는 함수이다.
우리 서비스에서는 preSend() 함수에 요청 종류에 따른 처리 로직을 구현하여 보안 로직을 수행하도록 했다.
WebSocket에서의 인증은 Interceptor를 사용한 이유에 대해서는 이전 게시글에서 정리해보아서 이번엔 건너뛰고, 앞 3개의 함수들은 Inbound쪽 함수들이었으므로 이번에는 Outbound쪽 함수들 코드를 뜯어보고 정리해보도록 하자.
- Outbound : 서버->클라이언트 브로드캐스트, SimpMessagingTemplate 이용
4. SimpMessagingTemplate
WebSocket에서 서버가 클라이언트에게 실시간 메시지를 전송할 때 사용하는 class이다. 내부 함수로는 convertAndSend()가 존재하여, 특정 채널을 구독하고 있는 사용자 모두에게 메시지를 전송할 수 있게 한다.
- Outbound에서는 Inbound의 interceptor를 사용하지 않았다. Inbound의 경우, 들어오는 메시지와 해당 메시지를 보내도 되는지 보안 로직을 검토한 후에 서버로 전송해야 하므로 custom interceptor를 구현했고, outbound의 경우 이미 인증된 사용자의 메시지 이므로, 메시지 전송 지점이기 때문에 기존에 우리 서비스에서 구현했던 보안 로직 위주의 interceptor를 사용하지 않았다(별도의 outbound interceptor를 둘 경우 클라이언트로 나가는 메시지를 최종 검증 및 로그 기록을 위해 가로챌 수 있다).
SimpMessagingTemplate를 쓰면 가장 편리한 점은, DTO 객체를 넣으면 자동으로 JSON으로 변환되어 프론트 쪽에서 표준화된 형태를 수신받고 필요시 원하는 방식으로 가공할 수 있어 협업이 원활해질 것이라 판단했다.
이외에도 여러가지 방법이 있는데,
- @SendTo 어노테이션 : @MessageMapping 컨트롤러 위해 해당 어노테이션을 붙여, 특정 채널을 구독중인 클라이언트들에게 메시지를 브로드캐스트 한다.
- 외부 메시지 브로커 연동 : 주로 멀티 pod 환경에서, 서버 간 메시지 공유가 필요할 때 사용하는 방법이다.
와 같은 방법들이 존재한다.
@SendTo의 경우, 컨트롤러에 메시지 전송이 묶이는 만큼 전송 시점/여러 목적지 등의 확장으로 고려하고 있을때 불편하다고 판단했고, 해당하는 service 계층에서 message를 DB에 저장하고 있으므로 해당 계층에서 DB 저장 및 브로드캐스트까지 처리하는 구조가 설계상 적절하다고 판단했다.
이용자수가 천명 단위일 것으로 예상되어 단일 pod 환경을 전제로 하였으므로 외부 메시지 브로커를 선택하지 않았다.
SimpMessagingTemplate의 경우, 원한다면 특정 사용자에게도 메시지를 보내는 기능을 보유하여 추후 관리자 페이지 등의 서비스 확장에도 대응할 수 있으며 복잡한 웹소켓 로직을 별도로 구현할 필요 없이 Spring이 제공해주는 기능을 통해 간결하게 구현이 가능하여 해당 방법을 선택했다.
WebSocket을 통한 양방향 실시간 메시지 전송 기술을 경험해보고 나니, WebSocket은 단순히 실시간 통신을 가능하게 하는 것을 넘어 내부에 메시지 전송 흐름(방향)과 책임 분리 설계에 대해 대략적으로 알게 되었으며, Spring에서 이를 어떻게 구조화하는지 파악할 수 있었다.
이후 서비스 확장 시 별도 메시지 브로커에 대해서도 공부하고 적용해 보아야겠다.