Kotlin 함수 | 함수 정의, 람다, 고차 함수

이 글의 핵심

Kotlin 코드를 읽다 보면 let과 apply, also가 뒤섞여 있어 무엇을 반환하는지 헷갈리기 쉽습니다. 스코프 함수를 수신 객체를 it으로 받는지 this로 받는지, 결과로 무엇을 돌려주는지 기준으로 구분하고, 고차 함수에 inline을 붙이면 람다 객체 생성 비용이 사라지는 이유까지 짚습니다.

들어가며

함수는 일급 객체로 다루어져, 변수에 담고 고차 함수의 인자로 넘기기 쉽습니다. 람다·익명 함수 문법이 짧아, 컬렉션 연산과 잘 맞습니다.


함수 정의, 기본 매개변수, 가변 인자

기본 함수

Kotlin 함수의 기본 형태입니다:

// 1. 반환값 없는 함수
fun greet(name: String) {
    // fun: 함수 선언 키워드
    // greet: 함수 이름
    // (name: String): 매개변수 (이름: 타입)
    // 반환 타입 생략 시 Unit (void와 유사)
    println("안녕하세요, $name님!")
    // $name: 문자열 템플릿 (변수 삽입)
}
greet("홍길동")  // 안녕하세요, 홍길동님!
// 2. 반환값 있는 함수 (블록 본문)
fun add(a: Int, b: Int): Int {
    // : Int : 반환 타입 명시 (필수)
    return a + b
    // return 키워드로 값 반환
}
val result = add(10, 20)
println(result)  // 30
// 3. 표현식 함수 (단일 표현식)
fun add(a: Int, b: Int) = a + b
// = 뒤에 표현식 작성
// 반환 타입 자동 추론 (Int)
// return 키워드 불필요
// 간결하고 읽기 쉬움
// 표현식 함수 예시
fun max(a: Int, b: Int) = if (a > b) a else b
fun square(x: Int) = x * x
fun isEven(x: Int) = x % 2 == 0
println(max(10, 20))   // 20
println(square(5))     // 25
println(isEven(4))     // true
// 4. Unit 반환 (명시적)
fun printSum(a: Int, b: Int): Unit {
    // Unit: 반환값이 없음을 명시 (생략 가능)
    // Java의 void와 유사하지만 실제로는 싱글톤 객체
    println(a + b)
    // return 생략 가능 (자동으로 Unit 반환)
}
// Unit 생략 (권장)
fun printSum(a: Int, b: Int) {
    println(a + b)
}

이 블록은 문법을 비교하려고 add와 printSum을 두 번씩 정의했기 때문에 한 파일에 그대로 넣으면 “Conflicting overloads” 오류가 납니다. 매개변수 타입까지 같은 함수는 반환 타입 표기 방식만 달라도 같은 함수로 취급되기 때문입니다.

블록 본문 함수는 반환 타입을 반드시 적어야 하고, 표현식 함수는 생략하면 추론됩니다. 표현식 함수가 짧고 편하지만, 공개 API라면 반환 타입을 명시하는 편이 안전합니다. fun parse(s: String) = s.toIntOrNull()처럼 추론에 맡기면, 나중에 본문을 바꾸는 순간 반환 타입이 Int?에서 다른 타입으로 조용히 바뀌어 호출하는 쪽 코드가 깨질 수 있습니다.

Unit이 Java의 void와 다른 점은 실제 값이 있는 타입이라는 것입니다. 그래서 () -> Unit 같은 함수 타입이나 제네릭 인자(List<Unit>)로 쓸 수 있고, “반환값이 없는 함수”도 다른 함수와 같은 방식으로 다룰 수 있습니다. 끝나지 않는 함수(항상 예외를 던지는 fail() 등)의 반환 타입은 Unit이 아니라 Nothing입니다. 함수 선언 위치:

// 최상위 함수 (파일 레벨)
fun topLevel() {
    println("최상위 함수")
}
class MyClass {
    // 멤버 함수 (클래스 내부)
    fun memberFunction() {
        println("멤버 함수")
    }
    
    // 지역 함수 (함수 내부)
    fun outer() {
        fun inner() {
            println("지역 함수")
        }
        inner()  // 지역 함수 호출
    }
}

Java와 달리 Kotlin은 클래스 밖, 파일 최상위에 함수를 둘 수 있습니다. 유틸리티 함수를 위해 StringUtils 같은 정적 메서드 모음 클래스를 만들 필요가 없는 이유입니다. JVM에서는 파일 이름을 딴 클래스(Utils.kt → UtilsKt)의 정적 메서드로 컴파일되므로, Java 코드에서는 UtilsKt.topLevel()로 호출합니다. 지역 함수는 바깥 함수의 지역 변수에 접근할 수 있어서, 한 함수 안에서만 반복되는 검증 로직을 묶을 때 유용합니다.

기본 매개변수

fun greet(name: String = "Guest", greeting: String = "안녕하세요") {
    println("$greeting, $name님!")
}
greet()                          // 안녕하세요, Guest님!
greet("홍길동")                  // 안녕하세요, 홍길동님!
greet("홍길동", "Hello")         // Hello, 홍길동님!
greet(greeting = "Hi")           // Hi, Guest님!

기본 매개변수는 Java에서 흔히 쓰던 오버로딩 여러 개를 함수 하나로 대체합니다. greet(greeting = "Hi")처럼 이름을 붙인 인자(named argument)와 함께 쓰면 앞쪽 인자를 건너뛸 수도 있고, createUser(name = "홍길동", isAdmin = false)처럼 불리언이나 숫자 인자의 의미가 호출부에 드러나 가독성이 좋아집니다.

Java에서 이 함수를 호출하면 기본값이 적용되지 않아 모든 인자를 넘겨야 합니다. JVM 바이트코드에는 기본값 개념이 없어 Kotlin 컴파일러가 별도의 합성 메서드로 처리하기 때문입니다. Java 쪽에서도 인자를 생략해 쓰게 하려면 함수에 @JvmOverloads를 붙여 오버로드를 생성하게 합니다. 또 기본값 표현식은 호출될 때마다 평가되므로 fun log(time: Long = System.currentTimeMillis())는 호출 시각을 매번 새로 가져옵니다.

가변 인자

fun sum(vararg numbers: Int): Int {
    return numbers.sum()
}
println(sum(1, 2, 3))        // 6
println(sum(1, 2, 3, 4, 5))  // 15
// 배열 전달
val nums = intArrayOf(1, 2, 3)
println(sum(*nums))  // spread 연산자

vararg numbers: Int는 함수 안에서 IntArray로 보입니다. Int 같은 기본 타입은 박싱 없는 원시 배열로 전달되어 효율적입니다. 이미 배열을 가지고 있다면 *(spread)로 풀어서 넘겨야 하며, sum(nums)처럼 그대로 넘기면 “Type mismatch: inferred type is IntArray but Int was expected” 오류가 납니다. List는 바로 펼칠 수 없어서 sum(*list.toIntArray())처럼 배열로 바꾼 뒤 넘깁니다. spread는 배열을 복사하므로, 아주 큰 배열을 반복해서 넘기는 코드라면 vararg 대신 IntArray나 List를 매개변수로 받는 편이 낫습니다.


람다식과 컬렉션 함수

기본 문법

// 람다 정의
val sum = { a: Int, b: Int -> a + b }
println(sum(10, 20))  // 30
// 타입 추론
val multiply: (Int, Int) -> Int = { a, b -> a * b }
// 단일 매개변수 (it)
val square: (Int) -> Int = { it * it }
println(square(5))  // 25

람다는 중괄호 안에 매개변수 -> 본문을 쓰고, 마지막 표현식의 값이 반환값이 됩니다. 람다 안에서 return을 쓰면 람다가 아니라 바깥 함수에서 빠져나가려 하므로(inline 함수에 넘긴 경우만 허용됩니다), 람다에서 값을 일찍 돌려주려면 return@filter 같은 레이블 반환을 씁니다.

타입 정보는 한쪽에만 있으면 됩니다. sum은 람다 매개변수에 타입을 적었고, multiply는 변수에 함수 타입 (Int, Int) -> Int를 적어 람다 쪽을 생략했습니다. 둘 다 없는 val f = { a, b -> a + b }는 “Cannot infer a type for this parameter” 오류가 납니다. it은 매개변수가 정확히 하나일 때만 쓸 수 있습니다.

람다 활용

Kotlin의 컬렉션 함수와 람다를 함께 사용하는 예제입니다:

val numbers = listOf(1, 2, 3, 4, 5)
// 1. filter: 조건에 맞는 요소만 선택
val evens = numbers.filter { it % 2 == 0 }
// { it % 2 == 0 }: 람다 표현식
// it: 암묵적 매개변수 (단일 매개변수일 때 사용)
// it % 2 == 0: 짝수 판별 조건
// 
// 동작:
// 1 → false (제외)
// 2 → true (포함)
// 3 → false (제외)
// 4 → true (포함)
// 5 → false (제외)
println(evens)  // [2, 4]
// 명시적 매개변수 사용
val evens2 = numbers.filter { num -> num % 2 == 0 }
// num: 명시적 매개변수 이름
// it 대신 의미 있는 이름 사용 가능
// 2. map: 각 요소를 변환
val doubled = numbers.map { it * 2 }
// 각 요소에 2를 곱함
// [1, 2, 3, 4, 5] → [2, 4, 6, 8, 10]
println(doubled)  // [2, 4, 6, 8, 10]
// map 실전 예시: 객체 변환
data class User(val name: String, val age: Int)
val users = listOf(
    User("홍길동", 25),
    User("김철수", 30)
)
val names = users.map { it.name }
println(names)  // [홍길동, 김철수]
// 3. forEach: 각 요소에 대해 작업 수행 (반환값 없음)
numbers.forEach { println(it) }
// 출력:
// 1
// 2
// 3
// 4
// 5
// forEach는 반환값이 없으므로 체이닝 불가
// map/filter와 달리 부수 효과(side effect)를 위해 사용
// 4. reduce: 배열을 하나의 값으로 축약
val sum = numbers.reduce { acc, num -> acc + num }
// acc: 누적값 (accumulator)
// num: 현재 요소
// 
// 동작:
// acc=1 (첫 요소), num=2 → 1+2=3
// acc=3, num=3 → 3+3=6
// acc=6, num=4 → 6+4=10
// acc=10, num=5 → 10+5=15
println(sum)  // 15
// fold: 초기값 지정 가능
val sum2 = numbers.fold(0) { acc, num -> acc + num }
// 0: 초기값
// reduce는 첫 요소가 초기값, fold는 명시적 초기값
// 실전 예시: 문자열 연결
val words = listOf("Hello", "World", "Kotlin")
val sentence = words.reduce { acc, word -> "$acc $word" }
println(sentence)  // Hello World Kotlin

함수의 마지막 인자가 람다이면 괄호 밖으로 뺄 수 있고(trailing lambda), 람다가 유일한 인자면 괄호 자체를 생략합니다. numbers.filter({ it % 2 == 0 })가 numbers.filter { it % 2 == 0 }로 쓰이는 이유입니다. 이 규칙 덕분에 고차 함수 호출이 if나 for 같은 제어문처럼 읽힙니다.

reduce와 fold의 차이는 빈 컬렉션에서 드러납니다. reduce는 첫 요소를 초기값으로 쓰므로 빈 리스트에서 UnsupportedOperationException: Empty collection can't be reduced.를 던집니다. 테스트 데이터에는 항상 값이 있다가 운영에서 필터 결과가 비는 순간 터지는 전형적인 버그라, 비어 있을 수 있는 목록에는 fold(0)이나 reduceOrNull을 씁니다. 또 reduce는 결과 타입이 요소 타입과 같아야 하지만 fold는 fold(StringBuilder()) { sb, n -> sb.append(n) }처럼 다른 타입으로 누적할 수 있습니다. 단순 합계와 문자열 연결은 각각 sum(), joinToString(" ")이 더 읽기 쉽습니다. 체이닝 예제:

val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val result = numbers
    .filter { it % 2 == 0 }      // 짝수만: [2, 4, 6, 8, 10]
    .map { it * it }             // 제곱: [4, 16, 36, 64, 100]
    .filter { it > 20 }          // 20 초과: [36, 64, 100]
    .sum()                       // 합계: 200
println(result)  // 200

체이닝은 읽기 쉽지만 List에 대한 filter, map은 단계마다 새 리스트를 만듭니다. 위 코드는 중간 리스트가 세 개 생깁니다. 요소가 수십 개라면 무시해도 되지만, 수십만 개거나 첫 몇 개만 필요한 경우(first { }, take(3))라면 numbers.asSequence()로 시작해 지연 평가하면 요소 하나씩 전체 단계를 통과시켜 중간 컬렉션을 만들지 않습니다. 반대로 작은 컬렉션에서는 Sequence의 오버헤드가 더 클 수 있으니 기본은 List, 큰 데이터나 조기 종료가 있을 때 Sequence로 기억하면 됩니다. 컬렉션 함수는 Kotlin 컬렉션에서 더 다룹니다.


함수를 받고 돌려주는 고차 함수

함수를 매개변수로

fun calculate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
    return operation(a, b)
}
val result1 = calculate(10, 5) { a, b -> a + b }  // 15
val result2 = calculate(10, 5) { a, b -> a * b }  // 50

operation: (Int, Int) -> Int는 “Int 두 개를 받아 Int를 돌려주는 함수”라는 타입입니다. calculate는 계산의 뼈대만 제공하고 실제 연산은 호출하는 쪽이 정합니다. 이미 있는 함수를 넘길 때는 calculate(10, 5, ::add)처럼 함수 참조(::)를 씁니다. 함수 타입도 nullable로 만들 수 있는데 ((Int) -> Int)?처럼 괄호로 감싸야 하고, 호출할 때는 operation?.invoke(a, b)로 씁니다.

함수를 반환

fun makeMultiplier(factor: Int): (Int) -> Int {
    return { number -> number * factor }
}
val double = makeMultiplier(2)
val triple = makeMultiplier(3)
println(double(5))  // 10
println(triple(5))  // 15

반환된 람다는 만들어질 때의 factor 값을 기억합니다(클로저). double과 triple은 같은 코드에서 나왔지만 각자 다른 factor를 품고 있습니다. Java 람다와 달리 Kotlin 람다는 바깥의 var 변수도 캡처해서 수정할 수 있는데, 컴파일러가 그 변수를 객체(Ref)로 감싸 공유하기 때문입니다. 편리하지만 여러 스레드에서 같은 람다를 실행하면 그 변수에 경쟁 상태가 생길 수 있습니다.


확장 함수

기본 확장

// String에 함수 추가
fun String.addExclamation() = "$this!"
println("Hello".addExclamation())  // Hello!
// Int에 함수 추가
fun Int.isEven() = this % 2 == 0
println(4.isEven())   // true
println(5.isEven())   // false

확장 함수 안의 this는 호출한 객체(수신 객체)입니다. 클래스를 고치지 않고 메서드처럼 호출할 수 있어서, 표준 라이브러리의 String.isBlank(), List.first() 같은 함수 대부분이 확장 함수로 구현되어 있습니다.

다만 진짜로 클래스에 메서드가 추가되는 것은 아닙니다. 컴파일하면 addExclamation(String receiver) 같은 정적 함수가 되므로 private 멤버에는 접근할 수 없고, 어떤 확장 함수가 호출될지는 변수의 선언 타입으로 컴파일 시점에 정해집니다. open class Shape와 class Circle : Shape()에 각각 fun Shape.name() = "Shape", fun Circle.name() = "Circle"을 정의해도 val s: Shape = Circle()에서 s.name()은 “Shape”를 돌려줍니다. 오버라이딩처럼 동작할 거라 기대하면 틀리는 부분입니다. 또 클래스에 같은 시그니처의 멤버 함수가 있으면 항상 멤버가 이기므로, 확장 함수는 호출되지 않고 경고만 뜹니다.

실전 확장 함수

// List 확장
fun <T> List<T>.secondOrNull(): T? {
    return if (size >= 2) this[1] else null
}
val list = listOf(1, 2, 3)
println(list.secondOrNull())  // 2
// String 확장
fun String.isValidEmail(): Boolean {
    return matches(Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"))
}
println("[email protected]".isValidEmail())  // true

secondOrNull처럼 제네릭 확장 함수는 <T>를 fun 뒤에 선언합니다. 표준 라이브러리의 getOrNull(1)이 같은 일을 하므로, 확장 함수를 만들기 전에 이미 있는 함수인지 찾아보는 것이 좋습니다. isValidEmail은 호출할 때마다 Regex 객체를 새로 만들어 정규식을 컴파일하므로, 자주 호출된다면 private val EMAIL_REGEX = Regex(...)로 한 번만 만들어 두세요. 이메일 정규식은 형식을 대략 걸러 내는 용도일 뿐이며, 실제로 존재하는 주소인지는 인증 메일로 확인해야 합니다.

확장 함수는 편하지만 프로젝트 전역에 흩어지면 “이 메서드가 어디서 왔지?”를 찾기 어려워집니다. 특정 기능 영역에서만 쓰는 확장은 해당 파일에 private으로 두거나, StringExtensions.kt처럼 모아 두는 규칙을 정해 두면 관리가 쉽습니다.


let, apply, also, run, with 스코프 함수

let

val name: String? = "홍길동"
name?.let {
    println("이름: $it")
    println("길이: ${it.length}")
}

다섯 스코프 함수는 두 가지 기준으로 나뉩니다.

함수블록 안의 객체반환값주 용도
letit블록 결과null 체크 후 변환
runthis블록 결과객체로 계산해 결과 얻기
withthis블록 결과확장 함수가 아닌 형태의 run
applythis객체 자신객체 설정
alsoit객체 자신로그·검증 같은 부수 작업

?.let은 name이 null이 아닐 때만 블록을 실행하고, 블록 안에서 it은 String?이 아니라 String으로 스마트 캐스트됩니다. if (name != null)과 같은 효과인데, var 프로퍼티처럼 다른 스레드가 바꿀 수 있어서 스마트 캐스트가 안 되는 경우에도 let은 값을 한 번 읽어 it에 고정하므로 안전하게 쓸 수 있습니다. name?.let { ... } ?: println("이름 없음")처럼 엘비스 연산자로 else를 붙이는 패턴도 흔한데, 블록의 마지막 식이 null을 반환하면 엘비스 쪽도 실행된다는 함정이 있어 분기가 중요하다면 if가 더 명확합니다.

apply

data class Person(var name: String = "", var age: Int = 0)
val person = Person().apply {
    name = "홍길동"
    age = 25
}

apply 블록 안에서는 this가 새 Person이라 name = ...이 this.name = ...으로 해석됩니다. 블록이 끝나면 설정이 끝난 객체 자신을 돌려주므로 생성과 초기화를 한 식으로 묶을 수 있습니다. Android의 Intent나 Paint처럼 setter가 많은 Java 객체를 설정할 때 특히 자주 씁니다.

this가 암묵적이라는 점이 함정이 되기도 합니다. 바깥 클래스에도 name 프로퍼티가 있으면 apply 안의 name은 가장 가까운 수신 객체인 Person의 것으로 해석되어, 바깥의 name을 쓰려 했다면 의도와 다르게 동작합니다. 스코프 함수를 중첩하면 이런 혼동이 더 커지므로, 바깥을 가리킬 때는 [email protected]처럼 레이블을 붙여 명시하세요.

also

val numbers = mutableListOf(1, 2, 3)
    .also { println("초기 리스트: $it") }
    .also { it.add(4) }
    .also { println("최종 리스트: $it") }

also는 객체를 it으로 받아 작업한 뒤 객체를 그대로 돌려주므로, 체인 중간에 로그를 끼워 넣어도 흐름이 바뀌지 않습니다. apply와 반환값은 같지만 it으로 받기 때문에 바깥의 this와 섞이지 않는다는 차이가 있습니다. “이 객체로 설정한다”면 apply, “이 객체를 가지고 추가로 무언가 한다”면 also라고 구분하면 읽는 사람이 의도를 파악하기 쉽습니다. 예제처럼 also { it.add(4) }로 리스트를 바꾸는 것은 가능하지만, also는 부수 작업용이라는 관례에 비추면 수정은 apply { add(4) }가 더 자연스럽습니다.

run

val result = "Hello".run {
    println(this)
    length
}
println(result)  // 5

with

val person = Person("홍길동", 25)
with(person) {
    println(name)
    println(age)
}

run과 with는 둘 다 this로 받고 블록 결과를 돌려주지만, run은 확장 함수라 person?.run { ... }처럼 안전 호출과 함께 쓸 수 있고 with는 일반 함수라 null 체크를 따로 해야 합니다. 그래서 nullable 객체에는 run, 이미 null이 아님이 확실한 객체의 여러 속성을 연달아 쓸 때는 with가 잘 어울립니다.

스코프 함수는 코드를 짧게 만들지만 남용하면 오히려 읽기 어렵습니다. a?.let { b -> b.c?.also { ... }?.run { ... } }처럼 세 단계 이상 중첩되면 it과 this가 무엇인지 매번 따져야 해서, 코드 리뷰에서 가장 자주 지적받는 Kotlin 코드 유형 중 하나입니다. 지역 변수에 이름을 붙이는 평범한 코드가 더 명확하다면 그쪽을 택하는 것이 좋습니다.


inline 함수

inline fun measureTime(block: () -> Unit) {
    val start = System.currentTimeMillis()
    block()
    val end = System.currentTimeMillis()
    println("실행 시간: ${end - start}ms")
}
measureTime {
    Thread.sleep(1000)
}

람다를 인자로 넘기면 JVM에서는 보통 함수 객체가 만들어지고, 바깥 변수를 캡처한 람다라면 호출할 때마다 새 객체가 할당됩니다. inline을 붙이면 컴파일러가 measureTime의 본문과 람다의 본문을 호출 지점에 그대로 복사해 넣으므로 함수 객체와 가상 호출이 사라집니다. filter, map, let, apply 같은 표준 라이브러리 고차 함수가 모두 inline인 이유입니다.

인라인의 부수 효과로 람다 안에서 바깥 함수의 return(non-local return)을 쓸 수 있습니다. list.forEach { if (it == 0) return }이 forEach가 아니라 감싸는 함수 전체에서 빠져나가는 것이 이 때문이라, forEach 안에서 continue처럼 다음 요소로 넘어가려다 함수가 끝나 버리는 실수가 흔합니다. 다음 요소로 넘어가려면 return@forEach를 씁니다. 람다를 다른 객체에 저장하거나 다른 스레드에서 실행해야 한다면 인라인할 수 없으므로 noinline이나 crossinline을 붙여야 합니다.

inline은 본문이 호출 지점마다 복사되어 바이트코드 크기가 늘어나므로, 람다를 받는 작은 함수에만 붙이는 것이 원칙입니다. 람다 매개변수가 없는 함수에 붙이면 효과가 거의 없어 IDE가 경고를 띄웁니다. 참고로 실행 시간 측정은 표준 라이브러리에 measureTimeMillis { }와 kotlin.time.measureTime { }이 이미 있으며, 예제처럼 같은 이름의 함수를 직접 정의하면 import에 따라 어느 쪽이 호출될지 헷갈릴 수 있습니다. 경과 시간을 잴 때는 시스템 시계 변경에 영향을 받는 currentTimeMillis()보다 System.nanoTime() 기반의 표준 함수가 정확합니다.


함수형 파이프라인과 DSL 스타일 예제

예제 1: 함수형 프로그래밍

data class User(val name: String, val age: Int, val city: String)
fun main() {
    val users = listOf(
        User("홍길동", 25, "서울"),
        User("김철수", 30, "부산"),
        User("이영희", 28, "서울"),
        User("박민수", 35, "서울")
    )
    
    // 서울에 사는 30세 미만 사용자
    val result = users
        .filter { it.city == "서울" }
        .filter { it.age < 30 }
        .map { it.name }
    
    println(result)  // [홍길동, 이영희]
}

예제 2: DSL 스타일

class HTML {
    private val content = StringBuilder()
    
    fun head(block: () -> Unit) {
        content.append("<head>")
        block()
        content.append("</head>")
    }
    
    fun body(block: () -> Unit) {
        content.append("<body>")
        block()
        content.append("</body>")
    }
    
    override fun toString() = content.toString()
}
fun html(block: HTML.() -> Unit): HTML {
    return HTML().apply(block)
}
val page = html {
    head { }
    body { }
}

DSL의 핵심은 html의 매개변수 타입 HTML.() -> Unit입니다. 이것은 수신 객체 지정 람다(lambda with receiver)로, 람다 안에서 this가 HTML 객체가 되어 head { }를 this.head { }처럼 호출할 수 있습니다. HTML().apply(block)이 바로 이 람다를 새 객체를 수신 객체로 삼아 실행하는 코드이며, apply의 매개변수도 T.() -> Unit입니다. Gradle Kotlin DSL, Ktor 라우팅, Jetpack Compose가 모두 같은 원리로 동작합니다.

이 예제의 head와 body는 () -> Unit을 받기 때문에 블록 안에서 태그를 더 추가할 방법이 없습니다. 실제 HTML 빌더라면 Head, Body 클래스를 따로 두고 fun body(block: Body.() -> Unit)처럼 각 단계에도 수신 객체 지정 람다를 써서 안에서 쓸 수 있는 함수를 제한합니다. 그런데 이렇게 중첩하면 안쪽 블록에서도 바깥 수신 객체의 함수가 보여서 body { head { } }처럼 잘못된 구조가 컴파일됩니다. @DslMarker 어노테이션을 붙인 마커로 클래스들을 표시하면 바깥 수신 객체의 암묵적 호출을 막아 이런 실수를 컴파일 오류로 잡을 수 있습니다.


함수 문법 요약

  1. 함수: fun, 표현식 함수, 기본 매개변수
  2. 람다: { a, b -> a + b }, it
  3. 고차 함수: 함수를 매개변수/반환
  4. 확장 함수: 기존 클래스에 함수 추가
  5. 스코프 함수: let, apply, also, run, with

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. let, apply, also, run, with 중 어떤 것을 언제 쓰나요?

A. 두 가지 기준으로 고르면 됩니다. 블록 안에서 객체를 it으로 받는지 this로 받는지, 그리고 블록의 결과를 반환하는지 객체 자신을 반환하는지입니다. null 체크 뒤 변환은 ?.let, 객체를 만들면서 속성을 설정할 때는 apply, 로그처럼 객체를 그대로 두고 부수 작업만 할 때는 also, 객체로 계산해 결과를 얻을 때는 run이나 with를 쓰는 것이 일반적입니다. 여러 개를 중첩하면 it과 this가 무엇을 가리키는지 헷갈리므로 한두 단계까지만 쓰는 편이 읽기 좋습니다.