PHpullh
심층 가이드/Kotlin/기초 문법

KOTLIN · 심층 가이드

Kotlin 기초 문법 완전 정리

val과 널 안전성부터 스마트 캐스트, 위임 속성, 인라인 클래스, KSP까지 Kotlin 문법 29편을 난이도 순으로 이어 붙여 정리한 묶음입니다.

주제 29개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

Kotlin 문법에서 진짜 무게중심은 타입 시스템이 널을 어떻게 다루느냐에 있습니다. StringString?은 다른 타입이고, 이 구분 덕분에 컴파일러가 NullPointerException을 대부분 막아 줍니다. 대신 Java에서 넘어오는 값에는 이 보장이 없습니다. 플랫폼 타입이라 불리는 이 값들은 컴파일러가 판단을 보류하기 때문에, Kotlin 코드인데도 런타임에 널이 튀어나오는 유일한 통로가 됩니다.

val vs var — 불변과 가변에서 시작해 Null 안전성 — ?, ?., ?:, !!로 넘어가는 흐름이 이 묶음의 척추입니다. if 표현식 & when 표현식이 값을 돌려준다는 사실을 받아들이면 스마트 캐스트 (Smart Cast)가 왜 자연스럽게 붙는지 보입니다. 뒤쪽 위임 속성 (Delegated Properties)계약 (Contracts)은 앞부분이 손에 익은 뒤 보셔도 됩니다.

스마트 캐스트는 컴파일러가 값이 변하지 않는다고 증명할 수 있을 때만 동작합니다. 그래서 var 프로퍼티나 다른 모듈의 open 프로퍼티는 널 검사를 통과해도 캐스트가 되지 않습니다. 지역 val에 한 번 담아 두면 해결됩니다. lateinit은 원시 타입과 널 허용 타입에 쓸 수 없고 초기화 전에 접근하면 전용 예외가 납니다. 값을 읽는 시점이 불확실하다면 by lazy 쪽이 낫습니다.

01val vs var — 불변과 가변

Kotlin의 두 변수 키워드. val은 재할당 불가(Java의 final), var은 재할당 가능. 기본은 val, 필요할 때만 var.

Kotlin code

// val = immutable (재할당 불가)
val name: String = "Kotlin"
val pi = 3.14159   // 타입 추론

// var = mutable (재할당 가능)
var count = 0
count = 10         // OK
count += 5         // OK

// name = "Java"  // ❌ 컴파일 에러!

fun main() {
    // 블록 내 지연 초기화도 가능
    val result: String
    result = if (count > 10) "크다" else "작다"
    println("$name / $result")
}
알아두면 좋은 점

val은 참조 불변입니다. val list = mutableListOf(1,2,3)에서 list 자체 재할당은 불가지만 list.add(4)는 가능합니다.

자주 하는 실수

val로 선언해도 내부 데이터가 가변(MutableList 등)이면 상태 변경이 가능합니다. 진짜 불변을 원하면 listOf()를 사용하세요.

02기본 데이터 타입

Kotlin의 숫자, 문자, 불리언 타입과 리터럴 표현법. Java와 달리 암시적 타입 변환이 없습니다.

Kotlin code

val age: Int       = 25
val salary: Long   = 50_000_000L   // _ 로 가독성 향상
val height: Float  = 175.5f
val weight: Double = 68.3
val isKotlin: Boolean = true
val grade: Char    = 'A'

// 타입 변환은 반드시 명시적으로
val x: Int = 100
val y: Long = x.toLong()    // ✅
val z: Double = x.toDouble()

// 다양한 진법 리터럴
val hex = 0xFF          // 255
val binary = 0b1010     // 10

fun main() {
    println("최대 Int: ${Int.MAX_VALUE}")
    println("최대 Long: ${Long.MAX_VALUE}")
}
알아두면 좋은 점

숫자에 _ 언더스코어를 사용하면 읽기 편합니다: 1_000_000

자주 하는 실수

val a: Int = 1; val b: Long = a → 컴파일 에러! a.toLong() 명시 필요. Java의 암시적 widening이 Kotlin엔 없습니다.

03Null 안전성 — ?, ?., ?:, !!

Kotlin의 핵심 기능. 컴파일 타임에 NullPointerException을 방지하는 타입 시스템.

Kotlin code

// Non-null 타입 (기본) — null 불가
var name: String = "Kotlin"
// name = null  // ❌ 컴파일 에러

// Nullable 타입 — ? 붙이기
var nickname: String? = "Dev"
nickname = null  // ✅

// Safe call (?.) — null이면 null 반환
val length = nickname?.length        // Int?

// Elvis operator (?:) — null이면 기본값
val len = nickname?.length ?: 0      // Int

// Non-null assertion (!!) — null이면 NPE
// 확실히 null이 아닐 때만 사용
val forced = nickname!!.length       // 위험!

// let으로 null 아닌 경우만 실행
nickname?.let { nick ->
    println("닉네임 길이: ${nick.length}")
}

// 안전한 타입 캐스팅
val obj: Any = "hello"
val str: String? = obj as? String    // 실패 시 null
알아두면 좋은 점

?: Elvis 연산자는 null일 때 기본값을 제공합니다. throw도 표현식이라 val x = y ?: throw IllegalArgumentException("null!")도 가능합니다.

자주 하는 실수

!!를 남용하면 Java NPE와 동일합니다. ?., ?:, let으로 해결 가능한 경우 !!는 피하세요.

04문자열 템플릿 & 포맷

문자열 보간(String Interpolation). $변수${표현식}으로 가독성 높은 문자열을 작성합니다.

Kotlin code

val lang = "Kotlin"
val version = 2
val score = 98.5

// 변수 직접 삽입
println("언어: $lang")

// 표현식 삽입 ${}
println("다음 버전: ${version + 1}")
println("대문자: ${lang.uppercase()}")
println("점수 등급: ${if (score >= 90) "A" else "B"}")

// 달러 기호 출력
println("가격: $100")
println("가격: ${'$'}100")  // 또는 이렇게

// Triple quote + 템플릿
val report = """
    |언어: $lang
    |버전: $version
    |점수: ${"%.1f".format(score)}점
""".trimMargin()
println(report)
알아두면 좋은 점

복잡한 표현식은 ${...}로, 단순 변수는 $변수명으로 사용합니다. IDE가 자동완성도 지원합니다.

자주 하는 실수

클래스 이름 바로 뒤에 .이 있을 때 $obj.field"${obj}".field로 오해됩니다. 반드시 ${obj.field}로 쓰세요.

05if 표현식 & when 표현식

Kotlin의 ifwhen문(statement)이 아닌 표현식(expression)입니다. 값을 반환합니다.

Kotlin code

val score = 85

// if는 표현식 — 삼항연산자 불필요
val grade = if (score >= 90) "A"
            else if (score >= 80) "B"
            else if (score >= 70) "C"
            else "F"

// when — Java switch보다 강력
val msg = when (score) {
    100          -> "만점!"
    in 90..99    -> "우수"
    in 80..89    -> "양호"
    in 70..79    -> "보통"
    else         -> "노력 필요"
}

// when without argument (if-else chain 대체)
when {
    score > 95  -> println("탑 5%!")
    score > 80  -> println("상위권")
    else        -> println("분발하세요")
}

// when + 타입 체크
fun describe(obj: Any): String = when (obj) {
    is Int    -> "정수: $obj"
    is String -> "문자열 길이: ${obj.length}"
    is List<*>-> "리스트 크기: ${obj.size}"
    else      -> "알 수 없는 타입"
}
알아두면 좋은 점

when을 표현식으로 쓸 때(값을 반환할 때) 컴파일러가 모든 케이스를 커버했는지 검사합니다. Sealed class와 함께 쓰면 else 없이도 완전성이 보장됩니다.

자주 하는 실수

when을 표현식으로 사용하면서 else를 빠뜨리면 컴파일 에러입니다.

06반복문 — for, while, repeat

Range, withIndex, until, downTo, step 등 Kotlin 반복문의 모든 패턴을 익힙니다.

Kotlin code

// 닫힌 범위 1..5 (5 포함)
for (i in 1..5) print("$i ")          // 1 2 3 4 5

// 열린 범위 until (5 미포함)
for (i in 1 until 5) print("$i ")     // 1 2 3 4

// 역순
for (i in 5 downTo 1) print("$i ")   // 5 4 3 2 1

// 스텝
for (i in 0..10 step 2) print("$i ") // 0 2 4 6 8 10

// 컬렉션 순회
val fruits = listOf("🍎", "🍌", "🍇")
for (fruit in fruits) println(fruit)

// 인덱스와 값 동시에
for ((idx, fruit) in fruits.withIndex()) {
    println("$idx: $fruit")
}

// while
var n = 5
while (n > 0) { print("$n "); n-- }

// repeat
repeat(3) { i -> println("반복 $i") }
알아두면 좋은 점

forEach에서는 break/continue를 사용할 수 없습니다. 이 경우 for문이나 run { list.forEach { if(...) return@run } } 패턴을 사용하세요.

자주 하는 실수

1..5는 5를 포함(closed range), 1 until 5는 5 미포함(half-open range)입니다. 배열 인덱스 순회 시 0 until arr.size 또는 arr.indices를 사용하세요.

07Destructuring &amp; 구조 분해

컴포넌트 함수를 통해 객체를 여러 변수로 한 번에 분해합니다.

Kotlin code

// Pair, Triple
val pair = Pair("Kotlin", 2024)
val (lang, year) = pair
println("$lang $year")

// data class는 자동으로 componentN() 생성
data class Point(val x: Int, val y: Int)
val (x, y) = Point(10, 20)
println("x=$x, y=$y")

// 특정 항목 무시: _
data class User(val id: Int, val name: String, val email: String)
val (_, name, _) = User(1, "Alice", "a@test.com")
println(name)

// Map 순회에서 destructuring
val map = mapOf("a" to 1, "b" to 2)
for ((key, value) in map) {
    println("$key → $value")
}

// withIndex destructuring
listOf("x", "y", "z").forEachIndexed { idx, item ->
    println("$idx: $item")
}
알아두면 좋은 점

data class의 구조 분해는 주 생성자 선언 순서에 의존합니다. 나중에 프로퍼티 순서를 바꾸면 구조 분해 코드가 조용히 잘못됩니다.

자주 하는 실수

data class 프로퍼티 순서를 변경하면 구조 분해 변수의 의미가 바뀝니다. 팀 코드에서는 Named arguments를 더 안전하게 사용하세요.

08lateinit &amp; lazy 지연 초기화

생성 시점에 초기화할 수 없는 변수를 안전하게 다루는 두 가지 방법.

Kotlin code

// lateinit — var에만 사용, non-null 보장 필요할 때
// (주로 DI, Android ViewModel, 테스트)
class UserService {
    lateinit var repository: UserRepository

    fun setup() {
        repository = UserRepository()
    }

    fun getUser() {
        // 초기화 전 접근 시 UninitializedPropertyAccessException
        if (::repository.isInitialized) {
            repository.find()
        }
    }
}

// lazy — val에 사용, 첫 접근 시 한 번만 초기화
// 기본적으로 thread-safe (LazyThreadSafetyMode.SYNCHRONIZED)
class Config {
    val heavyData: List<String> by lazy {
        println("처음 한 번만 실행")
        loadFromDisk()  // 비용이 큰 초기화
    }
}

fun loadFromDisk() = listOf("data1", "data2")
알아두면 좋은 점

lazy는 기본적으로 thread-safe합니다. 단일 스레드 환경에서 성능을 높이려면 by lazy(LazyThreadSafetyMode.NONE)을 사용하세요.

자주 하는 실수

lateinitInt, Boolean 같은 원시 타입에 사용할 수 없습니다. Nullable 타입에도 사용 불가입니다.

09Enum class 완전 활용

ordinal, name, values(), valueOf() 등 Enum의 모든 기능 활용법

Kotlin code

<span class="cm">// Enum class 완전 활용 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Enum class 완전 활용") }
알아두면 좋은 점

KOTLIN 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

10타입 변환 (Type Conversion)

Kotlin은 암묵적 타입 변환을 지원하지 않습니다. 명시적 변환 함수 toInt(), toLong(), toDouble() 등을 사용해야 합니다.

Kotlin code

fun main() {
    val intVal: Int = 42
    val longVal: Long = intVal.toLong()
    val doubleVal: Double = intVal.toDouble()
    val stringVal: String = intVal.toString()

    // 문자열 → 숫자 (실패 시 null)
    val parsed: Int? = "123".toIntOrNull()
    val invalid: Int? = "abc".toIntOrNull() // null

    println("Long: $longVal, Double: $doubleVal")
    println("Parsed: $parsed, Invalid: $invalid")
}
알아두면 좋은 점

toIntOrNull() 계열 함수를 사용하면 파싱 실패 시 예외 대신 null을 반환하여 안전하게 처리할 수 있습니다.

자주 하는 실수

val l: Long = intVal처럼 암묵적 변환을 시도하면 컴파일 오류가 발생합니다. 반드시 .toLong()을 호출하세요.

11범위 연산자 (Range Operator)

.. 연산자와 until, downTo, step을 사용하여 범위를 정의합니다. 반복문과 조건문에서 자주 활용됩니다.

Kotlin code

fun main() {
    // 닫힌 범위 (1~10 포함)
    for (i in 1..10) print("$i ")
    println()

    // 반개방 범위 (10 미포함)
    for (i in 0 until 10 step 2) print("$i ")
    println()

    // 역순 범위
    for (i in 10 downTo 1 step 3) print("$i ")
    println()

    // 범위 검사
    val score = 85
    println(score in 80..100) // true
}
알아두면 좋은 점

in 연산자로 값이 범위에 포함되는지 간결하게 검사할 수 있습니다. when 분기에서도 in 1..10 형태로 사용 가능합니다.

자주 하는 실수

..는 끝값을 포함하고 until은 포함하지 않습니다. 배열 인덱스 순회 시 0 until size를 사용해야 IndexOutOfBoundsException을 방지합니다.

12비교 연산 (Equality &amp; Comparison)

Kotlin은 구조적 동등성(==)과 참조 동등성(===)을 구분합니다. ==는 내부적으로 equals()를 호출합니다.

Kotlin code

fun main() {
    val a = "Hello"
    val b = "Hello"
    val c = String("Hello".toCharArray())

    println(a == b)   // true (구조적 동등)
    println(a === b)  // true (문자열 풀)
    println(a == c)   // true (구조적 동등)
    println(a === c)  // false (다른 객체)

    // Comparable 구현
    data class Version(val major: Int, val minor: Int) : Comparable<Version> {
        override fun compareTo(other: Version) =
            compareValuesBy(this, other, { it.major }, { it.minor })
    }
    println(Version(2, 0) > Version(1, 9)) // true
}
알아두면 좋은 점

compareValuesBy()를 사용하면 여러 속성을 순차적으로 비교하는 Comparable을 간결하게 구현할 수 있습니다.

자주 하는 실수

Java와 달리 Kotlin의 ==equals()를 호출합니다. 참조 비교가 필요하면 ===를 사용하세요.

13비트 연산 (Bitwise Operations)

Kotlin은 비트 연산을 중위 함수로 제공합니다. and, or, xor, shl, shr 등을 사용합니다.

Kotlin code

fun main() {
    val flags = 0b1010
    val mask  = 0b1100

    println(flags and mask)  // 0b1000 = 8
    println(flags or mask)   // 0b1110 = 14
    println(flags xor mask)  // 0b0110 = 6
    println(flags.inv())     // 비트 반전

    // 시프트 연산
    println(1 shl 4)   // 16 (왼쪽 시프트)
    println(32 shr 2)  // 8  (오른쪽 시프트)

    // 플래그 활용
    val READ = 1; val WRITE = 2; val EXEC = 4
    var perm = READ or WRITE
    println(perm and EXEC != 0)  // false
}
알아두면 좋은 점

비트 플래그 패턴은 권한 시스템이나 상태 관리에 유용합니다. or로 설정하고 and로 검사합니다.

자주 하는 실수

Kotlin에서는 &, | 기호 대신 and, or 등의 중위 함수를 사용합니다. Java 스타일 연산자를 사용하면 컴파일 오류가 발생합니다.

14구조 분해 선언 (Destructuring)

데이터 클래스나 componentN() 함수를 가진 객체를 여러 변수로 한 번에 분해할 수 있습니다.

Kotlin code

data class Point(val x: Int, val y: Int)

fun getUser() = Triple("김철수", 25, "서울")

fun main() {
    // data class 분해
    val (x, y) = Point(10, 20)
    println("x=$x, y=$y")

    // Triple 분해
    val (name, age, city) = getUser()
    println("$name, $age, $city")

    // Map 순회 시 분해
    val scores = mapOf("국어" to 90, "수학" to 85)
    for ((subject, score) in scores) {
        println("$subject: $score")
    }

    // 람다 파라미터 분해
    scores.forEach { (k, v) -> println("$k=$v") }
}
알아두면 좋은 점

사용하지 않는 변수는 _로 무시할 수 있습니다: val (_, age) = getUser()

자주 하는 실수

구조 분해는 위치 기반입니다. 데이터 클래스의 프로퍼티 순서가 바뀌면 분해 결과도 달라지므로 주의하세요.

15타입 추론 (Type Inference)

Kotlin 컴파일러는 초기값으로부터 타입을 자동 추론합니다. 명시적 선언 없이도 강타입 안전성을 유지합니다.

Kotlin code

fun main() {
    val name = "Kotlin"       // String 추론
    val count = 42            // Int 추론
    val pi = 3.14             // Double 추론
    val isActive = true       // Boolean 추론
    val items = listOf(1, 2)  // List<Int> 추론

    // 제네릭 타입 추론
    val map = mutableMapOf(
        "a" to 1,
        "b" to 2
    )  // MutableMap<String, Int> 추론

    // 함수 반환 타입 추론
    fun double(x: Int) = x * 2  // Int 반환 추론

    println(name::class.simpleName) // String
}
알아두면 좋은 점

공개 API의 함수 반환 타입은 명시적으로 선언하는 것이 좋습니다. 내부 구현 변경이 외부 인터페이스에 영향을 주는 것을 방지합니다.

자주 하는 실수

타입을 명시하지 않으면 의도와 다른 타입이 추론될 수 있습니다. val num = 1LLong이고 val num = 1Int입니다.

16스마트 캐스트 (Smart Cast)

is 검사 후 컴파일러가 자동으로 타입을 캐스팅합니다. 별도의 명시적 캐스트가 불필요합니다.

Kotlin code

fun describe(obj: Any): String = when (obj) {
    is String -> "문자열 길이: ${obj.length}"
    is Int    -> "정수의 2배: ${obj * 2}"
    is List<*> -> "리스트 크기: ${obj.size}"
    else       -> "알 수 없는 타입"
}

fun safeCast(value: Any) {
    // as? 안전 캐스트 (실패 시 null)
    val str: String? = value as? String
    println(str?.uppercase() ?: "문자열 아님")

    // 스마트 캐스트 + 조건 결합
    if (value is String && value.length > 5) {
        println("긴 문자열: $value")
    }
}

fun main() {
    println(describe("Hello"))
    println(describe(42))
    safeCast("Kotlin")
}
알아두면 좋은 점

whenis를 함께 사용하면 패턴 매칭처럼 타입별 분기를 깔끔하게 작성할 수 있습니다.

자주 하는 실수

var 변수는 다른 스레드에서 변경될 수 있어 스마트 캐스트가 적용되지 않습니다. 지역 val로 복사하거나 as를 사용하세요.

17Nullable 심화 (Elvis &amp; Let)

Kotlin의 null 안전 연산자들을 조합하여 복잡한 null 처리를 간결하게 작성합니다. ?., ?:, let의 활용법입니다.

Kotlin code

data class Address(val city: String?)
data class User(val name: String, val address: Address?)

fun process(user: User?) {
    // 안전 호출 체이닝
    val city = user?.address?.city ?: "알 수 없음"
    println("도시: $city")

    // let으로 null이 아닐 때만 실행
    user?.address?.city?.let { c ->
        println("확인된 도시: $c")
    }

    // also와 결합
    user?.also {
        println("사용자: ${it.name}")
    } ?: println("사용자 없음")
}

fun main() {
    process(User("김철수", Address("서울")))
    process(User("이영희", null))
    process(null)
}
알아두면 좋은 점

?: Elvis 연산자의 오른쪽에 throwreturn을 배치하여 조기 반환 패턴을 구현할 수 있습니다: val v = x ?: return

자주 하는 실수

let 블록 내에서 it은 이미 non-null입니다. 불필요하게 it?.property로 접근하지 마세요.

18열거형 클래스 (Enum Class)

enum class는 상수 집합을 정의하며, 프로퍼티와 메서드를 가질 수 있습니다. 추상 메서드를 통해 각 상수별 동작도 정의 가능합니다.

Kotlin code

enum class Direction(val dx: Int, val dy: Int) {
    NORTH(0, -1),
    SOUTH(0, 1),
    EAST(1, 0),
    WEST(-1, 0);

    fun opposite(): Direction = when (this) {
        NORTH -> SOUTH
        SOUTH -> NORTH
        EAST  -> WEST
        WEST  -> EAST
    }
}

fun main() {
    val dir = Direction.EAST
    println("${dir.name}: dx=${dir.dx}, dy=${dir.dy}")
    println("반대: ${dir.opposite()}")

    // values()와 valueOf()
    Direction.entries.forEach { println(it) }
    val south = Direction.valueOf("SOUTH")
    println("ordinal: ${south.ordinal}")
}
알아두면 좋은 점

Kotlin 1.9+에서는 values() 대신 entries 프로퍼티를 사용하세요. 매 호출마다 배열을 복사하지 않아 성능이 더 좋습니다.

자주 하는 실수

valueOf()에 존재하지 않는 이름을 전달하면 IllegalArgumentException이 발생합니다. 사용자 입력에는 entries.find를 사용하세요.

19상수 선언 (const val &amp; companion)

컴파일 타임 상수는 const val로, 런타임 상수는 val로 선언합니다. companion object 안에서 클래스 수준 상수를 정의합니다.

Kotlin code

// Top-level 상수
const val MAX_RETRY = 3
const val API_URL = "https://api.example.com"

class Config {
    companion object {
        const val TIMEOUT = 5000L
        val SUPPORTED_TYPES = listOf("json", "xml")

        fun isValid(type: String) =
            type in SUPPORTED_TYPES
    }
}

fun main() {
    println("Max retry: $MAX_RETRY")
    println("Timeout: ${Config.TIMEOUT}")
    println("json valid: ${Config.isValid("json")}")
}
알아두면 좋은 점

const val은 기본형과 String만 가능합니다. 컴파일 타임에 인라인되어 성능상 이점이 있습니다.

자주 하는 실수

const valcompanion object나 top-level에서만 선언 가능합니다. 함수 내부나 일반 클래스 본문에서는 사용할 수 없습니다.

20어노테이션 (Annotations)

Kotlin 어노테이션은 메타데이터를 코드에 첨부합니다. 커스텀 어노테이션을 정의하고 리플렉션으로 읽을 수 있습니다.

Kotlin code

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Logged(val tag: String = "DEFAULT")

@Logged(tag = "SERVICE")
fun processOrder(id: Int): String {
    return "주문 $id 처리 완료"
}

fun main() {
    val func = ::processOrder
    val annotation = func.annotations
        .filterIsInstance<Logged>()
        .firstOrNull()

    annotation?.let {
        println("태그: ${it.tag}")
    }

    // @JvmStatic, @JvmOverloads 등 Java 연동
    println(processOrder(42))
}
알아두면 좋은 점

@JvmStatic, @JvmOverloads, @JvmField는 Java 코드와의 상호운용성을 높이는 핵심 어노테이션입니다.

자주 하는 실수

AnnotationRetention.SOURCE로 지정하면 런타임에 리플렉션으로 읽을 수 없습니다. 런타임 접근이 필요하면 RUNTIME을 사용하세요.

21타입 별칭 (Type Alias)

typealias는 기존 타입에 새 이름을 부여하여 가독성을 높입니다. 복잡한 제네릭 타입이나 함수 타입에 유용합니다.

Kotlin code

typealias StringMap = Map<String, String>
typealias Predicate<T> = (T) -> Boolean
typealias UserCallback = (name: String, age: Int) -> Unit
typealias NodeSet = Set<Network.Node>

class Network {
    class Node(val id: String)
}

fun filter(list: List<Int>, pred: Predicate<Int>): List<Int> =
    list.filter(pred)

fun main() {
    val headers: StringMap = mapOf("Content-Type" to "application/json")
    val isEven: Predicate<Int> = { it % 2 == 0 }

    println(filter(listOf(1, 2, 3, 4), isEven))
    println(headers)
}
알아두면 좋은 점

콜백이나 고차 함수의 함수 타입에 typealias를 사용하면 파라미터 의미가 명확해져 코드 리뷰가 수월해집니다.

자주 하는 실수

typealias는 새로운 타입을 만드는 것이 아니라 별명일 뿐입니다. 타입 안전성이 필요하면 inline class를 사용하세요.

22연산자 오버로딩 (Operator Overloading)

operator 키워드로 기존 연산자의 동작을 재정의합니다. plus, times, compareTo 등 다양한 연산자를 오버로딩할 수 있습니다.

Kotlin code

data class Vector(val x: Double, val y: Double) {
    operator fun plus(other: Vector) =
        Vector(x + other.x, y + other.y)
    operator fun times(scalar: Double) =
        Vector(x * scalar, y * scalar)
    operator fun unaryMinus() = Vector(-x, -y)
    operator fun get(index: Int) = when (index) {
        0 -> x; 1 -> y
        else -> throw IndexOutOfBoundsException()
    }
}

fun main() {
    val a = Vector(1.0, 2.0)
    val b = Vector(3.0, 4.0)

    println(a + b)       // Vector(4.0, 6.0)
    println(a * 2.5)     // Vector(2.5, 5.0)
    println(-a)          // Vector(-1.0, -2.0)
    println(a[0])        // 1.0
}
알아두면 좋은 점

연산자 오버로딩은 수학적 의미가 직관적인 도메인 객체(벡터, 행렬, 복소수 등)에 사용하면 가독성이 크게 향상됩니다.

자주 하는 실수

연산자의 의미를 왜곡하지 마세요. plus가 덧셈이 아닌 동작을 하면 코드를 읽는 사람이 혼란스러워합니다.

23가시성 제한자 (Visibility Modifiers)

Kotlin의 네 가지 가시성 제한자: public(기본값), private, protected, internal을 이해합니다.

Kotlin code

// internal: 같은 모듈 내에서만 접근
internal class DatabaseConfig(
    private val host: String,
    private val port: Int
) {
    // protected: 하위 클래스에서 접근
    protected open fun connectionString() =
        "$host:$port"

    // public (기본): 어디서나 접근
    fun connect() {
        println("연결: ${connectionString()}")
    }
}

// Top-level private: 같은 파일 내에서만
private fun secretHelper() = "비밀 도우미"

// Top-level internal: 같은 모듈 내에서만
internal val sharedConfig = mapOf("env" to "prod")

fun main() {
    val db = DatabaseConfig("localhost", 5432)
    db.connect()
}
알아두면 좋은 점

internal은 멀티모듈 프로젝트에서 모듈 간 API 경계를 정의하는 데 매우 유용합니다. 공개하고 싶지 않은 구현 세부사항에 사용하세요.

자주 하는 실수

Kotlin의 protected는 Java와 달리 같은 패키지에서도 접근할 수 없고, 오직 하위 클래스에서만 접근 가능합니다.

24위임 속성 (Delegated Properties)

by 키워드로 프로퍼티의 getter/setter를 다른 객체에 위임합니다. lazy, observable, vetoable 등 표준 위임을 제공합니다.

Kotlin code

import kotlin.properties.Delegates

class UserSettings {
    // lazy: 첫 접근 시 초기화
    val config: Map<String, String> by lazy {
        println("설정 로드 중...")
        mapOf("theme" to "dark", "lang" to "ko")
    }

    // observable: 변경 감지
    var fontSize: Int by Delegates.observable(14) { _, old, new ->
        println("폰트 크기: $old → $new")
    }

    // vetoable: 변경 거부 가능
    var volume: Int by Delegates.vetoable(50) { _, _, new ->
        new in 0..100  // 범위 밖이면 거부
    }
}

fun main() {
    val s = UserSettings()
    println(s.config["theme"])
    s.fontSize = 18
    s.volume = 200  // 거부됨
    println("Volume: ${s.volume}") // 50
}
알아두면 좋은 점

lazy는 기본적으로 스레드 안전합니다. 단일 스레드 환경이라면 lazy(LazyThreadSafetyMode.NONE)으로 오버헤드를 줄이세요.

자주 하는 실수

lazy 프로퍼티는 val에만 사용 가능합니다. 변경 가능한 지연 초기화가 필요하면 lateinit var을 사용하세요.

25인라인 클래스 / Value Class

@JvmInline value class는 래퍼 타입을 만들되 런타임 오버헤드를 제거합니다. 타입 안전성과 성능을 동시에 확보합니다.

Kotlin code

@JvmInline
value class Email(val value: String) {
    init {
        require(value.contains("@")) { "유효하지 않은 이메일" }
    }
    val domain: String get() = value.substringAfter("@")
}

@JvmInline
value class UserId(val id: Long)

fun sendMail(to: Email, from: Email) {
    println("${from.value} → ${to.value}")
}

fun main() {
    val email = Email("user@example.com")
    println("도메인: ${email.domain}")

    // 타입 안전: UserId와 Long을 혼동할 수 없음
    val userId = UserId(12345L)
    // sendMail(userId, email) // 컴파일 오류!
    sendMail(email, Email("admin@example.com"))
}
알아두면 좋은 점

도메인 원시 타입(이메일, ID, 금액 등)에 value class를 사용하면 파라미터 순서 실수를 컴파일 타임에 잡을 수 있습니다.

자주 하는 실수

value class는 정확히 하나의 프로퍼티만 가질 수 있습니다. 여러 프로퍼티가 필요하면 data class를 사용하세요.

26컨텍스트 리시버 (Context Receivers)

컨텍스트 리시버는 함수가 특정 컨텍스트 내에서만 호출되도록 제한합니다. 의존성을 암시적으로 전달하는 강력한 기능입니다.

Kotlin code

// 로깅 컨텍스트
interface Logger {
    fun log(message: String)
}

// 트랜잭션 컨텍스트
interface Transaction {
    fun execute(sql: String)
}

class ConsoleLogger : Logger {
    override fun log(message: String) = println("[LOG] $message")
}

// context(Logger)를 사용하면 Logger 스코프에서만 호출 가능
// Kotlin 2.0+ 문법 (실험적)
fun Logger.saveUser(name: String) {
    log("사용자 저장: $name")
    println("DB에 $name 저장 완료")
}

fun main() {
    val logger = ConsoleLogger()
    with(logger) {
        saveUser("김철수")
    }
}
알아두면 좋은 점

컨텍스트 리시버는 DI 프레임워크 없이도 의존성 주입 패턴을 구현할 수 있게 합니다. DSL 설계에 특히 유용합니다.

자주 하는 실수

컨텍스트 리시버는 아직 실험적 기능입니다. 프로덕션 코드에서는 -Xcontext-receivers 컴파일러 플래그와 함께 신중하게 사용하세요.

27계약 (Contracts)

Kotlin 계약(Contract)은 컴파일러에게 함수의 동작을 알려줍니다. 스마트 캐스트와 초기화 보장에 활용됩니다.

Kotlin code

import kotlin.contracts.*

@OptIn(ExperimentalContracts::class)
fun requireNotEmpty(value: String?): String {
    contract {
        returns() implies (value != null)
    }
    if (value.isNullOrEmpty()) {
        throw IllegalArgumentException("값이 비어있습니다")
    }
    return value
}

@OptIn(ExperimentalContracts::class)
inline fun <R> executeOnce(block: () -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}

fun main() {
    val input: String? = "Hello"
    val result = requireNotEmpty(input)
    // 여기서 result는 non-null로 스마트 캐스트됨
    println(result.length)

    val value: Int
    executeOnce { value = 42 }
    println(value) // 초기화 보장됨
}
알아두면 좋은 점

callsInPlace(block, EXACTLY_ONCE) 계약은 val 변수를 람다 내에서 초기화할 수 있게 합니다.

자주 하는 실수

계약은 실험적 API입니다. @OptIn(ExperimentalContracts::class)를 반드시 추가해야 하며, 허위 계약을 작성하면 런타임 오류가 발생합니다.

28멀티플랫폼 타입 (Kotlin Multiplatform)

Kotlin Multiplatform에서 expect/actual 선언으로 플랫폼별 구현을 분리합니다.

Kotlin code

// commonMain - 공통 코드
expect fun platformName(): String
expect class UUID {
    fun asString(): String
}

// 공통 비즈니스 로직
fun greet(): String {
    return "Hello from ${platformName()}!"
}

// jvmMain - JVM 구현
actual fun platformName(): String = "JVM"
actual class UUID(private val value: java.util.UUID) {
    actual fun asString(): String = value.toString()
}

// 사용 예시 (공통)
fun main() {
    println(greet())
    println("플랫폼: ${platformName()}")
}
알아두면 좋은 점

공통 모듈에 최대한 많은 비즈니스 로직을 작성하고, 플랫폼별 모듈은 I/O나 UI 등 플랫폼 특화 기능만 구현하세요.

자주 하는 실수

expect 선언에 대응하는 actual이 모든 타겟 플랫폼에 있어야 합니다. 누락하면 컴파일 오류가 발생합니다.

29어노테이션 프로세싱 (KSP)

Kotlin Symbol Processing(KSP)은 어노테이션 기반 코드 생성을 위한 컴파일러 플러그인입니다. KAPT보다 최대 2배 빠릅니다.

Kotlin code

// 커스텀 어노테이션 정의
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class AutoBuilder

// @AutoBuilder 적용
@AutoBuilder
data class UserDto(
    val name: String,
    val email: String,
    val age: Int = 0
)

// KSP가 생성할 빌더 (개념 예시)
// class UserDtoBuilder {
//     var name: String = ""
//     var email: String = ""
//     var age: Int = 0
//     fun build() = UserDto(name, email, age)
// }

fun main() {
    val user = UserDto(
        name = "김철수",
        email = "kim@example.com",
        age = 30
    )
    println(user)
}
알아두면 좋은 점

KSP는 KAPT 대비 빌드 속도가 크게 향상됩니다. Room, Moshi 등 주요 라이브러리가 KSP를 지원하므로 마이그레이션을 권장합니다.

자주 하는 실수

KSP는 Java 어노테이션 프로세서와 호환되지 않습니다. Java AP가 필요하면 여전히 KAPT를 사용해야 합니다.

정리하며

  • 기본은 val로 선언하고 재할당이 꼭 필요할 때만 var로 바꿉니다
  • Java에서 들어온 플랫폼 타입은 널 보장이 없으니 경계에서 검사합니다
  • var 프로퍼티는 스마트 캐스트가 안 되므로 지역 val에 담아 씁니다
  • lateinit은 원시 타입에 못 쓰며 불확실한 초기화는 by lazy로 처리합니다

더 깊이 들어가고 싶다면 Kotlin 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.