[SpringBoot] @RestControllerAdvice를 통해 에러처리를 리팩토링 해보자 with Kotlin

tkppp·2022년 3월 19일
0

기존 코드

// 에러 식별을 위한 열겨형 클래스
enum class ErrorCode {
    DUPLICATED_EMAIL_ADDRESS,
    DUPLICATED_NICKNAME,
    MEMBER_NOT_FOUND,
    REFRESH_TOKEN_EXPIRED,
    INVALID_ACCESS_TOKEN,
    LOGIN_FAIL
}

// 반환 클래스 타입이 두가지임을 해결하기 위한 인터페이스
interface ResponseDto {}

// 예외 발생시 반환할 객체
data class ErrorResponseDto(
    val errorCode: String,
    val message: String?
) : ResponseDto

// LoginController
@RestController
@RequestMapping("/api/login")
class LoginApiController(
    private val loginService: LoginService
) {

    @GetMapping("/success")
    fun loginSuccess(@RequestParam access: String, @RequestParam refresh: String): ResponseEntity<ResponseDto?> {
        loginService.saveTokenAtCache(access, refresh)
        return ResponseEntity.ok(LoginResponseDto(access, refresh))
    }

    @GetMapping("/fail")
    fun loginFail(): ResponseEntity<ResponseDto?>{
        return ResponseEntity(ErrorResponseDto(
            ErrorCode.LOGIN_FAIL.name, "Login Fail"
        ), HttpStatus.UNAUTHORIZED)
    }


    @PostMapping("/auth/reissue")
    fun reissueAuthenticationToken(@RequestBody requestBody: ReissueAuthTokenRequestDto): ResponseEntity<ResponseDto?> {
        return try {
            val result = loginService.reissueAccessToken(requestBody.accessToken, requestBody.refreshToken)
            ResponseEntity.ok(ReissueAuthTokenResponseDto(result))
        } catch (e: ExpiredRefreshTokenException) {
            ResponseEntity(
                ErrorResponseDto(
                    ErrorCode.REFRESH_TOKEN_EXPIRED.name, e.message
                ), HttpStatus.FORBIDDEN
            )
        } catch (e: InvalidAccessTokenException) {
            ResponseEntity(
                ErrorResponseDto(
                    ErrorCode.INVALID_ACCESS_TOKEN.name, e.message
                ), HttpStatus.FORBIDDEN
            )
        } catch (e: Exception) {
            ResponseEntity.internalServerError().body(null)
        }
    }

}

// LoginService
@Service
class LoginService(
    private val jwtTokenProvider: JwtTokenProvider,
    private val redisTemplate: RedisTemplate<String, String>
) {

    private val hashOps = redisTemplate.opsForHash<String, String>()

    fun saveTokenAtCache(accessToken: String, refreshToken: String) {
        val key = "auth:login:${jwtTokenProvider.getEmailAddress(accessToken)}"
        val tokenData = hashMapOf(
            "accessToken" to accessToken,
            "refreshToken" to refreshToken
        )

        hashOps.putAll(key, tokenData)
        redisTemplate.expire(key, 14, TimeUnit.DAYS)
    }

    fun validateAccessToken(accessToken: String, refreshToken: String, key: String) {
        val storedAccessToken = hashOps.get(key, "accessToken")
        val storedRefreshToken = hashOps.get(key, "refreshToken")

        if (!jwtTokenProvider.validateToken(storedRefreshToken ?: "")) {
            throw ExpiredRefreshTokenException()
        }

        if (accessToken != storedAccessToken) {
            throw InvalidAccessTokenException()
        }
    }

    fun reissueAccessToken(accessToken: String, refreshToken: String): String? {
        val key = "auth:login:${jwtTokenProvider.getEmailAddress(accessToken)}"
        validateAccessToken(accessToken, refreshToken, key)
        val authentication = jwtTokenProvider.getAuthentication(accessToken)
        val newAccessToken = jwtTokenProvider.createToken(authentication, TokenType.ACCESS_TOKEN)

        hashOps.put(key, "accessToken", newAccessToken)
        return newAccessToken
    }
}

문제점

기존 로직은 서비스 레이어에서 커스텀한 예외가 발생하면 컨트롤러 레이어에서 잡아 try-catch 를 통해 성공 응답과 에러 응답을 보냈다. 이로 인해 생기는 문제점은 아래와 같다.

  1. 컨트롤러에서 반환하는 ResponseEntity<T>에서 제네릭 타입이 두가지 이므로 T에 Any 타입을 사용하거나 응답 DTO를 정의할 때 ResponseDto 인터페이스를 구현해야 한다.
  2. 예외 발생시 ResponseEntity를 반환하는 코드에서 코드의 중복이 심하다.
  3. 컨트롤러 레이어로 예외를 던져야 하기 때문에 필요한 커스텀 예외를 일일이 정의해야 한다.

해결방안

@RestControllerAdvice 사용

@RestControllerAdvice 어노테이션은 @ExceptionHandler, @ModelAttribute, @InitBinder 가 적용된 메서드들에 AOP를 적용해 Controller 레이어에 적용하기 위해 고안된 어노테이션이다
즉, 모든 @RestController 에 대한, 전역적으로 발생할 수 있는 예외를 잡아서 처리할 수 있다.

자매품으로 @ControllerAdvice 가 존재하는데 @Controller 에 적용된다.

@RestControllerAdvice 를 사용하면 컨트롤러 내에서는 에러처리를 할 필요가 없어지기 때문에 자연스럽게 ResponseEntity<T> 의 제네릭 타입은 성공 응답 객체 한 개이므로 1번 문제가 해결된다. 마찬가지로 에러 처리를 @RestControllerAdvice 로 전역적으로 처리하므로 2번 문제도 해결된다.

전역 커스텀 예외 사용

3번 문제는 새로운 CustomException 을 정의하고 에러 내용을 구체적으로 정의한ErrorCode 열거형 객체를 예외의 필드로 넘겨 하나의 예외만으로 모든 커스텀 에러를 처리하게 구현하여 해결하였다.

리팩토링 코드

// GlobalExceptionHandler - @RestControllerAdvice
@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(value = [CustomException::class])
    fun handlingCustomException(ex: CustomException): ResponseEntity<ErrorResponseDto> {
        val errorCode: ErrorCode = ex.errorCode
        val errorDto = ErrorResponseDto(errorCode = errorCode.name, message = errorCode.message)
        return ResponseEntity(errorDto, errorCode.status)
    }
}

// CustomException
class CustomException(
    val errorCode: ErrorCode
) : RuntimeException()

// ErrorCode - 내용 구체화
enum class ErrorCode(val status: HttpStatus, val message: String) {
    // 400 - Bad Request
    INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "업로드한 파일의 확장자가 올바르지 않습니다"),

    // 401 - Unauthorized
    LOGIN_FAIL(HttpStatus.UNAUTHORIZED, "로그인에 실패했습니다"),

    // 403 - Forbidden
    REFRESH_TOKEN_EXPIRED(HttpStatus.FORBIDDEN, "리프레시 토큰이 만료되었습니다"),
    INVALID_ACCESS_TOKEN(HttpStatus.FORBIDDEN, "유효하지 않은 엑세스 토큰입니다"),

    // 404 - Not Found
    MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND,"유저 정보를 찾을 수 없습니다"),

    // 409 - Conflict
    DUPLICATED_EMAIL_ADDRESS(HttpStatus.CONFLICT,"이미 가입된 이메일 주소입니다"),
    DUPLICATED_NICKNAME(HttpStatus.CONFLICT,"이미 존재하는 별명입니다"),

    // 500 - Internal Server Error
    FILE_UPLOAD_FAIL(HttpStatus.INTERNAL_SERVER_ERROR,"파일 업로드에 실패했습니다")
}

// LoginService
@Service
class LoginService(
    private val jwtTokenProvider: JwtTokenProvider,
    private val redisTemplate: RedisTemplate<String, String>
) {

    private val hashOps = redisTemplate.opsForHash<String, String>()

    fun saveTokenAtCache(accessToken: String, refreshToken: String) {
        val key = "auth:login:${jwtTokenProvider.getEmailAddress(accessToken)}"
        val tokenData = hashMapOf(
            "accessToken" to accessToken,
            "refreshToken" to refreshToken
        )

        hashOps.putAll(key, tokenData)
        redisTemplate.expire(key, 14, TimeUnit.DAYS)
    }

    fun validateAccessToken(accessToken: String, refreshToken: String, key: String) {
        val storedAccessToken = hashOps.get(key, "accessToken")
        val storedRefreshToken = hashOps.get(key, "refreshToken")

        if (!jwtTokenProvider.validateToken(storedRefreshToken ?: "")) {
            throw CustomException(ErrorCode.REFRESH_TOKEN_EXPIRED)
        }

        if (accessToken != storedAccessToken) {
            throw CustomException(ErrorCode.INVALID_ACCESS_TOKEN)
        }
    }

    fun reissueAccessToken(accessToken: String, refreshToken: String): String {
        val key = "auth:login:${jwtTokenProvider.getEmailAddress(accessToken)}"
        validateAccessToken(accessToken, refreshToken, key)
        val authentication = jwtTokenProvider.getAuthentication(accessToken)
        val newAccessToken = jwtTokenProvider.createToken(authentication, TokenType.ACCESS_TOKEN)

        hashOps.put(key, "accessToken", newAccessToken)
        return newAccessToken
    }
}

// LoginApiController
@RestController
@RequestMapping("/api/login")
class LoginApiController(
    private val loginService: LoginService
) {

    @GetMapping("/success")
    fun loginSuccess(@RequestParam access: String, @RequestParam refresh: String): ResponseEntity<LoginResponseDto> {
        loginService.saveTokenAtCache(access, refresh)
        return ResponseEntity(LoginResponseDto(access, refresh), HttpStatus.OK)
    }

    @GetMapping("/fail")
    fun loginFail(): ResponseEntity<ErrorResponseDto> {
        val errorCode = ErrorCode.LOGIN_FAIL
        return ResponseEntity(
            ErrorResponseDto(
                errorCode.name, errorCode.message
            ), errorCode.status
        )
    }

    @PostMapping("/auth/reissue")
    fun reissueAuthenticationToken(@RequestBody requestBody: ReissueAuthTokenRequestDto): ResponseEntity<ReissueAuthTokenResponseDto> {
        val result = loginService.reissueAccessToken(requestBody.accessToken, requestBody.refreshToken)
        return ResponseEntity(ReissueAuthTokenResponseDto(result), HttpStatus.OK)
    }

}

0개의 댓글