코틀린 타입 시스템: 왜 Long 타입으로 변환했는데 null이 됐을까?
F-Lab : 상위 1% 개발자들의 멘토링
안녕하세요! 저는 F-Lab 에서 백엔드 개발을 맡고 있는 Luca입니다.
개요
저희는 멘티들이 자신의 멘토링 프로젝트를 F-Lab GitHub 조직의 레포지토리에서 진행하도록 권장합니다.
아래와 같은 이유인데요:
- 멘티가 멘토링을 진행하며 Git과 Github에 익숙해지는 것
- 멘토가 프로젝트의 상황을 효율적으로 추적할 수 있음
그런데 최근, 멘티의 GitHub 계정을 연동하는 과정에서 GitHub ID가 null
로 저장되는 문제가 발생했습니다. 이 문제를 파악하고 해결한 과정을 공유하고자 합니다.
웹훅 데이터
앞선 이유 외에 F-Lab 조직의 레포지토리에서 멘토링 프로젝트를 진행하면 저희에게도 좋은 점이 있습니다. Github에서는 조직 단위의 이벤트와 웹훅을 제공하는데요. 저희는 멘티와 멘토분들께 나은 서비스를 제공하기 위해 데이터를 수집하는데 활용하고 있습니다.
다음은 저희가 수신한 웹훅 데이터의 예입니다:
{
"action": "member_added",
"membership": {
"url": "https://api.github.com/orgs/f-lab-edu/memberships/f-lab-luca",
"state": "active",
"role": "member",
"organization_url": "https://api.github.com/orgs/f-lab-edu",
"user": {
"login": "f-lab-luca",
"id": 147676366,
"type": "User",
"user_view_type": "public",
"site_admin": false
}
}
...
}
멘티가 조직 초대 이메일을 수락하면 위와 같은 데이터가 미리 작성한 웹훅 핸들러로 전송됩니다.
초기 접근 방식
저는 action
, login
, id
값을 추출하여 로그에 출력하기 위해 다음과 같은 코드를 작성했습니다:
val membership = body["membership"] as Map<*, *>
val user = membership["user"] as Map<*, *>
val login = user["login"] as? String
val id = user["id"] as? Long
log.info { "webhook action: ${body["action"]} github name: $login github id: $id" }
제가 기대했던 출력값은 다음과 같았습니다:
webhook action: member_added github name: f-lab-luca github id: 147676366
그리고 실제 출력된 값은 다음과 같았습니다:
webhook action: member_added github name: f-lab-luca github id: null
문제 분석
웹훅의 로그를 살펴보면 데이터는 정상적으로 전송된 것을 확인할 수 있습니다. 때문에 저는 변환에 문제가 있는 것 같다고 판단을 하고 차근차근 분석해나갔습니다.
테스트를 작성해보자
우선 다음과 같은 간단한 테스트를 작성했습니다:
class Test {
@Test
fun `id 값을 Long으로 변환`() {
val id = 14676366
val actual = id as? Long
actual shouldBe 14676366L
}
}
위 테스트는 성공할까요?
앞서 살펴본 문제와 같이 기대한 값이 null로 표시되고 있습니다. IDEA 상에서도 이를 표시해줍니다.
아래 문구를 살펴보면 변환이 절대 성공하지 않는다라고 되어있는데요. 왜 그런걸까요?
코틀린 타입 시스템
Kotlin In Action의 6장 “코틀린 타입 시스템”에서는 이런 내용이 나옵니다.
“as? 연산자는 어떤 값을 지정한 타입으로 캐스트한다. 값을 대상 타입으로 변환할 수 없으면 null로 변환한다”.
자바에서는 잠재적으로 값 손실이 발생할 수 있는 암시적 형 변환을 허용하지만 코틀린의 타입 시스템은 엄격하며 암시적인 형 변환을 허용하지 않습니다. 코틀린에서 Int
와 Long
은 모두 정수를 다루지만, 별개의 타입이며 as
연산자는 숫자 유형 변환이 아닌 호환되는 유형의 타입 간에만 작동합니다.
따라서 Int
를 as? Long
으로 변환하려는 시도는 항상 null
을 반환합니다. 만약 as
를 사용했다면, ClassCastException
이 발생했을 것입니다.
테스트를 수정해서 Int
로 변환하니 성공하는 모습입니다.
왜 id가 Int 타입으로 처리되었을까?
Map<String, Any?>
를 사용할 때 JSON 데이터는 Jackson에 의해 파싱됩니다. Jackson은 숫자 값을 가능한 가장 작은 적합한 타입으로 매핑합니다.
기본 설정으로 32비트 정수 범위(-2,147,483,648
~ 2,147,483,647
)에 해당하는 숫자는 Int
로 변환되고, 더 큰 숫자는 Long
으로 변환됩니다. 147676366
은 32비트 범위 내에 있으므로 Jackson은 이를 Int
로 역직렬화했습니다. 따라서 as? Long
변환이 실패한 것입니다.
해결책
Map
대신 DTO를 다음과 같이 작성해 문제를 해결했습니다:
@JsonIgnoreProperties(ignoreUnknown = true)
data class OrganizationEvent(
@JsonProperty("action")
val action: String,
@JsonProperty("membership")
val membership: Membership,
) {
val login: String
get() = membership.login
val id: Long
get() = membership.id
@JsonIgnoreProperties(ignoreUnknown = true)
data class Membership(
@JsonProperty("user") val user: User,
) {
val login: String
get() = user.login
val id: Long
get() = user.id
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class User(
@JsonProperty("login") val login: String,
@JsonProperty("id") val id: Long,
)
}
DTO에서는 id
타입을 명시적으로 Long
으로 정의했습니다. 또한 필요한 데이터만 받기 위해 Jackson에서 제공하는 @JsonIgnoreProperties
를 사용했습니다.
앞서 Jackson에서 역직렬화 방식 설정을 다루는 DeserializationFeature
를 살펴보면
Jackson은 기본적으로 Json에 담긴 모든 데이터들의 역직렬화를 시도하기 때문에 매핑할 프로퍼티가 없으면 JsonMappingException
이 발생하게 됩니다. @JsonIgnoreProperties
어노테이션은 특정 필드를 인식할 수 없을 때 이를 무시하며 예외를 회피할 수 있게 됩니다.
테스트를 작성하니 성공하는 모습입니다. 진작에 DTO로 정의했으면 어땠을까란 생각이 드네요.
왜 DTO에서는 잘 동작할까?
평소에는 전혀 의심을 하지 않았던 부분인데요. 제가 하나 의문점을 가진 것은 Map<String, Any?>
로 받을 때도 Json 데이터를 Jackson이 변환해줬을 터인데, 이번에는 User
의 id
프로퍼티를 Long
으로 작성했음에도 변환이 잘 이루어진 부분입니다.
아까도 보셨을 위 이미지의 문구를 다시 한 번 살펴보겠습니다. Jackson은 DTO에 타입이 명시되어 있다면 이를 우선합니다. JSON 데이터를 DTO의 Long
필드로 역직렬화할 때, Jackson은 작은 숫자 타입(Int
)도 자동으로 Long
으로 변환합니다. 때문에 문제가 발생하지 않고 정상적으로 변환이 됩니다.
배운 점
이번 문제를 통해 다음과 같은 중요한 점을 다시 확인할 수 있었습니다.
명시적인 타입 정의의 중요성: DTO를 사용하면 데이터 타입이 명시적으로 정의되어 예기치 못한 역직렬화 문제를 방지할 수 있습니다.
Kotlin 타입 시스템 이해: Int
와 Long
은 Kotlin에서 별개의 타입이며, 두 타입 간의 변환은 명시적으로 처리해야 합니다.
Jackson의 기본 동작: Jackson은 명시적으로 설정하지 않는 한, JSON 데이터를 가장 작은 적합한 숫자 타입으로 변환합니다.
DTO를 우선적으로 사용: Map
을 사용하는 것이 간단해 보일 수 있지만, DTO는 더 안전하고 신뢰할 수 있습니다.
맺으며
평소에는 DTO를 작성하여 매핑하는 것을 선호합니다. 이번 케이스에서는 단순하게 해보자란 생각으로 Map
으로 역직렬화를 시도했다가 이런 문제를 만났는데요. 덕분에 Kotlin의 타입 변환과 Jackson을 더 공부하게 되는 시간을 가지게 된 것 같습니다.
읽어주셔서 감사합니다. 😊
이 컨텐츠는 F-Lab의 고유 자산으로 상업적인 목적의 복사 및 배포를 금합니다.