Search
🚀

Stomp 를 사용하여 실시간 서비스 개발하기

태그
spring boot 3
spring security
stomp
분류
Spring Boot
목차
같이 보면 좋은 글

WebSocket Stomp 기본 설정

build.gradle 에 웹소켓 dependency 를 추가했다고 가정하고 진행합니다.
spring boot 버전은 3 입니다.

WebSocketSecurityConfiguration.java

이곳에서는 웹소켓의 엔드포인트와 발행과 구독 주소에 붙는 prefix 를 설정합니다.
@Configuration @EnableWebSocketMessageBroker @RequiredArgsConstructor public class WebSocketSecurityConfiguration implements WebSocketMessageBrokerConfigurer { private final CustomHandshakeHandler customHandshakeHandler; @Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker("/sub"); config.setApplicationDestinationPrefixes("/pub"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry .addEndpoint("/ws") .setAllowedOriginPatterns("*") .withSockJS(); } }
Java
복사
위와 같이 설정 파일을 작성한 후,
http://localhost:8080/ws 에 접속하면 웹소켓 연결이 시작됩니다.
발행 시 맨 앞에 /pub 가 자동으로 붙습니다.
구독 시 맨 앞에 /sub 가 자동으로 붙습니다.

MessageSecurityConfiguration.java

이곳에서는 메세지 전송에 대한 인증과 csrf 를 설정합니다.

deprecated - 스프링부트 버전에 따른 설정코드 변화

스프링 버전이 올라가면서 이전에 아래와 같이 웹소켓 설정을 작성할 경우 AbstractSecurityWebSocketMessageBrokerConfigurer 가 deprecated 되었다는 문구가 나타납니다.
import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.messaging.MessageSecurityMetadataSourceRegistry; import org.springframework.security.config.annotation.web.socket.AbstractSecurityWebSocketMessageBrokerConfigurer; @Configuration public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer { @Override protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) { messages .nullDestMatcher().permitAll() .simpDestMatchers("/pub/**").permitAll() .simpSubscribeDestMatchers("/sub/**").permitAll() .anyMessage().denyAll(); } @Override protected boolean sameOriginDisabled() { return true; // CSRF 보호 비활성화 } }
Java
복사
그렇다면 어떻게 수정해야 할까요? Security Configuration 처럼 @Bean 을 사용하여 아래와 같이 작성합니다.
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.Message; import org.springframework.messaging.simp.SimpMessageType; import org.springframework.messaging.support.ChannelInterceptor; import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity; import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; @Configuration @EnableWebSocketSecurity public class MessageSecurityConfiguration { @Bean AuthorizationManager<Message<?>> authorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) { messages .nullDestMatcher().permitAll() .simpTypeMatchers(SimpMessageType.CONNECT).permitAll() .simpTypeMatchers(SimpMessageType.SUBSCRIBE).permitAll() .simpTypeMatchers(SimpMessageType.MESSAGE).permitAll() .anyMessage().denyAll(); return messages.build(); } @Bean("csrfChannelInterceptor") public ChannelInterceptor csrfChannelInterceptor() { return new ChannelInterceptor() { }; } }
Java
복사
위와 같이 코드를 작성할 경우,
nullDestMatcher().permitAll()
목적지가 없는 메시지에 대해 모든 사용자에게 접근을 허용합니다.
simpTypeMatchers(SimpMessageType.CONNECT).permitAll()
CONNECT 유형의 메시지에 대해 모든 사용자에게 접근을 허용합니다.
simpTypeMatchers(SimpMessageType.SUBSCRIBE).permitAll()
SUBSCRIBE 유형의 메시지에 대해 모든 사용자에게 접근을 허용합니다.
simpTypeMatchers(SimpMessageType.MESSAGE).permitAll()
MESSAGE 유형의 메시지에 대해 모든 사용자에게 접근을 허용합니다.
anyMessage().denyAll()
위에서 명시적으로 허용한 메시지 유형을 제외한 모든 메시지에 대해서는 접근을 거부합니다.

WebSocket 관련 사용자 인증/인가는 어떻게 해야 할까?

저의 프로젝트의 경우 유저가 jwt 토큰을 넣어 요청을 보내면 jwt 인증 필터 → 시큐리티 필터를 거쳐 유저가 인증이 되고 요청한 리소스를 받는 인가과정을 거치게 됩니다. 하지만 jwt 인증 필터와 시큐리티 필터에서 http://localhost:8080/ws 요청 또한 인증 절차를 거칠 수 있을까요?

일반 요청과 websocket 요청의 구조

일반 http 요청의 경우 토큰을 넣어 요청을 보내면 jwt 인증 필터에서 토큰을 검증하고, 유효한 경우 시큐리티 필터를 거쳐 유저 인증을 마칩니다. 그리고 후에 요청한 리소스를 응답으로 보내주게 되죠. 하지만 웹소켓의 경우 이런식으로 요청 → 응답 → 연결 해제 가 반복되는 구조가 아닙니다. 아래와 같이 한번 연결 된후 특정 주소를 구독할 경우, 해당 주소로 발행된 메세지를 지속적으로 받을 수 있습니다.

첫번째 생각한 방식

웹소켓 과정에서 인터셉터를 만들어서 인증 / 구독 / 발행 과정에서 인증과정을 거칠까? 하는 생각을 했습니다. 그래서 아래와 같이 인터셉터를 작성하고 등록해주었습니다.
import com.planner.travel.global.jwt.token.TokenAuthenticator; import com.planner.travel.global.jwt.token.TokenValidator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; import org.springframework.messaging.simp.SimpMessageType; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.messaging.support.ChannelInterceptor; import org.springframework.stereotype.Component; @Slf4j @RequiredArgsConstructor @Component @Order(Ordered.HIGHEST_PRECEDENCE + 99) public class WebSocketInterceptor implements ChannelInterceptor { private final TokenAuthenticator tokenAuthenticator; @Override public Message<?> preSend(Message<?> message, MessageChannel channel) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); String accessToken = accessor.getFirstNativeHeader("Authorization"); log.info("==========================================================================="); log.info("Received STOMP Message: " + message); log.info("All headers: " + accessor.toNativeHeaderMap()); log.info("Access Token: " + accessToken); log.info("Incoming message type: " + accessor.getMessageType()); log.info("==========================================================================="); if (SimpMessageType.CONNECT.equals(accessor.getMessageType()) || SimpMessageType.MESSAGE.equals(accessor.getMessageType()) || SimpMessageType.SUBSCRIBE.equals(accessor.getMessageType())) { log.info("==========================================================================="); log.info("accessor: " + accessor.getMessageType()); log.info("==========================================================================="); if (accessToken != null && accessToken.startsWith("Bearer ")) { tokenAuthenticator.getAuthenticationUsingToken(accessToken); accessor.getSessionAttributes().put("Authorization", accessToken); } else { log.error("Invalid or missing Authorization header"); throw new IllegalArgumentException("Invalid or missing Authorization header"); } } return message; } }
Java
복사
하지만 이렇게 작성한 경우 매 헤더에 아래와 같이 accessToken 이 그대로 노출되었습니다.
CONNECT accept-version:1.1,1.0 heart-beat:10000,10000 authorization:Bearer some_valid_token
Plain Text
복사
헤더가 있다는 것은 편하고 좋지만, 과연 이게 보안적으로 옳을까? 하는 생각이 들었습니다. 연결 / 구독 / 발행 때마다 토큰 인증을 거치 면서 인증정보의 노출 빈도가 생각보다 높을 것 같았기 때문입니다.

두번째 생각한 방식 (채택 )

웹소켓 연결 과정은 다음과 같습니다.
1.
http://localhost:8080/ws?token={token} 엔드포인트로 GET 요청(handshake 요청) 을 합니다.
서버에서는 이 요청을 핸드셰이크 핸들러(HandshakeHandler)가 처리합니다.
2.
서버는 클라이언트의 핸드셰이크 요청을 검증합니다.
3.
요청이 유효하면 서버는 HTTP 상태 코드 101(Switching Protocols)을 보내며, 웹소켓 연결로 변경 됩니다.
4.
클라이언트와 서버는 WebSocket 연결을 통해 메시지를 주고받을 수 있게 됩니다.
이 방식을 구현하기 위해서 아래와 같이 HandshakeHanlder 를 custom 해주었습니다.

CustomHandshakeHandler.java

import com.planner.travel.global.jwt.token.TokenAuthenticator; import com.planner.travel.global.jwt.token.TokenExtractor; import com.planner.travel.global.jwt.token.TokenValidator; import io.jsonwebtoken.ExpiredJwtException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.server.HandshakeFailureException; import org.springframework.web.socket.server.HandshakeHandler; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Map; @Slf4j @RequiredArgsConstructor @Component public class CustomHandshakeHandler implements HandshakeHandler { private final TokenExtractor tokenExtractor; private final TokenAuthenticator tokenAuthenticator; private final TokenValidator tokenValidator; @Override public boolean doHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws HandshakeFailureException { try { String accessToken = tokenExtractor.getAccessTokenFromRequest(request); tokenValidator.validateAccessToken(accessToken); tokenAuthenticator.getAuthenticationUsingToken(accessToken); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); log.info("==========================================================================="); log.info("Authentication: " + authentication.toString()); log.info("==========================================================================="); } catch (ExpiredJwtException | InsufficientAuthenticationException exception) { try { setResponse(response, HttpStatus.UNAUTHORIZED, "TOKEN_01"); } catch (IOException e) { throw new RuntimeException(e); } return false; } return true; } private void setResponse(ServerHttpResponse response, HttpStatus status, String errorCode) throws IOException { response.setStatusCode(status); response.getHeaders().add("Content-Type", "application/json"); String errorMessage = "{\"errorCode\": \"" + errorCode + "\"}"; response.getBody().write(errorMessage.getBytes(StandardCharsets.UTF_8)); response.getBody().flush(); } }
Java
복사
코드를 자세히 볼까요? 로직의 순서는 다음과 같습니다.
헤더에서 어세스 토큰을 꺼내옵니다.
String accessToken = tokenExtractor.getAccessTokenFromRequest(request);
Java
복사
TokenExtractor.java
어세스 토큰을 검증 합니다.
tokenValidator.validateAccessToken(accessToken);
Java
복사
TokenValidator.java
유저 정보를 꺼낸 후 시큐리티 에게 넘겨줍니다.
tokenAuthenticator.getAuthenticationUsingToken(accessToken);
Java
복사
TokenAuthenticator.java
유저 정보가 제대로 들어왔는지 확인합니다.
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); log.info("==========================================================================="); log.info("Authentication: " + authentication.toString()); log.info("===========================================================================");
Java
복사

만약 이 과정에서 에러가 터지면 어떻게 하나요?

이전에 customException 에 관련한 글을 작성한 적이 있습니다.
요약해서 말하면, 예외가 어디에서 발생 하느냐 에 따라 예외 처리를 다르게 해야 한다는 겁니다.
웹소켓 연결 과정을 따라 가다 보면 단순히 http://localhost:8080/ws 로 요청을 보내는 것 같지만, 여러 과정을 거쳐 switching 이 됩니다. 로그를 보다보면 ws?어쩌구 저쩌구 붙는 과정이 계속 되는 것을 알 수 있죠. 때문에 일단 Security 와 JWT 필터를 통과하도록 열어두어야 합니다. 하지만 이렇게 되면 해당 과정에서 나타날 수 있는 예외를 감지하지 못합니다. 하지만 프론트 에게는 에러의 원인을 알려야 하죠.
그래서 발생할 수 있는 예외를 catch 하여 이를 바로 http 응답 바디에 넣어 보내줍니다.
private void setResponse(ServerHttpResponse response, HttpStatus status, String errorCode) throws IOException { response.setStatusCode(status); response.getHeaders().add("Content-Type", "application/json"); String errorMessage = "{\"errorCode\": \"" + errorCode + "\"}"; response.getBody().write(errorMessage.getBytes(StandardCharsets.UTF_8)); response.getBody().flush(); }
Java
복사
이전에 custom 한 예외가 있다면 그에 맞추어 알맞은 상태 코드를 반환합니다.
헤더에 content type 을 명확하게 명시해줍니다.
에러 메세지 또한 이전에 custom 한 예외와 똑같이 맞추어 줍니다.
이를 응답 바디에 넣어줍니다.