Kotlin의 고차 함수와 람다

1. 개요

Kotlin에서 함수는 “일급 시민”입니다. 함수를 변수에 할당하고, 매개변수로 전달하고, 다른 함수에서 반환할 수 있습니다. 다른 함수를 받거나 반환하는 함수를 고차 함수라고 합니다. 이를 작성하는 가장 간단한 방법은 람다를 사용하는 것입니다.

이 글에서는 함수 타입, 람다 구문, 함수 참조, 클로저, 람다 with receiver, 그리고 inline 키워드를 다룹니다. 이 글을 읽은 후에는 고차 함수를 직접 작성할 수 있을 것입니다. 자주 발생하는 몇 가지 실수를 피하는 방법도 알게 될 것입니다.

2. 함수 타입

Kotlin의 모든 함수는 함수 타입이라는 타입을 가집니다. 구문은 괄호 안의 매개변수 타입 목록, 화살표, 그리고 반환 타입으로 이루어집니다:

fun main() {
    val add: (Int, Int) -> Int = { a, b -> a + b }
    val greet: (String) -> Unit = { name -> println("안녕하세요, $name") }
    val now: () -> Long = { System.currentTimeMillis() }

    println(add(2, 3))
    greet("Lan")
    println(now() > 0)
}

설명:

  • (Int, Int) -> Int는 두 개의 Int를 받아서 Int를 반환하는 함수입니다.
  • (String) -> Unit은 의미 있는 값을 반환하지 않는 함수이며, Java의 void와 비슷합니다.
  • () -> Long은 매개변수가 없는 함수입니다.
  • 일반 함수처럼 함수 변수를 호출합니다: add(2, 3). 이는 add.invoke(2, 3)과 동일합니다.
5
안녕하세요, Lan
true

함수 타입은 nullable일 수도 있습니다. 이 경우 전체 타입을 괄호로 감싸야 합니다: ((Order) -> Unit)?. 안전하게 호출하려면 callback?.invoke(order)를 사용합니다.

3. 첫 번째 고차 함수 작성하기

전체 글에서 사용할 간단한 모델을 만들겠습니다. 금액은 부동소수점 오류를 피하기 위해 Long 타입(단위: 원)으로 사용합니다:

data class Order(val id: String, val customer: String, val amount: Long)

fun calculateTotal(orders: List<Order>, priceOf: (Order) -> Long): Long {
    var total = 0L
    for (order in orders) {
        total += priceOf(order)
    }
    return total
}

fun main() {
    val orders = listOf(
        Order("A1", "Lan", 100_000),
        Order("A2", "Minh", 250_000),
        Order("A3", "Lan", 50_000),
    )

    val full = calculateTotal(orders) { it.amount }
    val discounted = calculateTotal(orders) { it.amount * 90 / 100 }

    println(full)
    println(discounted)
}

calculateTotal은 고차 함수입니다. priceOf 매개변수가 (Order) -> Long 타입이기 때문입니다. 이 함수는 각 주문의 가격을 계산하는 방법을 알지 못합니다. 호출자에게 그것을 맡깁니다. 덕분에 같은 루프를 원가와 할인된 가격 모두에 사용할 수 있습니다.

400000
360000

4. 람다 구문

4.1. 다양한 작성 방법

람다는 항상 {}로 감싸져 있습니다. 매개변수는 -> 앞에, 함수 본문은 뒤에 위치합니다. 마지막 식이 반환값이므로 return을 쓸 필요가 없습니다:

val isLarge: (Order) -> Boolean = { order ->
    val threshold = 200_000
    order.amount > threshold   // 반환값
}

val isLargeShort: (Order) -> Boolean = { it.amount > 200_000 }

val ignoreSecond: (Order, Int) -> String = { order, _ -> order.id }
  • 람다가 하나의 매개변수만 있을 때, 선언을 생략하고 암시적 이름 it을 사용할 수 있습니다.
  • 사용하지 않는 매개변수는 _로 지정하여 코드를 명확히 하고 컴파일러 경고를 피합니다.

4.2. Trailing 람다

함수의 마지막 매개변수가 함수 타입이면, 람다를 괄호 밖에 위치시킬 수 있습니다. 그것이 유일한 매개변수라면 ()를 생략할 수 있습니다:

val orders = listOf(
    Order("A1", "Lan", 100_000),
    Order("A2", "Minh", 250_000),
)

val large = orders.filter { it.amount > 200_000 }
val total = calculateTotal(orders) { it.amount }
val sorted = orders.sortedByDescending { it.amount }.map { it.id }

println(large.map { it.id })
println(sorted)
[A2]
[A2, A1]

이것이 Kotlin 컬렉션 API 코드가 자연어처럼 읽히는 이유입니다. 표준 라이브러리의 모든 filter, map, sortedByDescending은 고차 함수입니다.

5. 함수 참조

올바른 시그니처를 가진 함수가 이미 있다면, 람다로 감싸지 않아도 됩니다. ::를 사용하세요:

fun isVip(order: Order): Boolean = order.amount >= 200_000

class TaxService(private val ratePercent: Long) {
    fun withTax(order: Order): Long = order.amount * (100 + ratePercent) / 100
}

fun main() {
    val orders = listOf(Order("A1", "Lan", 100_000), Order("A2", "Minh", 250_000))
    val taxService = TaxService(10)

    println(orders.filter(::isVip).map { it.id })   // top-level 함수
    println(orders.map(Order::amount))              // 속성 참조
    println(calculateTotal(orders, taxService::withTax)) // 바운드 참조
}
  • ::isVip는 top-level 함수를 참조합니다.
  • Order::amount는 속성을 참조합니다. 타입은 (Order) -> Long입니다.
  • taxService::withTax는 바운드 참조입니다: 함수가 이미 taxService 객체에 바인딩되어 있습니다.
[A2]
[100000, 250000]
385000

함수 참조는 함수 이름이 이미 의도를 명확히 할 때 람다보다 읽기 쉬운 경우가 많습니다.

6. 함수 반환과 클로저

6.1. 함수를 반환하는 함수

고차 함수는 함수를 반환할 수도 있습니다. 이는 구성에 따라 동작을 생성할 때 유용합니다:

fun discountFor(level: String): (Long) -> Long {
    val percent = when (level) {
        "GOLD" -> 80
        "SILVER" -> 90
        else -> 100
    }
    return { amount -> amount * percent / 100 }
}

fun main() {
    val goldPrice = discountFor("GOLD")
    val normalPrice = discountFor("NONE")

    println(goldPrice(500_000))
    println(normalPrice(500_000))
}
400000
500000

반환된 람다는 discountFor가 실행을 마친 후에도 percent 변수를 “기억합니다”. 이 메커니즘을 클로저라고 합니다.

주의: when 내부에서 "GOLD" -> { amount -> amount * 80 / 100 }을 쓰지 마세요. 그 위치에서 {}는 블록으로 해석되지, 람다가 아니므로 컴파일되지 않습니다. 값을 먼저 계산한 후 위의 예제처럼 람다를 반환하세요. 또는 람다를 괄호로 감싸도 됩니다: ({ amount -> ... }).

6.2. 클로저가 변수를 수정할 수 있습니다

Java의 람다와 다르게, Kotlin 람다는 캡처한 var 변수를 수정할 수 있습니다:

fun orderIdGenerator(prefix: String): () -> String {
    var next = 1
    return { "$prefix-${next++}" }
}

fun main() {
    val nextId = orderIdGenerator("ORD")
    println(nextId())
    println(nextId())
    println(nextId())
}
ORD-1
ORD-2
ORD-3

컴파일러는 next를 Ref 같은 참조 객체로 래핑하여 람다와 외부 함수가 같은 값을 공유할 수 있도록 합니다.

주의: 클로저가 공유 변수를 수정하는 것은 thread-safe하지 않습니다. 여러 스레드가 동시에 nextId()를 호출하면 중복된 ID를 받을 수 있습니다. 이 경우 AtomicInteger를 사용하세요.

7. Lambda with receiver

Kotlin은 특별한 함수 타입이 있습니다: T.() -> R. 이 람다 내에서 this는 타입 T의 객체입니다. 접두사 없이 그 멤버에 직접 접근할 수 있습니다. 이것이 buildString, apply, 또는 Gradle Kotlin DSL 같은 DSL의 기초입니다:

class OrderBuilder {
    var id: String = ""
    var customer: String = ""
    var amount: Long = 0

    fun build(): Order = Order(id, customer, amount)
}

fun order(block: OrderBuilder.() -> Unit): Order =
    OrderBuilder().apply(block).build()

fun main() {
    val o = order {
        id = "B1"
        customer = "Hoa"
        amount = 120_000
    }
    println(o)
}
Order(id=B1, customer=Hoa, amount=120000)

block의 타입은 OrderBuilder.() -> Unit입니다. 따라서 람다 내에서 id = "B1"은 실제로 this.id = "B1"입니다. apply(block)은 방금 생성한 빌더와 함께 람다를 호출한 후 그 빌더 자체를 반환합니다.

8. inline과 람다 내 return

8.1. 람다의 비용

JVM에서 각 람다는 일반적으로 객체로 컴파일됩니다. 람다가 변수를 캡처하면 호출할 때마다 새 객체가 생성될 수 있습니다. 자주 호출되는 함수의 경우 이 비용이 누적됩니다.

함수를 inline으로 표시하면 이 문제가 해결됩니다. 컴파일러는 함수 본문과 람다 본문을 호출 위치에 직접 복사하므로 객체가 생성되지 않습니다:

inline fun <T> measure(label: String, block: () -> T): T {
    val start = System.nanoTime()
    val result = block()
    println("$label: ${(System.nanoTime() - start) / 1_000} µs")
    return result
}

fun main() {
    val orders = (1..1_000).map { Order("O$it", "C$it", it * 1_000L) }
    val total = measure("합계 계산") { calculateTotal(orders) { it.amount } }
    println(total)
}
합계 계산: 412 µs
500500000

측정된 시간은 각 머신마다 다를 것입니다. 표준 라이브러리의 filter, map, forEach는 모두 inline입니다.

8.2. Non-local return과 레이블

inline 함수로 전달된 람다 내에서 일반 return 문은 외부 함수를 빠져나갑니다, 단지 람다만이 아닙니다:

fun findFirstLarge(orders: List<Order>, threshold: Long): Order? {
    orders.forEach {
        if (it.amount > threshold) return it   // findFirstLarge를 빠져나감
    }
    return null
}

fun printPaidOrders(orders: List<Order>) {
    orders.forEach {
        if (it.amount == 0L) return@forEach    // 현재 요소만 건너뜀
        println(it.id)
    }
    println("완료")
}
  • return it은 non-local return입니다. 즉시 findFirstLarge를 종료합니다.
  • return@forEach는 레이블이 있는 return입니다. 현재 람다 실행을 종료할 뿐이며, continue처럼 작동합니다.

inline이 아닌 함수의 경우 람다 내 일반 return은 컴파일러 오류가 발생합니다. 반드시 레이블을 사용해야 합니다.

주의: inline 함수가 람다를 다른 컨텍스트로 전달하면, 예를 들어 다른 스레드에서 실행하면, 매개변수를 crossinline으로 표시하여 non-local return을 금지하세요. 람다를 변수나 필드에 저장하려면 noinline을 사용하세요.

9. 고차 함수 테스트하기

테스트 의존성을 pom.xml에 추가하세요. 프로젝트에 kotlin-maven-plugin이 이미 설정되어 있어야 합니다:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.25.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

고차 함수는 테스트하기 매우 쉽습니다. 간단한 람다를 전달하고 결과를 확인하면 됩니다:

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class PricingTest {

    private val orders = listOf(
        Order("A1", "Lan", 100_000),
        Order("A2", "Minh", 250_000),
        Order("A3", "Lan", 50_000),
    )

    @Test
    fun `calculateTotal applies the given price function`() {
        assertThat(calculateTotal(orders) { it.amount }).isEqualTo(400_000L)
        assertThat(calculateTotal(orders) { 1L }).isEqualTo(3L)
    }

    @Test
    fun `discountFor returns function matching customer level`() {
        assertThat(discountFor("GOLD")(500_000)).isEqualTo(400_000L)
        assertThat(discountFor("SILVER")(500_000)).isEqualTo(450_000L)
        assertThat(discountFor("UNKNOWN")(500_000)).isEqualTo(500_000L)
    }

    @Test
    fun `generator keeps its own state per closure`() {
        val first = orderIdGenerator("ORD")
        val second = orderIdGenerator("INV")

        assertThat(listOf(first(), first(), second()))
            .containsExactly("ORD-1", "ORD-2", "INV-1")
    }
}

세 번째 테스트는 중요한 점을 확인합니다: orderIdGenerator를 호출할 때마다 자신의 next 변수를 가진 별도의 클로저가 생성됩니다.

10. 흔한 실수와 사용하면 안 되는 경우

  • 너무 긴 람다. 람다 본문이 몇 줄을 초과하면 이를 이름 있는 함수로 추출한 후 ::로 전달하세요. 코드를 읽고 테스트하기가 더 쉬워집니다.
  • 중첩된 it 남용. orders.map { it.items.filter { it.price > 0 } }를 작성하면 내부 it이 외부 it을 가립니다. 매개변수에 명확한 이름을 지정하세요.
  • 모든 것을 inline으로 만들기. 람다 매개변수를 가진 작은 함수만 inline으로 만드세요. 큰 inline 함수는 bytecode를 부풀립니다. 람다가 없는 inline 함수는 거의 도움이 되지 않으며 컴파일러가 경고합니다.
  • return 혼동. forEach 내 일반 return은 외부 함수를 빠져나갑니다. 이것이 많은 까다로운 버그의 원인입니다. 요소를 건너뛰려면 return@forEach를 사용하세요.
  • 무거운 객체 캡처. 오래 보관되는 람다(예: 리스너)는 캡처하는 모든 변수에 대한 참조를 유지합니다. 이는 메모리 누수를 야기할 수 있습니다. 예를 들어 Android에서 Activity 전체를 유지하는 경우입니다.
  • 불필요한 추상화. 처리 방법이 하나뿐이고 변경되지 않는다면 일반 함수로 충분합니다. 단지 “미래를 대비하기 위해” 람다 매개변수를 추가하지 마세요.

11. 결론

이 글에서 함수 타입이 함수의 시그니처를 설명하는 방법을 배웠습니다. 람다와 함수 참조는 함수 값을 생성하는 두 가지 방법입니다. 고차 함수는 “틀”을 “동작”에서 분리하는 함수를 받거나 반환합니다. 클로저, DSL을 구축하는 람다 with receiver, 객체 비용을 제거하는 inline, 그리고 non-local return이 작동하는 방식도 배웠습니다.

이 개념들을 숙달하면 Kotlin 표준 라이브러리를 훨씬 더 쉽게 읽을 수 있습니다. 또한 자신의 코드에 고차 함수 API를 언제 작성해야 하는지 알게 될 것입니다.

kotlinlambdahigher-order functionfunctional programming