Kotlin

[Kotlin] 애노테이션 -2 (JKid 라이브러리를 통해 알아보기)

sh1mj1 2024. 4. 10. 20:20

이전 글([Kotlin] 애노테이션 - 1 (적용과 사용 지점 대상))에서 이어집니다. 

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

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

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

(직렬화/역직렬화 설명이 포함된 글 을 참고)

직렬화에는 JSON 형식이 자주 쓰인다.

자바와 JSON 을 변환할 때 Jackson(잭슨)라이브러리와 GSON(지슨) 라이브러리가 자주 쓰인다. 이들도 코틀린과 완전히 호환된다.

 

JSON 직렬화를 위한 JKid 라는 순수 코틀린 라이브러리를 통해 공부해보자. (https://github.com/yole/jkid

JKid 라이브러리는 GSON 이나 Jackson 라이브러리만큼 다양한 기능을 제공하거나 유연하지는 않지만, 충분히 성능이 좋으며, 소스 코드의 양이 많지 않기 때문에 이것을 사용하여 공부한다.

JKid 의 serialize/deserialize 함수

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

class PersonTest {
    @Test fun test() {
        val person = Person("Alice", 29)
        val json = """{"age": 29, "name": "Alice"}"""

        assertEquals(json, serialize(person))
        assertEquals(person, deserialize<Person>(json))
    }
}

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

반대로 `deserializae` 함수를 통해 객체로 만들 수 있다. 

JSON 에는 객체의 타입이 저장되지 않기 때문에 타입 인자로 클래스를 명시해야 한다. 여기서는 `<Person>` 클래스를 타입 인자로 넘겼다.

 

실제로는 다른 값 객체 클래스나 여러 값으로 이루어진 컬렉션 타입의 프로퍼티도 들어갈 수 있다.

data class Author(val fullName: String)
data class Book(val name: String, val authors: List<Author>)


class BookTest {
    @Test fun test() = testJsonSerializer(
            value = Book("Lord of the Rings", listOf(Author("J.R.R.Tolkien"))),
            json = """{"authors": [{"fullName": "J.R.R.Tolkien"}], "name": "Lord of the Rings"}"""
    )
}

 

 애노테이션을 활용해서 객체를 직렬화/역직렬화하는 방법을 제어할 수 있다.

JKID 라이브러리는 JSON 으로 직렬화할 때 기본적으로 모든 프로퍼티를 직렬화하며 프로퍼티 이름을 키로 사용한다.

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

class AnnotationsTest {
    @Test fun test() = testJsonSerializer(
            value = Person("Alice"),
            json = """{"first_name": "Alice"}"""
    )
}

 `firstName` 프로퍼티를 JSON 으로 저장할 때 사용하는 키를 `@JsonName` 을 통해 변경했다.

`age` 프로퍼티를 직렬화/역직렬화에 사용하지 않기 위해 `@JsonExclude` 애노테이션을 사용한다.

직렬화 대상에서 제외할 `age` 프로퍼티에는 반드시 디폴트 값을 지정해야 한다.

애노테이션 선언

이제 제이키드 구현을 살펴보자. 그 중 애노테이션 먼저.

각 애노테이션의 모든 코드를 한 번에 보기보다는 핵심 코드부터 천천히 보자.

@JsonExclude

annotation class JsonExclude

 `@JsonExclude` 는 아무 파라미터도 없는 가장 단순한 애노테이션이다.

애노테이션 클래스는 오직 선언이나 식과 관련 있는 메타데이터의 구조를 정의하기 때문에 내부에 아무 코드도 들어있을 수 없다.

그래서 컴파일러는 애노테이션 클래스에서 바디를 정의하지 못하게 막는다.

@JsonName

 파라미터가 있는 애노테이션을 정의하려면 애노테이션 클래스의 주 생성자에 파라미터를 선언해야 한다.

annotation class JsonName(val name: String)

 모든 파라미터 앞에 `val` 을 붙여야 한다. 코틀린의 일반 클래스의 주생성자 선언 구문을 똑같이 사용한다.

자바의 애노테이션 선언 value()

반면에 자바 애노테이션 선언은 아래와 같다.

public @interface JsonName {
    String value();
}

 자바에서 어떤 애노테이션을 적용할 때, 속성이 단 하나라면 `value` 를 제외한 모든 속성에는 이름을 명시해야 한다.

 

반면에 코틀린의 애노테이션 적용 문법은 일반적인 생성자 호출과 같다.

따라서 인자의 이름을 명시하기 위해 이름 붙인 인자 구문을 사용할 수도 있고 이름을 생략할 수도 있다.

 

아래 예시를 보면 이해하기 편할 것치다.

annotation class UserInfo1(
    val name: String,
    val age: Int,
    val email: String = "" // 기본값을 가지는 프로퍼티
)

@UserInfo1("Alice", 30)
class Foo1

 코틀린 애노테이션 적용에서는 인자의 이름을 생략할 수 있다. 생성자 호출과 굉장히 유사하다.

public @interface UserInfo2 {
    String value();

    int age();

    String email() default ""; // 기본값을 가지는 속성
}

@UserInfo2(value = "Bob", age = 25)
class Foo2 {
}
public @interface UserInfo3 {
    String value();
}

@UserInfo3("Bob")
class Foo3 {
}

 애노테이션의 속성이 단 하나만 있을 때 그 속성이 `value` 라면 `value = "Bob"` 의 형태에서 `value =` 를 생략할 수 있다.

public @interface UserInfo4 {
    String name();
}

@UserInfo4("Bob") // [COMPILE ERROR] Cannot find method 'value'
class Foo4 {
}

 하지만 애노테이션의 속성이 단 하나만 있더라도 그 속성이 `value` 가 아니라면, `name = "Bob"` 형태에서 `name =` 를 생략할 수없다.

만약 생략한다면 컴파일 에러가 발생한다.

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

코틀린 애노테이션 클래스에도 애노테이션을 붙일 수 있다.

애노테이션 클래스에 적용할 수 있는 애노테이션메타애노테이션(meta-annotation)이라 부른다.

 

표준 라이브러리의 몇몇 메타애노테이션들은 컴파일러가 애노테이션을 처리하는 방법을 제어한다.

프레임워크에서도 메타 애노테이션을 제공하는 것이 있다.

여러 DI 라이브러리들이 메타애노테이션을 사용해 주입할 수 있는, 타입이 같은 여러 객체를 식별해낸다.

@Target

표준 라이브러리의 메타애노테이션 중 가장 흔히 쓰이는 것인 `@Target` 이다.

JKid 의 `@JsonExclude` 와 `@JsonName` 애노테이션도 적용 가능한 대상을 지정하기 위해 `@Target`을 사용한다.

@Target(AnnotationTarget.PROPERTY)
annotation class JsonExclude

 `@Target` 메타애노테이션은 애노테이션을 적용할 수 있는 요소의 타입을 지정한다.

애노테이션 클래스에 대해 구체적인 `@Target` 을 지정하지 않으면, 모든 선언에 적용할 수 있는 애노테이션이 된다.

하지만 JKid 라이브러리는 프로퍼티 애노테이션만을 사용해야 하므로 `@Target` 을 꼭 지정해야 한다.

AnnotationTarget

`AnnotationTarget` 은 애노테이션이 붙을 수 있는 대상이 정의된 enum 이다. 그 안에 entry 는 아래와 같다.

  • `CLASS`: 클래스, 인터페이스, 객체에 애노테이션을 적용할 수 있음. 애노테이션 클래스도 여기서 포함됨
  • `ANNOTATION_CLASS`: 오직 애노테이션 클래스에만 적용될 수 있음.
  • `PROPERTY`: 프로퍼티에 애노테이션을 적용할 수 있음
    • 이것을 target 으로 한 애노테이션은 자바 코드에서 사용할 수 없다.
      자바에서 사용하려면 아래처럼 `AnnotationTarget.FIELD` 를 두번째 target 으로 추가해야 한다.
      `@Target(AnnotationTarget.PROPERTY, AnnotaitonTarget.FIELD)`
  • `FIELD`: 프로퍼티의 backing field 를 포함한 필드에 애노테이션을 적용할 수 있음.
  • `LOCAL_VARIABLE`: 로컬 변수에 애노테이션을 적용할 수 있음.
  • `VALUE_PARAMETER`: 함수나 생성자의 값 파라미터에 애노테이션을 적용할 수 있음.
  • `CONSTRUCTOR`: 생성자(기본 생성자나 보조 생성자)에만 애노테이션을 적용할 수 있음.
  • `FUNCTION`: 함수에 애노테이션을 적용할 수 있음.(생성자는 포함되지 않음)
  • `PROPERTY_GETTER`: 프로퍼티의 게터(getter)에만 애노테이션을 적용할 수 있음.
  • `PROPERTY_SETTER`: 프로퍼티의 세터(setter)에만 애노테이션을 적용할 수 있음.
  • `TYPE`: 타입 사용에 애노테이션을 적용할 수 있음. 변수 선언, 함수 파라미터, 반환 타입 등 타입이 사용되는 모든 곳에 적용될 수 있음.
  • `EXPRESSION`: 표현식에 애노테이션을 적용할 수 있음.
  • `FILE`: 파일 전체에 애노테이션을 적용할 수 있음. 파일 내의 모든 선언에 영향을 미칠 수 있음.
  • `TYPEALIAS`: 타입 별칭에 애노테이션을 적용할 수 있음. (코틀린 1.1 버전부터 사용 가능함을 나타냄)

필요하다면 둘 이상의 대상을 타겟으로 설정할 수 있다.

@Target(AnnotationTarget.CLASS, AnnotationTarget.METHOD)

@Retention 애노테이션

`@Retention` 은 정의 중인 애노테이션 클래스를 소스 수준에서만 유지할지, `.class` 파일에 저장할지, 런타임에 Reflection 을 사용해 접근할 수 있게 할지를 지정하는 메타애노테이션이다.

자바 컴파일러는 기본적으로 애노테이션을 `.class` 파일에는 저장하지만, 런타임에는 사용할 수 없게 한다.

하지만 대부분의 애노테이션은 런타임에도 사용할 수 있어야 하므로 코틀린에서는 기본적으로 애노테이션의 `@Retention` 을 `RUNTIME`으로 지정한다.

실제로 아래처럼 코드를 바이트 코드로 바꾼 후 디컴파일하면 자동으로 `@Retention`이 `RUNTIME`으로 지정되는 것을 볼 수 있다.

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

어떤 클래스를 선언 메타 데이터로 참조 할 수 있는 기능이 필요할 때도 있다.

클래스 참조를 파라미터로 하는 에노테이션 클래스를 선언하면 그런 기능을 사용할 수 있다.

JKid Library 에 있는 `@DeserializeInterface` 는 인터페이스 타입인 프로퍼티에 대한 역직렬화를 제어할 때 쓰는 애노테이션이다.

인터페이스의 인스턴스를 직접 만들 수는 없으므로, 역질렬화 시에 어떤 클래스를 사용해서 인터페이스를 구현할지를 지정할 수 있어야 한다.

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
)

 직렬화된 `Person` 인스턴스를 역직렬화하는 과정에서 `company` 프로퍼티를 표현하는 JSON 을 읽으면 ,
JKid 는 그 프로퍼티 값에 해당하는 JSON 을 역직렬화하면서 `CompanyImpl` 의 인스턴스를 만들어서
`Person` 인스턴스의 `company` 프로퍼티에 설정한다.

이렇게 역직렬화를 사용할 클래스를 지정하기 위해 `@DeserializeInterface` 애노테이션의 인자로 `CompanyImpl::class` 를 넘긴다.

일반적으로 클래스를 가리키려면 클래스 이름 뒤에 `::class` 키워드를 붙여야 한다.

 

그렇다면 `@DeserializeInterface`가 어떻게 정의되었는지 보자.

annotation class DeserializeInterface(val targetClass: KClass<out Any>)

 `KClass` 는 `java.lang.Class` 타입과 같은 클래스 타입이다.

코틀린 클래스에 대한 참조를 저장할 때 `KClass` 타입을 사용한다. 이와 관련해서는 나중에 리플렉션을 다룰 때 자세히 다룬다.

`KClass` 의 타입 파라미터`<out Any>`는 이 `KClass`의 인스턴스가 가리키는 코틀린 타입을 지정한다.

 `CompanyImpl::class` 의 타입은 `KClass<CompanyImpl>` 이며,  `KClass<out Any>` 의 하위 타입이다.

`out` 키워드 덕분에 공변성이 적용되기 때문이다.

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

기본적으로 JKid 는 원시 타입이 아닌 프로퍼티를 중첩된 객체로 직렬화한다.

data class Person(val name: String, val address: Address)
data class Address(val street: String, val city: String)

 이런 클래스가 있다면 `Person("John Doe", Address("123 Main St", "Anytown"))`은 아래처럼 JSON 으로 변환된다는 의미이다.

{
  "name": "John Doe",
  "address": {
    "street": "123 Main St",
    "city": "Anytown"
  }
}

이 동작을 변경하고 싶다면 값을 직렬화하는 로직을 직접 제공하면 된다.

 

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

이 직렬화 클래스는 `ValueSerializer` 인터페이스를 구현해야 한다.

interface ValueSerializer<T> {
    fun toJsonValue(value: T): Any?
    fun fromJsonValue(jsonValue: Any?): T
}

@Target(AnnotationTarget.PROPERTY)
annotation class CustomSerializer(val serializerClass: KClass<out ValueSerializer<*>>)

 `ValueSerializer` 타입을 참조하려면 항상 타입 인자를 제공해야 한다.

이 애노테이션이 어떤 타입에 대해 쓰일지 알수 없으므로 스타 프로젝션(`*`) 을 사용할 수 있다. (스타 프로젝션 참조)

 클래스를 애노테이션 인자로 받아야할 때마다 같은 패턴을 사용할 수 있다.

애노테이션 파라미터 타입에 `KClass<out 허용할 클래스 이름>`을 쓰면 된다.

제네릭 클래스를 인자로 받아야 한다면 `KClass<out 허용할 클래스 이름<*>>`처럼 허용할 클래스의 이름 뒤에 스타 프로젝션(`*`)을 덧붙인다.

 

그렇다면 예시를 들어보자.

class StringSerializer : ValueSerializer<String> {
    override fun toJsonValue(value: String): Any? = value.toUpperCase()
    override fun fromJsonValue(jsonValue: Any?): String = jsonValue.toString().toLowerCase()
}

data class Person(
    @CustomSerializer(serializerClass = StringSerializer::class)
    val name: String
)

 위에서는 JSON 으로 직렬화할 때는 대문자로, JSON 으로부터 역직렬화할 때는 소문자로 변환하도록 `CustomSerializer` 를 사용하고 있다.

object DateSerializer : ValueSerializer<Date> {
    private val dateFormat = SimpleDateFormat("dd-mm-yyyy")

    override fun toJsonValue(value: Date): Any? =
            dateFormat.format(value)

    override fun fromJsonValue(jsonValue: Any?): Date =
            dateFormat.parse(jsonValue as String)
}

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

 이번에는 날짜를 특정 형식으로 변환하고 있다.

 

이렇게 애노테이션 선언/적용에서의 중요한 내용을 모두 살펴보았다.

다음 글은 리플렉션(reflection) 관련 내용이다.