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 필드를 확인해보면 출력하려는 형식으로 응답한 것을 확인할 수 있습니다.