개발
home
🦋

Spring boot 예외 처리 - @RestControllerAdvice, @ExceptionHandler

Created
2022/02/14
Tags
SpringBoot
Exception
2022-02-14 @이영훈
제가 실무에서 Spring boot에서 예외처리하는 방식을 기록으로 남깁니다. @RestControllerAdvice와 @ExceptionHandler를 사용하여 예외를 처리하였습니다.
예외가 발생했을 때 다음과 같은 형식의 응답을 반환하고자 합니다.
{ "statusCode": 401, "message": "JWT is expired", "error": JWT_EXPIRED }
JSON
복사
statusCode는 http status code입니다. message 필드는 서버에서 내려줄 예외에 대한 설명이고 error는 서버에서 enum class로 미리 정의한 에러 코드입니다.

예외 발생시 반환할 클래스

// src/main/kotlin/{project}/exception/advice/ExceptionResult.kt class ExceptionResult( val statusCode: Int, val message: String, val error: ErrorCode? = null, )
Kotlin
복사

예외 처리할 클래스 작성

HttpException을 부모 클래스로 작성하고 4xx 에러와 5xx 에러를 상속받아 작성하였습니다
처리할 예외들의 부모 클래스 HttpException은 RuntimeException을 상속받아 throw로 예외로 던질 수 있습니다. ErrorCode는 enum 클래스로 정의하여 클라이언트와 미리 약속한 코드를 사용할 수 있게 하였습니다.
// src/main/kotlin/{project}/exception/exceptions/HttpException.kt open class HttpException( val statusCode: Int, val customMessage: String, val error: ErrorCode? = null, ) : RuntimeException() enum class ErrorCode { JWT_EXPIRED, JWT_MALFORMED, // more... }
Kotlin
복사
서버에서 직접 처리할 4xx 에러와 5xx 에러를 HttpException을 상속받아 정의하였습니다. 코틀린에서는 하나의 파일에 여러 클래스를 정의할 수 있어 여러 파일로 나누지 않고 작성할 수 있어 편리합니다. 자바에서는 클래스 하나 당 하나의 파일로 만드셔야 합니다.
// src/main/kotlin/{project}/exception/exceptions/HttpExceptions.kt // 4xx 에러 class BadRequestException(message: String, error: ErrorCode? = null) : HttpException(400, message, error) class UnauthorizedException(message: String, error: ErrorCode? = null) : HttpException(401, message, error) class ForbiddenException(message: String, error: ErrorCode? = null) : HttpException(403, message, error) class NotFoundException(message: String, error: ErrorCode? = null) : HttpException(404, message, error) class MethodNotAllowedException(message: String, error: ErrorCode? = null) : HttpException(405, message, error) class NotAcceptableException(message: String, error: ErrorCode? = null) : HttpException(406, message, error) class RequestTimeoutException(message: String, error: ErrorCode? = null) : HttpException(408, message, error) class ConflictException(message: String, error: ErrorCode? = null) : HttpException(409, message, error) class GoneException(message: String, error: ErrorCode? = null) : HttpException(410, message, error) class PreconditionFailedException(message: String, error: ErrorCode? = null) : HttpException(412, message, error) class PayloadTooLargeException(message: String, error: ErrorCode? = null) : HttpException(413, message, error) class UnsupportedMediaTypeException(message: String, error: ErrorCode? = null) : HttpException(415, message, error) class ATeapotException(message: String, error: ErrorCode? = null) : HttpException(418, message, error) class MisdirectedException(message: String, error: ErrorCode? = null) : HttpException(421, message, error) class UnprocessableEntityException(message: String, error: ErrorCode? = null) : HttpException(422, message, error) // 5xx 에러 class InternalServerErrorException(message: String, error: ErrorCode? = null) : HttpException(500, message, error) class NotImplementedException(message: String, error: ErrorCode? = null) : HttpException(501, message, error) class BadGatewayException(message: String, error: ErrorCode? = null) : HttpException(502, message, error) class ServiceUnavailableException(message: String, error: ErrorCode? = null) : HttpException(503, message, error) class GatewayTimeoutException(message: String, error: ErrorCode? = null) : HttpException(504, message, error) class HttpVersionNotSupportedException(message: String, error: ErrorCode? = null) : HttpException(505, message, error)
Kotlin
복사

RestControllerAdvice 작성

@RestControllerAdvice를 사용하여 모든 @Controller에서 발생하는 예외를 처리해주도록 합니다.
그리고 각각의 예외에 대해서 @ExceptionHandler로 처리하면 됩니다. 4xx, 5xx 에러 모두를 하나씩 처리하기에 너무 많기 때문에 부모 클래스 HttpExcpetion을 처리하여 자식 에러를 모두 처리하는 방식으로 구현하였습니다. @ExceptionHandler는 딱맞는 타입이 있는 지 먼저 체크한 후에 없으면 부모 클래스 → 그 다음 부모 클래스...로 검색하기 때문입니다.
// src/main/kotlin/{project}/exception/advice/ExceptionControllerAdvice.kt import me.leedo.exceptionapi.exception.exceptions.ExceptionResult import me.leedo.exceptionapi.exception.exceptions.HttpException import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice @RestControllerAdvice class ExceptionControllerAdvice { @ExceptionHandler fun httpExceptionHandler(e: HttpException): ResponseEntity<ExceptionResult> { val exceptionResult = ExceptionResult(e.statusCode, e.customMessage, e.error) return ResponseEntity(exceptionResult, HttpStatus.valueOf(e.statusCode)) } /* 이렇게 똑같은 코드 계속 반복하기 싫어서 부모 클래스(HttpException)로 한 번에 처리 @ExceptionHandler(UnauthorizedException::class) fun unauthorizedExceptionHandler(e: UnauthorizedException): ResponseEntity<ExceptionResult> { val exceptionResult = ExceptionResult(e.statusCode, e.customMessage, e.error) return ResponseEntity(exceptionResult, HttpStatus.valueOf(e.statusCode)) } @ExceptionHandler fun forbiddenExceptionHandler(e: ForbiddenException): ResponseEntity<ExceptionResult> { val exceptionResult = ExceptionResult(e.statusCode, e.customMessage, e.error) return ResponseEntity(exceptionResult, HttpStatus.valueOf(e.statusCode)) } */ }
Kotlin
복사

테스트용 컨트롤러 작성

// src/main/kotlin/{project}/example/ErrorHandlerController.kt @RestController @RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) class ErrorHandlerController { @GetMapping("/404") fun error404() { throw NotFoundException("There is no matching user") } @GetMapping("/401") fun error401( @RequestParam expired: Boolean, ) { if (expired) { throw UnauthorizedException("JWT is expired", ErrorCode.JWT_EXPIRED) } throw UnauthorizedException("JWT is malformed", ErrorCode.JWT_MALFORMED) } }
Kotlin
복사

테스트코드 작성

// src/test/kotlin/{project}/example/ErrorHandlerControllerTest.kt import org.hamcrest.Matchers.equalTo import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @AutoConfigureMockMvc @Transactional internal class HealthControllerTest { @Autowired private lateinit var mockMvc: MockMvc @Test fun test404Error() { mockMvc.perform(get("/404")) .andExpect(status().isNotFound) .andExpect(jsonPath("$.statusCode", equalTo(404))) .andExpect(jsonPath("$.message", equalTo("There is no matching user"))) .andExpect(jsonPath("$.error", equalTo(null))) .andDo(print()) } @Test fun test401ErrorCase1() { mockMvc.perform(get("/401?expired=true")) .andExpect(status().isUnauthorized) .andExpect(jsonPath("$.statusCode", equalTo(401))) .andExpect(jsonPath("$.message", equalTo("JWT is expired"))) .andExpect(jsonPath("$.error", equalTo("JWT_EXPIRED"))) .andDo(print()) } @Test fun test401ErrorCase2() { mockMvc.perform(get("/401?expired=false")) .andExpect(status().isUnauthorized) .andExpect(jsonPath("$.statusCode", equalTo(401))) .andExpect(jsonPath("$.message", equalTo("JWT is malformed"))) .andExpect(jsonPath("$.error", equalTo("JWT_MALFORMED"))) .andDo(print()) } }
Kotlin
복사
출력 결과는 다음과 같습니다. Body 필드를 확인해보면 출력하려는 형식으로 응답한 것을 확인할 수 있습니다.

Reference