[Kotlin]10장. 애노테이션과 리플렉션

sonnie
lucky-sonnie
Published in
11 min readFeb 24, 2021

애노테이션을 사용하면 라이브러리가 요구하는 의미를 클래스에게 부여할 수 있고, 리플렉션을 사용하면 실행 시점에 컴파일러 내부 구조를 분석할 수 있다. 코틀린에서 애노테이션을 사용하는 문법은 자바와 똑같지만 애노테이션을 선언할 때 사용하는 문법은 자바와 약간 다르다. 리플렉션 API의 일반 구조는 자바와 같지만 세부 사항에서 약간 차이가 있다.

10.1 애노테이션 선언과 적용

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

10.1.1 애노테이션 적용

자바와 다른 코틀린의 애노테이션 인자를 지정하는 문법

  • 클래스를 애노테이션 인자로 지정할때는 @MyAnnotation(MyClass::class) 처럼 ::class 를 클래스 이름 뒤에 넣어야 한다.
  • 다른 애노테이션을 인자로 지정할 때는 인자로 들어가는 애노테이션의 이름 앞에 @를 넣지 않아야 한다. 아래의 ReplaceWith 앞에 @를 사용하지 않는다.
@Deprecated(ReplaceWith("removeAt(index)"))
fun remove(index: Int) {...}
  • 배열을 인자로 지정하려면 @RequestMapping(path=arrayOf("/foo", "/bar")) 처럼 arrayOf 함수를 사용한다. 자바에서 선언한 애노테이션 클래스를 사용한다면 value라는 이름의 파라미터가 필요에 따라 자동으로 가변 길이 인자로 변환된다. 따라서 그런 경우에는 @JavaAnnotationWithArrayValue("abc", "foo", "bar") 처럼 arrayOf 함수를 쓰지 않아도 된다.

애노테이션 인자를 컴파일 시점에 알 수 있어야 한다. 따라서 임이의 프로퍼티를 인자로 지정할 수는 없다. 프로퍼티를 애노테이션 인자로 사용하려면 그 앞에 const 변경자를 붙여야 한다. 컴파일러는 const 가 붙은 프로퍼티를 컴파일 시점 상수로 취급한다.

const val TEST_TIMEOUT=100L
@Test(timeout=TEST_TIMEOUT) fun testMethod() {...}

const가 붙은 프로퍼티는 파일의 맨 위나 object 안에 선언해야 하며 원시 타입이나 String으로 초기화 해야만 한다.

10.1.2 애노테이션 대상

애노테이션을 붙일 때 코틀린 애노테이션인지, 자바 애노테이션인지 표시해야 할 때가 있다. 사용 지점 대상 선언으로 애노테이션을 붙일 요소를 정할 수 있다. 사용 지점 대상은 @ 기호와 애노테이션 이름 사이에 붙으며, 애노테이션 이름과는 콜론(:) 으로 분리된다. @get:Rule 의 뜻은 get@Rule 어노테이션을 프로퍼티 게터에 적용하라는 뜻이다.

자바에 선언된 애노테이션을 사용해 프로퍼티에 애노테이션을 붙이는 경우 기본적으로 프로퍼티의 필드에 그 애노테이션이 붙는다. 하지만 코틀린으로 애노테이션을 선언하면 프로퍼티에 직접 적용할 수 있는 애노테이션을 만들 수 있다.

  • property: 프로퍼티 전체. 자바에서 선언된 애노테이션에는 이 사용 지점 대상을 사용할 수 없다.
  • field: 프로퍼티에 의해 생성되는 필드
  • get: 프로퍼티 게터
  • set: 프로퍼티 세터
  • receiver: 확장 함수나 프로퍼티의 수신 객체 파라미터
  • param: 생성자 파라미터
  • setparam: 세터 파라미터
  • delegate: 위임 프로퍼티의 위임 인스턴스를 담아둔 필드
  • file: 파일 안에 선언된 최상위 함수와 프로퍼티를 담아두는 클래스

자바와는 달리 코틀린에서는 애노테이션의 인자로 클래스나 함수선언, 타입 외에 임의의 식을 허용한다. 가령 @Suppress 는 컴파일러 경고를 무시하기 위한 애노테이션이다.

@Suppress("UNCHECKED_CAST")
val String = list as List<String>

자바 API를 애노테이션으로 제어하기

코틀린은 코틀린으로 선언한 내용을 자바 바이트 코드로 컴파일하는 방법과 코틀린 선언을 자바에 노출하는 방법을 제어하기 위한 애노테이션을 많이 제공한다. 이런 애노테이션 중 일부는 자바 언어의 일부 키워드를 대신한다.

예를들어 @Volatile@Strictfp 애노테이션은 자바의 volatile과 strictfp 키워드를 대신한다. 다음 애노테이션을 사용하면 코틀린 선언을 자바에 노출시키는 방법을 변경할 수 있다.

  • @JvmName 은 코틀린 선언이 만들어내는 자바 필드나 메소드 이름을 변경한다.
  • @JvmStatic 을 메소드, 객체 선언, 동반 객체에 적용하면 그 요소가 정적 메소드로 노출된다. (이미 동반 객체로 선언을 하면 static이 아닌가?)
  • @JvmOverloads 를 사용하면 디폴트 파라미터 값이 있는 함수에 대해 컴파일러가 자동으로 오버로딩한 함수를 생성해준다.
  • @JvmField 를 프로퍼티에 사용하면 게터나 세터가 없는 공개된 자바 픨드로 프로퍼티를 노출시킨다.

애노테이션 선언

코틀린의 애노테이션은 다음과 같이 선언할 수 있다.

annotation class MyAnnotation

자바에서 @interface 라는 다소 모호한 이름으로 선언하던 것과 달리 확실히 annotation 클래스라는 것을 명시해주고 있다. 애노테이션 클래스는 선언이나 식과 관련 있는 메타데이터의 구조를 정의하기 때문에 내부에 어떤 코드도 들어갈 수 없다. 만약 파라미터가 있는 애노테이션을 적용하고자 한다면 애노테이션 클래스의 주 생성자에 파라미터를 선언해야한다.

annotation class MyAnnotation(val name: String)

주의해야 할 점은 애노테이션 클래스의 모든 파라미터에 val을 반드시 붙여야한다는 점이다.

메타애노테이션

애노테이션 클래스에 적용할 수 있는 애노테이션을 메타애노테이션이라고 부른다. 표준 라이브러리에서 가장 일반적으로 쓰이는 메타 애노테이션을 꼽으라면 @Target 애노테이션이다. 이 애노테이션은 애노테이션을 적용할 수 있는 범위를 지정한다. 클래스에 사용할 것인지, 메소드에 사용할 것인지 등

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

리플렉션은 실행 시점에 객체의 프로퍼티와 메소드에 접근할 수 있게 해주는 방법이다. 일반적으로 객체의 매소드나 프로퍼티에 접근할 때는 프로그램 소스코드 안에 구체적인 선언이 있는 메소드나 프로퍼티 이름을 사용하며, 컴파일러는 그런 이름이 실제로 가리키는 선언을 컴파일 시점에 찾아내서 선언이 실제 존재함을 보장해준다.

그러나 타입과 관계 없이 객체를 다뤄야 하거나 객체가 제공하는 메소드나 프로퍼티 이름을 오직 실행 시점에만 알 수 있는 경우가 존재한다. JSON 직렬화 라이브러리가 바로 그 예이다. 직렬화 라이브러리는 어떤 객체든 JSON으로 변환할 수 있어야 하고, 실행 시점에 되기 전까지는 라이브러리가 직렬화할 프로퍼티나 클래스에 대한 정보를 알 수 없다. 이런 경우 리플렉션을 사용해야 한다.

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

  1. 자바에서 제공하는 java.lang.reflect 패키지이다. 자바 리플렉션 API가 필요한 이유는 코틀린 클래스는 일반 자바 바이트 코드로 컴파일되기 때문이다. 자바 리플렉션 API는 코틀린 클래스를 컴파일한 바이트코드도 완벽히 지원한다.
  2. 코틀린의 kotlin.reflect 에서 제공하는 API이다. 이 API는 자바에서는 없는 프로퍼티나 널이 될 수 있는 타입과 같은 코틀린 고유 개념에 대한 리플렉션을 제공한다.

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

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

MyClass::class 라는 식으로 KClass의 인스턴스를 얻을 수 있다. 실행 시점에 객체의 클래스를 얻기 위해서는 객체의 javaClass 프로퍼티를 사용해 객체의 자바 클래스를 얻어야한다. javaClass는 java.lang.Object.getClass() 와 동일하다. 자바 클래스를 얻었다면 .kotlin확장 프로퍼티를 통해 자바에서 코틀린 리플렉션 API로 옮겨올 수 있다.

val person = Person("Harry", 27)
val kClass = person.javaClass.kotlin
>>> println(kClass.simpleName)
Person
// memberProperties -> members 로 변경
>>>kClass.members.forEach{ println(it.name)}
age
name
...

KClass의 내부 선언은 다음과 같이 선언되어 있다.

public interface KClass<T : Any> : KDeclarationContainer, KAnnotatedElement, KClassifier {

public val simpleName: String?
public val qualifiedName: String?
override val members: Collection<KCallable<*>>
public val constructors: Collection<KFunction<T>>
...
}

출처 : https://github.com/JetBrains/kotlin/blob/1.1.3/core/builtins/src/kotlin/reflect/KClass.kt

잘 보면 클래스의 모든 멤버 목록이 KCallable 인스턴스의 컬렉션이라는 사실을 알 수 있다. KCallable은 함수와 프로퍼티를 아우르는 공통 상위 인터페이스이다. 그 내부에는 call 메소드가 존재하며 이 call을 사용하면 함수나 프로퍼티의 게터를 호출할 수 있다.

public fun call(vararg args: Any?): R
fun foo(x:Int) = println(x)
val kFunction = ::foo
kFunction.call(42)
// 42

::foo 의 값 타입이 리플렉션 API에 존재하는 KFunction 클래스의 인스턴스임을 알 수 있다. 이 ::foo 의 타입 KFunction1<Int, Unit>에는 파라미터와 반환 값 타입 정보가 들어있다. 1은 이 함수의 파라미터가 1개라는 것을 의미한다. KFunction1 인터페이스를 통해 함수를 호출하려면 invoke 메소드를 사용해야 한다.

KFunction2<P1, P2, R> == operator fun invoke(p1: P1, p2: P2): R

KFunctionN의 invoke 메소드를 호출할 때는 인자 개수나 타입이 맞아 떨어지지 않으면 컴파일이 안된다. KFunction 의 인자 타입과 반환 타입을 모두 다 안다면 invoke를 호출하는게 낫다. call 메소드는 모든 타입의 함수에 적용할 수 있는 일반적인 메소드지만 타입 안정성을 보장해주진 않는다.

코틀린에서는 컴파일러가 생성한 합성 타입 (ex. KFunctionN, KProperty etc)을 사용하기 때문에 원하는 수만큼 많은 파라미터를 갖는 함수에 대한 인터페이스를 사용할 수 있다. 합성 타입을 사용하기 때문에 코틀린은 kotlin-runtime.jar 의 크기를 줄일 수 있고 함수 파라미터 개수에 대한 인위적인 제약을 피할 수 있다.

KProperty는 call 메소드를 호출할 수 있고, get 메소드 또한 지원한다.

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

최상위 수준이나 클래스 안에 정의된 프로퍼티만 리플렉션으로 가져올 수 있고 함수의 로컬 변수에는 접근할 수 없다.

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

@JsonExclude 를 이용해 직렬화에서 제외하는 방법.

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

val propreties = KClass.members.filter{   it.findAnnotation<JsonExclude>() == null}

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

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

KCallable.call 은 function 이나 constructor 를 호출할 수 있고, argument 들을 list 형태로 받을 수 있다. 많은 경우에 잘 작동하지만 제약사항이 있다. default parameter value 를 제공하지 않는다.

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

callBy 는 전달하는 Map 에 빠진 param 이 있다면 default value 가 자동으로 사용된다. 즉 default value 를 잘 지원한다. Map 으로 전달하니, named-argument 를 사용하는 것처럼 순서에도 영향을 받지 않는다. Primary constructor 를 호출하는 데에도 사용될 수 있다.

출처: https://jusunglee.tistory.com/entry/Annotation-Reflextion [IT 정글]

참고: Kotlin In Action (드미트리 제메로프, 스베트라나 이사코바)

https://minkukjo.github.io/language/2020/05/09/Kotlin-10/

--

--