책 정리/코틀린 인 액션

(코틀린 인 액션) 10장 애노테이션과 리플렉션

긍.응.성 2022. 9. 5. 23:30
반응형

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

💡 다루는 내용

  • 애노테이션 적용과 정의
  • 리플렉션을 사용해 실행 시점에 객체 내부 관찰
  • 코틀린 실전 프로젝트 예제

애노테이션(annotation) - 라이브러리가 요구하는 의미를 클래스에게 부여할 수 있다.

리플렉션(reflection) - 실행 시점에 컴파일러 내부 구조를 분석할 수 있다.

10.1 애노테이션 선언과 적용

10.1.1 애노테이션 적용

@Test fun testTrue() {
    Assert.assertTrue(true)
}
  • 코틀린도 자바와 같은 방법으로 애노테이션을 적용한다.
data class Date(val millisSinceEpoch: Long) {
    private val interval = LocalDateTime.ofInstant(Instant.ofEpochMilli(millisSinceEpoch), ZoneId.of("UTC"))

    @Deprecated("Use the new month() method",
        level = DeprecationLevel.ERROR,
        replaceWith = ReplaceWith("month()")
    )
    fun monthNumber(): Int = interval.get(ChronoField.MONTH_OF_YEAR)
}

>>> val epoch = Date(0)
>>> println(epoch.~~monthNumber~~())
  • 코틀린의 @Deprecated 애노테이션의 자바와 같은 의미지만, 추가 기능이 지원된다.

코틀린과 자바의 애노테이션 인자 지정 문법 차이점

  • 클래스를 애노테이션 인자로 지정 시 ::class를 클래스 이름 뒤에 넣어야 한다.
    • @MyAnnotation(MyClass::class)
  • 다른 애노테이션을 인자로 지정할 때는 인자로 들어가는 애노테이션의 이름 앞에 @를 넣지 말아야 한다.
    • ReplaceWith은 애노테이션이나 @Deprecated의 인자로 들어갈 때 @가 들어가지 않는다.
    • @Deprecated("Use the new month() method", replaceWith = ReplaceWith("month()"))
  • 배열을 인자로 사용하려면 arrayOf 함수를 사용한다. → [ ] 로도 표현 가능하다
    • @*RequestMapping*(path = arrayOf(”/foo”, “/bar”))
    • `@KotlinAnnotationWithArrayValue(path = ["foo", "bar"])`
    • 자바에서 선언한 애노테이션 클래스인 경우 파라미터가 value만 존재한다면 이는 가변 길이 인자(vararg)로 변환되어 arrayOf을 사용하지 않아도 된다.
      • `@JavaAnnotationWithArrayValue("foo", "bar")`
      • 변수명이 value 이외에 다른 이름이거나, value외에 추가로 파라미터가 존재한다면 가변 길이 인자로 사용할 수 없다.
const val TEST_TIMEOUT = 100L

@Test(timeout = TEST_TIMEOUT) fun testMethod() { ... }
  • 애노테이션 인자는 컴파일 시점에 알 수 있어야 하므로, 프로퍼티를 애노테이션 인자로 사용하기 위해선 const 변경자를 통해 상수로 취급해야 한다.

10.1.2 애노테이션 대상 (Annotation use-site targets)

코틀린 프로퍼티는 자바의 필드이자 접근자의 역할을 한다.

그렇기에 코틀린 프로퍼티에 붙인 애노테이션은 생성자, 필드, 게터, 세터 등과 같은 위치에 대응될 수 있다.

사용 지점 대상(use-site target) 선언을 통해 애노테이션이 생성될 정확한 위치를 지정할 수 있다.

지점 대상은 @기호와 애노테이션 이름 사이에 붙으며, 애노테이션 이름과는 콜론으로 분리된다.

annotation class Ann

class Example(@field:Ann val foo: Int,    // annotate Java field
              @get:Ann val bar: Int,      // annotate Java getter
              @param:Ann val quux: Int)   // annotate Java constructor parameter
public final class Example {
   @Ann
   private final int foo;
   private final int bar;
   private final int quux;

   public final int getFoo() {
      return this.foo;
   }

   @Ann
   public final int getBar() {
      return this.bar;
   }

   public final int getQuux() {
      return this.quux;
   }

   public Example(int foo, int bar, @Ann int quux) {
      this.foo = foo;
      this.bar = bar;
      this.quux = quux;
   }
}
  • 자바에 선언된 애노테이션을 프로퍼티에 붙이면 field: 로 적용된다.
    • 자바에 선언된 애노테이션도 사용 지점 대상 선언이 가능하다.
@file:JvmName("Foo")

package org.jetbrains.demo
  • @file 은 파일 안에 선언된 최상위 함수와 프로퍼티를 담아두는 클래스에 적용된다.
  • 파일 맨 위에 @file:JvmName 을 명시하여 컴파일될 파일의 이름을 지정할 수 있다.
    • 3.2.3절
  • 사용 지점 대상을 지정하지 않은 경우 애노테이션의 @Target 에 따라 지점 대상이 선택된다.
    • param, property, field 중 존재하는 Target이 있을 시 우선순위 따라 적용된다.
  • volatile이나 strictfp(strict floating point)와 같은 자바의 키워드는 코틀린에서 애노테이션을 통해 지원된다(@Volatile, @Strictfp)

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

jkid 라이브러리에 대한 간략한 설명이다.

serialize : 직렬화

deserialize : 역직렬화

@JsonExclude : 직렬화, 역직렬화 시 프로퍼티 무시

@JsonName : 직렬화 시 프로퍼티 이름 지정

10.1.4 애노테이션 선언

annotation class JsonExclude

annotation class JsonName(val name: String)
  • class 키워드 앞에 annotation 변경자를 붙여 선언한다.
  • 애노테이션 클래스는 본문을 정의하지 못한다.
  • 주 생성자를 통해 파라미터가 있는 애노테이션을 선언할 수 있다.
    • 애노테이션 클래스의 파라미터는 모두 val로 선언되어야 한다.
  • 자바 애노테이션을 코틀린에서 사용하는 경우 value를 제외한 모든 인자에 이름 붙은 인자 구문을 사용하여야 한다(인자가 하나라도 붙여야 한다).

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

메타 애노테이션(meta-annotation): 애노테이션 클래스에 적용할 수 있는 애노테이션

@Target(AnnotationTarget.PROPERTY)
annotation class JsonExclude

@Target(AnnotationTarget.PROPERTY)
annotation class JsonName(val name: String)
  • @Target 을 통해 애노테이션을 적용할 수 있는 요소의 유형을 지정한다.
  • 미 지정 시 모든 선언에 적용할 수 있다.
  • 애노테이션이 붙을 수 있는 대상 enum 클래스는 AnnotationTarget이다.
    • 자바의 ElementType
@Target(AnnotationTarget.ANNOTATION_CLASS)
annotation class BindingAnnotation

@BindingAnnotation
annotation class MyBinding
  • AnnotationTarget.ANNOTATION_CLASS 를 통해 메타애노테이션을 직접 만들 수 있다.
  • PROPERTY는 자바 코드에서 사용할 수 없으므로 FIELD를 두 번째 대상으로 추가해주어야 사용 가능하다.

@Retention 애노테이션

  • @Retention 은 정의 중인 애노테이션 클래스를 소스 수준에서만 유지할지, .class 파일에 저장할지, 실행 시점에 리플렉션을 사용하여 접근할 수 있게 할지를 지정하는 애노테이션이다.
  • 자바 컴파일러는 기본적으로 애노테이션을 .class 파일에 저장하지만 런타임에 사용할 수 없게 한다.
  • 코틀린에서는 기본적으로 애노테이션의 @RetentionRUNTIME으로 지정한다.

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

@Target(AnnotationTarget.PROPERTY)
annotation class DeserializeInterface(val targetClass: KClass<out Any>)

interface Company {
    val name: String
}

data class CompanyImpl(override val name: String) : Company

data class Person(
    val name: String,
    @DeserializeInterface(CompanyImpl::class) val company: Company
        // 실제로 사용 시 구체 클래스를 전달한다.
)
  • 애노테이션의 파라미터가 클래스인 경우 KClass 타입을 사용한다.
  • ::class 를 통해 KClass 타입을 가져올 수 있다.
  • KClass<out 허용할 클래스 이름> 을 통해 클래스를 인자로 받을 수 있다.
    • 공변성을 위해 out 을 붙여준다.

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

@Target(AnnotationTarget.PROPERTY)
annotation class CustomSerializer(val serializerClass: KClass<out ValueSerializer<*>>)
  • 애노테이션 파라미터로 제네릭 클래스를 받는 경우이다.
  • KClass<out 허용할 클래스 이름<*>> 을 통해 제네릭 클래스를 인자로 받을 수 있다.
    • 타입 파라미터를 알 수 없기에 스타 프로젝션을 사용한다.

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

  • 리플렉션을 통해 실행 시점에 동적으로 객체의 프로퍼티와 메서드에 접근할 수 있다.
  • 코틀린 리플렉션 사용을 위해 자바의 java.lang.reflect 패키지와 코틀린의 kotlin.reflect 패키지를 사용해야 한다.

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

KClass

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

val person = Person("Alice", 29)
val kClass = person.javaClass.kotlin
println(kClass) // class com.study.kotlin.chapter10.Person

kClass.memberProperties.forEach { println(it.name) }
//age
//name
  • 실행 시점에 객체의 클래스를 얻으려면 javaClass 프로퍼티를 사용해 자바 클래스를 얻어야 한다.
    • javaClass = java.lang.Object.getClass()
  • 자바 클래스에 .kotlin 확장 프로퍼티를 통해 코틀린 리플렉션 API 를 얻어올 수 있다.
  • memberProperties를 통해 클래스와 모든 조상 클래스 내부에 정의된 비확장 프로퍼티를 모두 가져올 수 있다.
println("kClass.simpleName ${kClass.simpleName}") // 선언한 클래스 명
println("kClass.qualifiedName ${kClass.qualifiedName}") // fqcn
println("kClass.members ${kClass.members}") // 생성자를 제외한 접근 가능한 함수와 프로퍼티(상위 클래스에 선언된것을 포함한다)
println("kClass.constructors ${kClass.constructors}") // 생성자
println("kClass.nestedClasses ${kClass.nestedClasses}") // 내부 클래스
println("kClass.objectInstance ${kClass.objectInstance}") // 해당 클래스 타입으로 선언된 object(싱글턴 객체). 미존재시 null
---
kClass.simpleName Person
kClass.qualifiedName com.study.kotlin.chapter10.Person
kClass.members.toString() [val com.study.kotlin.chapter10.Person.age: kotlin.Int, val com.study.kotlin.chapter10.Person.name: kotlin.String, fun com.study.kotlin.chapter10.Person.equals(kotlin.Any?): kotlin.Boolean, fun com.study.kotlin.chapter10.Person.hashCode(): kotlin.Int, fun com.study.kotlin.chapter10.Person.toString(): kotlin.String]
kClass.constructors.toString() [fun <init>(kotlin.String, kotlin.Int): com.study.kotlin.chapter10.Person]
kClass.nestedClasses.toString() []
kClass.objectInstance null
  • kClass에서 제공하는 다양한 기능

KCallable, KFunction

  • 클래스의 모든 멤버(함수와 프로퍼티)는 KCallable이다.
  • KCallable.call()을 통해 함수나 프로퍼티의 게터를 호출할 수 있다.
    • call() 함수 사용 시 인자 개수가 정의된 파라미터 수와 맞아떨어져야만 한다
      • 개수가 맞지 않는 경우 IlleaglArgumentException 발생
  • 함수는 KFunction, 프로퍼티는 KProperty 인터페이스로 KCallable을 상속한다.
fun foo(x: Int) = println(x)

val kFunction = ::foo
kFunction.call(42)
// 42
kFunction.call(0, 1)
// java.lang.IllegalArgumentException: Callable expects 1 arguments, but 2 were provided.
  • :: 를 통해 KFunction 클래스의 인스턴스를 가져올 수 있다.
fun sum(x: Int, y: Int) = x + y
val kFunction2: KFunction2<Int, Int, Int> = ::sum
println(kFunction2.invoke(1, 2) + kFunction2(3, 4)) // 10

kFunction2(1) // Error:(21, 17) Kotlin: No value passed for parameter 'p2'
  • KFunctionN 을 통해 호출할 함수의 파라미터 개수를 지정할 수 있다.
  • KFunctionN에서N은 인자의 개수를 의미하며 타입 파라미터 지정 시 인자의 타입과 반환 타입을 포함하여 N+1 개의 타입을 명시해야한다.
    • KFunction2<Int, Int, Int> → N=2, 인자 개수 2개, 지정할 타입 파라미터 3개
  • KFunctionNinvoke() 메서드를 통해 함수를 호출할 수 있다.
    • invoke 메서드의 파라미터 개수가 맞지 않는 경우 컴파일 타임에 오류를 잡아준다.
  • KFunctionN 인터페이스가 정의되는 시점과 방법
    • KFunctionN 타입은 KFunction을 확장하며, N과 파라미터 개수가 같은 invoke를 추가로 포함
    • 이런 함수 타입들은 컴파일러가 생성한 합성 타입이며 정의된 소스는 찾을 수 없다.
    • 합성 타입을 통해 코틀린은 kotlin-runtime.jar의 크기를 줄일 수 있으며, 파라미터 개수에 대한 제약을 피한다.
// 1. (Int) -> Boolean
fun isOdd(x: Int) = x % 2 != 0
// 2. (String) -> Boolean
fun isOdd(s: String) = s == "brillig" || s == "slithy" || s == "tove"

val numbers = listOf(1, 2, 3)
println(numbers.filter(::isOdd)) // refers to isOdd(x: Int)
// [1, 3]

val predicate: (String) -> Boolean = ::isOdd   // refers to isOdd(x: String)
println(listOf("brillig", "brave", "tove").filter(predicate = predicate))
// [brillig, tove]
  • 오버로드된 함수에 대해서도 문맥을 통해 타입을 알 수 있다면 KFunction는 이를 파악하여 동작한다.
  • filter에서 기대하는 함수 타입은 predicate((Int) → Boolean) 이므로 첫 번째로 선언된 isOdd 메서드의 KFunction 인스턴스가 전달되어 동작한다.
  • 타입을 명시하여 문맥을 직접 제공함으로써 원하는 KFunction 인스턴스를 가져올 수 있다.

KProperty

  • call() 메서드를 통해 프로퍼티의 게터를 호출할 수 있다.
  • 하지만 KProperty는 get() 메서드를 제공하기에 이를 사용하자!
// 최상위 프로퍼티
var counter = 0

fun main() {
    val kProperty = ::counter
    kProperty.set(50)
    println(kProperty.get())
}
  • 최상위 프로퍼티는 KProperty0 인스턴스로 표현되며, 인자가 없는 get 메서드가 있다.
  • var 에 대하여 KProperty를 가져오는 경우 실질적으로 KMutableProperty 인스턴스를 가져온다.
  • val 에 대해선 KProperty 를 가져오며 KMutableProperty 와 차이점은 setter를 지원하지 않는다.
val person = Person("Alice", 29)
val memberProperty = Person::age // 멤버 프로퍼티
println(memberProperty.get(person)) // 객체 인스턴스를 넘겨 get 호출
// 29
  • 클래스 안에 정의된 멤버 프로퍼티의 참조는 KProperty1 인스턴스로 표현된다.
  • 멤버 프로퍼티는 특정 객체에 속해있는 프로퍼티이므로 get 함수를 통해 값을 가져오기 위해선 해당 객체 인스턴스를 넘겨야 한다.
  • KProperty1은 제네릭 클래스이며 *<수신 객체 타입, 프로퍼티 타입>*을 타입 파라미터로 갖는다.
  • KPropertyNKProperty2까지 존재한다
    • KProperty2는 클래스의 멤버인 확장 프로퍼티의 참조를 위해 사용된다.
data class Foo(val tag: String) {
    val Int.echo get() = "I'm extension on $this within ${this@Foo}"
}

val foo = Foo("tag")
val kProperty2: KProperty2<Foo, Int, String> =
    Foo::class.declaredMemberExtensionProperties.first() as KProperty2<Foo, Int, String>
println(kProperty2.get(foo, 5)) // I'm extension on 5 within Foo(tag=tag)
  • 함수 내부의 로컬 변수에 대한 참조는 아직 지원하지 않는다.
코틀린 리플렉션 API의 인터페이스 계층 구조
  • KClass, KFunction, KParameter는 모두 KAnnotatedElement를 확장한다.

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

private fun StringBuilder.serializeObject(obj: Any) {
    val kClass = obj.javaClass.kotlin // KClass
    val properties = kClass.memberProperties // Collection<KCallable>

    properties.joinTo(this, prefix = "{", postfix = "}") { prop ->
        serializeString(prop.name)
        append(": ")
        serializePropertyValue(prop.get(obj))
    }
}
  • 앞서 살펴본 코틀린 리플렉션 API를 통해 직렬화를 구현한다.
  • 직렬화 할 객체의 프로퍼티 정보는 javaClass.kotlin.memberProperties 를 통해 가져올 수 있다.
  • KProperty 컬렉션을 순회하며 각 프로퍼티에 대하여 namevalue 를 조회 가능하다.

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

public interface KAnnotatedElement {
  public val annotations: List<Annotation>
}
  • 리플렉션 API의 최상위엔 KAnnotatedElement 인터페이스가 존재하며 적용된 애노테이션 리스트를 프로퍼티로 갖는다
private fun StringBuilder.serializeObject(obj: Any) {
    obj.javaClass.kotlin.memberProperties
        .filter { it.findAnnotation<JsonExclude>() == null }
        .joinTo(this, prefix = "{", postfix = "}") { prop -> 
            val jsonNameAnn = prop.findAnnotation<JsonName>()
            val propName = jsonNameAnn?.name ?: prop.name
            serializeString(propName)
            append(": ")
            val value = prop.get(obj)
            val jsonValue = prop.getSerializer()?.toJsonValue(value) ?: value
            serializePropertyValue(jsonValue)
        }
}
  • findAnnotation 확장 함수를 통해 특정 해당 프로퍼티에 애노테이션이 적용되었는지 확인할 수 있다.

10.2.4 JSON 파싱과 객체 역직렬화

jkid 라이브러리의 역직렬화 과정을 설명한 장이다.

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

KCallable.callBy는 디폴트 파라미터 값을 지원한다.

interface KCallable<out R> {
    fun callBy(args: Map<KParameter, Any?>): R
}
  • 파라미터와 파라미터에 해당하는 값을 연결해주는 맵을 인자로 받는다.
  • 인자로 전달한 맵에서 파라미터를 찾을 수 없고 디폴트 값이 있다면, 그 디폴트 값을 사용한다.
  • 파라미터의 순서를 지킬 필요가 없다는 것이 call() 함수와 비교했을 때 장점이다.
  • args 맵에 들어있는 각 값의 타입은 파라미터 타입과 일치해야 한다.
    • KParameter.type 을 통해 파라미터의 타입을 알 수 있다.
private fun ensureAllParameterPresent(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}")
        }
    }
}
  • KType을 통해 해당 타입이 nullable인지(isMarkNullable), 디폴트 파라미터 값이 있는지(isOptional) 등의 정보를 알 수 있다.

지점 대상은 @기호와 애노테이션 이름 사이에 붙으며, 애노테이션 이름과는 콜론으로 분리된다.

지점 대상은 @기호와 애노테이션 이름 사이에 붙으며, 애노테이션 이름과는 콜론으로 분리된다.

반응형