HTTP vs WebSocket?
- HTTP는 클라이언트가 서버로 Request를 보내고, 서버가 Response를 주는 요청-응답 구조이다.
- 반면 WebSocket은 클라이언트-서버 간의 실시간 양방향 통신이 가능한 통신 프로토콜이다.
- 즉, 클라이언트의 Request 없이도 서버가 클라이언트로 정보를 실시간으로 보내줄 수 있다. 어떻게?
- 서버는 클라이언트와의 연결 정보를 알아야만 Response(또는 메시지)를 보낼 수 있다. 웹소켓에서는 클라이언트와 서버가 최초 한 번만 연결을 맺으면, 서버가 그 클라이언트에 대한 연결 정보를 메모리 상에 유지한다. 그래서 매번 클라이언트의 Request 없이도, 그 연결(세션)을 이용해 메시지를 역방향으로 보낼 수 있는 것이다.
- HTTP와 WebSocket 모두 OSI 7계층에 위치하며, 동시에 TCP (4계층) 위에서 동작한다.
- 클라이언트에서 최초 연결 시 HTTP와 마찬가지로 3 way-handshake를 통해 양방향 연결을 맺고, 바로 연결을 끊어버리는 HTTP와는 다르게 쭉 이 연결된 파이프라인을 통해 메시지를 양방향으로 주고받는다.
- HTTP는 매번 클라이언트->서버로 요청을 보낼 때마다 새로 TCP 연결을 시도하므로, 지속적 통신에 있어서 성능 저하가 생기고 서버에 부하가 간다.
- WebSocket은 최초 한 번만 연결을 맺으면 계속 TCP 연결을 유지하면서, HTTP 형태가 아닌 간결한 메시지 포맷으로 양방향 통신을 하기 때문에 지속적 통신에 있어서 성능이 더 좋고, 서버 부담도 줄어든다.
- 또한, 웹소켓도 최초 연결 시 HTTP 헤더를 통해 토큰 같은 인증 정보를 보낼 수 있어서, 인증이 필요한 경우 이를 활용할 수 있다.
트러블 슈팅
저희 프로젝트는 JWT 기반 인증 시스템을 사용하고 있습니다. 보통 HTTP 요청의 경우, 클라이언트는 Authorization 헤더에 JWT를 담아서 보내고, 서버는 필터체인을 통해 이 토큰을 검증합니다. 그런데 웹소켓 통신으로 업그레이드되면 문제가 생깁니다. 웹소켓 연결은 초기 핸드셰이크가 HTTP 요청으로 딱 한번 이루어지는데, 이때는 JWT를 보낼 수 있지만, 연결이 성립되고 나면 이후 통신은 STOMP 프로토콜을 사용하게 됩니다.
문제는, STOMP 메시지에서는 HTTP 헤더가 아니라 별도의 STOMP 헤더를 사용한다는 것입니다. 그래서 HTTP 필터체인이 JWT를 검증했던 방식으로는 후속 메시지에 담긴 JWT를 확인할 수가 없습니다. 초기 핸드셰이크 단계에서 HandshakeInterceptor를 사용해 JWT를 검증하려고 했지만, 이 인터셉터는 오직 연결 초기 단계에서만 동작하기 때문에, 실제 연결이 수립되고 난 후에는 JWT 검증이 이루어지지 않는 문제가 있었습니다.
핵심은 바로 이 부분입니다. HandshakeInterceptor는 아래와 같은 메서드를 가지고 있습니다.
public interface HandshakeInterceptor {
boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception;
void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, @Nullable Exception exception);
}
이 인터셉터는 오직 연결 핸드셰이크 시점에만 호출되므로, 이후 전송되는 STOMP 메시지에는 적용되지 않습니다.
반면, ChannelInterceptor는 다음과 같이 STOMP 메시지가 컨트롤러에 도달하기 전, 즉 모든 메시지 프레임이 처리되기 전에 동작합니다.
public interface ChannelInterceptor {
@Nullable
default Message<?> preSend(Message<?> message, MessageChannel channel) { return message; }
// 나머지 메서드들...
}
구현 방식:
웹소켓 연결이 성립된 후, 클라이언트가 전송하는 모든 STOMP 메시지에는 HTTP 헤더 대신 STOMP 헤더가 포함됩니다. 이때, ChannelInterceptor를 구현하여, STOMP CONNECT 프레임이 도착할 때 Authorization 헤더에서 JWT를 추출하고, 해당 토큰의 유효성을 검사합니다. 유효한 경우, 추출한 사용자 정보를 기반으로 Spring Security의 인증 객체(UsernamePasswordAuthenticationToken)를 생성하여 WebSocket 세션에 저장합니다. 이렇게 하면 연결 이후 전송되는 모든 메시지에서 일관된 인증 정보를 사용할 수 있게 됩니다.
핸드셰이크 vs. 메시지 레벨:
HandshakeInterceptor는 웹소켓 연결을 위한 초기 HTTP 업그레이드 요청(핸드셰이크) 시에만 동작합니다. 그러나 이 초기 요청 단계에서는 보안 정책이나 프록시 때문에 Authorization 헤더가 누락될 수 있습니다. 반면, ChannelInterceptor는 웹소켓 연결이 완전히 성립된 후, STOMP 프로토콜을 통해 전송되는 CONNECT, SEND 등의 모든 메시지 프레임에 대해 동작하므로, 이 단계에서는 STOMP 헤더에 포함된 Authorization 값을 안정적으로 확인할 수 있습니다.
메시지 래핑:
ChannelInterceptor의 preSend 메서드에서는 수신한 메시지를 StompHeaderAccessor로 래핑합니다. 이를 통해 STOMP 프레임 내의 헤더 정보에 쉽게 접근할 수 있습니다. 예를 들어, StompHeaderAccessor.getNativeHeader(“Authorization”)를 호출하면, 클라이언트가 STOMP CONNECT 프레임에 담아 전송한 JWT 토큰을 추출할 수 있습니다. 이 과정을 통해 JWT를 검증하고, 사용자 정보를 세션에 저장하는 작업을 수행할 수 있습니다.
즉, 처음 웹소켓 연결 시 HandshakeInterceptor로는 JWT 토큰을 제대로 받지 못하는 한계가 있었지만, ChannelInterceptor에서는 STOMP 프로토콜 내의 헤더를 직접 다룰 수 있기 때문에, 연결이 성립된 이후에도 계속해서 JWT를 검증할 수 있게 된 것입니다. 이렇게 하면 후속 메시지에서도 안정적으로 사용자 인증 정보를 유지할 수 있어, 웹소켓 통신에서도 일관된 보안을 구현할 수 있습니다.
WebSocketConfig 클래스에서는 아래와 같이 ChannelInterceptor 구현체를 등록했습니다.
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final ChatPreHandler chatPreHandler;
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(chatPreHandler);
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic"); // 클라이언트가 구독할 대상
registry.setApplicationDestinationPrefixes("/app"); // 클라이언트가 메시지를 보낼 때 사용
}
}
테스트 채팅 구현 화면
현재 프로젝트에서는 메시지 브로커와 메시지 큐를 Spring Boot 내부 메모리에 구축된 SimpleBroker를 활용하여 관리하고 있습니다. 이 방식은 개발 환경이나 소규모 애플리케이션에서는 간편하게 사용할 수 있습니다. 다만, 웹소켓 서버가 N개로 확장될 때, 각 서버가 개별적으로 내부 메모리 리소스를 사용하게 되어, 동일한 토픽에 대한 메시지 공유가 어려워지는 문제가 발생하게 됩니다.
이러한 문제를 해결하기 위해서는 외부 메시지 브로커를 사용해야 합니다. 외부 브로커를 도입하게 되면, 모든 서버가 중앙에서 토픽과 구독자 정보를 전역적으로 관리할 수 있으므로, 어느 서버에 연결된 클라이언트라도 동일한 토픽의 메시지를 수신할 수 있게 됩니다.
고려해볼 만한 외부 브로커 소프트웨어로는 RabbitMQ, Redis, Kafka 등이 있으며, 이러한 솔루션을 도입할 경우 확장성과 안정성 측면에서 많은 이점을 얻을 수 있습니다.
이번 프로젝트에서는 외부 브로커 대신 내부 SimpleBroker를 활용하여 내부 프로세스 리소스를 사용했는데 나중엔 Redis, RabbitMQ 같은 외부 브로커를 활용하여 다양한 시도를 해보고 싶습니다.
'개발일지' 카테고리의 다른 글
[SnowTaxi] 이메일 인증 로직과 비동기-멀티스레딩 (0) | 2024.01.28 |
---|