함수도 값처럼 다룰 수 있다
앞 장까지는 클래스를 만들고, 인터페이스로 역할을 나누고, 다형성으로 코드를 확장하는 방법을 배웠습니다. 이제는 다른 방향의 확장을 다룹니다. 바로 함수를 값처럼 전달하는 방식입니다.
코틀린에서는 함수를 변수에 담을 수 있습니다. 함수에 함수를 넘길 수도 있고, 함수가 함수를 반환할 수도 있습니다. 이 기능을 이해하면 코드가 훨씬 짧아지고, 조건이나 동작을 바깥에서 주입하는 구조를 만들 수 있습니다.
예를 들어 숫자 목록에서 짝수만 고르는 코드는 다음처럼 쓸 수 있습니다.
val numbers = listOf(1, 2, 3, 4, 5, 6)
val evenNumbers = numbers.filter { it % 2 == 0 }
println(evenNumbers) // [2, 4, 6]여기서 { it % 2 == 0 }이 람다입니다. filter는 각 원소를 검사하는 함수를 매개변수로 받습니다. 코틀린의 간결한 표현력은 이런 람다와 고차 함수를 이해할 때 본격적으로 드러납니다.

람다 표현식의 기본 문법
람다 표현식은 이름 없는 함수입니다. 보통 한 번 쓰고 버릴 작은 동작을 표현할 때 사용합니다.
기본 형태는 다음과 같습니다.
{ 매개변수 -> 본문 }예를 들어 정수를 하나 받아 두 배로 만드는 람다는 다음과 같습니다.
val double: (Int) -> Int = { number -> number * 2 }
println(double(10)) // 20double 변수의 타입은 (Int) -> Int입니다.
이 뜻은 다음과 같습니다.
Int하나를 받아서Int하나를 반환하는 함수
람다의 마지막 식이 반환값이 됩니다.
val isAdult: (Int) -> Boolean = { age ->
age >= 20
}위 람다는 age >= 20의 결과인 Boolean 값을 반환합니다. 람다 안에서 마지막 줄의 값이 곧 결과가 된다는 점을 기억하면 됩니다.
it 키워드를 사용하는 상황
람다의 매개변수가 하나뿐이고, 그 타입을 컴파일러가 알 수 있다면 매개변수 이름을 생략할 수 있습니다. 이때 코틀린은 기본 이름으로 it을 제공합니다.
val numbers = listOf(1, 2, 3, 4, 5)
val doubled = numbers.map { it * 2 }
val greaterThanThree = numbers.filter { it > 3 }it은 "현재 처리 중인 값"이라고 생각하면 쉽습니다.
위 코드는 다음 코드와 같습니다.
val doubled = numbers.map { number -> number * 2 }
val greaterThanThree = numbers.filter { number -> number > 3 }간단한 조건이나 변환에는 it이 편합니다. 하지만 항상 좋은 것은 아닙니다.
다음과 같은 경우에는 매개변수 이름을 직접 쓰는 편이 더 읽기 쉽습니다.
val users = listOf("kim", "lee", "park")
val result = users.map { name ->
name.uppercase()
}특히 람다가 중첩되면 it이 무엇을 가리키는지 헷갈릴 수 있습니다.
val groups = listOf(
listOf("a", "bb"),
listOf("ccc", "dddd")
)
val lengths = groups.map { group ->
group.map { word -> word.length }
}이 코드에서 바깥 람다의 값은 group, 안쪽 람다의 값은 word입니다. 둘 다 it으로 쓰면 짧아지지만, 의미는 흐려집니다.
정리하면 다음과 같습니다.
람다가 짧고 매개변수가 하나뿐이면
it을 사용한다.람다 본문이 길거나 중첩되면 이름을 붙인다.
이름을 붙이면 코드가 조금 길어지지만 의도가 분명해진다.
람다를 마지막 인자로 넘기기
코틀린에서는 함수의 마지막 매개변수가 람다라면 괄호 밖으로 뺄 수 있습니다. 이를 trailing lambda라고 부릅니다.
fun repeatTask(times: Int, task: () -> Unit) {
for (i in 1..times) {
task()
}
}이 함수는 정수와 함수를 받습니다. task의 타입은 () -> Unit입니다. 매개변수 없이 실행되고, 특별한 값을 반환하지 않는 함수라는 뜻입니다.
호출할 때는 이렇게 쓸 수 있습니다.
repeatTask(3) {
println("Hello")
}원래 형태는 다음과 같습니다.
repeatTask(3, {
println("Hello")
})하지만 람다를 괄호 밖으로 빼면 코드가 더 자연스럽습니다. 코틀린 표준 라이브러리의 let, run, apply, also, filter, map, forEach 같은 함수들이 이런 스타일을 자주 사용합니다.
고차 함수란 무엇인가
고차 함수는 함수를 매개변수로 받거나, 함수를 반환하는 함수입니다.
먼저 함수를 매개변수로 받는 예를 보겠습니다.
fun calculate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}
val sum = calculate(10, 5) { x, y -> x + y }
val difference = calculate(10, 5) { x, y -> x - y }
val product = calculate(10, 5) { x, y -> x * y }
println(sum) // 15
println(difference) // 5
println(product) // 50calculate 함수는 계산 방식을 직접 정하지 않습니다. 대신 operation이라는 함수를 받아서 실행합니다.
이 구조의 장점은 명확합니다.
계산의 흐름은 calculate가 담당하고, 실제 계산 방식은 호출하는 쪽에서 결정합니다. 즉, 변하는 부분을 함수로 분리한 것입니다.
이번에는 함수를 반환하는 예를 보겠습니다.
fun discountPolicy(isMember: Boolean): (Int) -> Int {
return if (isMember) {
{ price -> price - 1000 }
} else {
{ price -> price }
}
}
val memberDiscount = discountPolicy(true)
val normalDiscount = discountPolicy(false)
println(memberDiscount(10000)) // 9000
println(normalDiscount(10000)) // 10000discountPolicy는 Boolean 값을 받아서 할인 함수를 반환합니다. 반환된 함수는 나중에 가격을 받아 실제 금액을 계산합니다.
이처럼 함수를 값처럼 다루면 조건, 동작, 정책을 코드 밖에서 유연하게 조립할 수 있습니다.
함수 타입 읽는 법
람다와 고차 함수를 제대로 쓰려면 함수 타입을 읽을 수 있어야 합니다.
(Int) -> String이 타입은 Int를 받아 String을 반환하는 함수입니다.
(Int, Int) -> Int이 타입은 Int 두 개를 받아 Int를 반환하는 함수입니다.
() -> Unit이 타입은 아무 값도 받지 않고, 의미 있는 반환값도 없는 함수입니다. Unit은 자바의 void와 비슷하지만, 코틀린에서는 하나의 타입으로 다루어집니다.
함수 타입도 nullable로 만들 수 있습니다.
var onClick: (() -> Unit)? = null
onClick = {
println("Clicked")
}
onClick?.invoke()onClick은 함수이거나 null일 수 있습니다. nullable 함수 타입을 호출할 때는 ?.invoke()를 사용하면 안전합니다.
매개변수 이름을 타입에 함께 적을 수도 있습니다.
val login: (id: String, password: String) -> Boolean이름은 타입 검사에 영향을 주지 않습니다. 하지만 코드를 읽는 사람에게 어떤 값이 들어오는지 알려 줍니다.
람다, 익명 함수, 함수 참조의 차이
코틀린에서 함수를 값처럼 전달하는 방법은 하나만 있는 것이 아닙니다. 대표적으로 세 가지가 있습니다.
람다 표현식
익명 함수
함수 참조
각각의 쓰임이 조금씩 다릅니다.
람다 표현식
람다는 가장 자주 쓰는 방식입니다.
val square: (Int) -> Int = { number -> number * number }짧은 동작을 전달할 때 적합합니다.
val names = listOf("kim", "lee", "park")
val upperNames = names.map { it.uppercase() }람다는 간결합니다. 대신 본문이 길어지면 가독성이 떨어질 수 있습니다. 람다 안에서 여러 일을 처리하기 시작하면 별도 함수로 분리하는 편이 좋습니다.
익명 함수
익명 함수는 이름이 없는 함수입니다. 람다와 비슷하지만 fun 키워드를 사용합니다.
val divide = fun(a: Int, b: Int): Int {
return a / b
}
println(divide(10, 2)) // 5익명 함수는 반환 타입을 명시하기 쉽고, return의 의미가 분명합니다. 익명 함수 안의 return은 그 익명 함수만 종료합니다.
val checker = fun(number: Int): Boolean {
if (number < 0) return false
return number % 2 == 0
}복잡한 조건이 있고 중간에 반환해야 한다면 람다보다 익명 함수가 읽기 쉬울 수 있습니다.
함수 참조
이미 존재하는 함수를 그대로 전달할 때는 함수 참조를 사용할 수 있습니다.
fun isEven(number: Int): Boolean {
return number % 2 == 0
}
val numbers = listOf(1, 2, 3, 4, 5, 6)
val evenNumbers = numbers.filter(::isEven)
println(evenNumbers) // [2, 4, 6]::isEven은 isEven 함수를 값처럼 가리킵니다.
클래스의 멤버나 프로퍼티도 참조할 수 있습니다.
data class User(val name: String, val age: Int)
val users = listOf(
User("Kim", 30),
User("Lee", 17),
User("Park", 22)
)
val names = users.map(User::name)
println(names) // [Kim, Lee, Park]함수 참조는 이미 만들어 둔 함수나 프로퍼티를 그대로 전달할 때 좋습니다. 반대로 전달하기 전에 값을 조금 바꾸거나 조건을 덧붙여야 한다면 람다가 더 적합합니다.
val adultNames = users
.filter { it.age >= 20 }
.map { it.name.uppercase() }선택 기준은 간단합니다.
짧게 즉석에서 동작을 만들면 람다
반환 타입과
return흐름을 분명히 하고 싶으면 익명 함수이미 있는 함수를 그대로 넘기면 함수 참조
클로저: 람다가 바깥 변수를 기억하는 방식
람다는 자신이 선언된 위치의 바깥 변수를 사용할 수 있습니다. 이렇게 람다가 외부 변수를 함께 붙잡는 것을 클로저라고 합니다.
var count = 0
val increase = {
count++
}
increase()
increase()
println(count) // 2increase 람다는 바깥에 있는 count를 사용합니다. 이때 count의 값만 복사하는 것이 아니라, 변수 자체를 참조합니다. 그래서 람다 안에서 값을 바꾸면 바깥의 count도 바뀝니다.
클로저는 유용합니다. 상태를 기억하는 작은 함수를 만들 수 있기 때문입니다.
fun counter(): () -> Int {
var count = 0
return {
count++
count
}
}
val next = counter()
println(next()) // 1
println(next()) // 2
println(next()) // 3counter 함수가 끝난 뒤에도 반환된 람다는 count를 기억합니다.
하지만 주의할 점도 있습니다. 람다가 외부의 mutable 변수, 즉 변경 가능한 변수를 많이 사용하면 코드의 흐름을 추적하기 어려워집니다.
var total = 0
listOf(1, 2, 3, 4).forEach {
total += it
}
println(total) // 10이 코드는 동작하지만, 가능하다면 값을 직접 변경하는 방식보다 결과를 반환하는 함수를 사용하는 편이 더 안전합니다.
val total = listOf(1, 2, 3, 4).sum()람다 안에서 바깥 변수를 바꾸는 코드는 다음 경우에 특히 조심해야 합니다.
람다가 나중에 실행될 때
여러 곳에서 같은 변수를 함께 사용할 때
비동기 코드나 여러 스레드에서 실행될 때
반복문 안에서 람다를 만들고 나중에 실행할 때
핵심은 단순합니다. 클로저는 편리하지만 숨은 상태를 만들 수 있습니다. 람다가 외부 값을 읽기만 하는 것은 비교적 안전합니다. 외부 값을 변경하기 시작하면 코드의 예측 가능성이 낮아집니다.
inline 함수가 필요한 이유
고차 함수는 함수를 값처럼 전달합니다. 이때 람다는 내부적으로 객체처럼 다루어질 수 있고, 호출 과정도 한 단계 추가될 수 있습니다. 대부분의 일반적인 코드에서는 큰 문제가 되지 않습니다. 하지만 아주 자주 호출되는 작은 고차 함수라면 비용이 신경 쓰일 수 있습니다.
이때 코틀린은 inline 함수를 제공합니다.
inline fun measure(block: () -> Unit) {
val start = System.currentTimeMillis()
block()
val end = System.currentTimeMillis()
println("실행 시간: ${end - start}ms")
}
measure {
println("작업 실행")
}inline 함수는 호출 지점에 함수 본문과 람다 본문을 펼쳐 넣는 방식으로 컴파일될 수 있습니다. 쉽게 말해, 함수 호출을 줄이고 람다 전달 비용을 줄이는 데 도움이 됩니다.
하지만 모든 함수에 inline을 붙이면 좋은 것은 아닙니다.
inline은 다음과 같은 경우에 어울립니다.
람다를 매개변수로 받는 작은 함수
자주 호출되는 유틸리티 함수
람다 호출 비용을 줄이고 싶은 경우
비지역 반환이 필요한 경우
반대로 함수 본문이 크거나 람다를 받지 않는 함수라면 inline의 이점이 작습니다. 오히려 호출 지점마다 코드가 늘어나 결과물이 커질 수 있습니다.
inline과 비지역 반환
inline 함수는 성능뿐 아니라 return의 동작에도 영향을 줍니다.
일반 람다 안에서는 바깥 함수를 직접 return할 수 없습니다. 하지만 인라인 함수에 전달된 람다에서는 바깥 함수를 종료하는 return이 가능합니다. 이를 비지역 반환이라고 합니다.
대표적인 예가 forEach입니다. forEach는 인라인 함수입니다.
fun findFirstEven(numbers: List<Int>): Int? {
numbers.forEach {
if (it % 2 == 0) return it
}
return null
}
println(findFirstEven(listOf(1, 3, 5, 8, 9))) // 8여기서 return it은 람다만 종료하는 것이 아닙니다. findFirstEven 함수 전체를 종료합니다.
이 동작은 편리하지만, 처음 보면 헷갈릴 수 있습니다. 람다만 빠져나가고 싶다면 라벨을 붙인 반환을 사용합니다.
fun printOnlyOdd(numbers: List<Int>) {
numbers.forEach {
if (it % 2 == 0) return@forEach
println(it)
}
}return@forEach는 현재 forEach 람다 실행만 건너뜁니다. 반복문의 continue와 비슷하게 볼 수 있습니다.
익명 함수에서는 return이 항상 익명 함수 자신만 종료합니다.
fun printOnlyOdd(numbers: List<Int>) {
numbers.forEach(fun(number) {
if (number % 2 == 0) return
println(number)
})
}이 코드는 짝수일 때 익명 함수만 종료하고, 다음 원소 처리를 계속합니다.
noinline과 crossinline
인라인 함수의 람다 매개변수는 기본적으로 함께 인라인됩니다. 하지만 모든 람다를 인라인할 수 있는 것은 아닙니다.
람다를 다른 변수에 저장하거나, 다른 함수에 그대로 전달해야 한다면 noinline을 붙일 수 있습니다.
inline fun runTasks(
first: () -> Unit,
noinline second: () -> Unit
) {
first()
executeLater(second)
}
fun executeLater(task: () -> Unit) {
task()
}first는 인라인될 수 있지만, second는 다른 함수에 전달되므로 noinline으로 표시합니다.
또 하나는 crossinline입니다. 인라인 람다를 나중에 실행하는 형태로 감쌀 때 비지역 반환을 막아야 할 수 있습니다.
inline fun runSafely(crossinline block: () -> Unit) {
val task = Runnable {
block()
}
task.run()
}crossinline을 붙이면 그 람다 안에서 바깥 함수를 종료하는 비지역 반환을 할 수 없습니다. 람다가 다른 실행 흐름 안에서 호출될 때 안전성을 지키기 위한 장치라고 이해하면 됩니다.
처음부터 noinline, crossinline을 깊게 외울 필요는 없습니다. 다만 inline 함수에서 람다를 저장하거나 나중에 실행하려 하면 이런 키워드가 필요할 수 있다는 정도는 기억해 두면 좋습니다.
함수형 스타일은 무엇을 바꾸는가
함수형 스타일의 핵심은 "어떻게 처리할지"보다 "무엇을 할지"를 더 직접적으로 표현하는 데 있습니다.
다음 코드를 보겠습니다.
val numbers = listOf(1, 2, 3, 4, 5, 6)
val result = mutableListOf<Int>()
for (number in numbers) {
if (number % 2 == 0) {
result.add(number * 10)
}
}
println(result) // [20, 40, 60]같은 코드를 람다와 고차 함수로 쓰면 다음과 같습니다.
val result = numbers
.filter { it % 2 == 0 }
.map { it * 10 }
println(result) // [20, 40, 60]두 코드는 같은 일을 합니다. 하지만 두 번째 코드는 의도가 더 잘 보입니다.
짝수만 고른다.
고른 값을 10배로 바꾼다.
물론 항상 함수형 스타일이 정답은 아닙니다. 복잡한 흐름, 중간 상태 변경, 예외 처리, 조기 종료가 많다면 일반 반복문이 더 읽기 쉬울 수 있습니다.
좋은 코틀린 코드는 두 방식을 적절히 섞습니다. 간단한 변환과 필터링에는 람다를 사용하고, 복잡한 절차에는 명확한 함수와 반복문을 사용하면 됩니다.
작은 예제: 검증 규칙을 함수로 분리하기
고차 함수는 규칙을 외부에서 주입할 때 유용합니다. 다음 예제는 문자열을 검증하는 함수입니다.
fun validate(value: String, rule: (String) -> Boolean): Boolean {
return rule(value)
}
val notEmpty = validate("kotlin") { it.isNotBlank() }
val longEnough = validate("kotlin") { it.length >= 6 }
println(notEmpty) // true
println(longEnough) // true검증 흐름은 validate가 담당합니다. 어떤 규칙으로 검증할지는 호출하는 쪽에서 정합니다.
규칙이 자주 쓰인다면 함수로 분리하고 함수 참조로 넘길 수 있습니다.
fun isValidPassword(password: String): Boolean {
return password.length >= 8 && password.any { it.isDigit() }
}
val result = validate("abc12345", ::isValidPassword)
println(result) // true이 구조는 테스트하기도 좋습니다. 규칙을 하나씩 분리해서 확인할 수 있고, 새로운 규칙을 추가해도 validate 함수를 크게 바꿀 필요가 없습니다.
마무리
람다와 고차 함수는 코틀린을 코틀린답게 쓰기 위한 중요한 도구입니다.
람다는 이름 없는 작은 함수입니다. 매개변수가 하나뿐이면 it으로 간결하게 표현할 수 있습니다. 고차 함수는 함수를 매개변수로 받거나 반환하는 함수입니다. 이를 사용하면 변하는 동작을 외부에서 주입할 수 있습니다.
함수를 전달하는 방식에는 람다, 익명 함수, 함수 참조가 있습니다. 짧은 동작은 람다로, 반환 흐름이 중요한 코드는 익명 함수로, 이미 존재하는 함수는 함수 참조로 전달하면 됩니다.
클로저는 람다가 외부 변수를 기억하게 해 줍니다. 하지만 외부의 변경 가능한 상태를 많이 캡처하면 코드가 예측하기 어려워질 수 있습니다.
inline은 람다 호출 비용을 줄이고, 인라인 람다에서 비지역 반환을 가능하게 합니다. 다만 모든 함수에 붙이는 키워드는 아닙니다. 작은 고차 함수에 신중하게 사용하는 도구입니다.
다음 장에서는 람다와 고차 함수가 가장 많이 쓰이는 영역인 컬렉션을 다룹니다. filter, map, forEach, groupBy 같은 함수들을 통해 데이터를 더 간결하고 안전하게 다루는 방법을 익히게 됩니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.