[Spring] WebSocket으로 채팅 구현하기 - 인터셉터 설정 및 인증 처리

김강욱·2024년 5월 14일
0

Spring

목록 보기
13/17
post-thumbnail

이번 포스팅에서는 인터셉터 설정을 통한 인증 및 메시지 전처리에 대해서 알아보도록 하겠습니다.

우선 WebSocketSTOMP의 관계, WebSocket과 스프링 시큐리티 필터와의 관계에 대해서 간단하게 알아보고 인터셉터 설정을 해보도록 하겠습니다.

😁 WebSocket과 STOMP의 관계

먼저 WebSocketStomp에 대해서 살펴보도록 하겠습니다.

WebSocket은 데이터를 전송하는 방법(전송계층)을 제공하는 역할을 하지만 STOMP는 데이터가 어떻게 구성되고, 어떻게 처리되어야 하는지에(응용 계층) 대한 규칙과 형식을 제공합니다.

STOMPWebSocket 위에서 동작할 수 있는 프로토콜입니다. STOMP를 사용함으로써, WebSocket 연결을 통해서 전달되는 메시지를 보다 구조화된 방식으로 주고받을 수 있게 됩니다.

STOMP를 이용한 통신의 흐름은 다음과 같습니다.

  1. 발신자는 STOMP 프로토콜을 사용하여 메시지의 형식을 맞추고, 이 메시지를 WebSocket 연결을 통해 전달합니다.

  2. 수신자는 WebSocket을 통해 해당 메시지를 수신하게 되는데, 메시지 내용은 STOMP 프로토콜을 따르는 형식으로 구성되어있습니다.

STOMP는 메시지를 구독하고 발행하며, 헤더를 통해 추가적인 정보를 전달할 수 있는 기능 등을 제공하여 메시지 기반 통신을 보다 풍부하고 유연하게 만들어주는 역할을 합니다.

WebSocket은 이러한 메시지가 실시간으로 효율적으로 전송될 수 있는 통로를 제공해줍니다.



😁 WebSocket과 스프링 시큐리티 필터

이번에는 WebSocket과 스프링 시큐리티 필터의 관계를 살펴보도록 하겠습니다.

WebSocket 통신을 연결할 때 HTTP 요청을 통해 초기 WebSocket Handshaking이 이루어집니다. 해당 과정에서 스프링 시큐리티 필터를 거치게 됩니다.

초기 Handshaking 단계에서 사용자 인증과 권한 부여를 수행할 수 있고, 이를 통해 WebSocket 연결 전에 사용자가 인증되고 적절한 권한을 가지고 있는지 검증이 가능합니다.

하지만 WebSocket으로 전달된 헤더 정보를 스프링 시큐리티에서 사용할 수 없습니다.

사용자 인증 정보(토큰 등)을 헤더 정보에 담아서 보낼 때는 HandshakeInterceptor라는 인터셉터를 통해서 handshake 요청을 가로채서 헤더 정보를 꺼내와서 인증 처리를 해줄 수 있습니다.



😁 인터셉터 설정을 통한 인증 처리 적용해보기

스프링 프레임워크에서 WebSocketSTOMP를 사용할 때 이용할 수 있는 인터셉터는 두 가지 종류가 있습니다.

1. HandshakeInterceptor

HandshakeInterceptorWebSocket 초기 연결의 handshake 과정에서 동작하는 인터셉터입니다.

클라이언트가 WebSocket 연결을 시도할 때 초기 HTTP 요청을 가로채어 추가적인 검사나 수정을 할 수 있습니다. 예를 들어, 사용자 인증을 검사하거나, HTTP 헤더를 수정하거나 추가하는 등의 작업을 할 수 있습니다.

또한 handshake 단계에서 연결을 거부할 수 있는 기능도 제공합니다. 이를 통해 사용자가 조건을 만족하지 못할 경우 WebSocket 연결 자체를 막을 수 있습니다.

Spring HandShakeInterceptor 예시 코드

// HandShakeInterceptor 생성
@Slf4j
public class HttpHandshakeInterceptor implements HandshakeInterceptor {

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        // 여기서 request 헤더 또는 파라미터에서 인증 정보를 추출하고 검증할 수 있습니다.
        return true; // 인증에 성공하면 true를 반환하여 연결을 계속 진행합니다.
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
        // 핸드셰이크 후 처리
    }
}

// 인터셉터 등록해주기 
@RequiredArgsConstructor
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final StompHandler stompHandler;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/sub");
        registry.setApplicationDestinationPrefixes("/pub");
    }

    **@Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        System.out.println("WebSocket endpoint registered");
        registry.addEndpoint("/ws-stomp").setAllowedOriginPatterns("*").withSockJS().setInterceptors(new HttpHandshakeInterceptor());
    }**

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompHandler);
    }
}


2. ChannelInterceptor

ChannelInterceptorWebSocket 연결이 성공한 후, 실제 메시지가 송수신될 때 동작하는 인터셉터입니다.

메시지를 보내거나 받을 때 이를 가로채어 메시지의 내용을 수정하거나 검증하는 등의 처리를 할 수 있습니다. 예를 들어, 특정 사용자의 메시지만 필터링하거나, 메시지 내용에 따라 다른 동작을 수행하게 할 수 있습니다.

WebSocketConfig에 StompHandler 인터셉터 설정

@RequiredArgsConstructor
@Configuration
@EnableWebSocketMessageBroker
public class WebSockConfig implements WebSocketMessageBrokerConfigurer {

    private final StompHandler stompHandler;

    // 내용 생략 .......

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompHandler);
    }
}

ChannelInterceptor 코드 예시

package com.websocket.chat.config.handler;

// import ... 생략

@Slf4j
@RequiredArgsConstructor
@Component
public class StompHandler implements ChannelInterceptor {

    private final JwtTokenProvider jwtTokenProvider;

    // websocket을 통해 들어온 요청이 처리 되기전 실행된다.
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        if (StompCommand.CONNECT == accessor.getCommand()) { // websocket 연결요청
            String hi = accessor.getFirstNativeHeader("hi");
            String bye = accessor.getFirstNativeHeader("bye");
            log.info("CONNECT {}", hi);
            log.info("CONNECT {}", bye);
        } else if (StompCommand.SUBSCRIBE == accessor.getCommand()) { // 채팅룸 구독요청
            System.out.println("message: ");
            System.out.println(message);
            // header정보에서 구독 destination정보를 얻고, roomId를 추출한다.
            String roomId = chatService.getRoomId(Optional.ofNullable((String) message.getHeaders().get("simpDestination")).orElse("InvalidRoomId"));
            // 채팅방에 들어온 클라이언트 sessionId를 roomId와 맵핑해 놓는다.(나중에 특정 세션이 어떤 채팅방에 들어가 있는지 알기 위함)
            String sessionId = (String) message.getHeaders().get("simpSessionId");
            chatRoomRepository.setUserEnterInfo(sessionId, roomId);
            // 채팅방의 인원수를 +1한다.
            chatRoomRepository.plusUserCount(roomId);
            // 클라이언트 입장 메시지를 채팅방에 발송한다.(redis publish)
            String name = Optional.ofNullable((Principal) message.getHeaders().get("simpUser")).map(Principal::getName).orElse("UnknownUser");
            chatService.sendChatMessage(ChatMessage.builder().type(ChatMessage.MessageType.ENTER).roomId(roomId).sender(name).build());
            log.info("SUBSCRIBED {}, {}", name, roomId);
        } else if (StompCommand.DISCONNECT == accessor.getCommand()) { // Websocket 연결 종료
            // 연결이 종료된 클라이언트 sesssionId로 채팅방 id를 얻는다.
            String sessionId = (String) message.getHeaders().get("simpSessionId");
            String roomId = chatRoomRepository.getUserEnterRoomId(sessionId);
            // 채팅방의 인원수를 -1한다.
            chatRoomRepository.minusUserCount(roomId);
            // 클라이언트 퇴장 메시지를 채팅방에 발송한다.(redis publish)
            String name = Optional.ofNullable((Principal) message.getHeaders().get("simpUser")).map(Principal::getName).orElse("UnknownUser");
            chatService.sendChatMessage(ChatMessage.builder().type(ChatMessage.MessageType.QUIT).roomId(roomId).sender(name).build());
            // 퇴장한 클라이언트의 roomId 맵핑 정보를 삭제한다.
            chatRoomRepository.removeUserEnterInfo(sessionId);
            log.info("DISCONNECTED {}, {}", sessionId, roomId);
        }
        return message;
    }
}

위의 코드에서는 StompCommand 상태에 따라 전처리 작업을 하도록 분기 처리를 하는 로직입니다.

WebSocket 연결이 성립된 후의 메시지 교환 과정에서는 HTTP 요청과는 다르게 스프링 시큐리티 필터 체인을 거치지 않습니다.

대신 스프링에서 인터셉터(Interceptors) 또는 채널 인터셉터(Channel Interceptors)를 사용하여 WebSocket 메시지에 대한 보안 로직 및 추가 작업을 진행할 수 있습니다.

profile
TO BE DEVELOPER

0개의 댓글

관련 채용 정보