최근 BigQuery 를 연동하는 프로젝트에서 Kotlin 을 사용하게 되었다. JPA와 같이 table, entity 간의 차이를 해결해주는 기능이 없는 탓에 BigQuery 의 다양한 API를 직접 호출해서 사용해야 한다. 여기에 맨 처음엔 Util 클래스를 정의하여 사용했지만, 후에 리팩토링을 거치면서 kotlin extension 을 사용하게 되었다.
그래서 이번 기회에 Kotlin extension 에 대한 정리를 하며 한계는 어디인지, 어떻게 사용해야 효율적인지 사용법을 정리해두고자 한다.
Kotlin extensions 의 정의
Kotlin 에서는 class, interface 를 상속하지 않고, decorator pattern 을 사용하지 않고도 새로운 기능을 추가하는 방법을 제공하며, 이를 kotlin extensions 이라 한다.
예를 들어, 수정할 수 없는 third party library, class, interface 에 새로운 함수를 추가할 수 있다. 이러한 함수는 일반적인 방법으로 호출할 수 있고, 원래 클래스에 있는 함수처럼 동작한다. 기존 클래스에 새로운 속성을 정의할 수 있는 확장 속성도 있다.
간단한 사용법
UTC로 저장되어 있는 시간을 UTC+9(KST)로 반환할 때 다음과 같은 함수를 정의할 수 있다.
val KST: ZoneId = ZoneId.of("Asia/Seoul")
val UTC: ZoneId = ZoneId.of("UTC")
fun LocalDateTime.convertUtcToKstZonedDateTime(): ZonedDateTime {
return atZone(UTC).withZoneSameInstant(KST)
}
fun LocalDateTime.convertUtcToKst(): LocalDateTime {
return convertUtcToKstZonedDateTime().toLocalDateTime()
}
Kotlin
복사
이 함수를 다음과 같이 호출한다.
@Test
fun `한국시간으로 변환한다`() {
Article(
title = "오늘은 Kotlin extensions 을 알아본다 ",
content = "Kotlin extension 으로 가독성을 높히자",
writtenAt = LocalDateTime.of(2022, 12, 18, 14, 0, 0),
updatedAt = LocalDateTime.of(2022, 12, 19, 14, 0, 0),
).let {
it.writtenAt.convertUtcToKst()
}.also {
assertThat(it).isEqualTo(LocalDateTime.of(2022, 12, 18, 23, 0, 0))
}
}
Kotlin
복사
LocalDateTime 에는 convertUtcToKst 라는 함수가 존재하지 않는다.
Kotlin 파일을 하나 만들어서 대상 클래스.extension함수이름 으로 정의하면 그대로 확장 함수가 만들어진다.
그래서 Kotlin extensions 는 어떤 클래스의 멤버 메소드인 것처럼 호출할 수 있지만, 그 class의 밖에서 정의된 함수이다.
수신자. receiver
Kotlin extensions에 관련된 글을 읽다보면 “수신자”, “receiver” 라는 단어가 자주 나온다.
위 예제에서 LocalDateTime 은 수신 객체 타입이 되고, extensions 내부가 수신 객체가 된다.
즉 extensions 을 받는 곳이 수신자 이다.
Extensions 는 정적으로 결정된다.
이러한 extensions 는 statically 하게 결정된다. 실제 class를 수정하는 것이 아니며, 실제로 새로운 member를 삽입하는 것이 아니라 . 을 통해서 호출 가능한 새로운 함수를 만드는 것이다.
그래서 runtime 에서 동적으로 변경되지 않는다.
open class Person
class Man: Person()
fun Person.name(): String = "Person"
fun Man.name(): String = "Man"
fun printIt(person: Person) {
println(person.name())
}
fun main() {
printIt(Person())
printIt(Man())
}
Kotlin
복사
Person
Person
Process finished with exit code 0
Plain Text
복사
console 의 실행 결과
이 예제에서 보면 printIt 내부에선 Person type의 name() 함수를 호출한다.
즉, 매개변수의 type 이 Man 이라 하더라도 매개변수 type에 의존하여 실행되는 함수가 결정되는 것이다.
그렇다면 signature 가 member function과 동일한 extensions 이 있다면 어떻게 될까?
open class Person {
fun name() {
println("class name is person")
}
}
fun Person.name() {
println("Extension, name is in extensions")
}
fun main() {
Person().name()
}
Kotlin
복사
class name is person
Process finished with exit code 0
Plain Text
복사
결과는 member로 정의된 function 이 호출된다. 하지만, 다행인 것은 overloading 은 가능하다.
Nullable receiver
Kotlin extensions 는 nullable receiver type도 정의가 가능하다.
이러한 extensions 는 body 에서 this == null 과 같이 수신자의 값이 null 인지 확인할 수 있다.
fun Any?.toString(): String {
if (this == null) return "null"
// null check 이후 부터 this 는 non-null type 으로 auto casting이 된다.
return toString()
}
Kotlin
복사
Extension properties
확장함수의 훌륭한 기능은 properties 를 확장하는 것이다.
val <T> List<T>.lastIndex: Int
get() = size - 1
Kotlin
복사
이건 알고리즘 풀 때 사용하면 좋을듯 물론 Kotlin 으로 알고리즘을 풀진 않지만
중요한 점은 Kotlin extensions 은 초기화에 해당하지 않는다. 실제 class 에 member variable 을 추가하는 것이 아니다. 이 동작은 철저하게 getter 와 setter를 통한 명시적인 함수의 실행으로 정의된다.
Extension properties를 사용하려고 리팩토링을 시도했으나 결국 properties getter, setter를 호출하는것 보다 매개변수로 넘기는 것을 택했다.
Companion object extensions
Kotlin class가 만약 companion object 로 정의된 함수, property 가 존재한다면 함수와 property 모두 companion 으로 extensions을 정의할 수 있다.
그렇다면 클래스이름.함수() 와 같이 호출이 가능해진다.
class MyClass {
// REQUIRED
companion object { }
}
fun MyClass.Companion.printCompanion() { println("companion") }
fun main() {
MyClass.printCompanion()
}
Kotlin
복사
어떠한 클래스에 Companion extension을 정의할 수 없다면 companion object 가 없기 때문이다.
그런데 여기서 kotlin extensions 의 한계가 있다.
예를 들어 LocalDateTime 의 of(), now() 와 같은 함수는 static 함수이고, kotlin 에서 companion object 는 java 의 static 과 같은 역할로 간주된다.
하지만 static이 companion object 인 것은 아니다. 따라서 java에서 가져온 libarary 들은 companion object extensions을 만들 수 없게 된다.
사용 사례
Kotlin extensions을 사용하게 된 계기는 BigQuery를 이용한 지표였다.
JPA 수준으로 entity mapping이 되지 않기 때문에 schema에 없는 column의 값을 찾으면 에러를 반환한다.
하지만, 항상 특정 컬럼이 존재하지 않는 경우도 있어 이 문제를 해결하는데 사용했다.
fun TableResult.exportColumns(): Set<String> {
return this.schema.fields.map {
it.name
}.toSet()
}
fun FieldValueList.getString(key: String, tableResult: TableResult): String? {
val columns = tableResult.exportColumns()
return if (columns.contains(key)) getString(key) else null
}
fun FieldValueList.getString(key: String): String? {
val value = get(key)
return if (value?.value == null) {
null
} else {
value.stringValue
}
}
Kotlin
복사
이러한 확장 함수를 만들어 특정 컬럼이 스키마에 존재하면 값을, 없으면 null을 반환하도록 만들었다.
덕분에 개발 속도는 빨라질 수 있었다. Kotlin 에선 Util 클래스를 만드는 대신 이러한 확장함수를 사용할 예정이다.