Java와 비교했을 때 Kotlin은 컴파일 타임에서의 null-safety를 가질 수 있다는 장점이 있다.
이를 통해 NullPointerException과 같은 런타임 오류 발생을 줄이고 더욱 안전한 코드를 작성할 수 있다.
하지만 generic에 있어서는 얘기가 조금 달라질 수 있는데
generic은 컴파일 타임에 타입 체크가 이루어져 타입 안정성을 보장하지만, 런타임에는 타입 정보가 삭제되는 타입 소거(type erasure)가 발생하며 이로 인해 Kotlin의 null-safety가 깨질 수도 있다.
이번 포스팅에서는 Kotlin과 Spring MVC를 사용할 때, 요청 List 내 element의 null-safety가 깨지는 케이스에 대해 알아보고 이에 대한 대처에 대해 알아보도록 하겠다.
(코드 예제는 여기에서 확인할 수 있다)
문제 상황 - 요청 List 내 element에 null이 들어갈 수 있다
요청값에 대한 validation을 위해 Spring Validation의 annotation을 사용할 수 있지만,
Kotlin을 사용하면서 굳이 non-null 필드에 대한 annotation(@NotNull)을 사용하지 않기도 한다.
이 경우는 generic을 사용하지 않는다면 해당 필드에 null 값이 들어가지 않아 null-safety 관점에서 문제가 없다.
하지만 generic을 사용하는 경우 null-safety에 문제가 생길 수 있다.
아래 컨트롤러 코드를 살펴보면 요청 필드는 non-null String을 generic으로 설정한 List이다.
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
@RestController
class RequestController {
@PostMapping
fun jackson(@RequestBody request: Request): Request {
println("Contains null? : ${request.values.any { it == null }}")
return request
}
}
data class Request(
val values: List<String>,
)
그리고 요청 List 내에 null을 포함하여 요청을 보내면 null 값이 그대로 보내지며 런타임 시에 아무런 오류가 발생하지 않는다.
curl -X POST http://localhost:8080 -H "Content-Type: application/json" -d '{"values": ["api", null]}'
{"values":["api",null]}
문제의 원인: Jackson library
분명 non-null로 generic을 지정하였는데 런타임 시에 아무런 오류가 나지 않는 이유는 무엇일까?
서론에서 말한 것과 같이 generic의 경우 컴파일 타임에 타입 체크가 이뤄지지만,
런타임에는 generic 타입 정보가 삭제되는 타입 소거(type erasure)가 발생한다.
이로 인해서 Spring에서 HTTP 요청의 body를 @RequestBody가 붙은 class로 클래스로 변환할 때 사용하는 Jackson library에서 해당 List 필드에 null이 들어가도 문제를 발생하지 않는 것이다.
이는 테스트로도 확인할 수 있다.
아래와 같이 null이 포함된 JSON string을 ObjectMapper로 역직렬화 시 그대로 null이 들어가있음을 확인할 수 있다.
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import kotlin.test.assertEquals
@SpringBootTest
class DeserializationTest {
private data class ListOfNonNullElementsData(
val values: List<String>,
)
@Autowired
private lateinit var mapper: ObjectMapper
@Test
fun `serialization for list of non-null elements test`() {
val serialized = mapper.readValue<ListOfNonNullElementsData>("""{"values": ["a", null]}""")
assertEquals(serialized.values, listOf("a", null))
}
}
문제 해결 방법
filter 처리
가장 쉬운 방법은 Controller에서 null인 데이터를 필터링하는 방법이다.
List에 null 값이 들어갈 수 있는 엔드포인트가 몇 개 없다면 편하게 적용할만한 방법이겠지만
이 방법을 적용하기 찝찝한 부분은 컴파일 레벨에서는 non-null 타입이기 때문에 IDE에서 경고를 보여준다는 것이고
이는 코드에 배경을 모른다면 성급하게 코드를 수정할 수 있는 여지를 준다는 것이다.
@JsonSetter(contentNulls = Nulls.~)
Jackson에서 제공하는 기능을 활용하여 List에 null이 들어갔을 때 해당 값을 처리할 수 있는 방법이 있다.
@com.fasterxml.jackson.annotation.JsonSetter 어노테이션을 사용하면 JSON string의 List 내부에 null이 포함되어 있을 때 역직렬화 결과를 어떻게 낼 것인지를 설정할 수 있다.
null이 있으면 이를 무시하는 @JsonSetter(contentNulls = Nulls.SKIP)이 있고
@SpringBootTest
class DeserializationTest {
private data class SkipOnNullData(
@JsonSetter(contentNulls = Nulls.SKIP)
val values: List<String>,
)
@Autowired
private lateinit var mapper: ObjectMapper
@Test
fun `serialization for list of non-null elements test - skip`() {
val serialized = mapper.readValue<SkipOnNullData>("""{"values": ["a", null]}""")
assertEquals(serialized.values, listOf("a"))
}
}
null이 있으면 Exception을 throw 하는 @JsonSetter(contentNulls = Nulls.FAIL)이 있다.
@SpringBootTest
class DeserializationTest {
private data class FailOnNullData(
@JsonSetter(contentNulls = Nulls.FAIL)
val values: List<String>,
)
@Autowired
private lateinit var mapper: ObjectMapper
@Test
fun `serialization for list of non-null elements test - fail`() {
assertThrows<Exception> {
mapper.readValue<FailOnNullData>("""{"values": ["a", null]}""")
}.apply {
this.printStackTrace()
}
}
}
SKIP을 할지 FAIL을 할지는 상황에 맞게 적용하면 되며
큰 변경 없이 어노테이션만 추가하면 쉽게 적용할 수 있는게 가장 큰 장점이라고 생각한다.
하지만 개인적으로 가장 큰 단점은 요청 필드가 List<String> 에서 List<String?>으로 바뀌었을 때는 null 값을 허용하고 싶어서 타입을 바꾼 것이겠지만,
@JsonSetter가 달려있다면 generic 타입에 무관하게 적용되기 때문에 원치않게 기존 null 처리 로직이 동작할 수 있다는 것이다.
즉, generic 타입 변경 시 잊지 않고 어노테이션도 변경 해줘야한다는 점이다.
Kotlin multiplatform serialization
Kotlin에서 multiplatform을 위한 직렬화 라이브러리를 제공한다.
https://github.com/Kotlin/kotlinx.serialization
GitHub - Kotlin/kotlinx.serialization: Kotlin multiplatform / multi-format serialization
Kotlin multiplatform / multi-format serialization - GitHub - Kotlin/kotlinx.serialization: Kotlin multiplatform / multi-format serialization
github.com
Spring Framework에서는 JSON 직렬화를 위해 기본적으로 Jackson을 사용하지만,
Kotlin multiplatform serialization 설정을 하면 @kotlinx.serialization.Serializable 어노테이션이 달린 클래스에 대해 Kotlin multiplatform serialization 직렬화를 적용할 수 있다.
class KMSTest {
@Serializable
private data class ListOfNonNullElementsData(
val values: List<String>,
)
@Serializable
private data class ListOfNullableElementsData(
val values: List<String?>,
)
@Test
fun `serialization for list of non-null elements test`() {
assertThrows<Exception> {
Json.Default.decodeFromString<ListOfNonNullElementsData>("""{"values": ["a", null]}""")
}.apply {
this.printStackTrace()
}
}
@Test
fun `serialization for list of nullable elements test`() {
assertDoesNotThrow {
Json.Default.decodeFromString<ListOfNullableElementsData>("""{"values": ["a", null]}""")
}
}
}
(kotlinx.serialization.json.Json을 사용한 이유는 Spring 내부적으로 이를 사용하기 때문이다)
개인적으로는 앞서 방식의 단점을 모두 커버할 수 있는, 코틀린 사용 시 가장 좋은 방법이라고 생각한다.
하지만 추가로 라이브러리를 세팅해줘야하는 것은 조금 번거롭고 이 문제를 풀기위한 방법으로는 투머치일 수도 있다.
'Java & Kotlin > Kotlin' 카테고리의 다른 글
reified를 통해 generic type 정보 가져오기 (0) | 2023.01.16 |
---|---|
[MockK] mock 객체가 호출되지 않았음을 검증하기 (1) | 2022.10.21 |
JPA entity의 VO로 Kotlin value class 사용하기 (3) | 2022.06.01 |
[MockK] 메서드 호출 순서 검증하기 (0) | 2022.04.15 |
[MockK] 인자 값 그대로 리턴하기 (0) | 2022.04.14 |
댓글