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를 언제 작성해야 하는지 알게 될 것입니다.