본문 바로가기
kotlin

10. 애노테이션과 리플렉션

by AsCE_hyunseung 2022. 3. 26.

10. 애노테이션과 리플렉션

  • 애노테이션을 사용하면 라이브러리가 요구하는 의미를 클래스에게 부여할 수 있다.
  • 리플렉션을 사용하면 런타임에 컴파일러 내부 구조를 분석할 수 있다.

10.1 애노테이션 선언과 적용

  • 메타 데이터를 선언에 추가하면 애노테이션을 처리하는 도구가 컴파일 시점이나 실행 시점에 적절한 처리를 해준다.

10.1.1 애노테이션 적용

  • 함수나 클래스 등 여러 다른 코드 구성 요소에 애노테이션을 붙일 수 있다.
  • 애노테이션의 인자로는 원시 타입의의 값, 문자열 , enum, 클래스 참조, 다른 애노테이션 클래스, 앞의 요소들로 이뤄진 배열이 들어갈 수 있다.
  • 클래스 애노테이션 인자로 지정할 때는 @MyAnnotation(MyClass::class) 처럼 ::class를 클래스 이름 뒤에 넣어야한다.
  • 다른 애노테이션을 인자로 지정할 때는 인자로 들어가는 애노테이션의 이름 앞에 @를 넣지 않아야 한다. @Deprecated(”Use removeAt(index) instead.”, ReplaceWith(”removeAt(index)”)) ReplaceWith도 애노테이션이다.
  • 애노테이션 인자를 컴파일 시점에 알 수 있어야 한다. 그래서 임의의 프로퍼티를 인자로 지정할 수는 없다.
  • 프로퍼티를 애노테이션 인자로 사용하려면 그 앞에 const 변경자를 붙여야한다. 컴파일러가 const가 붙은 프로퍼티를 컴파일 시점 상수로 취급하기 때문이다.
const val TEST_TIMEOUT = 100L

@Test(timeout = TEST_TIMEOUT) fun testMethod() {...}
  • const가 붙은 프로퍼티는 파일의 맨 위나 object 안에 선언해야 하며, 원시 타입이나 String으로 초기화해야만 한다.

10.1.2 애노테이션 대상

  • 사용 지점 대상 선언으로 애노테이션을 붙일 요소를 정할 수 있다.
  • @사용지점대상: 애노테이션 이름 (ex. @get:Rule )
  • 규칙을 지정하려면 공개 필드나 메소드 앞에 @Rule을 붙여야 한다.
  • 코틀린의 필드는 기본적으로 비공개이다.
class HasTempFolder {
    @get:Rule // 프로퍼티가 아니라 게터에 애노테이션이 붙는다.
    val folder = TemporaryFolder()
}
  • 자바에 선언된 애노테이션을 사용해 프로퍼티에 애노테이션을 붙이는 경우 기본적으로 프로퍼티의 필드에 그 애노테이션이 붙는데 반해
  • 코틀린으로 애노테이션을 선언하면 프로퍼티에 직접 적용할 수 있는 애노테이션을 만들 수 있다.

사용 지점대상을 지정할 때 지원하는 대상 목록

  1. property
    • 프로퍼티 전체. 자바에서 선언된 애노테이션에는 이 사용 지점 대상을 사용할 수 없다.
  2. field
    • 프로퍼티에 의해 생성되는 필드
  3. get
    • 프로퍼티 게터
  4. set
    • 프로퍼티 세터
  5. receiver
    • 확장 함수나 프로퍼티의 수신 객체 파라미터
  6. param
    • 생성자 파라미터
  7. setparam
    • 세터 파라미터
  8. delegate
    • 위임 프로퍼티의 위임 인스턴스를 담아둔 필드
  9. file
    • 파일 안에 선언된 최상위 함수와 프로퍼티를 담아두는 클래스
  • 자바와는 달리 코틀린에서는 애노테이션 인자로 클래스나 함수 선언이나 타입 외에 임의의 식을 허용한다.
fun test(list: List<*>) {
    @Suppress("UNCHECKED_CAST")
    val strings = list as List<String>
}

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

직렬화

  • 객체 → 텍스트 or 이진 형식으로 변환.
data class Person(val name: String, val age:Int)

fun main() {
   val person = Person("Alice", 29)
    println(serialize(person)) //{"age": 29, "name": "Alice"}
}

역직렬화

  • 텍스트 or 이진 형식 → 객체로 변환.
data class Person(val name: String, val age: Int)

fun main() {
    val json = """{"age": 29, "name": "Alice"}"""
    println(deserialize<Person>(json)) // Person(name=Alice, age=29)
}
  • @JsonExclude 애노테이션을 사용하면 직렬화나 역직렬화 시 그 프로퍼티를 무시할 수 있다.
  • @JsonName 애노테이션을 사용하면 프로퍼티를 표현하는 키/값 쌍의 키로 프로퍼티 이름 대신 애노테이션이 지정한 이름을 쓰게 할 수 있다.
data class Person(
    @JsonName ("alias") val firstName: String,
    @JsonExclude val age: Int? = null
)

10.1.4 애노테이션 선언

  • @JsonExclude 애노테이션은 아무 파라미터도 없는 가장 단순한 애노테이션이다
annotation class JsonExclude
  • 애노테이션 클래스는 오직 선언이나 식과 관련있는 메타데이터의 구조를 정의하기 때문에 내부에 아무 코드도 들어있을 수 없다.
  • 그래서 컴파일러는 애노테이션 클래스에서 본문을 정의하지 못하게 막는다.
  • 파라미터가 있는 애노테이션을 정의하려면 애노테이션 클래스의 주 생성자에 파라미터를 선언해야 한다.
annotation class JsonName(val name: String)

10.1.5 메타애노테이션: 애노테이션을 처리하는 방법 제어

  • 코틀린 애노테이션 클래스에도 애노테이션을 붙일 수 있다.
  • 애노테이션 클래스에 적용할 수 있는 애노테이션을 메타애노테이션이라고 부른다
  • @Target 메타애노테이션은 애노테이션을 적용할 수 있는 요소의 유형을 지정한다
  • 애노테이션 클래스에 대해 구체적인 @Target을 지정하지 않으면 모든 선언에 적용할 수 있는 애노테이션이 된다.
@Target(AnnotationTarget.PROPERTY)
annotation class JsonExclude

10.1.6 애노테이션 파라미터로 클래스 사용

  • @DeserializeInterface 는 클래스 참조를 인자로 받는다
annotation class DeserializeInterface(val targetClass: KClass<out Any>)
  • KClass 는 자바 java.lang.Class 타입과 같은 역할을 하는 코틀린 타입이다.
  • 코틀린 클래스에 대한 참조를 저장할 때 KClass타입을 사용한다.

10.1.7 애노테이션 파라미터로 제네릭 클래스 받기

  • @CustomSerializer 애노테이션은 커스텀 직렬화 클래스에 대한 참조를 인자로 받는다.
  • 이 직렬화 클래스는 ValueSerializer 인터페이스를 구현해야만 한다.
interface ValueSerializer<T> {
    fun toJsonValue(value: T): Any?
    fun fromJsonValue(jsonValue: Any?): T
}

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

annotation class CustomSerializer(
    val serializerClass: KClass<out ValueSerializer<*>>
)
  • 클래스를 인자로 받아야 하면 애노테이션 파라미터 타입에 KClass<out 허용할 클래스 이름>을 쓴다.
  • 제네릭 클래스를 인자로 받아야 하면 KClass<out 허용할 클래스<*>>처럼 쓴다.

10.2 리플렉션: 실행 시점에 코틀린 객체 내부 관찰

리플렉션

  • 실행 시점에 동적으로 객체의 프로퍼티와 메소드에 접근할 수 있게 해주는 방법(동적 바인딩)
  • 보통 객체의 메소드나 프로퍼티에 접근할 떄는 프로그램 소스코드 안에 구체적인 선언이 있는 메소드나 프로퍼티 이름을 사용하며, 컴파일러는 그런 이름이 실제로 가리키는 선언을 컴파일 시점에 찾아내서 해당하는 선언이 실제 존재함을 보장한다 (정적 바인딩)
  • 타입과 관계없이 객체를 다뤄야 하거나 객체가 제공하는 메소드나 프로퍼티 이름을 실행 시점에만 알 수 있는 경우에 쓴다.
  • 대표적인 예로 JSON 직렬화 라이브러리를 사용할때 리플렉션이 필요하다.
  • 어떤 객체든 JSON 으로 변환할 수 있어야 하고, 실행 시점이 되기 전까지는 라이브러리가 직렬화할 프로퍼티나 클래스에 대한 정보를 알 수 없다.

코틀린에서 리플렉션을 사용하려면 두 가지 서로 다른 리플렉션 API를 다뤄야 한다.

  1. java.lang.reflect 패키지를 통해 제공하는 표준 리플렉션 api
    • 자바 리플랙션 api도 코틀린 클래스를 컴파일한 바이트코드를 완전히 지원한다.
    • 리플렉션을 사용하는 자바 라이브러리와 코틀린 코드가 완전히 호환된다
  2. 코틀린이 kotlin.reflect 패키지를 통해 제공하는 코틀린 리플렉션 api
    • 자바에는 없는 프로퍼티나 null이 될 수 있는 타입과 같은 코틀린 고유 개념에 대한 리플렉션을 제공한다.

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

  • 실행 시점에 객체의 클래스를 얻으려면 먼저 객체의 javaClass 프로퍼티를 사용해 객체의 자바 클래스를 얻어야한다.
class Person(val name: String, val age: Int)

fun main() {
    val person = Person("Alice", 29)
    val kClass = person.javaClass.kotlin // KClass<Person>의 인스턴스를 반환
    println(kClass.simpleName) // Person
}
  • 클래스의 모든 멤버 목록이 KCallable 인스턴스의 컬렉션이다
public interface KClass<T : kotlin.Any> : kotlin.reflect.KDeclarationContainer, kotlin.reflect.KAnnotatedElement, kotlin.reflect.KClassifier {
    public abstract val members: kotlin.collections.Collection<kotlin.reflect.KCallable<*>>
}

KCollable

  • 함수와 프로퍼티를 아우르는 공통 상위 인터페이스
  • call()을 사용하면 함수나 프로퍼티의 게터를 호출 할 수 있다.
public interface KCallable<out R> : kotlin.reflect.KAnnotatedElement {
    public abstract fun call(vararg args: kotlin.Any?): R
}
  • ::foo식의 값 타입이 리플렉션 API에 있는 KFuntion 클래스의 인스턴스이다.
  • call에 넘긴 인자 개수와 원래 함수에 정의된 파라미터 개수가 맞아 떨어져야한다.
fun foo(x: Int) = println(x)

fun main() {
    val kFunction = ::foo
    kFunction.call(42) //42
}
  • KFunctionN 인터페이스를 통해 함수를 호출하려면 invoke 메소드를 사용해야한다.
  • KFunction의 인자 타입과 반환 타입을 모두 다 안다면 Invoke 메소드를 호출하는게 낫다.
    • 왜냐면 call 메소드는 타입 안전성을 보장해주지않기 때문이다.
  • kFunction의 invoke 메소드를 호출할 때는 인자 개수나 타입이 맞아 떨어지지 않으면 컴파일 에러가난다.
import kotlin.reflect.KFunction2

fun sum(x: Int, y: Int) = x + y

fun main() {
    val kFunction: KFunction2<Int, Int, Int> = ::sum
    println(kFunction.invoke(1, 2) + kFunction(3, 4)) // 10
    kFunction(1) // No value passed for parameter 'p2'
}
  • 각 KFunctionN 타입은 KFunction을 확장하며 N과 파라미터 개수가 같은 invoke를 추가로 포함한다.
  • 이런 함수 타입들은 컴파일러가 생성한 합성 타입이다.
  • 코틀린에서는 컴파일러가 생성한 합성 타입을 사용하기 때문에 원하는 수만큼 많은 파라미터를 갖는 함수에 대한 인터페이스를 사용할 수 있다.

KProperty

  • KProperty의 call은 프로퍼티의 게터를 호출한다.
  • 그러나 값을 얻는 더 좋은 방법으로 프로퍼티 인터페이스는 get 메소드를 제공한다.
  • 최상위 프로퍼티는 KProperty0 인터페이스의 인스턴스로 표현되며, KProperty0 안에는 인자가 없는 get 메소드가 있다.
public actual interface KProperty0<out V> : KProperty<V>, () -> V {
    /**
     * Returns the current value of the property.
     */
    public actual fun get(): V
}
  • 멤버 프로퍼티는 KProperty1 인스턴스로 표현된다.
  • 멤버 프로퍼티는 어떤 객체에 속해 있는 프로퍼티이므로 멤버 프로퍼티의 값을 가져오려면 get 메소드에게 프로퍼티를 얻고자 하는 객체 인스턴스를 넘겨야한다.
var counter = 0

fun main() {
    val kProperty = ::counter
    kProperty.setter.call(21) // 리플렉션 기능을 통해 setter를 호출하면서 21을 인자로 넘김
    println(kProperty.get()) // 21
}
  • memberProperty가 Person 클래스의 age 프로퍼티를 참조하기 떄문에 memberProperty.get(person)은 동적으로 person.age를 가져온다
  • 수신 객체를 넘길 떄는 KProperty1의 타입 파라미터와 일치하는 타입의 객체만을 넘길 수 있다.
class Person(val name: String, val age: Int)

fun main() {
    val person = Person("Alice", 29)
    val memberProperty = **Person::age // memberProperty의 타입 KProperty1<Person, Int>**
    println(memberProperty.get(person))// 29
}
  • 최상위 수준이나 클래스 안에 정의된 프로퍼티만 리플렉션으로 접근할 수 있고 함수의 로컬 변수에는 접근할 수 없다.
fun main() {
    val x = 2
    val test = ::x // Unsupported [References to variables aren't supported yet]
}
  • KClass, KFunction, KParameter는 모두 KAnnotatedElement를 확장한다.

KClass

  • 클래스와 객체를 표현할 때 쓰인다.

KProperty

  • 모든 프로퍼티를 표현할 수 있다.
  • 접근자 메소드에 붙어 있는 애노테이션을 알아내려면 getter, setter를 통해야 한다.
  • getter, setter는 모두 KFunction을 확장한다.

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

  • 제이키드의 직렬화 함수 선언
  • 객체를 받아서 그 객체에 대한 JSON 표현을 문자열로 돌려준다
fun serialize(obj: Any): String
  • 이 함수는 객체의 프로퍼티와 값을 직렬화하면서 StringBuilder 객체 뒤에 직렬화한 문자열을 추가한다.
  • 이 append 호출을 더 간결하게 수행하기 위해 직렬화 기능을 Stringbuilder의 확장 함수로 구현한다.
  • 이렇게 하면 Stringbuilder 객체를 지정하지 않아도 append 메소드를 편하게 사용할 수 있다.
  • serializeObject가 수행하는 연산은 이 맥락을 벗어나면 쓸모가 없기 때문에 private으로 가시성을 지정한다.
private fun StringBuilder.serializeObject(x: Any) {
    append(...)
}
  • serialize는 대부분의 작업을 serializeObject에 위임한다.
  • 람다 본문에서 serializeObject(obj)를 호출해서 obj를 직렬화한 결과를 Stringbuilder에 추가한다.
fun serialize (obj: Any): String = buildString { serializeObject(obj) }
  • 클래스의 각 프로퍼티를 차례로 직렬화하는 코드이다.
  • prop 변수의 타입은 KProperty<Any, *>이며 prop.get(obj) 메소드 호출은 Any 타입의 값을 반환한다.
  • 이 경우 수신 객체 타입을 컴파일 시점에 검사할 방법이 없다.
  • 하지만 이 코드에서는 어떤 프로퍼티의 get에 넘기는 객체(obj)가 바로 그 프로퍼티를 얻어온 객체(obj)이기 때문에 항상 프로퍼티 값이 제대로 반환된다.
private fun StringBuilder.serializeObject(obj: Any) {
    val kClass = obj.javaClass.kotlin // 객체의 kClass를 얻는다.
    val properties = kClass.memberProperties // 클래스의 모든 프로퍼티를 얻는다.

    properties.joinToStringBuilder(this, prefix = "{", postfix = "}") { prop ->
        serializeString(prop.name) // 프로퍼티 이름을 얻는다.
        append(": ")
        serializePropertyValue(prop.get(obj)) // 프로퍼티 값을 얻는다.
    }
}

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

  • 함수는 인자로 전달받은 타입에 해당하는 애노테이션이 있으면 그 애노테이션을 반환한다.
  • 타입 파라미터를 reified로 만들어서 애노테이션 클래스를 타입 인자로 전달한다.
inline fun <reified T> KAnnotatedElement.findAnnotation(): T?
    = annotations.filterIsInstance<T>().firstOrNull
  • 이런식으로 @JSONExclude로 애노테이션된 프로퍼티를 없앨 수 있다.
val properties = kClass.memberProperties.filter { it.findAnnotation<JsonExclude>() == null}
val jsonNameAnn = prop.findAnnotation<JsonName>() // @JsonName 애노테이션이 있으면 그 인스턴스를 받는다.
val propName = jsonNameAnn?.name ?: prop.name // 애노테이션에서 "name" 인자를 찾고 그런 인자가 없으면 "prop.name"을 사용한다.
  • 프로퍼티 필터링을 포함하는 객체 직렬화
private fun StringBuilder.serializeObject(obj: Any) {
    obj.javaClass.kotlin.memberProperties
        .filter { it.findAnnotation<JsonExclude>() == null }
        .joinToStringBuilder(this, prefix = "{", postfix = "}") {
            serializeProperty(it, obj)
        }
}
  • 프로퍼티 값을 직렬화 하는 직렬화기 가져오기
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?>
}
  • 커스텀 직렬화기를 지원하는 프로퍼티 직렬화 함수
  • serializeProperty는 커스텀 직렬화기의 toJsonValue 함수를 호출해서 프로퍼티 값을 JSON 형식으로 변환한다.
  • 어떤 프로퍼티에 커스텀 직렬화기가 지정되어 있지 않다면 프로퍼티 값을 그냥 사용한다.
private fun StringBuilder.serializeProperty(
    prop: KProperty1<Any, *>, obj: Any
) {
    val name = prop.findAnnotation<JsonName>()?.name ?: prop.name
    serializeString(name)
    append(": ")

    val value = prop.get(obj)
    val jsonValue = prop.getSerializer()?.toJsonValue(value) // 프로퍼티에 대해 정의된 커스텀 직렬화기가 있으면 그 커스텀 직렬화기를 사용한다.
        ?: value // 커스텀 직렬화기가 없으면 일반적인 방법을 따라 프로퍼티를 직렬화한다.
    serializePropertyValue(jsonValue)
}

10.2.4 JSON 파싱과 객체 역 직렬화

  • 역직렬화할 객체의 타입을 실체화한 타입 파라미터로 deserialize 함수에 넘겨서 새로운 객체 인스턴스를 얻는다.
data class Author(val name: String)
data class Book(val title: String, val author: Author)

fun main() {
    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))
}
  • 제이키드의 JSON 역직렬화기는 3단계로 구현되어있다.
  1. 어휘 분석기
    • 여러 문자로 이뤄진 입력 문자열을 토큰의 리스트로 변환한다.
    • 문자 토큰
      • 문자를 표현
    • 값 토큰
      • 문자열 수, 불리언 값, null 상수를 말한다.
  2. 문법 분석기
    • 토큰의 리스트를 구조화된 표현으로 변환한다.
  3. 파싱한 결과로 객체를 생성하는 역직렬화 컴포넌트
    • 역직렬화기는 JsonObejct에 상응하는 코틀린 타입의 인스턴스를 점차 만들어내는 JsonObject 구현을 제공한다.
  • JsonObject 인터페이스는 현재 역직렬화하는 중인 객체나 배열을 추적한다.
  • 파서는 현재 객체의 새로운 프로퍼티를 발견할 때마다 그 프로퍼티의 유행에 해당하는 JsonObejct의 함수를 호출한다.
  • propertyName 파라미터는 JSON키를 받는다.
  • 파서가 객체를 값으로 하는 author 프로퍼티를 만나면 createObject(”author”)메소드가 호출된다.
interface JsonObject {
    fun setSimpleProperty(propertyName: String, value: Any?)

    fun createObject(propertyName: String): JsonObject

    fun createArray(propertyName: String): JsonObject
}
  • 기본 Seed 인터페이스는 JsonObject를 확장하면서 객체 생성 과정이 끝난 후 결과 인스턴스를 얻기 위한 spawn 메소드를 제공한다.
  • 중첩된 객체나 중첩된 리스트를 만들 떄 사용할 createCompositeProperty 메소드 선언이 들어있다.
interface Seed: JsonObject {
    val classInfoCache: ClassInfoCache

    fun spawn(): Any?

    fun createCompositeProperty(propertyName: String, isList: Boolean): JsonObject

    override fun createObject(propertyName: String) = createCompositeProperty(propertyName, false)

    override fun createArray(propertyName: String) = createCompositeProperty(propertyName, true)
}
  • 최상위 역직렬화 함수 deserialize
  • 파싱을 시작하려면 직렬화할 객체의 프로퍼티를 담을 ObjectSeed를 하나 생성해야한다.
  • 그리고 파서를 호출하면서 입력 스트림 리더인 json과 seed를 인자로 전달해야 한다.
  • 입력 데이터의 끝에 도달하면 spawn 함수를 호출해서 결과 객체를 생성한다.
fun <T: Any> deserialize(json: Reader, targetClass: KClass<T>): T {
    val seed = ObjectSeed(targetClass, ClassInfoCache())
    Parser(json, seed).parse()
    return seed.spawn()
}

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

KCallable.callBy

public fun callBy(args: Map<KParameter, Any?>): R
  • 이 메소드는 파라미터와 파라미터에 해당하는 값을 연결해주는 맵을 인자로 받는다.
  • 인자로 받는 맵에서 파라미터를 찾을 수 없는데, 파라미터 디폴트 값이 정의되어 있다면 그 디폴트 값을 사용한다.
  • 파라미터에 생성자 파라미터와 그 값을 연결해주는 맵을 넘기면 객체의 주 생성자를 호출할 수 있다.
    • ValueSerializer 메커니즘을 사용해 생성자를 호출할 때 사용하는 맵에 들어가는 값이 생성자 파라미터 정의의 타입과 일치하게 만든다.
    interface ValueSerializer<T> {
        fun toJsonValue(value: T): Any?
        fun fromJsonValue(jsonValue: Any?): T
    }
    

10.3 요약

  • 코틀린에서는 자바보다 더 넓은 대상에 애노테이션을 적용할 수 있다 (ex. 파일, 식)
  • @get:Rule 을 사용해 애노테이션의 사용 대상을 명시하면 한 코틀린 선언이 여러 가지 바이트코드 요소를 만들어내는 경우 정확히 어떤 부분에 애노테이션을 적용할지 지정할 수 있다.
  • 리플렉션 API를 통해 실행 시점에 객체의 메소드와 프로퍼티를 열거하고 접근할 수 있다.