[Kotlin in Action 2/e] 12장 어노테이션과 리플렉션

왕왕조현·2026년 2월 13일

Kotlin in Action 2/e

목록 보기
12/18
post-thumbnail

안녕하세요!

어노테이션과 리플렉션에 대한 개념 정리로 돌아온 개발자 꿈나무 김조현입니다.

이번 글에서는 어노테이션이 무엇인지 어떻게 사용하는지, 리플렉션이 무엇인지에 대해서 정리해보겠습니다.


어노테이션에 대하여

어노테이션은 @와 어노테이션 이름을 선언 앞에 넣으면 됩니다. 함수나 클래스 등 다른 여러 코드 구성 요소에 어노테이션을 붙일 수 있습니다.

어노테이션을 사용하면 선언에 추가적인 메타데이터를 연관시킬 수 있습니다. 그 후에 어노테이션이 설정된 방식에 따라 메타데이터를 소스코드, 컴파일된 클래스 파일, 런타임에 대해 작동하는 도구를 통해 접근할 수 있습니다.

예를 들어 kotlin.test를 사용한다면 테스트 메소드 앞에 @Test 어노테이션을 붙일 수 있습니다.

import kotlin.test.*

class MyTest {
	@Test
	fun testTrue() {
		assertTrue(1 + 1 == 2)
	}
}

@Test 어노테이션을 사용해 이 메소드를 테스트로 호출하라고 지시하는 것입니다.

@Deprecated 어노테이션은 사용 금지, 즉 선언이 더 이상 쓰이지 않게 될 것임을 표시하는 어노테이션입니다. 이 어노테이션은 최대 3개의 파라미터를 받습니다.

  • message는 사용 중단 예고의 이유를 설명합니다.
  • replaceWith는 지원이 종료될 API 기능을 더 쉽게 새 버전으로 전환할 수 있게 지원합니다.
  • level은 점진적으로 사용 중단을 지원하고자 할 때 사용합니다.
@Deprecated("Use removeAt(index) instead.", ReplaceWith("removeAt(index)"))
fun remove(index: Int) { /* ... */ }

이 예제는 remove 함수에 @Deprecated 어노테이션을 붙여 removeAt 함수로 대체돼야 한다는 사실을 표현하는 것입니다.

어노테이션 인자로는 기본 타입의 값, 문자열, 이넘, 클래스 참조, 다른 어노테이션 클래스, 앞의 요소들로 이뤄진 배열이 쓰일 수 있습니다.

  • 클래스를 어노테이션 인자로 지정할 때는 ::class를 클래스 이름 뒤에 넣어야 합니다.
  • 다른 어노테이션을 인자로 지정할 때는 어노테이션의 이름 앞에 @를 사용하지 않습니다.
  • 배열을 인자로 지정할 때는 @RequestMapping(path = [”/foo”, “/bar”])처럼 각괄호를 사용합니다.
  • 프로퍼티를 인자로 지정할 때는 어노테이션 인자를 컴파일 시점에 알 수 있어야하기 때문에 임의의 프로퍼티를 인자로 지정할 수 없습니다. 프로퍼티를 어노테이션 인자로 사용하려면 const 변경자를 붙여서 사용해야 합니다.

어노테이션이 참조할 수 있는 정확한 선언 지정하기

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

이럴 때 사용 지점 타깃 선언을 통해 어노테이션을 붙일 요소를 정할 수 있습니다. 사용 지점 타깃은 @ 기호와 어노테이션 이름 사이에 붙으며 어노테이션 이름과는 콜론으로 분리됩니다.

@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-----")
	}
}

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

사용 지점 타깃을 지정할 때 지원하는 타깃 목록은 아래와 같습니다.

  • property: 프로퍼티 전체
  • field: 프로퍼티에 의해 생성되는 필드
  • get: 프로퍼티 게터
  • set: 프로퍼티 세터
  • receiver: 확장 함수나 프로퍼티의 수신 객체 파라미터
  • param: 생성자 파라미터
  • setparam: 세터 파라미터
  • delegate: 위임 프로퍼티의 위임 인스턴스를 담아둔 필드
  • file: 파일 안에 선언된 최상위 함수와 프로퍼티를 담아두는 클래스

자바 API를 제어하는 방법은?

코틀린은 코틀린으로 선언한 내용을 자바 바이트코드로 컴파일하는 방법과 코틀린 선언을 자바에 노출하는 방법을 제어하기 위한 어노테이션을 많이 제공합니다. 어노테이션의 일부는 자바 언어의 일부 키워드를 대신하며, 어노테이션을 사용해 코틀린 선언을 자바에 노출시키는 방법을 변경할 수 있습니다.

  • @JvmName은 코틀린 선언이 만들어내는 자바 필드나 메소드 이름을 변경합니다.
  • @JvmStatic을 객체 선언이나 동반 객체의 메소드가 자바 정적 메소드로 노출됩니다.
  • @JvmOverloads를 사용하면 디폴트 파라미터 값이 있는 함수에 대해 컴파일러가 자동으로 오버로딩한 함수를 생성해줍니다.
  • @JvmField를 프로퍼티에 사용하면 대상 프로퍼티를 게터나 세터가 없는 공개된 자바 필드로 노출시킵니다.
  • @JvmRecord를 데이터 클래스에 사용하면 자바 레코드 클래스를 선언할 수 있습니다.

어노테이션을 활용해 JSON 직렬화 제어하기

직렬화는 객체를 저장 장치에 저장하거나 네트워크를 통해 전송하기 위해 텍스트나 이진 형식으로 변환하는 것입니다. 역직렬화는 반대로 텍스트나 이진 형식으로 저장된 데이터에서 원래의 객체를 만들어내는 것입니다.

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 함수에 변환할 타입 정보를 함께 전달하면 타입 객체로 돌려받습니다.

이런 직렬화나 역직렬화를 어노테이션을 활용해 제어할 수 있습니다.

  • @JsonExclude 어노테이션을 사용하면 직렬화나 역직렬화할 때 무시해야하는 프로퍼티를 표시할 수 있습니다.
  • @JsonName 어노테이션을 사용하면 프로퍼티를 표현하는 키로 프로퍼티 이름 대신 어노테이션이 지정한 문자열을 쓰게 할 수 있습니다.
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, KCallable, KFunction, KProperty

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에 대한 내용으로 돌아오겠습니다.

읽어주셔서 감사합니다!🙂‍↕️

profile
천천히, 꾸준히, 한 걸음씩

0개의 댓글