코딩과 결혼합니다

[Game_Crew] WebSocket으로 1대1 채팅기능 구현하기(1) - 적용x 본문

코딩과 매일매일♥/Game_Crew

[Game_Crew] WebSocket으로 1대1 채팅기능 구현하기(1) - 적용x

코딩러버 2023. 11. 1. 20:34
728x90

게임 경험의 향상을 위하여 채팅기능을 구현하려 한다. 채팅을 통해서 대화를 나눠봄으로 자신과 잘 맞는 플레이어인지 또 한번 검증을 할 수 있다. 카카오톡과 같이 다중 채팅방이 가능한 1:1 채팅 서비스를 만들것이다.

 

  1. Spring WebSocket Stomp로 채팅 구현하기
  2. WebSocket 연결전 tcp handshake 과정에서 JWT 인증하기
  3. Spring WebSocket Exception 에러 핸들링

Spring WebSocket Stomp로 채팅 구현하기

📌의존성 추가

    //websocket
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    implementation 'org.webjars:webjars-locator-core'
    implementation 'org.webjars:sockjs-client:1.5.1'
    implementation 'org.webjars:stomp-websocket:2.3.4'

    //mongo
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'

📌프로퍼티즈 설정

#MongoDB
spring.data.mongodb.uri=mongodb://<호스트>:<포트>/<데이터베이스>

명령 프롬프트(Windows) 또는 터미널(Mac/Linux)에서 mongo 명령어를 실행하여 MongoDB 쉘에 접속하여 use 명령어를 사용하여 데이터베이스를 생성


📌 WebSocketConfig

  • WebSocket을 구현하기 위한 설정 클래스
  • @ Annotation
    • @Configuration : 이 클래스가 스프링의 설정 클래스임을 나타냄
    • @EnableWebSocketMessageBroke : WebSocket 메시지 브로커의 구성을 설정할 수 있다.
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocket implements WebSocketMessageBrokerConfigurer {

    private final StompHandler stompHandler;
    private final StompExceptionHandler stompExceptionHandler;

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

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry){

        registry
                .setErrorHandler(stompExceptionHandler)
                .addEndpoint("/websocket-endpoint")
                .addInterceptors()
                .setAllowedOriginPatterns("*");
                
    }

	//tcp handshake시 jwt 인증
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration){
        registration.interceptors(stompHandler);
    }
}
  • configureMessageBroker
    브로커의 구성. "/sub" 접두사를 가진 목적지(구독자)에 메세지를 보낼 수 있도록 설정하고, "/pub"접두사를 가진 목적지(발행자)로부터의 메시지를 처리할 수 있도록 설정한다.
  • setApplicationDestinationPrefixes
    Stomp 엔드포인트를 등록하는 메서드이다. "/websocket-endpoint" 경로의 엔드포인트를 추가로 등록하고, setErrorHandler 메서드를 통해 Stomp 예외 처리 핸들러를 설정한다. setAllowedOriginPatterns 메서드를 통해 모든 원본 주소에서의 접근을 허용한다.
  • configureClientInboundChannel
    클라이언트의 인바운드 채널 구성을 설정한다. interceptors 메서드를 통해 stompHandler를 인터셉터로 등록 한다.

 

✏️메시지 브로커 
프로듀서(생산자)로부터 메시지를 받아서 컨슈머(소비자)에게 전달하는 역할을 한다. 메시지 브로커는 메시지를 전달하는 동안 메시지를 보관, 라우팅, 변환 및 관리하는 기능을 수행한다. 아래의 방식을 통해 시스템간의 비동기 메시징을 가능하게 한다.

  1. 프로듀서는 메시지를 생성하고 메시지 브로커에게 전달한다.
  2. 메시지 브로커는 이메시지를 큐에 저장한다.
  3. 컨슈머가 메시지를 요청핳면, 메시지 브로커는 큐에서 메시지를 꺼내 컨슈머에게 전달한다.

✏️인바운드 채널
클라이언트에서 서버로 데이터를 전송하는 경로를 의미한다.  WebSocket에서는 클라이언트로부터 들어오는 연결을 인바운드 채널이라고 부른다. 이 채널을 통해 들어오는 메시지는 서버에서 처리되고, 필요한 경우 다른 클라이언트에게 전달된다.


📌 Controller

  • WebSocket을 사용한 실시간 채팅을 처리하는 컨트롤러
  • @ Annotation
    • @MessageMapping : WebSocket 요청을 처리하는 메서드임을 나타낸다. 
    • @SendTo : 해당 경로로 메시지가 브로드캐스트된다.
@RestController
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/chat")
public class ChatController {

    private final ChatService chatService;

    @MessageMapping("/{roomNo}")
    @SendTo("/sub/{roomNo}")
    public ChatResponse broadcasting(final ChatRequest request,
                                     @DestinationVariable(value = "roomNo") final String chatRoomNo){

        log.info("{roomNo : {}, request : {}}", chatRoomNo, request);

        return chatService.recordHistory(chatRoomNo, request);
    }
}
  • ChatResponse broadcasting
    MessageMapping 애너테이션에 의해 WebSocket 요청이 들어오면 호출되는 메서드이다.
    ChatService의 recordHistory() 메서드를 호출하여 채팅기록을 저장한 후, ChatResponse 객체를 반환한다.

WebSocket 연결전 tcp handshake 과정에서 JWT 인증하기

📌 StompHandler

  • Stomp(WebSocket 하위 프로토콜) 메시지가 전송되기 전에 처리하는 역할을 한다.
  •  @Order(Ordered.HIGEST_PRECEDENCE + 99) : 해당 클래스의 우선순위를 설정한다. HIGEST_PRECEDENCE는 가장 높은 우선순위를 나타내며, + 99는 추가적인 우선순위를 설정한다.
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class StompHandler implements ChannelInterceptor {

    private final JwtUtil jwtUtil;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel){

        final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);

        if (StompCommand.CONNECTED == accessor.getCommand()){
            final String authorization = jwtUtil.extractJwt(accessor);

            jwtUtil.validateToken(authorization);
        }
        return message;
    }
}
  • preSend
    STOMP 메시지가 전송되기 전에 호출됩니다. message와 channel을 파라미터로 받는다.
  • accessor
    message를 StompHeaderAccessor로 감싸고, accessor 변수에 할당한다.
  • if()~
    메서드를 사용하여 STOMP 메시지의 커맨드를 가져온다. 이 조건문은 STOMP 메시지의 커맨드가 CONNEXTED인지 확인한다.
  • authorization
    accessor에서 JWT(Jason Web Token)를 추출한 다음 유효성을 검사한다.

📌 JwtUtil

    public String extractJwt(StompHeaderAccessor accessor){
        return accessor.getFirstNativeHeader("Authorization");
    }

    // 토큰 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException | SignatureException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }
  • extractJwt
    Stomp 프로토콜 메시지의 헤더에서 "Authorization"이라는 key 값을 가진 JWT 토큰을 추출
  • validateToken
    전달받은 JWT 토큰이 유효한지 검증

Spring WebSocket Exception 에러 핸들링

📌 StompExceptionHandler

      STOMP 프로토콜에서 발생하는 다양한 예외 사항을 처리하여 클라이언트에게 적절한 에러 메시지를 전달하도록 설계

@Component
public class StompExceptionHandler extends StompSubProtocolErrorHandler {
	private static final byte[] EMPTY_PAYLOAD = new byte[0];
    public StompExceptionHandler() {
        super();
    }

    @Override
    public Message<byte[]> handleClientMessageProcessingError(Message<byte[]> clientMessage,
                                                              Throwable ex) {
        final Throwable exception = converterTrowException(ex);
        if (exception instanceof UnauthorizedException) {
            return handleUnauthorizedException(clientMessage, exception);
        }
        return super.handleClientMessageProcessingError(clientMessage, ex);
    }

    private Throwable converterTrowException(final Throwable exception) {
        if (exception instanceof MessageDeliveryException) {
            return exception.getCause();
        }
        return exception;
    }

    private Message<byte[]> handleUnauthorizedException(Message<byte[]> clientMessage,
                                                        Throwable ex) {
        return prepareErrorMessage(clientMessage, ex.getMessage(), HttpStatus.UNAUTHORIZED.name());
    }

    private Message<byte[]> prepareErrorMessage(final Message<byte[]> clientMessage,
                                                final String message, final String errorCode) {
        final StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
        accessor.setMessage(errorCode);
        accessor.setLeaveMutable(true);
        setReceiptIdForClient(clientMessage, accessor);
        return MessageBuilder.createMessage(
                message != null ? message.getBytes(StandardCharsets.UTF_8) : EMPTY_PAYLOAD,
                accessor.getMessageHeaders()
        );
    }

    private void setReceiptIdForClient(final Message<byte[]> clientMessage,
                                       final StompHeaderAccessor accessor) {
        if (Objects.isNull(clientMessage)) {
            return;
        }

        final StompHeaderAccessor clientHeaderAccessor = MessageHeaderAccessor.getAccessor(
                clientMessage, StompHeaderAccessor.class);
        final String receiptId =
                Objects.isNull(clientHeaderAccessor) ? null : clientHeaderAccessor.getReceipt();
        if (receiptId != null) {
            accessor.setReceiptId(receiptId);
        }
    }

    //2
    @Override
    protected Message<byte[]> handleInternal(StompHeaderAccessor errorHeaderAccessor,
                                             byte[] errorPayload, Throwable cause, StompHeaderAccessor clientHeaderAccessor) {
        return MessageBuilder.createMessage(errorPayload, errorHeaderAccessor.getMessageHeaders());
    }
}



참고 : https://thalals.tistory.com/446