Table of contents
시리즈의 마지막
1편에서 val과 var를 배운 뒤로 긴 여정이었다. 변수, 제어 흐름, 함수, 클래스, 컬렉션, null 안전성, 제네릭, sealed class, 코루틴, DSL까지 다뤘다. 마지막 편에서는 실무 프로젝트에서 자주 마주치는 패턴 네 가지를 살펴본다. 위임, 어노테이션, 리플렉션, 그리고 Java 상호운용이다.
위임 — by 키워드
클래스 위임
인터페이스를 구현할 때 직접 모든 메서드를 작성하는 대신, 다른 객체에게 “너 대신 해”라고 맡기는 패턴이다. Kotlin은 이걸 by 키워드로 언어 차원에서 지원한다.
interface Logger {
fun log(message: String)
fun error(message: String)
}
class ConsoleLogger : Logger {
override fun log(message: String) = println("[LOG] $message")
override fun error(message: String) = println("[ERROR] $message")
}
// ConsoleLogger에게 Logger 구현을 위임
class UserService(logger: Logger) : Logger by logger {
fun createUser(name: String) {
log("유저 생성 시작: $name")
// 비즈니스 로직...
log("유저 생성 완료: $name")
}
// 필요하면 일부 메서드만 오버라이드
override fun error(message: String) {
println("[CRITICAL] $message") // 커스텀 동작
}
}
fun main() {
val service = UserService(ConsoleLogger())
service.createUser("Kim")
// [LOG] 유저 생성 시작: Kim
// [LOG] 유저 생성 완료: Kim
service.error("DB 연결 실패")
// [CRITICAL] DB 연결 실패
}
Logger by logger는 “Logger 인터페이스의 모든 메서드를 logger 객체가 처리하게 하겠다”는 선언이다. log() 호출이 자동으로 ConsoleLogger에게 전달된다. 필요한 메서드만 오버라이드해서 동작을 바꿀 수도 있다.
상속 대신 위임을 쓰면 클래스 간의 결합이 느슨해진다. “상속보다 합성을 선호하라”는 원칙을 Kotlin이 문법으로 지원하는 셈이다.
프로퍼티 위임
프로퍼티의 getter/setter 로직을 별도 객체에 위임할 수도 있다. 사실 6편에서 본 lazy가 프로퍼티 위임의 대표적인 예다.
import kotlin.properties.Delegates
class UserSettings {
// observable — 값이 바뀔 때마다 콜백
var theme: String by Delegates.observable("light") { _, old, new ->
println("테마 변경: $old → $new")
}
// vetoable — 조건을 만족해야만 변경
var fontSize: Int by Delegates.vetoable(14) { _, _, new ->
new in 8..72 // 8~72 범위만 허용
}
}
fun main() {
val settings = UserSettings()
settings.theme = "dark" // 테마 변경: light → dark
settings.theme = "solarized" // 테마 변경: dark → solarized
settings.fontSize = 20 // 변경됨
println(settings.fontSize) // 20
settings.fontSize = 100 // 거부됨 (범위 초과)
println(settings.fontSize) // 20 (이전 값 유지)
}
observable은 값이 바뀔 때마다 콜백을 실행한다. 로깅이나 UI 업데이트 트리거에 유용하다. vetoable은 조건에 따라 변경을 거부할 수 있어서 유효성 검사에 쓴다.
직접 위임 객체를 만들 수도 있다.
import kotlin.reflect.KProperty
class TrimmedString {
private var value: String = ""
operator fun getValue(thisRef: Any?, property: KProperty<*>): String = value
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
value = newValue.trim()
}
}
class UserForm {
var name: String by TrimmedString()
var email: String by TrimmedString()
}
fun main() {
val form = UserForm()
form.name = " Kim "
form.email = " kim@example.com "
println("'${form.name}'") // 'Kim'
println("'${form.email}'") // 'kim@example.com'
}
getValue와 setValue operator를 정의하면 어떤 객체든 프로퍼티 위임자로 쓸 수 있다.
어노테이션
Kotlin의 어노테이션은 Java와 거의 동일하다. annotation class로 정의하고, @로 사용한다.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Logged(val level: String = "INFO")
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class MaxLength(val value: Int)
class UserController {
@MaxLength(50)
var username: String = ""
@Logged("DEBUG")
fun getUser(id: Long): String {
return "User-$id"
}
@Logged
fun deleteUser(id: Long) {
println("유저 삭제: $id")
}
}
@Target은 어노테이션을 붙일 수 있는 위치를, @Retention은 어노테이션 정보가 어디까지 유지되는지를 지정한다. RUNTIME으로 설정하면 리플렉션으로 실행 시점에 읽을 수 있다.
Kotlin에서 주의할 점이 하나 있다. 프로퍼티에 어노테이션을 붙일 때 Java 필드, getter, setter 중 어디에 적용할지가 애매한 경우가 생긴다.
class Example {
@field:MaxLength(100) // Java 필드에 적용
var field1: String = ""
@get:Logged("INFO") // getter에 적용
var field2: String = ""
@set:Logged("WARN") // setter에 적용
var field3: String = ""
}
@field:, @get:, @set:을 붙여서 적용 대상을 명확히 지정할 수 있다. Spring이나 JPA를 쓸 때 이 구분이 중요해진다.
리플렉션
리플렉션은 실행 시점에 클래스의 구조를 분석하는 기능이다. 어노테이션과 함께 쓰면 프레임워크 수준의 자동화를 구현할 수 있다.
Kotlin 리플렉션을 사용하려면 kotlin-reflect 의존성이 필요하다.
import kotlin.reflect.full.*
data class User(
val id: Long,
val name: String,
val email: String,
val age: Int
)
fun main() {
val kClass = User::class
// 클래스 정보
println("클래스: ${kClass.simpleName}")
println("data class? ${kClass.isData}")
// 프로퍼티 목록
kClass.memberProperties.forEach { prop ->
println(" ${prop.name}: ${prop.returnType}")
}
}
클래스: User
data class? true
age: kotlin.Int
email: kotlin.String
id: kotlin.Long
name: kotlin.String
실용적인 예시를 하나 보자. 어노테이션과 리플렉션을 조합한 간단한 유효성 검사기다.
import kotlin.reflect.full.*
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class NotBlank
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class Min(val value: Int)
data class SignupRequest(
@NotBlank val username: String,
@NotBlank val email: String,
@Min(18) val age: Int
)
fun validate(obj: Any): List<String> {
val errors = mutableListOf<String>()
val kClass = obj::class
for (prop in kClass.memberProperties) {
val value = prop.getter.call(obj)
for (ann in prop.annotations) {
when (ann) {
is NotBlank -> {
if (value is String && value.isBlank()) {
errors.add("${prop.name}: 빈 값 불가")
}
}
is Min -> {
if (value is Int && value < ann.value) {
errors.add("${prop.name}: 최솟값 ${ann.value}")
}
}
}
}
}
return errors
}
fun main() {
val valid = SignupRequest("kim", "kim@mail.com", 25)
println(validate(valid)) // []
val invalid = SignupRequest("", "kim@mail.com", 15)
println(validate(invalid)) // [username: 빈 값 불가, age: 최솟값 18]
}
Spring Validation이나 Jackson이 내부적으로 이와 비슷한 방식으로 동작한다. 실무에서 직접 리플렉션을 쓸 일은 많지 않지만, 프레임워크가 어떻게 동작하는지 이해하는 데 도움이 된다.
단, 리플렉션은 컴파일 타임 안전성이 없고 성능 오버헤드가 있으므로 꼭 필요한 경우에만 쓰는 게 원칙이다.
Java 상호운용
Kotlin의 가장 큰 장점 중 하나는 Java와 100% 호환된다는 것이다. 기존 Java 코드를 그대로 쓸 수 있고, Kotlin 코드를 Java에서 호출할 수도 있다.
Kotlin에서 Java 코드 사용
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
fun main() {
// Java 클래스를 그대로 사용
val now = LocalDateTime.now()
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
println(now.format(formatter))
// Java 컬렉션도 자연스럽게
val map = ConcurrentHashMap<String, Int>()
map["kotlin"] = 1
map["java"] = 2
println(map) // {kotlin=1, java=2}
}
대부분의 경우 별다른 설정 없이 Java 라이브러리를 쓸 수 있다. Kotlin 컴파일러가 Java 타입을 자동으로 매핑해주기 때문이다. java.lang.String은 kotlin.String으로, int는 kotlin.Int로 변환된다.
플랫폼 타입 — null 안전성의 회색지대
Java에는 null 안전성 정보가 없다. Java에서 넘어온 값이 null인지 아닌지를 Kotlin 컴파일러가 판단할 수 없는 경우가 생기는데, 이걸 플랫폼 타입이라고 부른다.
// Java 코드 (가상)
// public class JavaUser {
// public String getName() { return null; } // null 반환 가능
// }
fun processJavaUser() {
// val name: String = javaUser.getName() // NPE 위험!
// val name: String? = javaUser.getName() // 안전
// Java 코드를 쓸 때는 Nullable로 받는 게 원칙이다
}
Java 라이브러리를 쓸 때는 @Nullable, @NotNull 어노테이션이 없는 한, 반환값을 String?처럼 nullable로 받는 게 안전하다. 이 습관만 들이면 Java 상호운용에서 NPE가 생길 일이 거의 없다.
Java에서 Kotlin 코드 사용
// Kotlin 파일: StringUtils.kt
@file:JvmName("StringUtils") // Java에서 보이는 클래스 이름 지정
@JvmStatic
fun String.isEmail(): Boolean {
return this.contains("@") && this.contains(".")
}
object AppConfig {
@JvmStatic
val version = "1.0.0"
@JvmStatic
fun getMaxRetries(): Int = 3
}
data class ApiResponse(
@JvmField val code: Int,
@JvmField val message: String
)
Java에서 이렇게 호출할 수 있다.
// Java에서 호출
StringUtils.isEmail("test@mail.com"); // @JvmName 덕분에
AppConfig.getVersion(); // @JvmStatic 덕분에
AppConfig.getMaxRetries();
ApiResponse resp = new ApiResponse(200, "OK");
int code = resp.code; // @JvmField 덕분에 getter 없이 직접 접근
주요 어노테이션을 정리하면 이렇다.
| 어노테이션 | 용도 |
|---|---|
@JvmStatic | companion object / object의 함수를 Java static으로 노출 |
@JvmField | 프로퍼티를 Java 필드로 직접 노출 (getter/setter 없이) |
@JvmName | Java에서 보이는 이름을 변경 |
@JvmOverloads | 디폴트 파라미터가 있는 함수를 Java용 오버로드로 생성 |
@Throws | checked exception 정보를 Java에 전달 |
@JvmOverloads
Kotlin의 디폴트 파라미터는 Java에서 인식되지 않는다. @JvmOverloads를 쓰면 Java 스타일의 오버로드 메서드가 자동으로 만들어진다.
class HttpClient {
@JvmOverloads
fun get(
url: String,
timeout: Int = 5000,
headers: Map<String, String> = emptyMap()
): String {
println("GET $url (timeout=${timeout}ms, headers=$headers)")
return "response"
}
}
fun main() {
val client = HttpClient()
client.get("https://api.example.com")
client.get("https://api.example.com", 10000)
client.get("https://api.example.com", 3000, mapOf("Auth" to "Bearer token"))
}
GET https://api.example.com (timeout=5000ms, headers={})
GET https://api.example.com (timeout=10000ms, headers={})
GET https://api.example.com (timeout=3000ms, headers={Auth=Bearer token})
@JvmOverloads가 있으면 Java에서 get(url), get(url, timeout), get(url, timeout, headers) 세 가지 버전을 모두 호출할 수 있다.
12편에 걸쳐 Kotlin의 기초부터 심화까지 다뤘다. 변수와 타입에서 시작해서 코루틴과 DSL을 거쳐 Java 상호운용까지 왔다. 이 정도면 Kotlin으로 실무 프로젝트를 시작하기에 충분한 기반이 된다.
물론 이 시리즈에서 다루지 못한 주제도 있다. 멀티플랫폼, Compose, Ktor, 테스트 전략 같은 것들은 각각 별도의 시리즈가 필요할 만큼 깊은 주제다. 하지만 이 12편이 제공하는 기초 위에서라면 어떤 방향으로든 확장해나갈 수 있을 것이다.
Loading comments...