Programming/Kotlin

코틀린(kotlin) - 리플렉션: 실행 시점에 코틀린 객체 내부 관찰

JunsuKim 2021. 12. 26.
728x90

리플렉션을 간단하게 말하면 실행 시점에 동적으로 객체의 프로퍼티와 메소드에 접근할 수 있게 해주는 방법이다.

보통 객체의 메소드나 프로퍼티에 접근할 때, 컴파일러는 메소드나 프로퍼티의 이름이 실제로 가리키는 선언을 컴파일 시점에 찾아내 해당하는 선언이 실제 존재함을 보장한다.

하지만 타입과 관계없이 객체를 다뤄야 하거나 객체가 제공하는 메소드나 프로퍼티 이름을 오직 실행 시점에만 알 수 있는 경우라면 리플렉션을 사용해야 한다.

코틀린에서 리플렉션을 사용하기 위해선 두 가지의 다른 리플렉션 API를 다뤄야 한다.

첫 번째는 자바가 java.lang.reflect 패키지를 통해 제공하는 표준 리플렉션이다.

코틀린 클래스는 일반 자바 바이트코드로 컴파일되므로 자바 리플렉션 API도 코틀린 클래스를 컴파일한 바이트코드를 완벽히 지원한다.

따라서 리플렉션을 사용하는 자바 라이브러리와 코틀린 코드가 완전히 호환한다.

두 번째는 코틀린이 kotlin.reflect 패키지를 통해 제공하는 코틀린 리플렉션 API이다.

이 API는 자바에는 없는 프로퍼티나 널이 될 수 있는 타입과 같은 코틀린 고유 개념에 대한 리플렉션을 제공한다.

하지만 현재 코틀린 리플렉션 API는 자바 리플렉션 API를 완전히 대체할 수 있는 복잡한 기능을 제공하지는 않는다.

 

※ 코틀린 리플렉션 API가 코틀린 클래스만 다룰 수 있는 것은 아니다. 코틀린 리플렉션 API를 사용해도 다른 JVM 언어에서 생성한 바이트코드를 충분히 다룰 수 있다.

코틀린 리플렉션 API: KClass, KCallable, KFunction, KProperty

코틀린 리플렉션 API를 사용할 때 먼저 접하게 되는 것은 클래스를 표현하는 KClass다.

java.lang.Class에 해당하는 KClass를 사용하면 클래스 안에 있는 모든 선언을 열거하고 각 선언에 접근하거나 클래스의 상위 클래스를 얻는 등의 작업이 가능하다.

실행 시점에 객체의 클래스를 얻으려면 먼저 객체의 javaClass 프로퍼티를 사용해 객체의 자바 클래스를 얻어야 하며,

javaClass는 자바의 java.lang.Object.getClass()와 같다.

자바 클래스를 얻었다면 .kotlin 확장 프로퍼티를 통해 자바에서 코틀린 리플렉션 API로 옮겨올 수 있다.

class Person(val name: String, val age: Int)

>>> import kotlin.reflect.full.*
>>> val person = Person("Alice", 29)
>>> val KClass = person.javaClass.kotlin // KClas<Person>의 인스턴스를 반환
>>> println(KClass.simpleName)
Person
>>> KClass.memberProperties.forEach { println(it.Name) }
age
name

KClass 선언을 찾아보면 클래스의 내부를 살펴볼 때 사용할 수 있는 다양한 메소드를 볼 수 있다.

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-relfect 라이브러리를 통해

제공하는 확장 함수이다.

이러한 확장 함수를 사용하기 위해선 import kotlin.reflect.full.*로 확장 함수 선언을 임포트해야 한다.

클래스의 모든 멤버의 목록은 KCallable 인스턴스의 컬렉션이다.

KCallable은 함수와 프로퍼티를 아우르는 공통 상위 인터페이스이다.

 

Kcallable 안에 있는 call 메소드는 함수나 프로퍼티의 게터를 호출할 수 있다.

interface KCallable<out R> {
    fun call(vararg args: Any?): R
    ...
}

call을 사용할 땐 함수 인자를 vararg 리스트로 전달한다.

리플렉션이 제공하는 call을 사용해 함수를 호출하는 코드를 보자.

fun foo(x: Int) = println(x)
>>> val kFunction = ::foo
>>> kFunction.call(42)
42

::foo 식의 값 타입은 리플렉션 API에 있는 KFunction 클래스의 인스턴스이다.

이 함수 참조가 가리키는 함수를 호출하려면 KCallable.call 메소드를 호출한다.

위의 코드에서는 42라는 숫자 하나만 인자로 넘겼으나, call에 넘길 인자 개수는 원래 함수에 정의된 파라미터 개수가 맞아떨어져야 한다.

 

함수를 호출하기 위해 더 구체적인 메소드를 사용할 수도 있다.

::foo의 타입 KFunction1<Int, Unit>에는 파라미터와 반한 값 타입 정보가 들어있다.

KFunction1 인터페이스를 통해 함수를 호출하려면 invoke 메소드를 사용해야 한다.

invoke는 정해진 개수의 인자만을 받아들이며 인자 타입은 KFunction1 제네릭 인터페이스의 첫 번째 타입 파라미터와 같다. 게다가 kFunction을 직접 호출할 수도 있다.

하지만 인자 개수나 타입이 맞아 떨어지지 않으면 컴파일이 되지 않아 모두 다 알 때 invoke 메소드를 호출하는 게 낫다.

call 메소드는 모든 타입의 함수에 적용할 수 있는 일반적인 메소드지만 타입 안정성을 보장해주지는 않는다.

 

KProperty의 call 메소드를 호출할 수도 있다.

KProperty의 call은 프로퍼티의 게터를 호출한다. 하지만 프로퍼티 인터페이스는 프로퍼티 값을 얻는 더 좋은 방법으로 get 메소드를 제공한다.

get 메소드에 접근하려면 프로퍼티가 선언된 방법에 따라 올바른 인터페이스를 사용해야 한다.

최상위 프로퍼티는 KProperty() 인터페이스의 인스턴스로 표현되며, 그 안에는 인자가 없는 get 메소드가 있다.

var counter = 0
>>> val kProperty = ::counter
>>> KProperty.setter.call(21)
>>> println(kProperty.get())
21

멤버 프로퍼티는 KProperty1 인스턴스로 표현된다. 

그 안에는 인자가 1개인 get 메소드가 들어있다.

멤버 프로퍼티는 아딴 객체에 속해 있는 프로퍼티이므로 멤버 프로퍼티의 값을 가져오려면 get 메소드에게 프로퍼티를 얻고자 하는 객체 인스턴스를 넘겨야 한다.

class Person(val name: String, val age: Int)
>>> val person = Person("Alice", 29)
>>> val memberProperty = Person::age
>>> println(memberProperty.get(person))
29

KProperty1은 제네릭 클래스이다.

memberProperty 변수는 KProperty<Person, Int> 타입으로 첫 번째 타입 파라미터는 수신 객체 타입,

두 번째 타입 파라미터는 프로퍼티 타입을 표현한다.

따라서 수신 객체를 넘길 때 KProperty1의 타입 파라미터와 일치하는 타입의 객체만을 넘길 수 있다.

 

KClass, KFunction, KParameter는 모두 KAnnotatedElement를 확장한다.

리플렉션을 사용한 객체 직렬화 구현

제이키드의 직렬화 함수 선언을 살펴보자.

fun serialize(obj: Any): String

이 함수는 객체를 받아 그 객체에 대한 JSON 표현을 문자열로 돌려준다.

이 함수는 객체의 프로퍼티와 값을 직렬화하며 StringBuilder 객체 뒤에 직렬화한 문자열을 추가한다.

append 호출을 더 간결하게 수행하기 위해 직렬화 기능을 StringBuilder의 확장 함수로 구현하여 별도로 StringBuilder의 객체를 지정하지 않아도 append 메소드를 사용할 수 있게 해 준다.

private fun StringBuilder.serializedObjec(x: Any) {
    append{...}
}

함수 파라미터를 확장 함수의 수신 객체로 바꾸는 방식은 코틀린에서 흔히 사용되는 패턴이다.

이렇게 확장 함수를 정의한 결과 serialized는 대부분의 작업을 serializeObject에 위임한다.

fun serialize(obj: Any): String = buildString { serializeObject(obj) }

이 코드는 람다 본문에서 serializeObject(obj)를 호출해서 obj를 직렬화한 결과를 StringBuilder에 추가한다.

 

이제 직렬화 함수의 기능을 보겠다.

기본적으로 직렬화 함수는 객체의 모든 프로퍼티를 직렬화한다.

원시 타입이나 문자열은 적절히 JSON 수, boolean, 문자열 값 등으로 변환되고, 컬렉션은 JSON 배열로 직렬화된다.

원시 타입이나 문자열, 컬렉션이 아닌 다른 타입인 프로퍼티는 중첩된 JSON 객체로 직렬화된다.

애노테이션을 활용한 직렬화 제어

어떤 프로퍼티를 직렬화에서 제외하고 싶을 때 @JsonExclude를 쓸 수 있다.

KAnnotatedElement 인터페이스에는 annotations 프로퍼티가 있다. annotations는 소스 코드 상에서 해당 요소에 적용된 모든 애노테이션 인스턴스의 컬렉션이다.

KPropery는 KAnnotatedElement를 확장하므로 property, annotations를 통해 프로퍼티의 모든 애노테이션을 얻을 수 있다.

하지만 여기서는 모든 애노테이션을 사용하지 않고, 어떠 한 애노테이션을 찾기만 하면 된다.

이럴 때 findAnnotation이라는 함수를 사용한다.

inline fun <refied T> KAnnotatedElement.findAnnotation(): T? = annotations.filterIsInstance<T>().firstOrNull()

findAnnotation 함수는 인자로 전달받은 타입에 해당하는 애노테이션이 있으면 그 애노테이션을 반환한다.

findAnnotation을 표준 라이브러리 함수인 filter와 함께 사용하면 @JsonExclude로 애노테이션 된 프로퍼티를 없앨 수 있다.

val properties = kClass.memberProperties.filter{ it.findAnnotation<JsonExclude>() == null }

@JsonName은 존재 여부뿐 아니라 애노테이션에 전달한 인자도 알아야 한다.

@JsonName의 인자는 프로퍼티를 직렬화해서 JSON에 넣을 때 사용할 이름이다.

이 경우에도 findAnnotation을 사용할 수 있다.

val jsonNameAnn = prop.findAnnotation<JsonName>()
val propName = jsonNameAnn?.name ?: prop.name

프로퍼티에 @JsonName 애노테이션이 없다면 jsonNameAnn이 널이다.

이 경우 여전히 prop.name을 JSON의 프로퍼티 이름으로 사용할 수 있다.

프로퍼티에 애노테이션이 있다면 애노테이션이 지정하는 이름을 대신 사용한다.

 

나머지 애노테이션인 @CustomSerializer를 구현해보자.

이 구현은 getSerializer라는 함수에 기초한다. getSerializer는 @CustomSerializer를 통해 등록한 ValueSerializer 인스턴스를 반환한다.

data class Person {
    val name: String,
    @CustomSerializer(DateSerializer::class) val birthDate: Date
}

Person 클래스가 위와 같이 있다면 birthDate 프로퍼티를 직렬화하며 getSerializer()를 호출하면 DateSerializer 인스턴스를 얻을 수 있다.

getSerializer 구현을 더 잘 이해할 수 있게 돕기 위해 @CustomSerializer 선언을 다시 보자.

annotation class CustomSerializer (
    val serializerClass: KClass<out ValueSerializer<*>>
}

getSerializer 구현은 다음과 같다.

fun KProperty<*>.getSerializer(): ValueSerializer<Any?>? {
    val customSerializerAnn = findAnnotation<CustomSerializer>() ?: return null
    val serializerClass = customSerializerAnn.serializerClass
    val valueSerializer = serializerClass.objectInstance ?: serializerClass.createInstance()
    @Suppress("UNCHECKED_CAST")
    return valueSerializer as ValueSerializer<Any?>

getSerializer가 주로 다루는 객체가 KProperty 인스턴스이기 때문에 KProperty의 확장 함수로 정의한다.

getSerializer는 findAnnotation 함수를 호출해서 @CustomSerializer 애노테이션이 있는지 찾고, 있다면 그 애노테이션의 serailizerClass가 직렬화기 인스턴스를 얻기 위해 사용해야 할 클래스이다.

 

@CustomSerializer의 값으로 클래스와 객체를 처리하는 방식에 있어 모두 KClass 클래스로 표현된다.

다만 객체에는 object 선언에 의해 생성된 싱글턴을 가리키는 objectInstance라는 프로퍼티가 있다는 것이 클래스와 다르다.

또한 serializeProperty 구현 안에서 getSerializer를 사용할 수 있다.

private fun StringBuilder.serializeProperty(prop: KProperty<Any, *>, obj: Any) {
    val name = prop.findAnnotation<JsonName>()?.name ?: prop.name
    serializeString(name)
    append(": ")
    val value = prop.get(obj)
    val jsonValue = prop.getSerializer()?.to JsonValue(value) ?: value
    serializePropertyValue(jsonValue)
}

serializeProperty는 커스텀 직렬화기의 toJsonValue 함수를 호출해서 프로퍼티 값을 JSON 형식으로 변환한다.

어떤 프로퍼티에 커스텀 직렬화기가 지정돼 있지 않다면 프로퍼티 값을 그냥 사용한다.

JSON 파싱과 객체 역직렬화

제이키드 라이브러리의 나머지 절반인 역직렬화 로직에 대해 보자.

API를 다시 봐보면 직렬화와 마찬가지로 함수 하나로 이뤄져 있다.

inline fun <reified T: Any> deserialize(json: String): T

 

제이키드의 JSON 역직렬화기는 흔히 쓰는 방법을 따라 3단계로 구현돼 있다.

첫 번째 단계는 어휘 분석기(lexical analyzer)렉서(lexer)라고 부른다.

어휘 분석기는 여러 문자로 이뤄진 입력 문자열을 토큰의 리스트로 변환한다.

토큰은 문자 토큰 값 토큰으로 나뉜다.

문자 토큰은 문자로 표현하며 JSON 문법에서 중요한 의미가 있고, 값 토큰은 운자열, 수, 불리언 값, null 상수를 말한다.

두 번째 단계는 문법 분석기(syntax analyzer)파서(parser)라고 부른다.

파서는 토큰의 리스트를 구조화된 표현으로 변환한다.

제이키드에서 파서는 JSON의 상위 구조를 이해하고 토큰을 JSON에서 지원하는 의미 단위로 변환하는 일을 한다.

그런 의미 단위로는 키/값 쌍과 배열이 있다.

마지막 단계는 파싱 한 결과로 객체를 생성하는 역직렬화 컴포넌트다.

JsonObject 인터페이스는 현재 역직렬화하는 중인 객체나 배열을 추적한다.

파서는 현재 객체의 새로운 프로퍼티를 발견할 때마다 그 프로퍼티의 유형에 해당하는 JsonObject 함수를 호출한다.

interface JsonObject {
    fun setSimpleProperty(propertyName: String, value: Any?)
    fun createObject(propertyName: String): JsonObject
    fun createArray(propertyNameL String): JsonObject
}

 

제이키드는 데이터 클래스와 함께 사용하려는 의도로 만든 라이브러리다.

따라서 제이키드는 JSON에서 가져온 이름/값 쌍을 역직렬화하는 클래스의 생성자에 넘긴다.

제이키드는 객체를 생성한 다음 프로퍼티를 설정하는 것을 지원하지 않는다. 따라서 제이키드 역직렬화기는 JSON에서 데이터를 읽는 과정에서 중간에 만든 프로퍼티 객체들을 어딘가에 저장해 뒀다가 나중에 생성자를 호출할 때 써야 한다.

최종 역직렬화 단계: callBy(), 리플렉션을 사용해 객체 만들기

ClassInfo는 최종 결과인 객체 인스턴스를 생성하고 생성자 파라미터 정보를 캐시하며, ObjectSeed 안에서 쓰인다.

ClassInfo 구현을 자세히 보기 전에 리플렉션을 통해 객체를 만들 때 사용할 API를 몇 가지 보자.

KCallable.call은 인자 리스트를 받아 함수나 생성자를 호출해준다. 유용한 경우도 많지만 디폴트 파라미터 값을 지원하지 않는다는 한계가 있다.

제이키드에서 역직렬화 시 생성해야 하는 객체에 디폴트 생성자 파라미터 값이 있고 제이키드가 그런 디폴트 값을 활용할 수 있다면 JSON에서 관련 프로퍼티를 꼭 지정하지 않아도 된다.

-> 디폴트 파라미터 값을 지원하는 KCallable.callBy를 사용

interface KCallable<out R> {
    fun callBy(args: Map<KParameter, Any?>): R
    ...
}

이 메소드는 파라미터와 이에 해당하는 값을 연결해주는 맵을 인자로 받는다.

인자로 받은 맵에서 파라미터를 찾을 수 없는데, 파라미터 디폴트 값이 정의돼 있다면 그 디폴트 값을 사용한다.

이 방식의 다른 장점으로는 파라미터의 순서를 지킬 필요가 없다는 것이다.

따라서 객체 생성자에 원래 정의된 파라미터 순서에 신경쓰지 않고 JSON에서 이름/값 쌍을 읽어서 이름과 일치하는

파라미터를 찾은 후 맵에 파라미터 정보와 값을 넣을 수 있다.

여기서 타입을 제대로 처리하기 위해 신경을 써야 하는데, args 맵에 들어있는 각 값의 타입이 생성자의 파라미터 타입과 일치해야 한다.

그렇지 않다면 IllegalArgumentException이 발생한다.

특히 숫자 타입을 처리할 때 조심해야 하는데, 파라미터가 Int, Long, Double 등의 타입 중 어떤 것인지를 확인해서 JSON에 있는 숫자 값을 적절한 타입으로 변환해야만 한다. KParameter.type 프로퍼티를 활용하면 파라미터 타입을 알 수 있다.

 

ClassInfoCache는 리플렉션 연산의 비용을 줄이기 위한 클래스다.

직렬화와 역직렬화에 사용하는 애노테이션들이 파라미터가 아니라 프로퍼티에 적용된다.

하지만 객체를 역직렬화할 땐 프로퍼티가 아닌 생성자 파라미터를 다뤄야 한다.

따라서 애노테이션을 꺼내려면 파라미터에 해당하는 프로퍼티를 찾아야 한다.

JSON에서 모든 키/값 쌍을 읽을 대마다 이런 검색을 수행하면 코드가 아주 느려질 것이다.

따라서 클래스 별로 한 번만 검색을 수행하고 검색 결과를 캐시에 넣어둔다.

728x90

댓글