Springboot3 Websocket 채팅 (Stomp, SockJS, Spring Security)

박은빈·2023년 2월 27일
11

자바

목록 보기
11/25

팀원들과 프로젝트를 진행하던도중 스프링부트3에서 채팅을 구현하는 일을 맡았다

하지만 채팅을 구현할때 websocket을 이용하고 spring security도 적용해야됐기 때문에
이런저런 노력끝에 구현에 성공을 했고 내가 시도한 방법을 기억하기위해 벨로그에 글을 쓰게되었다

의존성 추가

먼저 springboot, spring security, websocket, stomp, sockJS를 사용하기위해 의존성을 추가시켜야한다.

//spring-boot의 버전에 따라 의존성 버전이 다를 수 있습니다
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:sockjs-client:1.5.1'
implementation 'org.webjars:stomp-websocket:2.3.4'
implementation 'org.springframework:spring-messaging:6.0.3'
implementation 'org.springframework.security:spring-security-messaging:6.0.2'

웹소켓 Stomp 설정

stomp는 Simple Text Oriented Messaging Protocol의 약자로 이름에서 알 수 있듯이 기존 웹소켓 통신을 메시지 형태로 나타내주는 프로토콜이다

통신의 형태는 아래와 같다

COMMAND
header:(header)
header2:(header2)
destination:/chat/1

{"id":"1","name":"joypeb","message":"안녕"}

스프링에서 stomp를 설정을 해주어야 프론트에서 stomp형식으로 보낸 요청이 잘 전달된다

먼저 ChatConfig라는 클래스를 만들고 그 안에서 stomp설정을 해 주겠다

@Configuration
@EnableWebSocketMessageBroker
public class ChatConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        //stomp의 접속 주소
        registry.addEndpoint("/ws").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //클라이언트의 send요청 처리
        registry.setApplicationDestinationPrefixes("/pub");
        //sub하는 클라이언트에게 메시지 전달
        registry.enableSimpleBroker("/sub");

    }
}

EnableWebSocketMessageBroker라는 어노테이션을 추가하고
WebSocketMessageBrokerConfigurer클래스를 상속받으면 stomp에 대한 설정이 가능하다

메소드 오버라이드를 통해 registerStompEndpoints를 재정의 하면 클라이언트에서 서버로 통신할때 사용할 엔드포인트를 정의할 수 있다.
나는 간단하게 ws라고 하였다. 그리고 클라이언트에서 sockJS를 이용할 것이기때문에 withSockJS()를 추가시켜주었다

configureMessageBroker메소드는 요청과 응답에 관한 엔드포인트를 설정할 수 있다.
나는 클라이언트에서 보내는 요청을 /pub으로 클라이언트에게 메시지를 보내는 응답을 /sub으로 설정하였다

이러면 간단하게 stomp설정이 완료되었다. 이제 프론트에서 /ws에 해당하는 엔드포인트로 웹소켓 통신 요청을 보내보자

//stomp와 sockJS관련 임포트 혹은 html에서 스크립트를 넣어야한다
let socket = new SockJS('/ws');
let stompClient = Stomp.over(socket);
stompClient.connect({},onConnected,onError);

function onConnected() {
  ...
}

function onError() {
  ...
}

연결이 잘 될까?
잘 될수도 있지만 실제 서버에 띄우게되면 동작하지 않을것이다

이유는 바로 SOP때문이다
sop에 관한 내용은 다음에 설명하기로하고 간략하게 말하면
https://www.naver.com:443과 같은 주소를 origin이라고하는데
서버의 origin과 클라이언트의 origin이 다르면 정보탈취나 유출방지를 위해 데이터 확인을 막아버리는 것을
SOP(Same Origin Policy)라고 한다

그리고 특정한 origin만 허용하기위해 사용하는게 CORS(Cross Origin Resource Sharing)이라고한다.

클라이언트의 origin과 서버의 origin이 다르기때문에 특정한 origin만을 허용시켜 연결이 잘 동작되게 해야한다

그러기위해서는 setAllowedOriginPatterns()를 사용해야한다

스프링부트2까지는 setAllowedOrigins로도 가능했지만 스프링부트3부터는 setAllowedOriginPatterns만 사용해야 cors를 사용할 수 있다.

@Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        //stomp의 접속 주소
        registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
    }

endpoint뒤에 붙여주면 되는데 * 은 와일드카드로 모든 origin에 대해서 허용하겠다는 소리다
그 말은 보안에 아주 취약하다는 소리이기때문에 실제 서버에 올릴때는 서버 주소에 해당하는 origin을 적도록 하자

이렇게 cors를 적용하고 다시 실행하면 정상적으로 동작한다

pub sub적용

클라이언트에서 만약 메시지를 보낼경우 어떤 엔드포인트로 보내야할까?
해당 엔드포인트는 우리가 아까 작성한 pub을 이용하면 된다

stompClient.send("/pub/chat/enter",
        messageHeader,
        JSON.stringify({
            'id':id,
            'name':name,
            'chatType':'JOIN'
        }));

또한 메시지를 보냈는데 다른 사람들이 보낸 메시지를 어떻게 받을까?
그거는 sub를 이용하면 된다. 클라이언트에서 특정한 엔드포인트를 구독하게되면 서버에서 해당 엔드포인트로 메시지를 보내는 것이 가능하다

이러한 형태로 send를 보내게 되면 스프링 컨트롤러에서 메시지를 받고 처리해서 sub(구독자)들에게 뿌려주어야한다

스프링에서는 MessageMapping이 존재한다
이 어노테이션은 pub으로 요청이 왔을때 해당 요청을 받아 처리하게 된다

private final SimpMessageSendingOperations template;

@MessageMapping("/chat/enter")
    public void enter(@Payload ChatRequest chatRequest, SimpMessageHeaderAccessor headerAccessor) {
        template.convertAndSend("/sub/chat/room/"+chatRequest.getId(),chatRequest);
    }

위의 코드는 간단하게 /pub/chat/enter로 요청이 왔을경우 해당 메서드가 실행이 되고 /sub/chat/room/{id}를 구독하고있는 구독자들에게 요청할대 같이 온 chatRequest를 전달한다는 내용이다.

그리고 쓰이지는 않았지만 SimpMessageHeaderAccessor를 이용해 Stomp의 헤더에 접근해서 헤더에 관한 내용들도 사용이 가능하다.

클라이언트에서는 subscribe와 unsubscribe로 구독과 구독취소가 가능하다 유튜브같이

또한 하나의 페이지에서 한번에 2개의 구독도 가능한데 그럴때는 서버의 config에서 엔드포인트를 하나 더 추가시킨다

registry.enableSimpleBroker("/sub","/sub/list");

그리고 구독을 할때 stomp의 헤더에 id를 집어넣어 구분을 시켜준다
그렇게 되면 2개가 구독이되고 구독을 취소하고싶으면 unsubscribe에 해당 id를 넣어주면 된다

stompClient.subscribe('/sub/chat/room/'+roomId, onMessageReceived, {'id':'message'});
stompClient.unsubscribe('message');

Spring security 적용

이 글은 stomp에 spring security JWT를 적용하는 것을 설명하기때문에 기본적인 필터체인이나 spring security설정, JWT발급같은건 다 했다고 생각하겠다

jwt는 보통 헤더에 토큰의 정보를 넣고 필터체인을 통해서 토큰 유효성 검사를 하는 로직이다
하지만 일반 통신에서 websocket통신으로 업그레이드를 하면서 중요한 문제가 생기는데
바로 header를 마음대로 건드리지 못한다는 것이다. 보안의 이유때문이라는데 어쨋든 안됨

그래서 방법을 생각하다가 하나를 찾은게 stomp에도 header가 존재한다는 것이었다
그렇기때문에 stomp header에 토큰을 넣고 유효성 검사를 하면 오케이인데

두번째문제로 필터체인에서 stomp에 해당하는 message 객체를 잡아내지 못한다는 것이다

산넘어 산이네 진짜.. 그렇게 찾아보던중 역시 방법은 존재했다

바로 ChannelInterceptor라는 것이다
ChannelInterceptor가 무엇이냐 인터셉터, 말 그대로 슬쩍 한다는 것이다
필터체인에 통과하고 컨트롤러에 가기 전 Message객체가 있으면 해당 객체를 슬쩍 가져와서 내 맘대로 주무르고 다시 리턴시킬수 있는 클래스이다

해당 클래스를 이용해 컨트롤러에 가기 전 Stomp Message객체를 슬쩍 가져와서 JWT유효성 검사를 하고 리턴시키는 방법을 이용하겠다

그러기 위해서는 config에서 ChannerInterceptor를위한 메서드를 오버라이드 시켜줘야한다.

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

    private final ChatPreHandler chatPreHandler;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        //stomp의 접속 주소
        registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //클라이언트의 send요청 처리
        registry.setApplicationDestinationPrefixes("/pub");
        //sub하는 클라이언트에게 메시지 전달
        registry.enableSimpleBroker("/sub","/sub/list");

    }

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

이제 stomp요청을 받을때 해당 ChatPreHandler라는 클래스를 거치게 된다

그러면 ChatPreHandler를 만들어보자

@Configuration
@RequiredArgsConstructor
@Slf4j
public class ChatPreHandler implements ChannelInterceptor {

    private final JwtUtil jwtUtil;
    Long memberId = 0L;
    
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        try {
            StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

            String authorizationHeader = String.valueOf(headerAccessor.getNativeHeader("Authorization"));

            StompCommand command = headerAccessor.getCommand();


            if(command.equals(StompCommand.UNSUBSCRIBE) || command.equals(StompCommand.MESSAGE) || 
            command.equals(StompCommand.CONNECTED || command.equals(StompCommand.SEND))
            	return message;
            else if (command.equals(StompCommand.ERROR)) {
                throw new MessageDeliveryException("error");
            }

            if (authorizationHeader == null) {
                log.info("chat header가 없는 요청입니다.");
                throw new MalformedJwtException("jwt");
            }

            //token 분리
            String token = "";
            String authorizationHeaderStr = authorizationHeader.replace("[","").replace("]","");
            if (authorizationHeaderStr.startsWith("Bearer ")) {
                token = authorizationHeaderStr.replace("Bearer ", "");
            } else {
                log.error("Authorization 헤더 형식이 틀립니다. : {}", authorizationHeader);
                throw new MalformedJwtException("jwt");
            }

            try {
                memberId = JwtUtil.getMemberId(token);
            } catch (JsonProcessingException e) {
                throw new MalformedJwtException("jwt");
            }

            boolean isTokenValid = jwtUtil.validateToken(token);

            if (isTokenValid) {
                this.setAuthentication(message, headerAccessor);
            }
        } catch (ApplicationException e) {
            log.error("JWT에러");
            throw new MalformedJwtException("jwt");
        } catch (MessageDeliveryException e) {
            log.error("메시지 에러");
            throw new MessageDeliveryException("error");
        }
        return message;
    }

    private void setAuthentication(Message<?> message, StompHeaderAccessor headerAccessor) {
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(memberId, null, List.of(new SimpleGrantedAuthority(MemberRole.USER.name())));
        SecurityContextHolder.getContext().setAuthentication(authentication);
        headerAccessor.setUser(authentication);
    }
}

ChannelInterceptor를 상속받아 구현한 클래스이다 여기서 preSend메서드를 오버라이드해서 그 안에서 JWT 유효성 검사와 다른 검사들을 처리할 수 있다.

JWT 검사에 관한 내용을 빼고 설명을 하자면

StompHeaderAccessor를 이용해 Stomp 메시지 객체의 헤더에 접근이 가능하다
그리고 해당 헤더에서 이름이 Authorization인 헤더의 값을 꺼내 String 형식으로 변환시킨다.
그게 바로 JWT이다

그리고 header에서 command를 꺼내올 수 있는데 command란 Stomp의 상태를 의미한다
예를들어 연결할때는 CONNECT, 연결되면 CONNECTED, 전송은 SEND, 구독은 SUBSCRIBE 등이 있다

내가 생각하기에 모든 요처에 JWT검사를 하면 자원낭비가 심하게 될것이고 성능저하가 일어나기때문에
특정 부분에만 JWT검사를 하도록 command를 분류시켰다

구독을 취소할때, 메시지를 응답받을때, 연결이 완료되었을때는 JWT검사가 필요 없다고 판단되어서 바로 메시지를 리턴하게 작성했다. 메시지를 보낼때도 바로 리턴하게 작성했는데 이거는 넣을지 말지 생각중인데 일단 넣었다

이렇게 클래스를 작성하면 JWT검사까지 완료가 되었다
여기서 토큰이나 메시지에 문제가 있어서 실패할경우 에러를 발생시키게 된다

에러가 발생될경우 서버에서는 확인이 가능하지만 클라이언트에서는 확인이 불가능하다

그렇기 때문에 에러 핸들링이 필요하다

에러 핸들링

Interceptor를 통해 JWT를 검사하던중 에러가 발생하면 특정 클래스로 이동해서 에러를 핸들링하게 된다

이를위해 config에서 하나를 추가시켜주어야한다

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

    private final ChatPreHandler chatPreHandler;
    private final ChatErrorHandler chatErrorHandler;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        //stomp의 접속 주소
        registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
        registry.setErrorHandler(chatErrorHandler);
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //클라이언트의 send요청 처리
        registry.setApplicationDestinationPrefixes("/pub");
        //sub하는 클라이언트에게 메시지 전달
        registry.enableSimpleBroker("/sub","/sub/list");

    }

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

setErrorHandler라는 메서드를 통해서 에러가 발생할 경우 ChatErrorHandler로 가게 설정했다

그리고 ChatErrorHandler에서는 메시지 객체를 만들어서 반환시키는데
메시지 안에 에러내용을 담아 반환시키고 클라이언트에서 에러내용을 처리해 적절한 조치를 취하게 설정했다

@Component
public class ChatErrorHandler extends StompSubProtocolErrorHandler {

    public ChatErrorHandler() {
        super();
    }

    @Override
    public Message<byte[]> handleClientMessageProcessingError(Message<byte[]> clientMessage, Throwable ex) {
        if(ex.getCause().getMessage().equals("jwt")) {
            return jwtException(clientMessage, ex);
        }

        if(ex.getCause().getMessage().equals("error")) {
            return messageException(clientMessage, ex);
        }

        return super.handleClientMessageProcessingError(clientMessage, ex);
    }

    //메시지 예외
    private Message<byte[]> messageException(Message<byte[]> clientMessage, Throwable ex) {
        return errorMessage(ErrorCode.INVALID_MESSAGE);
    }

    //jwt 예외
    private Message<byte[]> jwtException(Message<byte[]> clientMessage, Throwable ex) {
        return errorMessage(ErrorCode.INVALID_TOKEN);
    }

    //메시지 생성
    private Message<byte[]> errorMessage(ErrorCode errorCode) {
        String code = String.valueOf(errorCode.getMessage());

        StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);

        accessor.setMessage(String.valueOf(errorCode.getStatus()));
        accessor.setLeaveMutable(true);

        return MessageBuilder.createMessage(code.getBytes(StandardCharsets.UTF_8), accessor.getMessageHeaders());
    }
}

StompSubProtocolErrorHandler를 상속받고 handleClientMessageProcessingError메서드를 오버라이드 한다

예외 클래스가 ex에 담겨있는데 ChatPreHandler에서 예외가 발생한경우 메시지를 jwt혹은 error로 입력해두었다 그 메시지를 가지고 에러를 구분해 메시지를 만들어준다

직접 만든 ErrorCode를 이용해 메시지를 작성했다

errorCode의 getMessage()를 이용해 미리 작성해둔 메시지 내용을 구한다

그리고 헤더에 command를 ERROR로 설정시키고
setLeaveMutable(true)를 설정시켜 메시지가 변할수 있게 설정을 한다 그래야 메시지에 값을 넣고 클라이언트에서 확인이 가능하기때문이다
그 후 getStatus()를 이용해 에러코드를 헤더에 넣는다

마지막으로 메시지를 만드는 createMessage를 하고 메시지 상태코드 code를 body에 accessor를 헤더에 넣고 리턴시켜주면 완성이다

이렇게 되면 command가 ERROR이기 때문에 onError 메서드가 동작하게 되고 해당 메서드에서 로직을 처리해주면 된다. onError로 오지 않을경우 구독이 왔을때 메시지를 분류하는 작업에서 처리하면 된다

profile
안녕하세요

0개의 댓글