Kotlin 고급 기능 | DSL, 리플렉션, 애노테이션
이 글의 핵심
제네릭 함수 안에서 T::class를 쓰려다 타입 소거 때문에 막히는 경험이 reified를 이해하는 출발점입니다. 수신 객체 지정 람다로 DSL이 만들어지는 원리, 리플렉션의 런타임 비용, 델리게이트로 프로퍼티 로직을 재사용하는 방법을 짚어 라이브러리 코드를 읽고 직접 만들 수 있게 합니다.
들어가며
DSL 빌더·인라인·리플렉션 등은 라이브러리 API를 부드럽게 만드는 데 사용됩니다. 팀이 읽기 쉬운 수준으로만 도입하는 것이 좋습니다.
람다와 확장 함수로 만드는 DSL
DSL이란?
DSL은 특정 도메인에 특화된 언어입니다. Kotlin의 람다와 확장 함수를 활용해 자연스러운 DSL을 만들 수 있습니다.
HTML DSL 예제
class HTML {
private val content = StringBuilder()
fun body(block: Body.() -> Unit) {
content.append("<body>")
Body().apply(block).render(content)
content.append("</body>")
}
fun render() = content.toString()
}
class Body {
private val content = StringBuilder()
fun h1(text: String) {
content.append("<h1>$text</h1>")
}
fun p(text: String) {
content.append("<p>$text</p>")
}
fun render(sb: StringBuilder) {
sb.append(content)
}
}
fun html(block: HTML.() -> Unit): HTML {
return HTML().apply(block)
}
// 사용
val page = html {
body {
h1("제목")
p("내용입니다.")
}
}
println(page.render())
이 DSL이 동작하는 핵심은 block: Body.() -> Unit이라는 수신 객체 지정 람다(lambda with receiver) 타입입니다. 일반 람다 (Body) -> Unit은 it.h1("제목")처럼 인자를 통해 접근해야 하지만, 수신 객체 지정 람다 안에서는 this가 Body이므로 h1("제목")처럼 멤버를 바로 호출할 수 있습니다. Body().apply(block)는 새 Body 객체를 수신 객체로 삼아 람다를 실행하고 그 객체를 돌려주며, 이것이 html { body { ... } } 같은 중첩 구조가 만들어지는 원리입니다. Gradle의 build.gradle.kts, Ktor의 라우팅, kotlinx.html이 모두 같은 원리로 만들어져 있습니다.
직접 DSL을 만들어 보면 곧 부딪히는 문제가 바깥 수신 객체의 누수입니다. body { } 블록 안에서도 바깥 HTML이 여전히 암시적 수신 객체로 살아 있어서, body { body { h1("?") } }처럼 말이 안 되는 중첩이 컴파일됩니다. 이를 막으려면 @DslMarker를 붙인 어노테이션을 만들어 HTML과 Body 클래스에 달아 두면, 안쪽 블록에서 바깥 수신 객체의 멤버를 this@html 없이 호출할 때 컴파일 에러가 납니다. kotlinx.html의 @HtmlTagMarker가 이 역할을 합니다. 또 이 예제는 text를 그대로 붙이므로 사용자 입력이 들어가면 <script>가 그대로 삽입됩니다. 실제로 HTML을 만든다면 <, >, &, "를 이스케이프해야 합니다.
테스트 DSL
class TestSuite {
private val tests = mutableListOf<Test>()
fun test(name: String, block: () -> Unit) {
tests.add(Test(name, block))
}
fun run() {
tests.forEach { it.run() }
}
}
data class Test(val name: String, val block: () -> Unit) {
fun run() {
try {
block()
println("✓ $name")
} catch (e: AssertionError) {
println("✗ $name: ${e.message}")
}
}
}
fun suite(block: TestSuite.() -> Unit): TestSuite {
return TestSuite().apply(block)
}
// 사용
suite {
test("덧셈 테스트") {
assert(1 + 1 == 2)
}
test("곱셈 테스트") {
assert(2 * 3 == 6)
}
}.run()
테스트 DSL은 블록을 즉시 실행하지 않고 저장해 두었다가 나중에 실행한다는 점에서 HTML DSL과 다릅니다. test("이름") { ... }의 람다는 Test 객체에 보관되고, run()을 호출해야 실행됩니다. Kotest나 Spek 같은 테스트 프레임워크가 테스트를 먼저 수집하고 필터링·병렬 실행하는 구조가 이것입니다.
그런데 이 예제를 그대로 실행하면 조건을 일부러 틀리게 바꿔도 모든 테스트가 통과한다는 것을 발견하게 됩니다. Kotlin/JVM의 assert()는 Java의 assert와 같은 메커니즘이라 JVM을 -ea(enable assertions) 옵션으로 실행하지 않으면 아무 검사도 하지 않습니다. 테스트 코드에서는 항상 실행되는 check(), require(), 또는 kotlin.test의 assertEquals 같은 함수를 써야 합니다. 또 catch (e: AssertionError)만 잡고 있어서, 테스트 본문에서 IllegalStateException 같은 다른 예외가 나면 스위트 전체가 중단되고 나머지 테스트는 실행되지 않습니다. 테스트 러너라면 Throwable을 잡아 실패로 기록하는 편이 맞습니다.
리플렉션으로 클래스 정보 읽고 객체 만들기
기본 리플렉션
import kotlin.reflect.full.*
class User(val name: String, val age: Int) {
fun greet() = "안녕하세요, $name님!"
}
fun main() {
val user = User("홍길동", 25)
val kClass = user::class
// 클래스 정보
println("클래스명: ${kClass.simpleName}")
println("패키지: ${kClass.qualifiedName}")
// 프로퍼티 조회
kClass.memberProperties.forEach { prop ->
println("${prop.name}: ${prop.get(user)}")
}
// 함수 조회
kClass.memberFunctions.forEach { func ->
println("함수: ${func.name}")
}
}
memberProperties, memberFunctions 같은 확장은 kotlin.reflect.full 패키지에 있고, 별도 의존성인 org.jetbrains.kotlin:kotlin-reflect가 있어야 동작합니다. 이 라이브러리 없이 실행하면 컴파일은 되지만 런타임에 KotlinReflectionNotSupportedError: Kotlin reflection implementation is not found at runtime이 납니다. kotlin-reflect는 수 MB 크기라 Android 앱에서는 APK 크기와 첫 호출 지연이 부담이 되므로, ::class.java로 Java 리플렉션을 쓰거나 리플렉션 자체를 피하는 경우가 많습니다.
출력해 보면 memberFunctions에 greet뿐 아니라 equals, hashCode, toString도 포함되어 있습니다. Any에서 상속받은 멤버까지 나오기 때문이며, 직접 선언한 것만 원하면 declaredMemberFunctions를 씁니다. 또 memberProperties가 돌려주는 순서는 선언 순서라고 보장되지 않으므로, 필드 순서가 중요한 직렬화 코드라면 primaryConstructor.parameters 순서를 기준으로 삼아야 합니다.
리플렉션으로 객체 생성
import kotlin.reflect.full.createInstance
import kotlin.reflect.full.primaryConstructor
class Person(val name: String, val age: Int)
fun main() {
val kClass = Person::class
// 생성자로 생성
val constructor = kClass.primaryConstructor!!
val person = constructor.call("홍길동", 25)
println("${person.name}, ${person.age}")
}
call은 인자를 vararg Any?로 받기 때문에 개수나 타입이 틀려도 컴파일러가 잡아 주지 못하고, 실행 시점에 IllegalArgumentException이 납니다. 매개변수 이름으로 값을 넘기려면 callBy(mapOf(constructor.parameters[0] to "홍길동", ...))를 쓰는데, 이 방식은 기본값이 있는 매개변수를 맵에서 빼면 기본값을 적용해 줍니다. Jackson의 Kotlin 모듈이 JSON에 없는 필드에 기본값을 채워 넣는 것이 바로 이 기능 덕분입니다. import한 createInstance()는 모든 매개변수가 선택적이거나 인자 없는 생성자가 있을 때만 쓸 수 있고, Person처럼 필수 매개변수가 있으면 IllegalArgumentException을 던집니다. primaryConstructor!!의 !!도 인터페이스나 주 생성자가 없는 클래스에서는 null이 되어 NPE가 난다는 점을 기억해 두세요.
커스텀 애노테이션과 처리
커스텀 애노테이션
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class Entity(val tableName: String)
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class Column(val name: String)
@Entity(tableName = "users")
class User(
@Column(name = "user_name")
val name: String,
@Column(name = "user_age")
val age: Int
)
@Retention(AnnotationRetention.RUNTIME)은 이 어노테이션을 클래스 파일에 남겨 런타임 리플렉션으로 읽을 수 있게 합니다. 기본값도 RUNTIME이지만 의도를 드러내기 위해 명시하는 편이 좋고, SOURCE로 두면 컴파일 후 사라지므로 리플렉션으로는 찾을 수 없습니다. @Target은 어노테이션을 붙일 수 있는 위치를 제한하는데, 주 생성자의 val name처럼 하나의 선언이 매개변수, 프로퍼티, 필드, getter로 동시에 컴파일되는 곳에서는 어느 위치에 붙는지가 문제가 됩니다. 여기서는 @Target(PROPERTY)만 허용했으므로 프로퍼티에 붙지만, Java 프레임워크(JPA, Jackson 등)는 필드나 getter의 어노테이션을 읽는 경우가 많아서 @field:Column이나 @get:JsonProperty처럼 사용 위치를 명시해야 인식되는 경우가 있습니다. Kotlin으로 JPA를 쓸 때 “어노테이션을 붙였는데 무시된다”는 문제의 상당수가 이 사용 위치 지정 때문입니다.
애노테이션 처리
import kotlin.reflect.full.*
fun getTableName(obj: Any): String? {
val annotation = obj::class.findAnnotation<Entity>()
return annotation?.tableName
}
fun main() {
val user = User("홍길동", 25)
println("테이블명: ${getTableName(user)}") // users
}
프로퍼티의 @Column도 같은 방식으로 읽을 수 있습니다. obj::class.memberProperties.map { it.findAnnotation<Column>()?.name ?: it.name }처럼 쓰면 어노테이션이 있으면 그 이름을, 없으면 프로퍼티 이름을 컬럼명으로 쓰는 간단한 매핑이 됩니다. 이 방식은 매 호출마다 리플렉션을 수행하므로, 실제 ORM이나 직렬화 라이브러리는 클래스별로 결과를 한 번만 계산해 캐시해 둡니다. 더 나아가 kotlinx.serialization이나 Room처럼 컴파일 타임에 KSP/컴파일러 플러그인으로 코드를 생성하면 런타임 리플렉션 비용과 kotlin-reflect 의존성을 모두 없앨 수 있어, 새 라이브러리들은 이쪽을 택하는 추세입니다.
inline, reified, noinline, crossinline
기본 인라인
inline fun measureTime(block: () -> Unit) {
val start = System.currentTimeMillis()
block()
val end = System.currentTimeMillis()
println("실행 시간: ${end - start}ms")
}
measureTime {
Thread.sleep(100)
}
inline 함수는 호출하는 곳에 함수 본문과 람다 본문이 그대로 복사됩니다. 일반 고차 함수는 람다마다 Function 객체를 만들고(캡처한 변수가 있으면 호출마다 새로 할당), 호출할 때 인터페이스 메서드를 거치지만, 인라인되면 그런 객체와 간접 호출이 사라집니다. forEach, map, let, apply 같은 표준 라이브러리 함수가 모두 inline인 이유가 이것입니다. 인라인의 또 다른 효과는 비지역 반환(non-local return) 입니다. 인라인 람다 안의 return은 람다가 아니라 바깥 함수에서 빠져나가므로, list.forEach { if (it == 0) return }이 바깥 함수를 종료시킵니다. 람다만 빠져나가려면 return@forEach를 써야 합니다.
대신 본문이 호출 지점마다 복사되므로 큰 함수를 inline으로 만들면 바이트코드가 늘어납니다. 람다를 받지 않는 일반 함수에 inline을 붙이면 IntelliJ가 Expected performance impact from inlining is insignificant 경고를 띄우는데, 람다 할당을 없앤다는 이점이 없기 때문입니다. 참고로 실행 시간을 잴 때는 시스템 시계 조정의 영향을 받는 currentTimeMillis()보다 System.nanoTime()이 적합하고, 표준 라이브러리에도 kotlin.time.measureTime과 kotlin.system.measureTimeMillis가 이미 있습니다.
reified 타입 매개변수
inline fun <reified T> isInstance(value: Any): Boolean {
return value is T
}
inline fun <reified T> List<*>.filterIsInstance(): List<T> {
return this.filter { it is T }.map { it as T }
}
fun main() {
println(isInstance<String>("Hello")) // true
println(isInstance<Int>("Hello")) // false
val mixed: List<Any> = listOf(1, "two", 3, "four", 5)
val strings = mixed.filterIsInstance<String>()
println(strings) // [two, four]
}
reified 없이 fun <T> isInstance(value: Any) = value is T라고 쓰면 Cannot check for instance of erased type: T 컴파일 에러가 납니다. JVM 제네릭은 컴파일 후 타입 인자가 지워져서 런타임에는 T가 무엇인지 알 수 없기 때문입니다. inline 함수에서는 호출 지점마다 T 자리에 실제 타입(String, Int)이 채워진 코드가 복사되므로 is T, T::class 같은 연산이 가능해집니다. 대표적인 활용이 inline fun <reified T> Gson.fromJson(json: String) = fromJson(json, T::class.java)처럼 Class 객체를 인자로 넘기던 Java API를 감싸는 것입니다.
예제의 filterIsInstance는 원리를 보여 주려고 직접 만든 것이고, 표준 라이브러리에 같은 이름의 함수가 이미 있습니다. 같은 파일에 선언한 함수가 기본 import보다 우선하므로 예제는 직접 만든 버전을 호출합니다. 한 가지 한계도 있는데, reified로도 List<String>과 List<Int>는 구분할 수 없습니다. 리스트 객체 자체에는 원소 타입 정보가 없어서 value is List<String>은 여전히 컴파일 에러이고, T가 List<String>이면 실제로는 is List<*>만 검사됩니다.
noinline과 crossinline
inline fun foo(
inlined: () -> Unit,
noinline notInlined: () -> Unit
) {
inlined()
notInlined()
}
inline fun bar(crossinline block: () -> Unit) {
val runnable = Runnable { block() }
runnable.run()
}
inline 함수의 람다 매개변수는 기본적으로 모두 인라인되므로 값처럼 저장하거나 다른 함수에 넘길 수 없습니다. 복사된 코드 조각일 뿐 객체가 아니기 때문입니다. noinline은 특정 람다를 인라인하지 않고 일반 Function 객체로 남겨서, 변수에 저장하거나 인라인이 아닌 함수에 전달할 수 있게 합니다. crossinline은 람다를 인라인하되 비지역 반환을 금지합니다. 위 bar처럼 람다를 Runnable 같은 다른 실행 문맥 안에서 호출하면, 람다 안의 return이 bar를 호출한 바깥 함수에서 빠져나가는 것이 불가능해지므로 컴파일러가 crossinline을 요구합니다. 이를 빠뜨리면 Can't inline 'block' here: it may contain non-local returns 에러가 나는데, 이 메시지를 보면 crossinline을 붙이면 됩니다.
프로퍼티 델리게이트와 lazy
프로퍼티 델리게이트
import kotlin.properties.Delegates
class User {
var name: String by Delegates.observable("초기값") { prop, old, new ->
println("${prop.name}: $old → $new")
}
var age: Int by Delegates.vetoable(0) { prop, old, new ->
new >= 0 // 음수 거부
}
}
fun main() {
val user = User()
user.name = "홍길동" // name: 초기값 → 홍길동
user.age = 25
user.age = -1 // 거부됨
println(user.age) // 25
}
by 키워드는 프로퍼티의 getter/setter 동작을 다른 객체에 위임합니다. Delegates.observable은 값이 바뀐 뒤에 콜백을 호출하므로 로그 기록이나 UI 갱신에 쓰이고, vetoable은 값이 바뀌기 전에 콜백을 호출해 false를 반환하면 대입 자체를 취소합니다. 위 예제에서 -1 대입은 조용히 무시되어 age가 25로 남습니다. 거부 사실을 호출한 쪽이 알아야 한다면 이 방식보다 setter에서 require(value >= 0)로 예외를 던지는 편이 낫습니다. 조용히 무시되는 대입은 디버깅할 때 “분명히 값을 넣었는데 반영이 안 된다”는 혼란을 만듭니다.
직접 델리게이트를 만들려면 getValue/setValue 연산자를 가진 클래스를 작성하거나 ReadWriteProperty 인터페이스를 구현하면 됩니다. Android의 by viewModels(), SharedPreferences 래퍼, Map에서 값을 읽어 오는 val name: String by map 같은 것들이 모두 이 구조입니다.
lazy 델리게이트
val heavyObject: String by lazy {
println("초기화 중...")
"무거운 객체"
}
fun main() {
println("시작")
println(heavyObject) // 여기서 초기화
println(heavyObject) // 재사용
}
lazy는 처음 접근할 때 한 번만 블록을 실행하고 결과를 저장합니다. 기본 모드는 LazyThreadSafetyMode.SYNCHRONIZED라 여러 스레드가 동시에 처음 접근해도 초기화가 한 번만 일어나지만, 그만큼 동기화 비용이 있습니다. 단일 스레드(예: Android 메인 스레드)에서만 접근한다면 lazy(LazyThreadSafetyMode.NONE) { }로 그 비용을 없앨 수 있습니다. 초기화 블록에서 예외가 나면 값이 저장되지 않으므로 다음 접근 때 다시 초기화를 시도합니다. lazy는 val에만 쓸 수 있으며, 나중에 값을 대입해야 하는 var라면 lateinit var가 대안입니다.
JSON 빌더 DSL 만들기
class JsonObject {
private val properties = mutableMapOf<String, Any>()
infix fun String.to(value: Any) {
properties[this] = value
}
fun toJson(): String {
return properties.entries.joinToString(
separator = ", ",
prefix = "{",
postfix = "}"
) { (k, v) ->
val valueStr = if (v is String) "\"$v\"" else v.toString()
"\"$k\": $valueStr"
}
}
}
fun json(block: JsonObject.() -> Unit): JsonObject {
return JsonObject().apply(block)
}
// 사용
val user = json {
"name" to "홍길동"
"age" to 25
"active" to true
}
println(user.toJson())
// {"name": "홍길동", "age": 25, "active": true}
이 JSON DSL은 한 가지 재미있는 트릭을 씁니다. to는 원래 Pair를 만드는 표준 라이브러리의 중위 함수인데, JsonObject 안에 같은 이름의 멤버 확장 함수 String.to를 선언했습니다. 람다 안에서는 JsonObject가 암시적 수신 객체이고, 멤버 확장이 최상위 확장보다 우선하므로 "name" to "홍길동"이 Pair를 만드는 대신 properties에 값을 저장합니다. 편리하지만 같은 코드가 블록 밖에서는 전혀 다른 의미가 되므로, 실무 DSL에서는 혼동을 피하려고 "name" by "홍길동"처럼 다른 이름을 쓰거나 put("name", ...)을 쓰는 경우가 많습니다.
직렬화 부분은 학습용으로 단순화되어 있다는 점도 짚어 둡니다. 문자열 값에 따옴표나 줄바꿈이 있으면 이스케이프되지 않아 잘못된 JSON이 나오고, 중첩 객체나 리스트도 지원하지 않습니다. 실제로는 kotlinx.serialization의 buildJsonObject { put("name", "홍길동") } 같은 검증된 DSL을 쓰는 편이 안전합니다. 이 글의 기능들은 모두 강력하지만 남용하면 코드가 “읽을 수 있는 사람만 읽는” 상태가 되므로, 라이브러리나 설정 코드처럼 반복되는 패턴이 분명한 곳에서만 도입하는 편이 좋습니다.
고급 기능 요약
- DSL: 람다 + 확장 함수로 도메인 언어 구축
- 리플렉션: 런타임 타입 정보 조회
- 애노테이션: 메타데이터 정의 및 처리
- inline: 람다 오버헤드 제거
- reified: 제네릭 타입 런타임 접근
- 델리게이트: 프로퍼티 동작 위임
다음 단계
Kotlin 시리즈를 완료했습니다! 이제 다음 언어로 넘어가세요:
같이 보면 좋은 글
- C++26 리플렉션 기초 | ^^ 연산자·std::meta::info로 타입 정보 조회하기
- C++ 컴파일 타임 리플렉션 | C++26 Reflection·magic_enum·매크로 직렬화·검증
- C++26에서 달라지는 것
- C++ 리플렉션 구현 | 타입 정보·메타데이터·자동 직렬화 [#55-1]
- Kotlin 변수와 타입
- Kotlin 함수 | 함수 정의, 람다, 고차 함수
- Kotlin 컬렉션
자주 묻는 질문 (FAQ)
Q. reified는 왜 inline 함수에서만 쓸 수 있나요?
A. JVM의 제네릭은 런타임에 타입 정보가 지워지므로, 일반 함수 안에서는 value is T처럼 T를 검사할 수 없습니다. inline 함수는 호출하는 곳에 본문이 복사되면서 T 자리에 실제 타입이 들어가기 때문에, reified를 붙이면 is T나 T::class를 쓸 수 있습니다. 대신 호출할 때마다 코드가 복사되므로 본문이 큰 함수는 타입 관련 부분만 작은 inline 함수로 분리하는 편이 좋습니다.