본문 바로가기
Spring Framework/스프링

스프링 API 예외처리 방법 : @RestControllerAdvice [Kotlin]

by 스코리아 2024. 1. 8.

안녕하세요, 스코리아입니다.

오늘은 스프링의 @RestControllerAdvice 어노테이션을 이용해서 API 예외처리 하는 방법에 대해서 알아보겠습니다. 언어는 코틀린(Kotlin)으로 진행하겠습니다.

스프링 예외처리 - REST API

 

@RestControllerAdvice 예외처리가 필요한 이유?

만약 비즈니스 계층에서 팀원들이 각각 예외처리를 하고 있는 프로젝트라면, 발생시키는 에러 메시지, 규격, 규율 등이 달라 응답이 정규화되지 않을 것입니다. 하지만 이러한 예외처리 로직을 비즈니스 로직과 분리하여 @RestControllerAdvice 어노테이션이 적힌 클래스 한 곳에서 예외처리를 담당한다면, 일관된 형식의 응답을 제공할 수 있어, 코드의 가독성과 유지 보수성을 높이는데 도움이 될 것입니다.

 

즉 요약하자면, @RestControllerAdvice를 사용하면 프로젝트 전반에서 발생하는 예외를 일괄적으로 처리하기 때문에, 중복적인 코드를 줄일 수 있으며 안정성과 유지보수성을 높일 수 있습니다.

 

 

@ControllerAdvice와 @RestControllerAdvice는 어떻게 다른가?

'@ControllerAdvice'와 '@RestControllerAdvice' 모두 Spring 프레임워크에서 예외 처리를 위해 사용되는 어노테이션입니다. 이 어노테이션은 모두 전역 예외 처리를 지원하며, 예외가 발생했을 때 특정한 처리를 수행할 수 있도록 돕습니다. 다만, 차이점이 존재합니다.

 

1. @ControllerAdvice

- Spring MVC에서 사용되며, 주로 전통적인 웹 서비스를 하는 프로젝트에 사용됩니다.

- ModelAndView를 반환하거나 뷰이름(String)을 반환합니다. 

 

2. @RestControllerAdvice

- REST API 서비스를 하는 프로젝트에 사용됩니다.

- JSON, XML 형태로 ResponseEntitiy나 @ResponseBody를 통해 반환합니다.

 

즉, @ControllerAdvice 일반적인 뷰 애플리케이션에, @RestControllerAdvice REST API 서비스에 사용됩니다. 웹(뷰)과 API를 동시에 사용한다면 각각 정의해 주어도 무방합니다.

 

 

@ExceptionHandler란?

@ExceptionHandler 어노테이션은 스프링에서 특정 예외가 발생했을 때 @ExceptionHandler가 달린 메소드가 호출되도록 지정하는 어노테이션으로, 특정 컨트롤러나 @ControllerAdvice, @RestControllerAdvice 클래스 내에서 사용할 수 있습니다.

 

일반적으로 @ExceptionHandler는 예외처리 메소드를 정의하는 데 사용되며, 해당 메소드는 예외 타입을 매개변수로 받아서 해당 예외에 대한 처리 로직을 수행합니다. 이를 통해 예외가 발생했을 때 특정한 로직을 수행하거나 일관된 에러 응답을 제공할 수 있습니다.

 

@ExceptionHandler을 통해 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수가 있습니다. (스프링에서는 항상 자세한 것이 우선권을 가집니다)

 


API 예외처리 : 코드로 구현하기

이제 Kotlin 언어를 사용하여, 스프링에서 API 예외처리를 코드로 구현해 보겠습니다.

@RestControllerAdvice를 이용하여 다양한 방법으로 예외처리하는 방법을 공유해 드릴 테니, 직접 커스텀하여 사용해 주시면 되겠습니다.

 

1. ResultCode

먼저, 에러가 발생했을 때 statusCode와 message를 정의할 'ResultCode' 이름의 enum class를 만들어보겠습니다.

enum class ResultCode(val statusCode: Int, val message: String) {
    SUCCESS(HttpStatus.OK.value(), "성공"), // 200
    NOT_FOUND(HttpStatus.NOT_FOUND.value(), "요청하신 api를 찾을 수 없습니다."), // 404
    BAD_REQUEST(HttpStatus.BAD_REQUEST.value(), "항목이 올바르지 않습니다"), // 400
    INVALID_DATA(HttpStatus.BAD_REQUEST.value(), "데이터 처리 오류 발생"), // 400
    INVALID_JSON(HttpStatus.INTERNAL_SERVER_ERROR.value(), "전달된 JSON 형식이 올바르지 않습니다"), // 400
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 에러가 발생했습니다"), // 500
    TOKEN_EXPIRED(HttpStatus.FORBIDDEN.value(), "토큰이 만료되었습니다"), // 403
    LOGIN_ERROR(HttpStatus.BAD_REQUEST.value(), "아이디 혹은 비밀번호를 다시 확인하세요."), // 403
    INVALID_ACCESS_TOKEN(HttpStatus.FORBIDDEN.value(), "토큰이 유효하지 않습니다"), // 403
}

 

위와 같이 사용할법한 9개의 enum을 만들어두었습니다.

 

 

2. BaseReponse

다음으로 API 응답의 기본 규격을 만들어보겠습니다. 저는 statusCode, statusMessage, responseTime(API 호출 시간), data(API 호출 성공 시 나오는 데이터)를 멤버변수로 하는 BaseReponse 이름의 data class를 만들어주었습니다.

data class BaseResponse<T>(
    val statusCode: Int = ResultCode.SUCCESS.statusCode,
    val statusMessage: String? = ResultCode.SUCCESS.message,
    val responseTime: String = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
    val data: T? = null
)

 

 

3-0. CustomExceptionHandler

다음으로 @RestControllerAdvice 어노테이션을 사용하는 CustomExceptionHandler 이름의 클래스를 만들어보겠습니다.

@RestControllerAdvice
class CustomExceptionHandler {

    // @Valid 어노테이션이 사용될때, DTO 검증 실패시 예외처리
    @ExceptionHandler(MethodArgumentNotValidException::class)
    protected fun methodArgumentNotValidException(ex: MethodArgumentNotValidException): ResponseEntity<BaseResponse<String>> {
        val error = ex.bindingResult.allErrors[0]
        return ResponseEntity(BaseResponse(statusCode = ResultCode.BAD_REQUEST.statusCode, statusMessage = (error as FieldError).field + ": " + error.defaultMessage), HttpStatus.BAD_REQUEST)
    }

    /// 매개변수 값이 올바르게 처리 되지 않았을때 에러처리
    @ExceptionHandler(IllegalArgumentException::class)
    protected fun illegalArgumentException(ex: IllegalArgumentException): ResponseEntity<BaseResponse<String>> {
        return ResponseEntity(BaseResponse(statusCode = ResultCode.BAD_REQUEST.statusCode, statusMessage = ex.message), HttpStatus.BAD_REQUEST)
    }

    // 기본적인 에러 처리
    @ExceptionHandler(Exception::class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    fun handleException(ex: Exception): ResponseEntity<BaseResponse<String>> {
        val resultCode = when (ex) {
            is BadCredentialsException -> ResultCode.LOGIN_ERROR
            is BindException -> ResultCode.INVALID_DATA
            is HttpMessageNotReadableException -> ResultCode.INVALID_JSON
            is SignatureException, is SecurityException, is MalformedJwtException -> ResultCode.INVALID_ACCESS_TOKEN
            is ExpiredJwtException -> ResultCode.TOKEN_EXPIRED
            is NoHandlerFoundException -> ResultCode.NOT_FOUND
            else -> ResultCode.INTERNAL_SERVER_ERROR
        }

        return ResponseEntity(BaseResponse(resultCode.statusCode, resultCode.message), HttpStatusCode.valueOf(resultCode.statusCode))
    }

    // 잘못된 Request Method(GET, POST, PUT..)으로 호출되었을때 예외처리
    @ExceptionHandler(HttpRequestMethodNotSupportedException::class)
    protected fun httpRequestMethodNotSupportedException(ex: HttpRequestMethodNotSupportedException, req: HttpServletRequest): ResponseEntity<BaseResponse<String>> {
        return ResponseEntity(BaseResponse(statusCode = ResultCode.INTERNAL_SERVER_ERROR.statusCode, statusMessage = "Does not support request method '" + req.method + "'"), HttpStatus.INTERNAL_SERVER_ERROR)
    }
}

 

모든 메소드들은 ResponseEntitiy(JSON 타입)으로 반환하게 되며, ResponseEntitiy 안에는 2에서 만들었던 API 표준 응답 객체인 BaseReponse를 사용하게 됩니다.

 

위 메소드들을 통해 예외가 어떻게 발생하는지, 아래에서 예시를 통해 하나씩 알아보겠습니다.

 

 

3-1. methodArgumentNotValidException 메소드 예외 확인하기 [CustomExceptionHandler]

아래는 MemberDtoRequest (회원가입 request시 받는 DTO)입니다. @field:NotBlank, @field:Pattern 어노테이션이 적혀있는데, 필드 예외가 발생하면 MethodArgumentNotValidException이 발생하여, CustomExceptionHandler의  methodArgumentNotValidException 메소드가 실행됩니다.

data class MemberDtoRequest(
    var id: Long?,

    @field:NotBlank
    @field:Pattern(regexp = USER_ID_PATTERN, message = USER_ID_MESSAGE)
    @JsonProperty("userId")
    private val _userId: String?,

    @field:NotBlank
    @field:Pattern(regexp = PASSWORD_PATTERN, message = PASSWORD_MESSAGE)
    @JsonProperty("password")
    private val _password: String?
){
    val userId: String
        get() = _userId!!
    val password: String
        get() = _password!!
}

 

아래는 회원가입 컨트롤러입니다. memberDtoRequest 앞에 @Valid 어노테이션을 붙인 상태입니다. @Valid 어노테이션을 붙이지 않으면, DTO의 @field 어노테이션 검증이 작동하지 않으니, 꼭 붙여주셔야 합니다.

@RequestMapping("/api/member")
@RestController
class MemberController(
    private val memberService: MemberService
) {
    @PostMapping("/signup")
    fun signUp(@RequestBody @Valid memberDtoRequest: MemberDtoRequest): BaseResponse<Unit> {
        val resultMsg: String = memberService.signUp(memberDtoRequest)
        return BaseResponse(statusMessage = resultMsg)
    }
}

 

이제 테스트해 보겠습니다.

userId를 "a"로 request하여 API를 호출한 결과, DTO의 userId @field:Pattern의 regexp가 일치하지 않았기 때문에, MethodArgumentNotValidException이 발생하여 CustomExceptionHandler의 methodArgumentNotValidException 메소드가 호출되었습니다. 이 메소드 안에서는 ex.bindingResult.allErrors[0] (여러 필드에 문제가 있다면 문제가 있는 필드의 가장 첫 번째 필드)를 statusMessage로 가져옵니다.

3-1 : methodArgumentNotValidException 예외 발생 결과

 

 

3-2. illegalArgumentException 메소드 예외 확인하기 [CustomExceptionHandler]

회원가입 서비스에는 아래와 같이 중복 아이디를 검증하는 로직이 존재한다고 예를 들어보겠습니다.

private fun checkDuplicateUserId(userId: String) {
    val findMember: Member? = memberRepository.findByUserId(userId)
    if (findMember != null) {
        throw IllegalArgumentException("이미 등록된 ID 입니다.")
    }
}

 

'throw IllegalArgumentException(message)'  구조입니다.

 

회원가입 API를 호출하여 '중복 아이디 테스트'를 해보겠습니다.

CustomExceptionHandler의 illegalArgumentException 메소드에서 BaseReponse의 statusMessage를 ex.message로 사용하였기 때문에 "이미 등록된 ID 입니다" message가 출력되었습니다.

3-2 : illegalArgumentException 예외 발생 결과

 

 

3-3. handleException 메소드 예외 확인하기 [CustomExceptionHandler]

handleException 은 기본적인 예외, 즉 CustomExceptionHandler에서 별도로 처리하지 않은 예외들을 처리하는 메소드입니다. 그러므로 Exception.class (기본적인 예외)를 잡을 수 있는 @ExceptionHandler(Exception::class) 어노테이션을 붙였습니다. 다만, 여기서 일부 예외들을 when으로 분류하여 주었습니다.

 

4-1, 4-2와 동작결과는 비슷하므로 따로 테스트해보지 않겠습니다. 

 

 

3-4. httpRequestMethodNotSupportedException 메소드 예외 확인하기 [CustomExceptionHandler]

HttpRequestMethodNotSupportedException 은 Controller에서 Mapping 한 Request Method(POST, GET, PUT..) 등과 일치하지 않은 경우 발생하는 예외입니다. 회원가입 컨트롤러에는 @PostMapping 어노테이션이 적용되어 있으므로, POST가 아닌 다른 메소드로 호출되면 해당 예외가 발생할 것입니다.

 

GET으로 호출하여 테스트해 보면, 아래와 같이 예외가 발생합니다.

3-4 : httpRequestMethodNotSupportedException 예외 발생 결과

 

 

4. Custom 예외 처리

비즈니스 로직에서 statusCode와 statusMessage를 Custom 하게 설정하고 싶다면, 아래와 같이 사용해보세요.

 

우선 ApiCustomException 이름의 data class 만듭니다. 이때 RuntimeException을 상속받습니다.

data class ApiCustomException(
    val statusCode: Int,
    val statusMessage: String
) : RuntimeException()

 

CustomExceptionHandler 클래스에 아래의 코드를 추가해 주세요.

@ExceptionHandler에 ApiCustomException 클래스를 넣어주었습니다.

// 커스텀 에러 처리
@ExceptionHandler(ApiCustomException::class)
protected fun apiCustomException(ex: ApiCustomException): ResponseEntity<BaseResponse<String>> {
    return ResponseEntity(BaseResponse(statusCode = ex.statusCode, statusMessage = ex.statusMessage), HttpStatus.BAD_REQUEST)
}

 

아까 3-2에서 보았던 아이디 중복 체크 함수입니다. 아래와 같이 ApiCustomException에 statusCode와 statusMessage를 커스텀하게 설정하여, throw 해줄 수 있습니다.

 

private fun checkDuplicateUserId(userId: String) {
    val findMember: Member? = memberRepository.findByUserId(userId)
    if (findMember != null) {
         throw ApiCustomException(ResultCode.INVALID_DATA.statusCode, "이미 등록된 ID 입니다.")
    }
}

 

예외 응답 결과는 3-2와 동일합니다.

이외에도 다양한 방법으로 예외를 처리해 볼 수 있습니다.

 


지금까지 스프링의 API에서 예외처리 하는 방법에 대해서 알아보았습니다. @RestControllerAdvice 의미, 사용법 및 @ExceptionHandler가 무엇인지에 대해서 살펴보았습니다.

 

읽어주셔서, 감사합니다.