반응형

https://github.com/Seungkyu-Han/Toge-do-backend

 

GitHub - Seungkyu-Han/Toge-do-backend: Toge-do 앱의 백엔드 리포지토리입니다

Toge-do 앱의 백엔드 리포지토리입니다. Contribute to Seungkyu-Han/Toge-do-backend development by creating an account on GitHub.

github.com

프론트 팀원에게 에러가 발생한다고 연락이 왔다.

 

유효하지 않은 토큰으로 채팅을 보내면 에러가 발생한다는 것이었다.

물론 당연한 말이라고 생각이 되지만, 발생하는 에러가 500 에러이기에 해당 에러는 수정을 해야했다.

 

이 부분을 수정하려고 하니 현재 웹소켓도 문제가 있다고 생각되어, 이번 기회에 교체해보려고 한다.

 

현재 서버는

이렇게 연결을 한 후에 토큰을 전송하면서 인증을 하고 사용자 정보를 추출하고 있다.

    @MessageMapping("/{groupId}")
    fun publishChatMessage(
        @Parameter(hidden = true) @Header(HttpHeaders.AUTHORIZATION) accessToken: String,
        @DestinationVariable groupId: String,
        @RequestBody messageReqDto: MessageReqDto
    ): Mono<ResponseEntity<Void>> {
        return chatService.publishMessage(
            groupId = groupId,
            userId = jwtTokenProvider.getUserId(
                headerUtil.extractAccessTokenFromHeader(accessToken)
            )!!,
            message = messageReqDto.message
        ).then(Mono.fromCallable { ResponseEntity(HttpStatus.OK) })
    }

 

 

빠르게 개발하기 위해 이렇게 만들었었는데, 사실 어차피 연결이 완료되면 더 이상 인증할 필요가 없기 때문에 그냥 connect 할 때, 한 번만 인증을 하고 해당 세션을 유지하는 것이 맞을 것이다.

 

그렇기 때문에 이렇게 교체를 해보려고 한다.

 

우선 당연하게 Jwt 토큰을 추출할 component가 존재해야 한다.

 

우선 Stomp Config에 configureClientInboundChannel을 오버라이드 받아, interceptor에 새로운 필터를 작성한다.

@Configuration
@EnableWebSocketMessageBroker
class ChatConfig(
    private val stompErrorHandler: StompErrorHandler,
    private val filterChannelInterceptor: FilterChannelInterceptor
): WebSocketMessageBrokerConfigurer {

    override fun configureClientInboundChannel(registration: ChannelRegistration) {
        registration.interceptors(filterChannelInterceptor)
    }
}

 

이제 여기에 들어가는 FilterChannelInterceptor가 연결을 시작하는 중간에 검증을 하는 역할을 한다.

@Component
class FilterChannelInterceptor(
    private val headerUtil: HeaderUtil = HeaderUtil(),
    private val jwtTokenProvider: JwtTokenProvider
): ChannelInterceptor {

    override fun preSend(message: Message<*>, channel: MessageChannel): Message<*>? {
        val stompHeaderAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor::class.java)

        if (stompHeaderAccessor!!.command == StompCommand.CONNECT) {
            try{
                val bearerToken = stompHeaderAccessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION)
                val token = headerUtil.extractAccessTokenFromHeader(bearerToken!!)
                val userId = jwtTokenProvider.getUserId(token)
                stompHeaderAccessor.sessionAttributes?.put("userId", userId)
            }catch(e: Exception){
                throw AuthException()
            }
        }
        return message
    }
}

 

작성한 filter이다.

 

우선 메시지로부터 StompHeaderAccessor를 가져온다.

그리고 조건문을 이용해 StompCommand의 CONNECT, ERROR, MESSAGE 등등 중에서 연결을 시작할 때인 CONNECT 일 때만 동작하도록 한다.

 

Stomp로 연결을 하더라도, Header를 작성할 수가 있기 때문에 헤더에서 JwtToken을 가져와서 사용자 아이디를 추출한다.

그 다음에 StompHeaderAccesor의 세션에 넣어주는데, 이 때 그냥 유저의 아이디만을 넣어주는 것이 아닌 Authentication을 넣어줄 수도 있다.

 

나는 현재 프로젝트에서 Authentication이 구현되어 있지 않기 때문에 그냥 userId로 정보만 넣어줬다.

 

그리고 토큰에서 사용자 아이디를 추출하는 과정에서 생기는 모든 에러는 인증 에러로 생각하여, 모든 에러를 AuthException으로 바꾸어 throw해주었다.

 

일단 이렇게하면 연결 과정에서 토큰을 사용하여 인증을 수행하고, 실패한다면 연결을 종료할 수 있다.

하지만 사용자에게 인증이 실패했다는 내용은 알려줄 수 없게 된다.

 

그래서 이제 StompErrorHandler를 작성해야 한다.

다시 Stomp Config로 가서, 웹소켓에서 발생하는 모든 에러를 처리할 error handler를 추가해준다.

@Configuration
@EnableWebSocketMessageBroker
class ChatConfig(
    private val stompErrorHandler: StompErrorHandler,
    private val filterChannelInterceptor: FilterChannelInterceptor
): WebSocketMessageBrokerConfigurer {

    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.setErrorHandler(stompErrorHandler)
    }
}

 

이렇게 추가를 하고, ErrorHandler를 작성해보자.

@Configuration
class StompErrorHandler(
    private val objectMapper: ObjectMapper
): StompSubProtocolErrorHandler(){

    override fun handleClientMessageProcessingError(
        clientMessage: Message<ByteArray>?,
        ex: Throwable
    ): Message<ByteArray>? {

        if (ex is MessageDeliveryException && ex.cause is AuthException){
            val webSocketEventEnum = WebSocketEventEnum.AUTHORIZATION_ERROR

            val stompHeaderAccessor = StompHeaderAccessor.create(StompCommand.ERROR)

            stompHeaderAccessor.message = "Fail to send message"

            val errorBody = objectMapper.writeValueAsString(
                WebSocketEventDto(state = webSocketEventEnum.value, message = webSocketEventEnum.message)
            ).toByteArray(StandardCharsets.UTF_8)

            return MessageBuilder.createMessage(errorBody, stompHeaderAccessor.messageHeaders)
        }
        return super.handleClientMessageProcessingError(clientMessage, ex)
    }
}

 

우선 이때 발생하는 모든 에러는 MessageDeliveryException으로 온다.

그렇기에 에러가 발생하는 이유를 찾기 위해서는 MessageDeliveryException의 cause를 확인해야 한다.

나는 인증에서 생기는 모든 에러를 AuthException으로 처리했기 때문에, 해당 에러인지를 확인하고 그 때 에러메시지를 return하도록 했다.

 

내용을 message, body에 작성할 수 있는데, 나는 에러의 내용을 body에 적어주었다.

 

작성할 내용을 byteArray로 작성하여 넣어주면 된다.

 

이렇게 추가를 해두고, 만료된 토큰을 사용해보니

인증에 실패하면 클라이언트에게 에러메시지가 가는 것을 볼 수 있었다.

반응형

https://github.com/Seungkyu-Han/Toge-do-backend

 

GitHub - Seungkyu-Han/Toge-do-backend: Toge-do 앱의 백엔드 리포지토리입니다

Toge-do 앱의 백엔드 리포지토리입니다. Contribute to Seungkyu-Han/Toge-do-backend development by creating an account on GitHub.

github.com

이제 Stomp를 사용한 채팅 서버의 구현이다.

 

기존에 개발했던 채팅은 Websocket을 사용해서 소켓을 연결시켜두고, 거기에 Flux로 데이터를 전송해주었던 걸로 기억난다.

클라이언트로부터 데이터를 받을 수 있는 SSE 느낌으로 말이다.

 

이번에는 Stomp 라이브러리를 사용해 제대로 채팅을 구현해보려 한다.

 

Stomp는 우선 socket에 연결하고, subscribe하는 주소에 구독해 메시지를 받고 publish하는 주소로 메시지를 보낸다고 한다.

그래서 다른 방법으로는 테스트가 어렵고 저번에 작성했던 글의 방법으로 테스트해야 한다.

 

https://jiangxy.github.io/websocket-debug-tool/

 

WebSocket Debug Tool

 

jiangxy.github.io

 

해당 사이트로 설명을 해보자면

저렇게 url에 소켓을 연결하고 stomp subscribe에 서버에서 설정한 subscribe destination을 구독하면 해당 채팅방에서 생성된 메시지들이 실시간으로 전달된다.

사실 그냥 destication을 구독하는게 아니라 endpoint가 /subscribe면 뒤에 /subscribe/{chatGroupId} 이런 식으로 뒤에 채팅방의 정보가 있어야 한다.

 

마찬가지로 send destination을 통해 메시지를 보내며 이 때도 /publish/{chatGroupId}와 같이 채팅방의 정보가 추가되어야 한다.

헤더를 추가할 수도 있으며, 이거는 그냥 연결해두고 Rest API를 호출한다고 생각하면 편할것이다.

 

그럼 일단 Stomp를 사용하기 위해 추가해야 하는 라이브러리이다.

implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation ("org.springframework.boot:spring-boot-starter-websocket")

 

이렇게 websocket과 webflux만 추가해주면 된다고 한다.

나는 기존에 webflux를 사용하고 있어 websocket만 추가해주었다.

 

이제 stomp의 config다

@Configuration
@EnableWebSocketMessageBroker
class ChatConfig: WebSocketMessageBrokerConfigurer {

    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint("/websocket/v1/chat")
            .setAllowedOriginPatterns("*")
            .withSockJS()
        registry.addEndpoint("/websocket/v1/chat")
            .setAllowedOriginPatterns("*")
    }

    override fun configureMessageBroker(registry: MessageBrokerRegistry) {
        registry.enableSimpleBroker("/sub")
        registry.setApplicationDestinationPrefixes("/pub")
    }
}

 

우선 이런 식으로 작성하고 하나씩 설명해보자면

 

registerStompEndpoints는 웹소켓의 endpoint를 설정한다.

registry.addEndpoint에 해당 웹소켓의 end point를 설정해준다.

만약 사용하는 서버가 localhost:8080이라면 end point는 ws://localhost:8080/websocket/v1/chat이 될 것 이다.

setAllowedOriginPatterns에는 해당 웹소켓에 접속 가능한 주소를 적는 것이다.

클라우드 서버에서 사용하기 때문에 "*"로 지정해주었다.

withSockJs는 자바스크립트 라이브러리 관련된 것이라고 하는데, 나는 일단 몰라서 모두 등록해두었다.

 

configureMessageBroker는 위에서 보았던 subscribe, publish로 사용할 destination을 등록해주면 된다.

 

이제 message controller를 작성해보자.

@RestController안에 @MessageMapping을 적어주면 된다.

    @MessageMapping("/{groupId}")
    fun publishChatMessage(
        @Parameter(hidden = true) @Header(HttpHeaders.AUTHORIZATION) accessToken: String,
        @DestinationVariable groupId: String,
        @RequestBody messageReqDto: MessageReqDto
    ): Mono<ResponseEntity<Void>> {
        return chatService.publishMessage(
            groupId = groupId,
            userId = getUserIdFromToken(accessToken),
            message = messageReqDto.message
        ).then(Mono.fromCallable { ResponseEntity(HttpStatus.OK) })
    }

 

이렇게 원하는 Header를 가져올 수도 있고, dto를 정의해서 원하는 json 형식으로 값을 가져올 수 있다.

@DestinationVariable로 groupId를 가져왔으며, 해당 정보는 MongoDB에 그대로 저장한다.

 

이제 서버에서 클라이언트로 메시지를 보내는 방법이다.

simpMessageSendingOperations.convertAndSend(
    "/sub/${chatDocument.groupId}", messageDocumentToDto(chatDocument)

 

그냥 간단하게 SimpMessageSendingOperations에 convertAndSend로 구독하고 있는 채팅방으로 메시지 dto를 전송하면 된다.

 

해당 이벤트를 가져오는 방법은 전 포스팅에서 MongoDB changeStream 부분을 찾아보면 된다.

 

테스트 결과도 전 포스팅의 사진으로 대체하도록 하겠다.

 

Stomp로 처음 채팅방을 만들어 보았는데, 채팅방 관리가 굉장히 쉬웠다.

WebSocket만 사용해서 구현하는 거보다 난이도가 더 쉬운 것 같다.

+ Recent posts