Spring Boot + Kotlin 깔끔한 validation 처리 (feat. Jackson)
Spring boot

Spring Boot + Kotlin 깔끔한 validation 처리 (feat. Jackson)

스프링 부트 의존성 라이브러리를 확인했을 때 별도의 라이브러리를 추가하지 않더라도 기본 starter 킷에 Jackson 라이브러리가 포함되어 있는 것을 확인할 수 있습니다.

Spring Boot를 이용한 HTTP API 서버 구현시 요청의 body 값을 대부분 JSON으로 받게됩니다. 또한 응답을 Class Instance로 반환하면 JSON으로 변환하여 응답하게 됩니다. 때문에 직렬화와 역직렬화가 수행되는데, 스프링부트에서는 @RestController 어노테이션이 달린경우 Jackson 라이브러리가 직렬화와 역직렬화를 담당하게 됩니다.

대부분의 변환 과정에서 큰 문제는 없지만 스프링부트와 Kotlin을 함께 사용하게 된다면 이야기가 조금 달라집니다.

@RestController
class ExampleController {
    @PostMapping("/example")
    fun example(@RequestBody request: ExampleRequest): ExampleRequest {
        return request
    }
}

data class ExampleRequest(
    val exampleNumber: Int
)

Kotlin으로 작성된 위 API는 ExampleRequest 객체를 JSON으로 받아 그대로 응답해주는 역할을 수행합니다. 

하지만 exampleNumber를 null 값으로 요청하게 된다면 exampleNumber는 0으로 응답이 오게됩니다. 

@PostMapping("/example")
fun example(@RequestBody request: ExampleRequest): ExampleRequest {
    println(request.exampleNumber) // 0
    return request
}

위 처럼 로그를 찍어보면 exampleNumber의 값이 0인 것을 확인할 수 있습니다. 즉, 역직렬화에서 값이 주입되었다고 추론할 수 있습니다. 그렇다면 jackson에서 값이 주입되었는지 테스트 코드 작성을 통해 확인해보겠습니다.

data class IntValue(val value: Int)

class ObjectMapperTest : FreeSpec({
    val objectMapper = jacksonObjectMapper()

    "빈 JSON을 역직렬화하면 Int타입은 0이다." {
        // given
        val json = "{}"

        // when
        val result = objectMapper.readValue<IntValue>(json)

        // then
        result.value shouldBe 0
    }
})

위 상황에서 역시 0이 주입됩니다. 결론적으로 원인은 Jackson을 이용한 역직렬화에서 기본 값이 주입된 것이었습니다. 따라서 jacksonObjectMapper의 옵션 중 FAIL_ON_NULL_FOR_PRIMITIVES 옵션을 적용시킨 후 테스트해보겠습니다.

class ObjectMapperTest : FreeSpec({
    val objectMapper = jacksonObjectMapper()
        .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
        // 옵션 추가

    "빈 JSON을 Int타입이 존재하는 클래스로 역직렬화하면 MismatchedInputException이 발생한다." {
        // given
        val json = "{}"

        // when & then
        shouldThrow<MismatchedInputException> {
            objectMapper.readValue<IntValue>(json)
        }
    }
})

위와 같이 옵션을 킨 경우 에러가 발생함을 보실 수 있습니다. 그렇다면 다른 타입도 작동이 되는지 테스트 코드로 검증해보겠습니다.

data class IntValue(val value: Int)
data class DoubleValue(val value: Double)
data class LongValue(val value: Long)
data class BooleanValue(val value: Boolean)
data class ParentClass(val value: NestedClass)
data class NestedClass(val value: Int)

class ObjectMapperTest : FreeSpec({
    val objectMapper = jacksonObjectMapper()
        .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)

    "IntValue" {
        // given
        val json = "{}"

        // when & then
        shouldThrow<MismatchedInputException> {
            objectMapper.readValue<IntValue>(json)
        }
    }

    "DoubleValue" {
        // given
        val json = "{}"

        // when & then
        shouldThrow<MismatchedInputException> {
            objectMapper.readValue<DoubleValue>(json)
        }
    }

    "LongValue" {
        // given
        val json = "{}"

        // when & then
        shouldThrow<MismatchedInputException> {
            objectMapper.readValue<LongValue>(json)
        }
    }

    "BooleanValue" {
        // given
        val json = "{}"

        // when & then
        shouldThrow<MismatchedInputException> {
            objectMapper.readValue<BooleanValue>(json)
        }
    }

    "ParentClass" {
        // given
        val json = """
            {
              "value": {}
            }
        """.trimIndent()

        // when & then
        shouldThrow<MismatchedInputException> {
            objectMapper.readValue<ParentClass>(json)
        }
    }
})

그렇다면 nullable한 타입도 null 값으로 잘 들어가는지 확인해보겠습니다.

data class NullIntValue(val value: Int?)
data class NullDoubleValue(val value: Double?)
data class NullLongValue(val value: Long?)
data class NullBooleanValue(val value: Boolean?)

class ObjectMapperTest : FreeSpec({
    val objectMapper = jacksonObjectMapper()
        .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)

    "NullIntValue" {
        // given
        val json = "{}"

        // when
        val result = objectMapper.readValue<NullIntValue>(json)

        // then
        result.value shouldBe null
    }

    "NullDoubleValue" {
        // given
        val json = "{}"

        // when
        val result = objectMapper.readValue<NullDoubleValue>(json)

        // then
        result.value shouldBe null
    }

    "NullLongValue" {
        // given
        val json = "{}"

        // when
        val result = objectMapper.readValue<NullLongValue>(json)

        // then
        result.value shouldBe null
    }

    "NullBooleanValue" {
        // given
        val json = "{}"

        // when
        val result = objectMapper.readValue<NullBooleanValue>(json)

        // then
        result.value shouldBe null
    }
})

null 값으로 잘 들어가는지 확인이 끝났으니, 이제 Spring Boot에 적용해보겠습니다. 아래와 같이 application.yml 파일의 설정을 적용합니다.

spring:
  jackson:
    deserialization:
      FAIL_ON_NULL_FOR_PRIMITIVES: true

이제 nullable 하지않은 타입에 null 값이 들어왔을 때 예외를 발생시키게 됩니다. 이로써 exceptionHandler를 이용해 MismatchedInputException을 정형화된 메세지로 처리할 수 있습니다. 하지만 Spring Boot에서는 HttpMessageConverter가 역/직렬화를 담당하게 됩니다. 따라서 아래와 같이 handler를 작성할 수 있습니다.

@RestControllerAdvice
class ApiGlobalExceptionHandler {
	@ExceptionHandler(HttpMessageNotReadableException::class)
    fun handleHttpMessageNotReadableException(ex: HttpMessageNotReadableException): ResponseEntity<BadRequestMessage> {
        val message: String = when (val cause = ex.cause) {
            is JsonParseException -> "올바른 JSON 형식이 아닙니다."
            is JsonMappingException -> {
                when (cause) {
                    is InvalidFormatException -> "[${cause.path.joinToString(", ") { it.fieldName }}] 타입이 올바르지 않습니다."
                    else -> "[${cause.path.joinToString(", ") { it.fieldName }}] 값은 필수입니다."
                }
            }

            else -> "JSON 파싱 중 알 수 없는 오류입니다."
        }

        return ResponseEntity.ok(
            BadRequestMessage(message = message)
        )
    }
}

 

또한 비슷한 validation으로 아래와 같은 API가 있을 때 PathVariale 값이 잘못된 타입으로 넘어오는 경우가 있습니다.

@PostMapping("/example/{id}")
fun example(
    @PathVariable("id") id: Long,
    @RequestBody request: ExampleRequest
): ExampleRequest {
    return request
}

위 id 값이 문자열로 넘어왔을 때도 exception handler를 통해 함께 정형화된 에러메세지를 응답해보겠습니다.

// PathVariable, RequestParam 값 타입이 잘못되었을 때
@ExceptionHandler(MethodArgumentTypeMismatchException::class)
fun handleMethodArgumentTypeMismatchException(ex: MethodArgumentTypeMismatchException): ResponseEntity<BadRequestMessage> {
    val message = "${ex.parameter.parameterName} 값은 ${ex.parameter.parameterType} 타입이어야 합니다."
    return ResponseEntity.ok(
        BadRequestMessage(message = message)
    )
}

 

'Spring boot' 카테고리의 다른 글

SpringBoot ResponseBodyAdvice 특정 응답 값 암호화하기  (0) 2022.09.25