SY 개발일지

 프로젝트를 진행하면서 WebSocket과 STOMP를 이용해서 채팅기능을 구현하였습니다. 특히 REST API를 이용하여 JWT토큰으로 사용자 인가 처리를 하기 때문에 WebSocket에서도 이를 사용하기 위해 Spring Security와 결합하였습니다. 

 

WebSocketConfig.java 설정

우선 WebSocket과 STOMP를 함께 쓰기 위해서 스프링에서는 WebSocketMessageBrokerConfigurer 인터페이스를 구현해야 합니다.

@Order(Ordered.HIGHEST_PRECEDENCE + 99)
@Configuration
@EnableWebSocketMessageBroker // 웹소켓 메세지 핸들링 활성화
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { // websocket + stomp

    private final WebSocketSecurityInterceptor webSocketSecurityInterceptor;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/sub/**"); // 해당 주소를 구독하고 있는 클라이언트들에게 메세지 전달
        registry.setApplicationDestinationPrefixes("/pub"); // 클라이언트에서 보낸 메세지를 받을 prefix
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws") // SockJS 연결주소
                .setAllowedOriginPatterns("*") // 일단 모든 경로에 대해서 CORS 허용
        ;
    }

    @Bean
    public CorsWebFilter corsWebFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOriginPattern("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        source.registerCorsConfiguration("/**", config);
        return new CorsWebFilter(source);
    }

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

}

 

각 메서드를 하나씩 알아보겠습니다.

configureMessageBroker 메서드

configurerMessageBroker에서는 어느 곳으로 메세지를 전달하고 받을 것인지를 지정해줍니다.

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

 

enableSimpleBroker는 해당 주소를 구독하고 있는 클라이언트들에게 메세지를 전달하는 역할을 합니다.

즉, 예를들어 /sub/{roomId} 라고 할 때, 해당 주소를 구독하는 모든 클라이언트들에게 메세지를 보내게 됩니다.

 

setApplicationDestinationPrefixes는 클라이언트에서 보낸 메세지를 받을 prefix를 지정합니다. 이 prefix는 컨트롤러에서 메세지를 받을 때 사용합니다.

 

registerStompEndpoints 메서드

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/ws") // SockJS 연결주소
            .setAllowedOriginPatterns("*") // 일단 모든 경로에 대해서 CORS 허용
              .withSockJS() // 낮은 버전의 브라우저에서도 적용 가능
    // 주소: ws:localhost:8080/ws-stomp
    ;
}

 

이 메서드에서 endPoint로 지정된 주소로 소켓이 연결을 시도합니다.

 

configureClientInboundChannel 메서드

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

 

configureClientInboundChannel 에서는 웹소켓이 요청을 보내면 지정된 인터셉터가 요청을 낚아채 처리합니다. 이 작업은 우선순위를 높여 처리해야 하기 때문에 해당 클래스에 @Order(Ordered.HIGHEST_PRECEDENCE+99) 어노테이션을 붙입니다.

 

WebSocketSecurityInterceptor

해당 인터셉터에서 토큰의 유효성을 검사합니다. 이곳에서 UsernamePasswordAuthenticationToken을 구현하여 accessor의 user에 지정을 해두면 authorities가 설정이 되어 해당 authorities로 스프링 시큐리티에서 롤을 확인하여 처리합니다. 저희 프로젝트에서는 가게 회원만 채팅이 가능하기 때문에 role을 STORE로 한정지어 작성하였습니다.

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

    private final TokenProvider tokenProvider;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        log.info("token 유효성 확인");
        StompHeaderAccessor accessor =
                MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        // websocket 연결시 헤더의 jwt token 유효성 검증
        if (StompCommand.CONNECT == accessor.getCommand()) {
            String accessToken = accessor.getFirstNativeHeader("Authorization");
            // 토큰 검증
            if (StringUtils.hasText(accessToken) && accessToken.startsWith("Bearer ")) {
                String jwtToken = accessToken.substring(7); // "Bearer " 이후의 토큰 문자열 추출
                try {
                    // 검증 성공한 경우 Authentication 객체 생성
                    Authentication authentication = tokenProvider.getAuthentication(jwtToken);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                    accessor.setUser(authentication); // accessor에 등록
                } catch (Exception e) {
                    // 검증 실패 시 에러 로깅 등을 처리할 수 있음
                    log.error("WebSocket Connection Authentication Error: {}", e.getMessage());
                    throw new AccessDeniedException("Access Denied");
                }
            } else {
                // 토큰이 올바르지 않은 경우
                log.error("WebSocket Connection Authentication Error: Invalid Token Format");
                throw new AccessDeniedException("Access Denied");
            }
        }
        return message;
    }
}

 

해당 코드를, 

StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

 

다음과 같이 바꾸면

StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);

회원 롤을 체크하지 못합니다. 이 코드는 새로운 메세지를 생성하는 기능과 비슷하기 때문에, 현재 있는 메세지만을 바라봐야할 때에는 getAccessor를 이용해 가져와야 합니다. 

 

WebSocketSecurityConfig

@EnableWebSocketSecurity 어노테이션을 통해 Spring Security에서 관리하도록 해줍니다.

@Configuration
@EnableWebSocketSecurity
public class WebSocketSecurityConfig {

    @Bean
    public AuthorizationManager<Message<?>> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) {
        return messages
                .simpTypeMatchers(SimpMessageType.CONNECT).hasAuthority("STORE") // 가게 유저만 메세지 주고받기 가능. Connect 때 됏으면 그 후로는 체크 안해도 됨
                .simpDestMatchers("/ws/**", "/pub/**", "/sub/**").permitAll()
                .anyMessage().denyAll()
                .build();
    }

    @Bean("csrfChannelInterceptor") // for disable csrf
    public ChannelInterceptor csrfChannelInterceptor() {
        return new ChannelInterceptor() {
        };
    }

}

 

messageAuthorizationManager

@Bean
public AuthorizationManager<Message<?>> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) {
    return messages
            .simpTypeMatchers(SimpMessageType.CONNECT).hasAuthority("STORE") // 가게 유저만 메세지 주고받기 가능. Connect 때 됏으면 그 후로는 체크 안해도 됨
            .simpDestMatchers("/ws/**", "/pub/**", "/sub/**").permitAll()
            .anyMessage().denyAll()
            .build();
}

해당 빈은 메세지의 권한을 관리하는 빈입니다.

저희 프로젝트에서는 가게 회원만 메세지를 주고받을 수 있기 때문에 STORE로 한정지어주었습니다.

특히, CONNECT가 되었다는 것은 가게 회원임을 전제로 하기 때문에 메세지를 주고 받을 때에는 권한체크를 하지 않았습니다.

 

csrfChannelInterceptor

@Bean("csrfChannelInterceptor") // for disable csrf
public ChannelInterceptor csrfChannelInterceptor() {
    return new ChannelInterceptor() {
    };
}

해당 빈은 WebSocket에서 CSRF 를 확인하지 않게 하기 위해 작성한 코드입니다.

 

이제, WebSocket과 Spring Security, 그리고 STOMP의 연결 설정이 끝났습니다. 이제 컨트롤러에서 어떻게 사용하면 좋을지에 대해 말씀드리겠습니다.

ChatWebSocketController

@Controller
@RequiredArgsConstructor
@CrossOrigin("*")
public class ChatWebSocketController {

    private final ChatRoomService chatRoomService;
    private final ChatService chatService;
    private final SimpMessagingTemplate simpMessagingTemplate;

    @MessageMapping("/chat/{roomId}") // 여기로 전송되면 메서드 호출 -> websocketConfig prefixes에서 적용한건(pub) 앞에 생략
    public void chat(Authentication authentication,  @DestinationVariable Long roomId, @Payload ChatMessageReq message) {
        Integer stoId = Integer.parseInt(authentication.getName());
        ChatMessageRes res = chatService.addChat(stoId, roomId, message);
        MessageHeaders headers = new MessageHeaders(Collections.singletonMap(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON_VALUE));
        simpMessagingTemplate.convertAndSend("/sub/chat/" + roomId, res, headers);
    }

}

 

우선 @Controller 어노테이션을 사용합니다. 각각의 메세지 목적지는 @MessageMapping으로 이어지는데, 이전에 지정한 /prefix/~ 이후의 것을 작성하면 됩니다.

 

받은 데이터는 비지니스로직을 통해 처리한 후, simpMessgingTemplate.convertAndSend 메서드를 통해 다른 클라이언트들에게 보내지는데, 첫번재 파라미터로 구독 경로를 입력하면 됩니다.. 그러면 해당 경로를 구독하고 있는 사용자들에게 메세지가 전달되게 됩니다. 이후 작업은 프론트에서 하면 됩니다.

 

 

이렇게 Spring에서 WebSocket을 사용하는 방법 및 Spring Security를 연결하는 방법에 대해 알아보았습니다. 다음엔 해당 프로젝트에서 이렇게 사용한 WebSocket 통신 방법 및 STOMP에 대해 알아보도록 하겠습니다.

profile

SY 개발일지

@SY 키키

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!