
어노테이션과 리플렉션에 대한 개념 정리로 돌아온 개발자 꿈나무 김조현입니다.
이번 글에서는 어노테이션이 무엇인지 어떻게 사용하는지, 리플렉션이 무엇인지에 대해서 정리해보겠습니다.
어노테이션은 @와 어노테이션 이름을 선언 앞에 넣으면 됩니다. 함수나 클래스 등 다른 여러 코드 구성 요소에 어노테이션을 붙일 수 있습니다.
어노테이션을 사용하면 선언에 추가적인 메타데이터를 연관시킬 수 있습니다. 그 후에 어노테이션이 설정된 방식에 따라 메타데이터를 소스코드, 컴파일된 클래스 파일, 런타임에 대해 작동하는 도구를 통해 접근할 수 있습니다.
예를 들어 kotlin.test를 사용한다면 테스트 메소드 앞에 @Test 어노테이션을 붙일 수 있습니다.
import kotlin.test.*
class MyTest {
@Test
fun testTrue() {
assertTrue(1 + 1 == 2)
}
}
@Test 어노테이션을 사용해 이 메소드를 테스트로 호출하라고 지시하는 것입니다.
@Deprecated 어노테이션은 사용 금지, 즉 선언이 더 이상 쓰이지 않게 될 것임을 표시하는 어노테이션입니다. 이 어노테이션은 최대 3개의 파라미터를 받습니다.
@Deprecated("Use removeAt(index) instead.", ReplaceWith("removeAt(index)"))
fun remove(index: Int) { /* ... */ }
이 예제는 remove 함수에 @Deprecated 어노테이션을 붙여 removeAt 함수로 대체돼야 한다는 사실을 표현하는 것입니다.
어노테이션 인자로는 기본 타입의 값, 문자열, 이넘, 클래스 참조, 다른 어노테이션 클래스, 앞의 요소들로 이뤄진 배열이 쓰일 수 있습니다.
코틀린 소스코드에서 한 선언을 컴파일한 결과가 여러 자바 선언과 대응하는 경우, 여러 자바 선언에 각각 어노테이션을 붙여야 할 때가 있습니다.
이럴 때 사용 지점 타깃 선언을 통해 어노테이션을 붙일 요소를 정할 수 있습니다. 사용 지점 타깃은 @ 기호와 어노테이션 이름 사이에 붙으며 어노테이션 이름과는 콜론으로 분리됩니다.
@get:JvmName("obtainCertificate")
위 예시는 @JvmName 어노테이션을 프로퍼티 게터에 적용하라는 의미입니다.
명시적으로 프로퍼티의 게터와 세터의 @JvmName을 지정하고 싶으면 @get:JvmName()과 @set:JvmName()을 사용하면 됩니다.
class CertificateManager {
@get:JvmName("obtainCertificate")
@set:JvmName("putCertificate")
var certificate: String = "-----BEGIN PRIVATE KEY-----"
}
이런 어노테이션이 붙은 경우 자바 코드에서는 certificate 프로퍼티를 JvmName에 사용된 이름으로 사용할 수 있습니다.
class Foo {
public static void main(String[] args) {
var certManager = new CertificateManager();
var cert = certManager.obtainCertificate();
certManager.putCertificate("-----BEGIN CERTIFICATE-----")
}
}
자바에 선언된 어노테이션을 사용해 프로퍼티에 어노테이션을 붙이는 경우 기본적으로 프로퍼티의 필드에 그 어노테이션이 붙습니다. 하지만 코틀린으로 어노테이션을 선언하면 프로퍼티에 직접 적용할 수 있는 어노테이션을 만들 수 있습니다.
사용 지점 타깃을 지정할 때 지원하는 타깃 목록은 아래와 같습니다.
코틀린은 코틀린으로 선언한 내용을 자바 바이트코드로 컴파일하는 방법과 코틀린 선언을 자바에 노출하는 방법을 제어하기 위한 어노테이션을 많이 제공합니다. 어노테이션의 일부는 자바 언어의 일부 키워드를 대신하며, 어노테이션을 사용해 코틀린 선언을 자바에 노출시키는 방법을 변경할 수 있습니다.
직렬화는 객체를 저장 장치에 저장하거나 네트워크를 통해 전송하기 위해 텍스트나 이진 형식으로 변환하는 것입니다. 역직렬화는 반대로 텍스트나 이진 형식으로 저장된 데이터에서 원래의 객체를 만들어내는 것입니다.
data class Person(val name: String, val age: Int)
fun main() {
val person = Person("Alice", 29)
println(serialize(person))
// {"age": 29, "name": "Alice"}
}
Person 인스턴스를 serialize 함수에 전달하면 JSON표현이 담긴 문자열을 돌려받습니다.
fun main() {
val json = """{"name": "Alice", "age": 29}"""
println(deserialize<Person>(json))
// Person(name=Alice, age=29)
}
JSON 표현을 deserialize 함수에 변환할 타입 정보를 함께 전달하면 타입 객체로 돌려받습니다.
이런 직렬화나 역직렬화를 어노테이션을 활용해 제어할 수 있습니다.
data class Person(
@JsonName("alias") val firstName: String,
@JsonExclude val age: Int? = null
)
이 예제는 firstName 프로퍼티에 대해 alias라는 이름을 사용하고, age를 직렬화와 역직렬화 대상에서 제외하는 코드입니다.
직렬화 대상에서 제외할 때는 프로퍼티에 반드시 기본값을 지정해야만 합니다. 기본값을 지정하지 않으면 역직렬화를 할 때 인스턴스를 새로 만들 수 없기 때문입니다.
아무 파라미터도 없는 @JsonExclude 어노테이션의 선언문은 일반 클래스 선언과 비슷합니다. 단지 class 앞에 annotation 변경자가 붙어있는 점만 다릅니다.
어노테이션 클래스는 선언이나 식과 관련 있는 메타데이터의 구조만 정의하기 때문에 내부에 아무 코드도 들어올 수 없습니다.
annotation class JsonExclude
파라미터가 있는 어노테이션을 정의하려면 어노테이션 클래스의 주 생성자에 파라미터를 선언해야하며, 모든 파라미터를 val로 선언해야 합니다.
annotation class JsonName(val name: String)
public @interface JsonName {
String value();
}
자바에서 어노테이션을 선언한 경우 value라는 메소드가 있습니다. 어노테이션을 적용할 때 value 속성은 이름을 생략할 수 있습니다. 예를 들면 @JsonName(”custom_name”) 으로 사용할 수 있습니다. 이는 value 속성 하나만 사용할 때 가능합니다. 만약 여러 속성을 사용한다면 @JsonName(value=”custom_name”, bool=false) 등으로 이름을 나타내야 합니다.
또한 value()속성의 이름을 name() 으로 변경해도 됩니다. 다만 속성의 이름을 바꾸면 한 가지만 사용하더라도 @JsonName(name=”custom_name”) 과 같이 이름을 반드시 명시해야 합니다.
메타어노테이션은 어떤 어노테이션 클래스에 적용할 수 있는 어노테이션을 의미합니다. 메타어노테이션은 컴파일러가 어노테이션을 처리하는 방법을 제어합니다.
메타어노테이션의 예시로는 @Target 이 있습니다. @Target 메타어노테이션은 어노테이션을 적용할 수 있는 요소의 유형을 지정합니다. 어노테이션 클래스에 대해 구체적인 @Target을 지정하지 않으면 모든 선언에 적용할 수 있는 어노테이션이 됩니다.
@Target(AnnotationTarget.PROPERTY)
annotation class JsonExclude
어노테이션이 붙을 수 있는 타깃이 정의된 이넘은 AnnotationTarget입니다. 이 안에는 클래스, 파일, 프로퍼티, 프로퍼티 접근자, 타입, 식 등에 대한 이넘 정의가 들어있습니다. 필요하다면 둘 이상의 타깃을 한꺼번에 선언할 수도 있습니다.
클래스 참조를 파라미터로 하는 어노테이션 클래스를 선언하면 어떤 클래스를 선언 메타데이터로 참조할 수 있는 기능을 사용할 수 있습니다.
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
}
@DeserializeInterface는 인터페이스 타입인 프로퍼티에 대한 역직렬화를 제어할 때 사용하는 어노테이션입니다.
인터페이스는 인스턴스를 직접 만들 수 없기 때문에 어떤 클래스를 사용해 인터페이스를 구현할지 지정할 수 있어야합니다. 그렇기에 위의 코드에서는 CompanyImpl 클래스를 @DeserializeInterface 어노테이션의 인자로 넘깁니다.
KClass의 타입 파라미터는 이 KClass의 인스턴스가 가리키는 코틀린 타입을 지정합니다.
@CustomSerializer 어노테이션은 커스텀 직렬화 클래스에 대한 참조를 인자로 받습니다. 직렬화 클래스는 ValueSerializer 인터페이스를 구현해야합니다.
interface ValueSerializer<T> {
fun toJsonValue(value: T): Any?
fun fromJsonValue(jsonValue: Any?): T
}
annotation class CustomSerializer(
val serializerClass: KClass<out ValueSerializer<*>>
)
data class Person(
val name: String,
@CustomSerializer(DateSerializer::class) val birthDate: Date
)
ValueSerializer 클래스는 제네릭 클래스이므로 항상 타입 파라미터가 있습니다. 따라서 ValueSerializer 타입을 참조하려면 항상 타입 인자를 제공해야하지만 이 어노테이션이 어떤 타입에 대해 쓰일지 전혀 알 수 었습니다. 그렇기에 스타 프로젝션을 인자로 사용할 수 있습니다.
어노테이션의 파라미터는 ValueSerializer을 확장하는 클래스에 대한 참조만 올바른 인자로 인정됩니다. @CustomSerializer(Date::class)와 같은 어노테이션은 사용할 수 없습니다.
리플렉션은 실행 시점에 객체의 프로퍼티와 메소드에 접근할 수 있게 해주는 방법입니다.
보통 객체의 메소드나 프로퍼티에 접근할 때는 이름이 실제로 가리키는 선언을 정적으로 찾아내 해당하는 선언이 실제 존재함을 보장합니다. 하지만 직렬화 라이브러리의 경우는 어떤 객체든 JSON으로 변환할 수 있어야하기 때문에 특정 클래스나 프로퍼티만 참조할 수 없습니다.
이런 경우에 리플렉션을 사용해야 합니다.
코틀린에서 리플렉션을 사용하려면 보통 kotlin.reflect와 kotlin.reflect.full 패키지와 같은 코틀린 리플렉션 API를 다루면 됩니다. 차선책으로 java.lang.reflect 패키지에 정의된 자바 표준 리플렉션을 사용해도 됩니다.
KClass를 사용하면 클래스 안에 있는 모든 선언을 열거하고 각 선언에 접근하거나 클래스의 상위 클래스를 얻는 등의 작업이 가능합니다. MyClass::class 라는 식을 쓰면 KClass의 인스턴스를 얻을 수 있습니다.
import kotlin.reflect.full.*
class Person(val name: String, val age: Int)
fun main() {
val person = Person("Alice", 29)
val kClass = person::class // KClass<out Person>의 인스턴스를 반환한다.
println(kClass.simpleName)
// Person
kClass.memberProperties.forEach{ println(it.name) }
// age
// name
}
KCallable은 함수와 프로퍼티를 아우르는 공통 상위 인터페이스입니다. 이 안에는 call 메소드가 들어있습니다. call을 사용하면 함수나 프로퍼티의 게터를 호출할 수 있습니다.
fun foo(x: Int) = println(x)
fun main() {
val kFunction = ::foo
kFunction.call(42)
// 42
}
KCallable.call 메소드를 호출할 때는 call에 넘긴 인자의 개수와 원래 함수에 정의된 파라미터 개수가 맞아 떨어져야 합니다. 함수를 호출하기 위해 KFunction 을 사용할 수 있습니다.
::foo은 KFunction 클래스의 인스턴스로 KFunction1<Int, Unit>에는 파라미터와 반환값 타입 정보가 들어있습니다.
KFunction1 인터페이스를 통해 함수를 호출하려면 invoke 메소드를 이용해야 합니다. 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.invoke(3, 4))
// 10
kFunction(1)
// 컴파일 에러
}
call 메소드는 모든 타입의 함수에 적용할 수 있는 일반적인 메소드지만 타입 안전성을 보장해주지 않습니다. 반면 invoke 메소드는 타입을 직접 명시하기 때문에 타입 안전성을 챙길 수 있습니다.
KProperty의 call 메소드는 프로퍼티의 메소드를 호출하지만 프로퍼티 인터페이스는 get 메소드를 사용해 프러퍼티 값을 얻을 수 있습니다.
var counter = 0
fun main() {
val kProperty = ::counter
kProperty.setter.call(21)
println(kProperty.get())
// 21
}
모든 선언에 어노테이션이 붙을 수 있기 때문에 KClass, KFunction, KParameter 등 실행 시점에 선언을 표현하는 인터페이스들은 모두 KAnnotatedElement를 확장합니다.
KClass는 클래스와 객체를 표현할 때 쓰입니다. KProperty는 모든 프로퍼티를 표현할 수 있고, 그 하위 클래스인 KMutableProperty는 var로 정의한 변경 가능한 프로퍼티를 표현합니다.
이번 글에서는 어노테이션과 리플렉션에 대해 정리해봤습니다.
어노테이션과 리플렉션이라는 개념이 낯설어 공부하는데 무척 어려움을 느꼈습니다. 다만 어노테이션과 리플렉션을 활용한다면 직접 함수나 클래스를 미리 알고 있지 않아도 쉽게 사용할 수 있는 방법들을 보며 유용하게 사용할 수 있겠다는 생각이 들었습니다.
또한 remove라는 함수를 실제로 사용했을 때 컴파일러에서 경고와 함께 removeAt이라는 메소드로 변경하라는 오류를 본 적이 있습니다. 이런 메소드 관리도 어노테이션을 활용해 할 수 있다는 것을 보고 다양하게 활용될 수 있다는 것을 느꼈습니다.
이렇게 우리가 사용하는 라이브러리 안에는 다양한 자동화를 위한 도구로 어노테이션이 보이지 않는 곳에서 활용되고 있다는 것을 보며 코틀린을 보는 시야를 넓힐 수 있었습니다.
다음에는 DSL에 대한 내용으로 돌아오겠습니다.
읽어주셔서 감사합니다!🙂↕️