kotlin spring 에서 유용한 에러 반환하기.
이 글을 쓰는 동기
좋은 api 는 사용성이 좋아야한다고 생각한다. 무엇이 잘못되었을 때 잘못된 것이 무엇인지 안다는 것만으로도 빠르게 문제를 해결할 수 있게 된다. 복잡하게 생각하지 않더라도 RequestBody 의 특정 필드의 타입이 잘못되었거나 필요한 필드가 제공되지 않은 경우 왜 발생했는지만 알아도 빠르게 해결할 수 있다.
이 글에서 Request 필드에 대한 검증로직을 작성하고 유용한 에러를 반환하는 방법을 소개하려 한다.
api 작성하기
@RestController
class SomeController {
@PostMapping("/some-api")
fun someHandler(@RequestBody someBody: SomeBody): String {
return "your request is successfully processed"
}
}
data class SomeBody(
val name: String,
val age: Int,
)
위의 어플리케이션은 “/some-api” 라는 post api 를 제공한다. api 를 성공적으로 처리하기 위해 SomeBody 라는 형태의 데이터를 받는다. SomeBody 는 아래와 같은 json 형태를 가져야한다.
{
"name": "만냥이",
"age": 6
}
name 필드와 age 필드 모두 제공되어야 하며 각각의 타입은 String, Int 타입으로 제공되어야한다. 성공적으로 api 가 처리된다면 문제가 없겠지만 api 를 사용하는 클라이언트가 필요한 필드의 정보를 잘못 제공했을 수도 있다. 예를 들어 위의 api 에 name 필드를 제공하지 않은 상태서 api 를 호출한다면 Spring 의 기본 에러 반환 정보가 제공될 것이다.
// spring 기본 제공
{
"timestamp": "2022-06-02T13:39:43.894+00:00",
"path": "/some-api",
"status": 400,
"error": "Bad Request",
"requestId": "e5e0a6c0-1"
}
클라이언트가 위 에러를 받을 경우 어떻게 해야 이 오류를 해결할 수 있을까? api 구조를 알고 있다면 Bad Request 가 RequestBody 의 필드매핑에서 오류가 발생했다고 추측하여 잘못된 필드가 있는지 살펴볼 것이다. 위의 “/some-api” api 는 단순하여 에러를 쉽게 찾을 수 있지만, 실제로 사용하는 api 의 경우 필드가 더 복잡하여 에러의 이유를 알기 힘들 것이다. 어떤 필드에서 오류가 발생했는지 찾는데만 상당한 시간을 소비해야 할 수도 있다.
어떻게 하면 더 유용한 에러를 보여줄 수 있을까??
대형서비스들의 에러 반환 정보
대형 서비스들은 어떻게 에러 처리를 하는지 검색하던중 잘 정리된 글이 있었다.
https://blog.ull.im/engineering/2019/03/14/service-providers-errors.html
서비스마다 제각각이긴 하지만 클라이언트가 사용할 수 있는 정보들을 제공해주고 있었다. 에러 반환 내용을 정할때 클라이언트와 협의를 통해 어떤 내용을 반환해주면 좋을지를 상의하면 더 좋은 반환형식을 도출해낼 수 있을 것이다.
필자는 Requset 필드 검증 에러의 경우 아래와 같이 만들기로 했다.
{
"errors": [
{
"type": "INVALID_PARAMETER",
"message": "must be a well-formed email address",
"fieldName": "email",
"rejectedValue": "asdf"
},
{
"type": "INVALID_PARAMETER",
"message": "must be greater than or equal to 15",
"fieldName": "age",
"rejectedValue": "13"
},
{
"type": "INVALID_PARAMETER",
"message": "must not be null",
"fieldName": "isHuman",
"rejectedValue": null
}
]
}
에러의 타입이 무엇인지 왜 발생했는지 어떤 필드에서 에러가 발생되었는지 그리고 어떤 값이 제공되었는지를 알려주는 것을 목표로 구성했다. 필드에 대한 검증은 복수의 에러를 반환할 수 있기 때문에 에러 내용 또한 복수개로 구성할 수 있도록 했다.
필요한 의존성
gradle kts 기준으로 아래의 의존성을 추가해준다.
implementation("org.springframework.boot:spring-boot-starter-validation")
이 패키지는 Request 필드를 검증할 때 사용하는 validation 어노테이션을 제공한다. Request 필드를 검증하여 오류를 반환할 것이 아니라면 위 의존성은 추가하지 않아도 된다.
RequestBody 정보에 검증할 정보 추가
data class SomeBody(
@field:NotNull val name: String,
@field:Positive val age: Int,
)
name 필드에 NotNull, age 필드에 Positive 정보를 추가했다.
주의할 점이 있는데 어노테이션 적용을 할 때 @field: 를 통해 Property 의 필드에 적용할 것이라는 것을 명시해주어야한다. 적용범위를 설정하지 않으면 SomeBody 의 필드가 아니라 생성자 파라메터에 어노테이션이 적용된다. 실제로 빌드후 자바 파일을 보면 생성자 파라미터에 어노테이션이 적용되어 있을 것이다. 한 가지 더 주의해야 할 점은 일부 어노테이션이 Java Primitive 타입에 해당하는 코틀린 타입에 적용이 불가능하다. 예를들어 위의 age 필드는 코틀린에서는 Int 타입이지만 이는 자바에서 int 타입이다. Java Primitive 타입은 기본적으로 not null 해야하므로 별다른 설정을 하지 않았을 때 그리고 값이 제공되지 않았을 때는 기본값이 매핑이된다. (int 는 0, boolean 은 false …) 따라서 검사하고자 하는 타입에 ? 를 붙여 Java Primitive 타입이 아닌 형식으로 지정해주는 것이 좋다.
RequestBody 어노테이션에 이어 Valid 어노테이션을 붙이면 Request 필드를 바인딩하는 과정에서 우리가 설정한 검증을 하게된다.
@RestController
class SomeController {
@PostMapping("/some-api")
fun someHandler(@RequestBody @Valid someBody: SomeBody): String {
return "your request is successfully processed"
}
}
data class SomeBody(
@field:NotNull val name: String,
@field:Positive val age: Int,
)
api 를 테스트해보면 검증에 실패할 경우 spring 기본 에러 반환프레임에 http 400 코드로 오류가 발생하는 것을 알 수 있다. 유용한 에러내용을 반환하려면 여기서 에러가 발생한 이유와 필드, 적용값 등 필요한 정보를 제공해주는 것이 좋다.
유용한 에러내용 설정하기.
ControllerAdvice 어노테이션과 ExceptionHandler 어노테이션을 사용하면 특정에러 반환 내용을 커스텀 할 수 있다. ControllerAdvice 를 적용하면 모든 Controller 기반 Bean 들에 적용되며 일부 컨트롤러에만 적용하려면 Controller Bean 안에서 ExceptionHandler 를 정의하면 된다.
@ControllerAdvice
class ErrorHandling {
@ExceptionHandler(value = [MissingKotlinParameterException::class])
fun missingFormatArgumentExceptionHandler(e: MissingKotlinParameterException): ResponseEntity<Any> {
val error = ErrorFrame(
errors = listOf(
ErrorContent(
type = "INVALID_PARAMETER",
message = "${e.parameter.name} is missing or null",
fieldName = e.parameter.name.toString(),
rejectedValue = null
)
)
)
return ResponseEntity(error, HttpStatus.BAD_REQUEST)
}
// enum, LocalDateTime 매핑 오류.
@ExceptionHandler(value = [InvalidFormatException::class])
fun invalidFormatException(e: InvalidFormatException): ResponseEntity<Any> {
val error = ErrorFrame(
errors = listOf(
ErrorContent(
type = "INVALID_PARAMETER",
message = "${e.value} is not acceptable",
fieldName = e.path.first().fieldName,
rejectedValue = e.value.toString()
)
)
)
return ResponseEntity(error, HttpStatus.BAD_REQUEST)
}
// javax.validation.constraints 오류
@ExceptionHandler(value = [MethodArgumentNotValidException::class])
fun handleMethodArgumentNotValidException(e: MethodArgumentNotValidException): ResponseEntity<Any> {
val error = ErrorFrame(
errors = e.fieldErrors.map {
ErrorContent(
type = "INVALID_PARAMETER",
message = it.defaultMessage ?: "유효하지 않은 값입니다.",
fieldName = it.field,
rejectedValue = it.rejectedValue?.toString()
)
}
)
return ResponseEntity(error, HttpStatus.BAD_REQUEST)
}
@ExceptionHandler(value = [MissingServletRequestParameterException::class])
fun missingServletRequestParameterException(e: MissingServletRequestParameterException): ResponseEntity<Any> {
val error = ErrorFrame(
errors = listOf(
ErrorContent(
type = "INVALID_PARAMETER",
message = "${e.parameterName} is missing or null",
fieldName = e.parameterName,
rejectedValue = null
)
)
)
return ResponseEntity(error, HttpStatus.BAD_REQUEST)
}
// GetMapping 에서 LocalDateTime, Enum 오류발생시 사용
@ExceptionHandler(value = [MethodArgumentTypeMismatchException::class])
fun methodArgumentTypeMismatchException(e: MethodArgumentTypeMismatchException): ResponseEntity<Any> {
val error = ErrorFrame(
errors = listOf(
ErrorContent(
type = "INVALID_PARAMETER",
message = "${e.value} is not acceptable",
fieldName = e.name,
rejectedValue = e.value.toString()
)
)
)
return ResponseEntity(error, HttpStatus.BAD_REQUEST)
}
// Webflux 기준 PostMapping 오류
@ExceptionHandler(value = [WebExchangeBindException::class])
suspend fun webExchangeBindExceptionHandler(e: WebExchangeBindException): ResponseEntity<Any> {
val errors = ErrorFrame(
errors = e.fieldErrors.map {
ErrorContent(
type = "INVALID_PARAMETER",
message = it.defaultMessage ?: e.localizedMessage,
fieldName = it.field,
rejectedValue = it.rejectedValue.toString()
)
})
return ResponseEntity(errors, HttpStatus.BAD_REQUEST)
}
}
data class ErrorFrame(
val errors: List<ErrorContent>
)
data class ErrorContent(
val type: String,
val message: String,
val fieldName: String?,
val rejectedValue: String?,
)
에러 반환 내용에는 간단하게 에러 type, message, 그리고 어떤 필드에서 발생했는지, 그리고 어떤 값이 검증에 실패했는지를 보여주기 위해 ErrorContent 클래스를 정의했다. 어노테이션 검증의 예외 내용은 복수 필드의 정보를 포함할 수 있다.
위의 Error 종류에 대한 설명
- MethodArgumentNotValidException: 검증 어노테이션 적용후 RequestBody 필드에 검증이 실패한 경우 발생하는 오류
- MissingKotlinParameterException: 검증 어노테이션을 적용하지는 않았지만 NotNull 타입에 null 값이 적용된 경우 발생하는 오류 Java Primitive 에 해당하는 필드는 제외.
- InvalidFormatException: PostMapping 에서 LocalDateTime 또는 Enum 타입에 대해서 형식이나 잘못된 값이 들어온 경우 발생하는 오류
- MissingServletRequestParameterException: GetMapping 에서 NotNull 타입에 null 값이 적용된 경우 발생하는 오류 Java Primitive 에 해당하는 필드는 제외.
- MethodArgumentTypeMismatchException: GetMapping 에서 LocalDateTime 또는 Enum 타입에 대해서 형식이나 잘못된 값이 들어온 경우 발생하는 오류
- WebExchangeBindException: Webflux 기준 PostMapping 에서 RequestBody 검증 실패시 반환하는 오류
각 에러마다 메서드나 필드가 다르기 때문에 다르게 설정을 해야한다.
api 를 통해 유효하지 않은 필드가 제공될 경우 아래와 같은 에러가 반환된다.
{
"errors": [
{
"type": "INVALID_PARAMETER",
"message": "must not be null",
"fieldName": "notNullField",
"rejectedValue": "null"
},
{
"type": "INVALID_PARAMETER",
"message": "must be greater than 0",
"fieldName": "age",
"rejectedValue": "-1"
}
]
}
후기
ControllerAdvice 와 ExceptionHandler 어노테이션을 사용하여 처리할 에러를 정의하면 애플리케이션에서 유용한 에러를 반환하여 클라이언트의 생산성을 증대시킬 수 있다. 예를 들어 BusinessLogicException 같은 커스텀 Exception 을 만들어 서비스 layer 에서 에러가 발생할 경우 에러를 던지게 하는 식으로 계층을 구분하여 에러 내용을 만들 수 있다.