반응형

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

 

https://engineering.linecorp.com/ko/blog/port-and-adapter-architecture

 

지속 가능한 소프트웨어 설계 패턴: 포트와 어댑터 아키텍처 적용하기

들어가며 헥사고날 아키텍처(Hexagonal Architecture)로 더 잘 알려져 있는 포트와 어댑터 아키텍처(Ports and Adapters Architecture)는 인터페이스나 기반 요소(infrastructure)의 변경에 영향을 받지 않는 핵심 ..

engineering.linecorp.com

해당 글을 참고하였습니다.

 

현재의 시스템 구조이다.

여러개의 서버에서 사용하는 부분을 module로 추출해서 멀티모듈로 개발을 하다 보니, Hexagonal Architecture를 적용하고 싶어졌다.

이런 생각이 빨리 들었으면 더 쉽게 적용할 수 있었을텐데...아쉽니다.

 

우선 Heaxgonal Architecture의 설명이다.

애플리케이션의 핵심 비즈니스 로직과 외부 시스템(데이터베이스, API등)을 분리하여 시스템을 더욱 유연하고 테스트하기 쉽게 만드는 것을 목표로 하는 소프트웨어 설계 패턴이다.

외부에 포트와 어댑터만 노출하기 때문에 Ports and Adapters 패턴으로도 불린다고 한다.

이런 식으로 외부에 포트를 노출하고 있으며, primary 포트(어댑터)와 secondary포트(어댑터)로 나뉜다.

 

primary는 외부에서 요청을 받아야 동작하는 요소들을 말한다.

이런 식으로 Service로 인터페이스를 제공하고 있기에, 해당 인터페이스에 있는 메서드들이 포트가된다.

컨트롤러는 HTTP API의 요청을 받아 Service의 인터페이스를 연결해주고 있기 때문에 어댑터이다.

 

secondary는 애플리케이션이 호출하면 동작하는 요소들을 말한다.

보통 Repository 인터페이스로 개발을 하기에 Repository가 포트이고, 해당 Repository를 상속받는 구현체가 어댑터가 된다.

 

보통 애플리케이션이 핵심 로직으로, 변경이 굉장히 자주 일어나게 된다.

그렇기에 요청을 하고 받는 어댑터와의 결합도를 낮추어야 하는데, 이를 포트를 의존하는 방법으로 해결한다.

 

만약 여기에 빠른 속도를 위한 Redis를 추가하더라도, 기존의 핵심 도메인에는 영향을 주지 않고, 어댑터만 추가하게 되는 것이다.

이 때더 포트가 애플리케이션과 도메인을 보호하게 됩니다.

 

이외에도 더 많은 개념이 있는데, 일단 여기까지만 소개하고 현재 진행중인 프로젝트에 적용하는 방법을 생각해보았다.

 

우선 기존에도 도메인은 model module로 추출하여, 핵심 로직을 작성해 사용하고 있었다.

이 모듈을 그대로 domain으로 사용할 수 있을 것 같았다.

그 모듈을 중심으로 구성해보면

 

이렇게 만들 수 있지 않을까 생각이 든다.

 

사실 이렇게 블로그를 작성하고 다시 설계하면서 생각해보니, 기존의 설계와 크게 다르지 않다는 것을 알게되었다.

이렇게 만들면 각 단계에서 수행할 테스트도 명확해지기 때문에 좋았었다.

 

이 Hexagonal Architecture가 클린코드에서 굉장히 중요한 개념이라고 한다.

 

앞으로도 중복되는 코드들을 제거하고, 더 나은 설계를 적용하기 위해 다양한 방법을 생각해보도록 하자.

반응형

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

 

해당 프로젝트 중에 아래와 같은 data class를 object mapper를 사용하여 읽은 후 redis에 String으로 저장하는 코드가 있었다.

package vp.togedo.model.documents.personalSchedule

import org.bson.types.ObjectId
import vp.togedo.model.exception.personalSchedule.PersonalScheduleEndTimeBeforeStartTimeException
import vp.togedo.model.exception.personalSchedule.PersonalScheduleTimeIsNotRangeException

data class PersonalScheduleElement(
    val id: ObjectId = ObjectId.get(),
    val startTime: String,
    val endTime: String,
    val name: String,
    val color: String
){
    /**
     * 해당 개인 스케줄 요소의 시작 시간이 종료 시간보다 앞인지 확인
     * @return true
     * @throws PersonalScheduleEndTimeBeforeStartTimeException 종료 시간이 시작 시간보다 앞에 있음
     */
    private fun isStartTimeBefore(): Boolean{
        if(startTime.length != endTime.length ||
            startTime > endTime)
            throw PersonalScheduleEndTimeBeforeStartTimeException()
        return true
    }

    /**
     * 해당 스케줄의 시간이 범위 내에 있는지 확인
     * @param startTimeRange 시작 범위
     * @param endTimeRange 종료 범위
     * @return true
     * @throws PersonalScheduleTimeIsNotRangeException 유효한 시간 범위가 아님
     */
    private fun isTimeRange(
        startTimeRange: String,
        endTimeRange: String): Boolean{
        if(startTime.length != startTimeRange.length ||
            endTime.length != endTimeRange.length ||
            startTime !in startTimeRange..endTimeRange ||
            endTime !in startTimeRange..endTimeRange){
            throw PersonalScheduleTimeIsNotRangeException()
        }
        return true
    }

    /**
     * 유동 스케줄의 시간이 유효한지 확인
     * @return true
     * @throws PersonalScheduleTimeIsNotRangeException 유효한 시간 범위가 아님
     * @throws PersonalScheduleEndTimeBeforeStartTimeException 종료 시간이 시작 시간보다 앞에 있음
     */
    fun isValidTimeForFlexibleSchedule(): Boolean{
        return isStartTimeBefore() &&
                //00(년)_01(월)_01(일)_00(시)_00(분) ~ 99(년)_12(월)_31(일)_23(시)_59(분)
                isTimeRange(
                    startTimeRange = "0001010000",
                    endTimeRange = "9912312359",)
    }

    /**
     * 고정 스케줄의 시간이 유효한지 확인
     * @return true
     * @throws PersonalScheduleTimeIsNotRangeException 유효한 시간 범위가 아님
     * @throws PersonalScheduleEndTimeBeforeStartTimeException 종료 시간이 시작 시간보다 앞에 있음
     */
    fun isValidTimeForFixedSchedule(): Boolean{
        return isStartTimeBefore() &&
                //1(요일)_00(시)_00(분) ~ 7(요일)_23(시)_59(분)
                isTimeRange(
                    startTimeRange = "10000",
                    endTimeRange = "72359")
    }

}

 

그리고 여기에서 고정 일정을 추가하기 위해 isValidFimeForFixedSchedule만 호출을 하고 저장을 하는데, 계속 isValidTimeForFixedSchedule이 호출되어 Exception이 발생했다.

 

디버깅을 아무리 해보아도, 해당 API에서는 Fix 검증 코드만 사용하고 있었기에 버그를 잡기가 굉장히 어려웠다.

 

그러던 중 해당 에러가, redis 저장과정에서 생긴 다는 것을 알게 되었고 object mapper의 writeValueAsString에서 에러가 발생했다.

 

아니....왜 직렬화만 하라니까 함수를 호출하지?라고 생각을 하며 이유를 찾아보고 있었는데 함수의 이름 때문이었다.

 

직렬화 과정에서 함수 앞에 "is"가 붙어있으면 호출하여 해당 함수로 조건검사를 시도한다는 것이었다.

 

지금 함수명들이 IsValidTime이기 때문에 해당 함수들로 조건검사를 했던 것이다.

 

이 방법을 알고 있지 않았기 때문에 직접 조건검사를 하고 추가를 해주었고, 지금은 이 조건검사를 해제해야 했다.

 

@JsonIgnore

 

함수들에 이 annotation을 붙여주면 된다.

직렬화를 하지 않겠다는 것이다.

 

다음에는 굳이 ignore가 아닌, 직렬화를 조건검사를 한 후에 할 수도 있겠다라는 생각이 들었다.

+ Recent posts