기록하는 습관

[Kotlin In Action]10장 - 애노테이션과 리플렉션 본문

스터디/Kotlin In Action

[Kotlin In Action]10장 - 애노테이션과 리플렉션

로그뉴 2023. 5. 30. 10:41

** 이 글은 Kotlin In Action을 읽고 정리한 글입니다. **

 

10.1 애노테이션 선언과 적용

10.1.1 애노테이션 적용

애노테이션 적용을 위해서는 적용하는 대상 앞에 애노테이션을 붙이면 된다.

애노테이션은 '@'과 애노테이션 이름으로 이루어진다.

 

예를 들어 JUnit 프레임워크를 사용한다면 테스트 메서드 앞에 '@Test' 애노테이션을 붙이면 된다.

class MyTest{
  @Test fun testTrue(){
    Assert.assertTrue(true)
  }
}

 

애노테이션의 인자로는 원시 타입의 값, 문자열, enum, 클래스 참조, 다른 애노테이션 클래스, 배열이 들어갈 수 있다.

애노테이션의 인자를 지정하는 문법은 자바와 약간 다르다.

  • 클래스를 애노테이션 인자로 지정할 때는 @MyAnnotation (MyClass:class) 처럼 ::class 를 클래스 이름 뒤에 넣어야 한다. 
  • 다른 애노테이션을 인자로 지정할 때는 인자로 들어가는 애노테이션의 이름 앞에 @ 를 넣지 않아야 한다. 
  • 배열을 인자로 지정하려면 RequestMapping(path = arrayOf("/foo", "/bar")) 처럼 arrayOf 함수를 사용한다.

 

10.1.2 애노테이션 대상

코틀린 소스코드에서 한 선언을 컴파일한 결과가 여러 자바 선언과 대응하는 경우가 자주 있다. 그리고 이때 코틀린 선언과 대응하는 여러 자바 선언에 각각 애노테이션을 붙여야 할 때가 있다.

 

예를 들어 코틀린 프로퍼티는 기본적으로 자바 필드와 게터 메서드 선언과 대응한다. 프로퍼티가 변경 가능하면 세터에 대응하는 자바 세터 메서드와 세터 파라미터가 추가된다. 게다가 주 생성자에서 프로퍼티를 선언하면 이런 접근자 메서드와 파라미터 외에 자바 생성자 파라미터와도 대응이 된다. 따라서 애노테이션을 붙일 때 이런 요소 중 어떤 요소에 애노테이션을 붙일지 표시할 필요가 있다.  

 

사용 시점 대상 선언으로 애노테이션을 붙일 요소를 정할 수 있다. 사용 시점 대상은 @ 기호와 애노테이션 이름 사이에 붙으며, 애노테이션 이름과는 : 으로 분리된다.

 

사용 지점 대상을 지정할 때 지원하는 대상 목록은 아래와 같다.

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

 

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

직렬화란?

  • 직렬화(serialization)는 객체를 저장장치에 저장하거나 네트워크를 통해 전송하기 위해 텍스트나 이진 형식으로 변환하는 것이다.

역직렬화란?

  • 역직렬화(deserialization)는 텍스트나 이진 형식으로 저장된 데이터로부터 원래의 객체를 만들어낸다.

 

Person 의 인스턴스를 serialize 함수에 전달하면 JSON 표현이 담긴 문자열을 돌려받는다.

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

>>> val person = Person("Alice", 29)
>>> println(serialize(person))

{"age": 29, "name": "Alice"}

 

 

JSON 표현을 다시 객체로 만들려면 deserialize 함수를 호출한다.

>>> val json = """{"name": "Alice", "age": 29}"""
>>> println(deserialize<Person>(json))

Person(name=Alice, age=29)

JSON에는 객체의 타입이 저장되지 않기 때문에 JSON 데이터로부터 인스턴스를 만들려면 타입 인자로 클래스를 명시해야 한다. 여기서는 Person 클래스를 타입 인자로 넘겼다.

 

애노테이션을 활용해 객체를 직렬화하거나 역직렬화하는 방법을 제어할 수 있다. 객체를 JSON으로 직렬화할 때 제이키드 라이브러리는 기본적으로 모든 프로퍼티를 직렬화하면 프로퍼티 이름을 키로 사용한다. 애노테이션을 사용하면 이런 동작을 변경할 수 있다.

  • @JsonExclude 애노테이션을 사용하면 직렬화나 역직렬화 시 그 프로퍼티를 무시할 수 있다.
  • @JsonName 애노테이션을 사용하면 프로퍼티를 표현하는 키/값 쌍의 키로 프로퍼티 이름 대신 애노테이션이 지정한 이름을 쓰게 할 수 있다.

 

 

10.1.4 애노테이션 선언

@JsonExclude 애노테이션

  • 아무 파라미터도 없는 가장 단순한 애노테이션
  • 애노테이션 선언은 일반 클래스 선언처럼 보이지만 일반 클래스와 차이는 class앞에 annotation이라는 변경자가 붙어있다는 점이다.
  • 애노테이션은 오직 선언이나 식과 관련있는 메타데이터의 구조를 정의하기 때문에 내부에 아무 코드도 들어있을 수 없다.
@Target(AnnotationTarget.PROPERTY)
annotation class JsonExclude

 

@JsonName 애노테이션

  • 파라미터가 있는 애노테이션
  • 파라미터는 애노테이션 클래스의 주생성자에 파라미터를 선언해야 한다.
  • 다만 애노테이션 클래스는 모든 파리미터에 앞에 val을 붙여야 한다.
@Target(AnnotationTarget.PROPERTY)
annotation class JsonName(val name: String)

 

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

메타애노테이션이란?

  • 애노테이션 클래스에 적용할 수 있는 애노테이션

 

표준라이브러리에서는 몇 가지 메타애노테이션이 있으며, 그런 애노테이션은 컴파일러가 애노테이션을 처리하는 방법을 제어한다. 가장 흔히 쓰는 메타에노테이션은 @Taget이다. 제이키드 라이브러리는 프로퍼티 애노테이션만을 사용하므로, 애노테이션 클래스에 @Target을 지정해야 한다. 필요하다면 둘 이상의 대상을 한꺼번에 선언할수 있다. 

@Target(AnnotationTarget.PROPERTY, AnnotationTarget.METHOD)
annotation class JsonExclude

만약 메타애노테이션을 직접 만든다면 AnnotationTarget.ANNOTATION_CLASS를 대상으로 지정하라.

@Target(AnnotationTarget.ANNOTATION_CLASS)
annotation class BindingAnnotation

@BindingAnnotation
annotation class MyBinding

 

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

어떤 클래스를 선언 메타데이터로 참조할 수 있는 기능이 필요할 때에는 클래스 참조를 파라미터로 하는 애노테이션 클래스를 선언하면 그런 기능을 사용할 수 있다.

 

@DeserializeInterface애노테이션

 

 

직렬화된 Person 인스턴스를 역직렬화하는 과정에서 company 프로퍼티를 표현하는 JSON을 읽으면 제이키드는 그 프로퍼티 값에 해당하는 JSON을 역직렬화하면서 CompanyImpl의 인스턴스를 만들어 Person 인스턴스의 company 프로퍼티에 설정한다. 이렇게 역직렬화에 사용할 클래스를 지정하기 위해 DeserializeInterface 애노테이션의 인자로 CompanyImpl::class를 넘긴다.

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는 자바 java.lang.class타입과 같은 역할을 하는 코틀린 타입이다. 코틀린 클래스에 대한 참조를 저장할 때 KClass 타입을 사용한다.

 

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

기본적으로 제이키드는 원시 타입이 아닌 프로퍼티를 중첩된 객체로 직렬화 한다. 이런 기본동작을 변경하고 싶으면 값을 직렬화하는 로직을 직접 제공하면 된다. 클래스를 인자로 받아야 하면 애노테이션 파라미터 타입에 KClass<out 허용할 클래스 이름> 을 쓴다.  제네릭 클래스를 인자로 받아야 하면 KClass<out 허용할 클래스 이름<*>> 처럼 허용할 클래스 이름 뒤에 스타 프로젝션을 덧붙인다. 이 애노테이션이 어떤 타입에 대해 쓰일 지 알 수 없기 때문이다.

 

@CustomSerializer 애노테이션은 커스텀 직렬화 클래스에 대한 참조를 인자로 받는다.

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

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

 

 

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

리플렉션이란?

  • 실행 시점에 동적으로 객체의 프로퍼티와 메서드에 접근할 수 있게 해주는 방법

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

  1. java.lang.reflect 패키지를 통해 제공하는 표준 리플렉션
  2. 코틀린이 kotlin.reflect 패키지를 통해 제공하는 코틀린 리플렉션

 

JSON 직렬화 라이브러리는 어떤 객체든 JSON으로 변환할 수 있어야 하고, 실행 시점이 되기 전까지는 라이브러리가 직렬화할 프로퍼티나 클래스에 대한 정보를 알 수 없다. 이런 경우 리플렉션을 사용해야 한다.

 

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

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.*          # memberProperties 확장함수 import
>>> val person = Person("Alice", 29)
>>> val kClass = person.javaClass.kotlin  # kClass<Person>의 인스턴스를 반환한다.
>>> println(kClass.simpleName)
Person
>>> kClass.memberProperties.forEach { println(it.name) }
age
name

 

KClass에 대해 사용할 수 있는 다양한 기능은 실제로 kotlin-reflect 라이브러리 (implementation "org.jetbrains.kotlin:kotlin-reflect") 를 통해 제공하는 확장함수다. 이런 확장함수를 사용하기 위해서는 import kotlin.reflect.full.* 로 확장함수를 import해야 한다.

 

kCallable

함수와 프로퍼티를 아우르는 공통 상위 인터페이스이다. 그안에는 call메소드가 있다. call을 사용하면 함수나 프로퍼티의 게터를 호출할 수 있다.

interface KClass<T : Any>
{ 
    val simpleName: String?
    val qualifiedName: String?
    val members: Collection<KCallable<*>>
    val constructors: Collection<KFunction<T>>
    val nestedClasses: Collection<KClass<*>>
    ...
}
interface KCallable<out R> {
    fun call(vararg args: Any?): R
    ...
}

 

kFunction

리플렉션 call을 사용할 때는 함수 인자를 vararg 리스트로 전달한다. ::foo식의 값 타입이 리플렉션 API에 있는 KFunction 클래스의 인스턴스임을 알 수 있다. 이 함수 참조가 가르키는 함수를 호출하려면 KCallable.call 메소드를 호출한다.

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

 

kProperty

kProperty의 call 메서드를 호출할 수 있다. kProperty의 call은 프로퍼티의 게터를 호출한다.

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

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

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

 

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

아래 함수는 클래스의 각 프로퍼티를 차례로 직렬화한다. 결과 JSON은 { prop1: value1, prop2: value2 } 같은 형태다.

private fun StringBuilder.serializeObject(obj: Any){
    val kClass = obj.javaClass.kotlin
    val properties = kClass.memberProperties
    properties.jointToStringBuilder(
    		this, prefix = “{“, postfix = “}”) { prop -> 
        serializeString(prop.name)
        append(“: “)
        serializePropertyValue(prop.get(obj)) 
    }
}

 

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

 

 

 

10.2.4 JSON 파싱과 객체 역직렬화

 

 

 

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

 

 

 

 

 

 

 

 

 

Comments