코틀린에서 리플렉션을 사용하려면 두 가지 서로 다른 리플렉션 API를 다뤄야 한다.
첫 번째는 자바가 java.lang.reflect 패키지를 통해 제공하는 표준 리플렉션이다. 코틀린 클래스는 일반 자바 바이트코드로 컴파일되므로 자바 리플렉션 API도 코틀린 클래스를 컴파일한 바이트코드를 완벽히 지원한다.
두 번째 코틀린이 kotlin.reflect 패키지를 통해 제공하는 코틀린 리플렉션 API다. 이 API는 자바에는 없는 프로퍼티나 null이 될 수 있는 타입과 같은 코틀린 고유 개념에 대한 리플렉션을 제공한다. 하지만 현재 코틀린 리플렉션 API는 자바 리플렉션 API를 완전히 대체할 수 있는 복잡한 기능을 제공하지는 않는다. 따라서 나중에 보겠지만 자바 리플렉션을 대안으로 사용해야 하는 경우가 생긴다. 또한 코틀린 리플렉션 API가 코틀린 클래스만 다룰 수 있는 것은 아니라는 점을 잘 알아둬야 한다. 코틀린 리플렉션 API를 사용해도 다른 JVM 언어에서 생성한 바이트코드를 충분히 다룰 수 있다.
10.2.1 코틀린 리플렉션 API: KClass, KCallable, KFunction, KProperty
코틀린 리플렉션 API를 사용할 때 처음 접하게 되는 것은 클래스를 표현하는 KClass다. java.lang.Class에 해당하는 KClass를 사용하면 클래스 안에 있는 모든 선언을 열거하고 각 선언에 접근하거나 클래스의 상위 클래스를 얻는 등의 작업이 가능하다. MyClass:class라는 식을 쓰면 KClass의 인스턴스를 얻을 수 있다. 실행 시점에 객체의 클래스를 얻으려면 먼저 객체의 javaClass 프로퍼티를 사용해 객체의 자바 클래스를 얻어야 한다. javaClass는 자바의 java.lang.Object.getClass()와 같다. 일단 자바 클래스를 얻었으면 .kotlin 확장 프로퍼티를 통해 자바에서 코틀린 리플렉션 API로 옮겨올 수 있다.
class Person(val name: String, val age: Int)
import kotlin.reflect.full.* // memberProperties 확장 함수 임포트
>>> val person = Person("Alice", 29)
>>> val kClass = person.javaClass.kotlin // KClass<Person>의 인스턴스를 반환한다.
>>> pritln(kClass.simpleName)
Person
>>> kClass.memberProperties.forEach { println(it.name) }
age
name
이 예제는 클래스 이름과 그 클래스에 있는 프로퍼티 이름들을 출력하고 memberProperties를 통해 클래스와 모든 조상 클래스 내부에 정의된 비확장 프로퍼티를 모두 가져온다.
KClass 선언을 찾아보면(https://goo.gl/UNXeJM) 클래스의 내부를 살펴볼 때 사용할 수 있는 다양한 메소드를 볼 수 있다.
interface KClass<T : Any> {
val simpleName: String?
val qualifiedName: String?
val members: Collection<KCallable<*>>
val constructors: Collection<KFunction<T>>
val nestedClasses: Collection<KClass<*>>
...
}
memberProperties를 비롯해 KClass에 대해 사용할 수 있는 다양한 기능은 실제로는 kotlin-reflect 라이브러리를 통해 제공하는 확장 함수다. 이런 확장 함수를 사용하려면 import kotlin.reflect.full.*로 확장 함수 선언을 import해야 한다. KClass에 정의된 메소드 목록(확장 포함)을 표준 라이브러리 참조 문서(http://mng.bz/em4i)에서 볼 수 있다.
클래스의 모든 멤버의 목록은 KCallable 인스턴스의 컬렉션이다. KCallable은 함수와 프로퍼티를 아우르는 공통 상위 인터페이스다. 그 안에는 call 메소드가 들어있다. call을 사용하면 함수나 프로퍼티의 게터를 호출할 수 있다.
interface KCallable<out R> {
fun call(vararg args: Any?): R
...
}
call을 사용할 때는 함수 인자를 vararg 리스트로 전달한다. 다음 코드는 리플렉션이 제공하는 call을 사용해 함수를 호출할 수 있음을 보여준다.
fun foo(x: Int) = println(x)
>>> val kFunction = ::foo // foo 함수의 참조를 kFunction에 저장
>>> kFunction.call(42)
42
KProperty는 코틀린 리플렉션 API에서 클래스의 프로퍼티(속성)를 나타내는 인터페이스다다. 이는 해당 속성의 이름, 타입, 접근자(getter) 등의 메타데이터에 접근할 수 있게 해주며, KCallable의 하위 인터페이스로서 속성의 값을 리플렉션을 통해 읽어올 수 있다. 만약 속성이 변경 가능한 경우에는 KMutableProperty라는 하위 인터페이스를 사용해 값을 설정할 수도 있다.
10.2.2 리플렉션을 사용한 객체 직렬화 구현
우선 제이키드의 직렬화 함수 선언을 살펴보자.
fun serialize(obj: Any): String
이 함수는 객체를 받아서 그 객체에 대한 JSON 표현을 문자열로 돌려준다. 이 함수는 객체의 프로퍼티와 값을 직렬화하면서 StringBuilder 객체 뒤에 직렬화한 문자열을 추가한다. 이 append 호출을 더 간결하게 수행하기 위해 직렬화 기능을 StringBuilder의 확장 함수로 구현한다. 이렇게 하면 별도로 StringBuilder 객체를 지정하지 않아도 append 메소드를 편하게 사용할 수 있다.
private fun StringBuilder.serializeObject(x: Any) {
append(...)
}
함수 파라미터를 확장 함수의 수신 객체로 바꾸는 방식은 코틀린 코드에서 흔히 사용하는 패턴이다. serializeObject는 StringBuilder API를 확장하지 않는다는 점에 유의하라. serializeObject가 수행하는 연산은 지금 설명하는 이 맥락을 벗어나면 전혀 쓸모가 없다. 따라서 private으로 가시성을 지정해서 다른 곳에서는 사용할 수 없게 만든다. serializeObject를 확장 함수로 만든 이유는 이 코드 블록에서 주로 사용하는 개체가 어떤 것인지 명확히 보여주고 그 객체를 더 쉽게 다루기 위함이다.
이렇게 확장 함수를 정의한 결과 serialize는 대부분의 작업을 serializeObject에 위임한다.
fun serialize(obj: Any): String = buildString { serializeObject(obj) }
buildString은 StringBuilder를 생성해서 인자로 받은 람다에 넘긴다. 람다 안에서는 StringBuilder 인스턴스를 this로 사용할 수 있다. 이 코드는 람다 본문에서 serializeObject(obj)를 호출해서 obj를 직렬화한 결과를 StringBuilder에 추가한다.
기본적으로 직렬화 함수는 객체의 모든 프로퍼티를 직렬화한다. 원시 타입이나 문자열은 적절히 JSON수, 불리언, 문자열 값 등으로 변환된다. 컬렉션은 JSON 배열로 직렬화된다. 원시 타입이나 문자열, 컬렉션이 아닌 다른 타입인 프로퍼티는 중첩된 JSON 객체로 직렬화된다. 이러한 동작을 annotation을 통해 변경할 수 있다.
// 객체 직렬화하기
private fun StringBuilder.serializeObject(obj: Any) {
val kClass = obj.javaClass.kotlin // 객체의 KClass를 얻는다.
val properties = kClass.memberProperties // 클래스의 모든 프로퍼티를 얻는다.
properties.joinToStringBuilder(
this, perfix = "{", postfix = "}") { prop ->
serializeString(prop.name) // 프로퍼티 key
append(": ")
serializePropertyValue(prop.get(obj)) // 프로퍼티 value
}
)
}
10.2.3 annotation을 활용한 직렬화 제어
@JsonExclude는 어떤 프로퍼티를 직렬화에서 제외하고 싶을 때 쓸 수 있다. serializeObject 함수를 어떻게 수정해야 이 annotation을 지원할 수 있을지 알아보자.
제외하기 위해선 어떤 한 annotation을 찾기만 하면 된다. 이럴 때 findAnnotation이라는 함수를 쓸 수 있다.
inline fun <reified T> KAnnotatedElement.findAnnotation(): T?
= annotations.filterIsInstance<T>().findOrNull()
findAnnotation 함수는 인자로 전달받은 타입에 해당하는 annotation이 있으면 그 annotation을 반환한다. 이 함수는 타입 파라미터를 reified로 만들어서 annotation 클래스를 타입 인자로 전달한다.
이제 findAnnotation을 표준 라이브러리 함수인 filter와 함께 사용하면 @JsonExclude로 annotation된 프로퍼티를 없앨 수 있다.
val properties = kClass.memberProperties
.filter { it.findAnnotation<JsonExclude>() == null }
다음으로는 @JsonName을 어떻게 지원할 수 있을지 알아보자.
이 경우에는 annotation의 존재 여부뿐 아니라 annotation에 전달한 인자도 알아야 한다. @JsonName의 인자는 프로퍼티를 직렬화해서 JSON에 넣을 때 사용할 이름이다. 이 때도 findAnnotation 함수가 도움이 된다.
val jsonNameAnn = prop.findAnnotation<JsonName>() // @JsonName annotation이 있는 인스턴스를 얻는다.
val propName = jsonNameAnn?.name ?: prop.name // annotation에서 "name"인자를 찾고 그런 인자가 없으면 "prop.name"을 사용한다.
10.2.4 JSON 파싱과 객체 역직렬화
이제 제이키드 라이브러리의 나머지 절반인 역직렬화 로직에 대해 이야기해보자. API는 직렬화와 마찬가지로 함수 하나로 이뤄져 있다.
inline fun <reified T: Any> deserialize(json: String): T
data class Author(val name: String)
data class Book(val title: String, val author: Author)
>>> val json = """{"title": "Catch-22", "author": {"name": "J. Heller"}}"""
>>> val book = deserialize<Book>(json)
>>> println(book)
Book(title=Catch-22, author=Author(name=J. Heller))
역직렬화할 객체의 타입을 실체화한 타입 파라미터로 deserialize 함수에 넘겨서 새로운 객체 인스턴스를 얻는다.
JSON 문자열 입력을 파싱하고, 리플렉션을 사용해 객체의 내부에 접근해서 새로운 객체와 프로퍼티를 생성하기 때문에 JSON을 역직렬화하는 것은 직렬화보다 더 어렵다. 제이키드의 JSON 역직렬화기는 흔히 쓰는 방법을 따라 3단계로 구현돼 있다. 첫 단계는 어휘 분석기(lexical analyzer)로 렉서(lexer)라고 부른다. 두 번째 단계는 문법 분석기(syntax analyzer)로 파서(parser)라고 부른다. 마지막 단계는 파싱한 결과로 객체를 생성하는 역직렬화 컴포넌트다.
10.2.5 최종 역직렬화 단계: callBy(), 리플렉션을 사용해 객체 만들기
마지막으로 이해해야 할 부분은 최종 결과인 객체 인스턴스를 생성하고 생성자 파라미터 정보를 캐시하는 ClassInfo 클래스다. ClassInfo는 ObjectSeed 안에서 쓰인다. 하지만 ClasInfo 구현을 자세히 살펴보기 전에 리플렉션을 통해 객체를 만들 때 사용할 API를 몇 가지 살펴보자.
앞에서 KCallable.call을 살펴봤다. KCallable.call은 인자 리스트를 받아서 함수나 생성자를 호출해준다. 유용한 경우도 많지만 KCallable.call은 default 파라미터 값을 지원하지 않는다는 한계가 있다. 제이키드에서 역직렬화 시 생성해야 하는 객체에 디폴트 생성자 파라미터 값이 있고 제이키드가 그런 default 값을 활용할 수 있다면 JSOBN에서 관련 프로퍼티를 꼭 지정하지 않아도 된다. 따라서 여기서는 default 파라미터 값을 지원하는 KCallable.callyBy를 사용해야 한다.
interface KCallable<out R> {
fun callBy(args: Map<KParameter, Any?>): R
...
}
이 메소드는 파라미터와 파라미터에 해당하는 값을 연결해주는 맵을 인자로 받는다. 인자로 받은 맵에서 파라미터를 찾을 수 없는데, 파라미터 default 값이 정의돼 있다면 그 default 값을 사용한다. 이 방식의 다른 좋은 점은 파라미터의 순서를 지킬 필요가 없다는 점이다. 따라서 객체 생성자에 원래 정의된 파라미터 순서에 신경 쓰지 않고 JSON에서 이름/값 쌍을 읽어서 이름과 일치하는 파라미터를 찾은 후 맵에 파라미터 정보와 값을 넣을 수 있다.
타입 변환에는 커스텀 직렬화에 사용했던 ValueSerializer 인스턴스를 똑같이 사용한다. 프로퍼티에 @CustomSerializer annotation이 없다면 프로퍼티 타입에 따라 표준 구현을 불러와 사용한다.
// 값 타입에 따라 직렬화기를 가져오기
fun serializerForType(type: Type): ValueSerializer<out Any?>? =
when(type {
Byte::class.java -> ByteSerializer
Int::class.java -> IntSerializer
Boolean::class.java -> BooleanSerializer
// ...
else -> null
}
타입별 ValueSerializer 구현은 필요한 타입 검사나 변환을 수행한다.
// Boolean 값을 위한 직렬화기
Obejct BooleanSerializer : ValueSerializer<Boolean> {
override fun fromJsonValue(jsonValue: Any?): Boolean {
if (jsonValue !is Boolean) throw JKidException
}
override fun toJsonValue(value: Boolean) = value
}
callBy 메소드에 생성자 파라미터와 그 값을 연결해주는 맵을 넘기면 객체의 주 생성자를 호출할 수 있다. ValueSerializer 메커니즘을 사용해 생성자를 호출할 때 사용하는 맵에 들어가는 값이 생성자 파라미터 정의의 타입과 일치하게 만든다. 이제 이 API를 호출하는 부분을 살펴보자.
ClassInfoCache는 리플렉션 연산의 비용을 줄이기 위한 클래스다. 직렬화와 역직렬화에 사용하는 annotation들(@JsonName, @CustomSerializer)이 파라미터가 아니라 프로퍼티에 적용된다. 하지만 객체를 역직렬화할 때는 프로퍼티가 아니라 생성자 파라미터를 다뤄야 한다. 따라서 annotation을 꺼내려면 파라미터에 해당하는 프로퍼티를 찾아야 한다. JSON에서 모든 key/value 쌍을 읽을 때마다 이런 검색을 수행하면 코드가 아주 느려질 수 있다. 따라서 클래스별로 한 번만 검색을 수행하고 검색 결과를 캐시에 넣어둔다. 다음은 ClassInfoCache의 전체 구현이다.
// 리플렉션 데이터 캐시 저장소
class ClassInfoCache {
private val cacheData = mutableMapOf<KClass<*>, ClassInfo<*>>()
@Suppress("UNCHECKED_CAST")
operator fun <T : Any> get(cls: KClass<T>): ClassInfo<T> =
cacheData.getOrPut(cls) { ClassInfo(cls) } as ClassInfo<T>
}
맵에 값을 저장할 때는 타입 정보가 사라지지만, 맵에서 돌려받은 값의 타입인 ClassInfo<T>의 타입 인자가 항상 올바른 값이 되게 get 메소드 구현이 보장한다. getOrPut을 사용하는 부분을 자세히 살펴보자. cls에 대한 항목이 cacheData 맵에 있다면 그 항목을 반환한다. 그런 항목이 없다면 전달받은 람다를 호출해서 키에 대한 값을 계산하고, 계산한 결과 값을 맵에 저장한 다음에 반환한다.
ClassInfo 클래스는 대상 클래스의 새 인스턴스를 만들고 필요한 정보를 캐시해둔다. 설명에 필요하지 않은 일부 함수와 뻔한 초기화를 표시하지 않았다. 또한 여기서는 !!를 썼지만 실제 프로덕션 코드는 어떤 문제가 발생했는지를 알려주는 메시지가 들어있는 예외를 던진다.
// 생성자 파라미터와 annotation 정보를 저장하는 캐시
class ClassInfo<T : Any>(cls: KClass<T>) {
private val constructor = cls.primaryConstructor!!
private val jsonNameToParam = hashMapOf<String, KParameter>()
private val paramToSerializer =
hashMapOf<KParameter, ValueSerializer<out Any?>>()
private val jsonNameToDeserializeClass =
hashMapOf<String, Class<out Any>?>()
init {
constructor.parameters.forEach { cacheDataForParameter(cls, it) }
}
fun getConstructorParameter(propertyName: String): KParameter =
jsonNameToParam[propertyName]!!
fun deserializeConstructorArgument(
param: KParameter, value: Any?): Any? {
val serializer = paramToSerializer[param]
if (serializer != null) return serializer.fromJsonValue(value)
validateArgumentType(param, value)
return value
}
)
fun createInstance(arguments: Map<KParameter, Any?>: T {
ensureAllParametersPresent(arguments)
return constructor.callBy(arguments)
}
// ...
}
초기화 시 이 코드는 각 생성자 파라미터에 해당하는 프로퍼티를 찾아서 annotation을 가져온다. 코드는 데이터를 세 가지 맵에 저장한다. jsonNameToParam은 JSON 파일의 각 키에 해당하는 파라미터를 저장하며, paramToSerializer는 각 파라미터에 대한 직렬화기를 저장하고, jsonNameToDeserializeClass는 @DeserializeInterface annotation인자로 지정한 클래스를 저장한다. ClassInfo는 프로퍼티 이름으로 생성자 파라미터를 제공할 수 있으며, 생성자를 호출하는 코드는 그 파라미터를 파라미터와 생성자 인자를 연결하는 맵의 키로 사용한다.
cacheDataForParameter, validateArgumentType, ensureAllParametersPresent 함수는 이 클래스에 정의된 비공개 함수다. 다음은 ensureAllParametersPresent의 구현이다.
// 필수 파라미터가 모두 있는지 검증하기
private fun ensureAllParametersPresent(arguments: Map<KParameter, Any?>) {
for (param in constructor.parameters) {
if (arguments[param] == null && !param.isOptional && !param.type.isMarkedNullable) {
throw JKidException("Missing value for parameter ${param.name}")
}
}
}
이 함수는 생성자에 필요한 모든 필수 파라미터가 맵에 들어있는지 검사한다. 여기서 리플렉션 API를 어떻게 활용하는지 살펴보자. 파라미터에 default 값이 있다면 param.isOptional이 true다. 따라서 그런 파라미터에 대한 인자가 인자 맵에 없어도 아무 문제가 없다. 파라미터가 null이 될 수 있는 값이라면 type.isMarkedNullable이 true고 default 파라미터 값으로 null을 사용한다. 그 두 경우가 모두 아니라면 예외를 발생시킨다. 리플렉션 캐시를 사용하면 역직렬화 과정을 제어하는 annotation을 찾는 과정을 JSON 데이터에서 발견한 모든 프로퍼티에 대해 반복할 필요 없이 프로퍼티 이름 별로 단 한 번만 수행할 수 있다.
'Kotlin' 카테고리의 다른 글
[Kotlin] reified 키워드 (0) | 2025.04.03 |
---|---|
[Kotlin in Action] 10.1 annotation 선언과 적용 (0) | 2025.04.03 |
[Kotlin] 스타 프로젝션(*)과 Any의 차이 (0) | 2025.04.03 |
[Kotlin] 코틀린 선언 지점 변성과 자바 와일드카드 비교 (0) | 2025.04.03 |
[Kotlin in Action] 9.3 변성: 제네릭과 하위 타입 (0) | 2025.04.02 |
댓글