Kotlin in Action 2/e 서적을 읽으면서 정리하고자 쓴 글입니다.

람다 식과 멤버 참조
람다와 컬렉션
코틀린은 람다를 보다 직관적으로 사용할 수 있으며, 간결한 코드를 작성할 수 있습니다.
fun findTheOldest(peoples: List<Person>) {
var maxAge = 0
for (person in peoples) {
if (person.age > maxAge) {
maxAge = person.age
}
}
return maxAge
}
fun main() {
val peoples = listOf(Person("A", 10), Person("B", 15))
println(findTheOlest(peoples)) // 15
}
fun main() {
val peoples = listOf(Person("A", 10), Person("B", 15))
println(peoples.maxByOrNull {it.age}) // 15
}
현재 영역에 있는 변수에 접근
자바에서 익명 내부 클래스를 사용할 때, 외부 메서드의 로컬 변수가 `final`이 아니면 사용할 수 없습니다. 그러나 코틀린은 `final`이 아닌 변수에도 접근할 수 있으며, 그 값을 바꿀 수도 있습니다.
fun printProblemCouns(response: Collection<String>) {
var clientErrors = 0 // final이 아닌 변수 var
var serverErrors = 0
response.forEach {
if (it.startsWith("4") {
clientErrors++ // 람다 내에서 외부 변수 수정 가능
} else if (it.startsWith("5") {
serverErrors++
}
}
println("$clientErrors client errors, $serverErrors server errors")
}
🤔 어떻게 그런 동작이 가능한 것일까?
코틀린 컴파일러는 `var` 변수를 람다 안에 수정하려고 할 때 래퍼(Wrapper)라는 기법을 사용하게 됩니다.
- `val`: 변수 값을 람다 코드에 함께 저장합니다. (자바와 유사)
- `var`: 변수를 직접 저장하는 대신, 그 변수를 담고 있는 특별한 객체를 만듭니다. 람다는 이 객체를 참조하며 람다 안에서도 외부 변수를 수정할 수 있는 것처럼 보이게 됩니다.
다만, 주의할 점이 있습니다. 변수를 공유할 수 있다고 해서 항상 최신 값을 얻을 수 있는 것은 아닙니다. 특히 비동기 코드나 이벤트 핸들러에서 문제가 발생합니다.
fun tryToCountButtonClicks(button: Button): Int {
var clicks = 0
button.onClick { clicks++ } // 버튼이 클릭될 때 실행될 '약속'만 하고 함수는 바로 아래로 진행됨
return clicks // 버튼이 클릭되기도 전에 0을 반환해버림!
}
`onClick` 핸들러는 호출될 때마다 `clicks`의 값을 증가시키지만 그 값의 변경을 관찰할 수는 없습니다. 핸들러는 `tryToCountButtonClicks`가 `clicks`를 반환한 다음에 호출되기 때문입니다.
컬렉션 함수형 API
필수적인 함수: filter와 map
`filter` 함수는 컬렉션에서 조건에 맞지 않는 원소를 걸러내는 역할을 합니다. 즉, 원소의 개수를 줄일 수 있지만 각 소 자체를 변경하지 않습니다. 반면, 원소를 다른 형태로 변환해야 할 경우 `map` 함수를 사용합니다. `map` 함수는 주어진 람다를 컬렉션의 각 원소에 적용하고, 그 결과를 모아 새로운 컬렉션을 생성합니다.
fun main() {
val people = listOf(Person("Alice", 10), Person("Bob", 31))
val adults = people.filter { it.age > 30 })
println(adults) // [Person(name=Bob, age=31)]
val names = people.map { it.name }
println(names) // [Alice, Bob]
val numbers = mapOf(0 to "zero", 1 to "one")
val upperNumbers = numbers.mapValues { it.value.uppercase() }
println(upperNumbers) // {0=ZERO, 1=ONE}
}
컬렉션에 술어 적용: all, any, count, find
컬렉션에 대해 자주 수행하는 연산으로 컬렉션의 모든 원소가 어떤 조건에 만족하는지 판단하는 연산이 있습니다.
// Person을 받아 Boolean을 반환 (27세 이하)
val canBeIn27: (Person) -> Boolean = { person -> person.age <= 27 }
fun main() {
val people = listOf(Person("Alice", 27), Person("Bob", 31))
val all = people.all(canBeIn27) // 모든 사람이 canBeIn27 조건에 만족해야 true
println(all) // false
val any = people.any(canBeIn27) // 하나라도 만족하면 true
println(any) // true
val count = people.count(canBeIn27) // 조건을 만족하는 원소의 개수
println(count) // 1
val find = people.find(canBeIn27) // 조건을 만족하는 첫번째 원소
println(find) // Person(name=Alice, age=27)
}
| 함수 | 반환 타입 | 의미 |
| `all` | Boolean | 모두 만족하는가 |
| `any` | Boolean | 하나라도 만족하는가 |
| `count` | Int | 몇 개가 만족하는가 |
| `find` | T? | 처음으로 만족하는 원소 |
중첩된 컬렉션 안의 원소 처리: flatMap과 flatten
중첩된 컬렉션을 다룰 때 자주 필요한 작업은 안쪽 컬렉션의 원소를 한 번에 처리하는 것입니다. 코틀린은 이를 위해 `flatMap`과 `flatten` 함수를 제공합니다.
`flatMap` 함수는 컬렉션의 각 원소에 주어진 람다를 적용해 또 다른 컬렉션을 생성한 뒤, 그 결과로 만들어진 여러 개의 컬렉션을 하나의 컬렉션으로 평탄화합니다. 즉, `flatMap`은 `map`과 `flatten`을 결합한 연산입니다.
fun main() {
val strings = listOf("abc", "def")
// 각 문자열을 List<Char>로 변환한 뒤 하나의 리스트로 합침
println(strings.flatMap { it.toList() }) // [a, b, c, d, e, f]
}
각 문자열을 리스트로 변환(`map`)하고, 그 결과를 생성된 중첩 리스트에 하나의 리스트로 합칩니다(`flatten`).
`flatMap`은 객체 내부에 컬렉션을 포함하는 구조에서도 유용하게 사용할 수 있습니다.
data class Book(val title: String, val authors: List<String>)
fun main() {
val books = listOf(
Book("Thursday Next", listOf("Jasper Fforde")),
Book("Mort", listOf("Terry Pratchett")),
Book("Good Omens", listOf("Terry Pratchett", "Neil Gaiman"))
)
// 각 책의 저자 목록을 하나의 컬렉션으로 합친뒤 중복 제거
val result = books.flatMap { it.authors }.toSet()
println(result) // [Jasper Fforde, Terry Pratchett, Neil Gaiman]
}
반면 `flatten` 함수는 이미 중첩된 컬렉션이 있을 때, 별도의 변환 없이 이를 하나의 컬렉션으로 펼치는 역할만 수행합니다.
지연 연산(lazy) 컬렉션 연산
일반적으로 `List`, `Set` 등에서 `map`이나 `filter`를 연결하면 즉시 계산 방식이 적용됩니다. 즉, 수평적 처리를 의미하며 각 단계마다 연산 결과를 담는 임시 컬렉션이 생성됩니다.
val list = listOf(1, 2, 3, 4)
val result = list
.map { it * it } // 1단계: [1, 4, 9, 16] 생성
.filter { it % 2 == 0 } // 2단계: [4, 16] 생성
1단계에서 모든 원소를 순회하며 제곱한 뒤, 새로운 리스트 `[1, 4, 9, 16]`을 생성합니다. 이후 2단계에서 만들어진 리스트를 다시 순회하며 조건에 맞는 원소만 골라 또 다른 리스트 `[4, 16]`을 만듭니다.
시퀀스 연산 실행: 중간 연산과 최종 연산
이와 달리 지연 연산(lazy) 은 시퀀스를 기반으로 하며, 중간 결과 컬렉션을 생성하지 않고 각 원소를 단위로 연산 체인을 끝까지 처리하는 수직적 방식으로 동작합니다.
val list = listOf(1, 2, 3, 4)
val result = list.asSequence() //
.map { it * it }
.filter { it % 2 == 0 }
.toList() // 최종 연산 (이때 모든 계산이 시작)
원소 1부터 순서대로 `map` → `filter` 적용하여 탈락(1은 탈락), 원소 2 `map` → `filter` 적용(4는 합격),... 이러한 방식으로 원소 하나하나 끝까지 통과한 뒤 다음 원소로 넘어가는 구조입니다.
시퀀스 연산은 크게 2가지 종류로 나뉩니다.
- 중간 연산: `map`, `filter` 등 다른 시퀀스를 반환하며, 실제 계산은 수행하지 않고 어떻게 계산할지에 대한 설계도만 가집니다.
- 최종 연산: `toList()`, `find()`, `sum()`, `forEach()` 등 시퀀스를 다시 컬렉션으로 바꾸거나 구체적인 값을 얻는 연산으로 호출되는 순간에만 모든 계산이 시작됩니다.
🤔 왜 시퀀스를 다시 컬렉션으로 되돌려야 할까?
시퀀스는 데이터를 직접 저장하지 않는 계산 설계도와 같으므로, 인덱스 접근이나 데이터 재사용, 그리고 기존 리스트 기반 API와의 호환성을 위해 실제 데이터 저장소인 컬렉션으로 변환하여 사용합니다.
🤔일반 컬렉션과 지연 연산 어떻게 사용해야 할까?
일반 컬렉션은 소규모 데이터에서 빠른 속도를 보장하지만 중간 결과물로 인한 메모리 부담이 큰 반면, 시퀀스는 대규모 데이터나 복잡한 연산에서 임시 컬렉션 없이 메모리를 아끼고 최적화된 처리가 가능하므로 데이터의 양과 연산 단계에 맞춰 선택하는 것이 좋습니다.
| 구분 | 일반 컬렉션 (Eager) | 시퀀스 (Lazy) |
| 연산 방식 | 모든 원소에 대해 단계별 수행 (수평) | 각 원소 하나씩 전체 체인 통과 (수직) |
| 중간 결과물 | 매 단계마다 새로운 컬렉션 생성 | 생성 안함 (메모리 효율적) |
| 적합한 상황 | 데이터 양이 적고, 속도가 중요할 때 | 데이터 양이 아주 많거나 무한할 때 |
| 최종 연산 | 필요 없음 (즉시 실행) | 반드시 필요 (최종 연산 시점에 실행) |
💡자바 스트림(Java Stream)과의 비교
코틀린의 시퀀스는 자바 8의 스트림과 거의 유사합니다. 하지만 결정적인 차이가 있습니다. 병렬 처리: 자바 스트림은 parallelStream()을 통해 여러 CPU 코어를 활용한 병렬 연산이 가능하지만, 코틀린 시퀀스는 현재 직렬(Sequential) 처리만 지원합니다. 병렬 처리가 필요하다면 자바 스트림을 사용하거나 코루틴을 활용해야 합니다.
자바 함수형 인터페이스 활용
자바 메서드에 람다를 인자로 전달
함수형 인터페이스를 인자로 받는 자바 메서드에 코틀린 람다를 전달할 수 있습니다. 이때 객체 식을 사용하는 것과 람다를 사용하는 것에는 성능 상 차이가 있습니다.
- 객체 식 `object : ..`: 메서드를 호출할 때마다 새로운 객체가 생성
- 람다: 정의된 함수의 변수에 접근하지 않는 람다는 메서드 호출 시마다 대응하는 무명 객체를 반복 사용
// 객체 식: 호출 시마다 매번 새로운 인스턴스 생성
postponeComputation(1000, object : Runnable {
override fun run() { println(42) }
})
// 람다: 변수를 포획하지 않으므로 프로그램 전체에서 인스턴스가 단 하나만 생성
postponeComputation(1000) { println(42) }
변수 포획 시의 동작
람다가 주변 영역의 변수를 포획한다면 매 호출마다 같은 인스턴스를 사용할 수 없습니다. 이 경우 컴파일러는 주변 변수를 포획한 새로운 인스턴스를 매번 생성하여 전달합니다.
fun handlerComputation(id: String) {
// id를 포획하므로 호출할 때마다 새로운 Runnable 인스턴스가 생성
postponeComputation(1000) { println(id) }
}
🤔 객체 식과 람다 그래서 결론은?
코틀린의 람다는 내부적으로 무명 클래스로 컴파일되는데, 외부 변수를 포획하지 않을 때는 단 하나의 싱글톤 인스턴스만 생성하여 재사용함으로써 효율을 높이지만, 변수를 포획할 경우 상태 저장을 위한 필드가 추가된 객체를 매번 새로 생성하는 비용이 발생합니다. 다만, `inline` 함수에 전달되는 람다는 무명 클래스 생성 없이 본문 코드가 호출 지점에 직접 삽입되므로, 객체 생성 오버헤드 없이 가장 최적화된 성능을 제공합니다.
함수 앞에 `inline` 키워드를 붙이게 되면 컴파일러는 함수 호출 지점에 함수의 본문과 람다의 본문을 그대로 붙여 넣습니다. 실제 바이트 코드를 뜯어보면 함수 호출이 사라집니다. 즉, 객체 생성 비용이 없습니다.
// inline 키워드 추가
inline fun flow(action: () -> Unit) {
println("작업 시작")
action()
println("작업 끝")
}
fun main() {
flow { println("Hello!") }
}
// inline 사용 시 main 함수의 바이트 코드 추정
fun main() {
println("작업 시작")
println("Hello!") // 전달한 람다가 삽입되어 action 함수 호출 비용 X
println("작업 끝")
}
`inline` 함수는 다음에 정리할 고차 함수에서 구체적으로 다룹니다.
SAM 생성자: 람다를 함수형 인터페이스 명시적으로 변경
SAM(Single Abstract Method) 생성자는 컴파일러가 람다를 함수형 인터페이스로 자동 변환하지 못하는 상황(반환 값으로 사용하거나 변수에 할당할 때 등)에서, 람다를 특정 인터페이스의 인스턴스로 명시적으로 변환하기 위해 컴파일러가 제공하는 특수한 함수입니다.
// SAM 생성자 사용 예시
fun createAllDoneRunnable(): Runnable {
// Runnable이라는 이름을 직접 명시하여 람다를 Runnable 객체로 만듦
return Runnable { println("All done!") }
}
SAM 생성자는 람다를 변수에 담아두고 여러 곳에서 재사용할 때 유용합니다.
// 람다를 SAM 생성자로 감싸 변수에 저장
val handler = View.OnClickListener { view ->
println("버튼 클릭됨: ${view.id}")
}
// 여러 버튼에 동일한 리스너 인스턴스 적용
button1.setOnClickListener(handler)
button2.setOnClickListener(handler)
수신 객체 지정 람다: with & apply
자바 람다와 차별화되는 코틀린만의 특징 중 하나는 수신 객체 지정 람다입니다. 람다 블록 내부에 특정 객체의 메서드를 마치 자신의 것처럼 호출할 수 있게 해 주어, 코드의 가독성을 높이고 DSL(도메인 특화 언어) 작성을 용이하게 만듭니다.
with: 객체를 인자로 전달하여 조작하기
`with` 함수는 첫 번째 인자로 받은 객체를 두 번째 인자인 람다의 수신 색채로 만듭니다. 람다 내부에서는 `this`를 통해 객체에 접근할 수 있으며, `this`를 생략하고 곧바로 멤버 함수를 호출할 수도 있습니다.
// ❌ 일반적인 구조
fun alphabet(): String {
val sb = StringBuilder()
for (letter in 'A'..'Z') {
sb.append(letter)
}
sb.append("\nNow I know the alphabet!")
return sb.toString()
}
// ✅ with 함수를 사용하여 중복 제거한 경우
fun alphabet(): String = with(StringBuilder()) {
for (letter in 'A'..'Z') {
append(letter) // this.append에서 this 생략 가능
}
append("\nNow I know the alphabet!")
toString() // 람다의 마지막 식의 결과를 반환
}
apply: 수신 객체를 그대로 반환하기
`with` 함수는 람다의 결괏값이 필요할 때 유용하지만, 가끔은 연산을 수행한 후 객체 자신이 다시 필요한 경우가 있습니다. 이때 `apply`를 사용합니다.
// ✅ apply를 사용하여 StringBuilder 객체 자체를 반환받는 경우
fun alphabet() = StringBuilder().apply {
for (letter in 'A'..'Z') {
append(letter)
}
append("\nNow I Know the alphabet!")
}.toString() // apply는 StringBuilder를 반환하므로, 마지막에 toString() 호출 가능
코틀린 표준 라이브러리는 `StringBuilder`를 이용한 문자열 생성을 좀 더 구체적이고 우아한 함수인 `buildString`을 제공합니다.
fun alphabet() = buildString {
for (letter in 'A'..'Z') {
append(letter) // 수신 객체인 StringBuilder의 append 호출
}
append("\nNow I know the alphabet!")
// 명시적인 toString() 호출이 필요 없음
}
| 함수 | 수신 객체 전달 방식 | 반환값 | 주요 용도 |
| `with` | 함수의 인자로 전달 | 람다의 마지막 식 | 객체를 이용해 결과를 만들고 싶을 때 |
| `apply` | 확장 함수 형태로 호출 | 수신 객체 자신 | 객체의 속성을 설정(초기화)할 때 |
요약
- 람다를 사용하면 코드 조각을 다른 함수에게 인자로 넘길 수 있다.
- 코틀린은 람다가 함수 인자인 경우 괄호 밖으로 빼낼 수 있고, 람다의 인자가 단 하나뿐인 경우 인자 이름을 지정하지 않고 `it`이라는 디폴트 이름을 사용할 수 있다.
- 자바와 다르게 람다 안에 있는 코드는 그 람다가 들어있는 바깥 함수의 변수를 읽거나 쓸 수 있다.
- `filter`, `map`, `all`, `any` 등의 함수를 활용하면 컬렉션에 대한 대부분의 연산을 직접 원소를 이터레이션 하지 않고 수행할 수 있다.
- 시퀀스를 사용하면 중간 결과를 담는 컬렉션을 생성하지 않고도 컬렉션에 대한 여러 연산을 조합할 수 있다.
- 함수형 인터페이스(추상 메서드가 단 하나뿐인 SAM 인터페이스)를 인자로 받는 자바 함수를 호출할 경우 람다를 함수형 인터페이스 인자 대신 넘길 수 있다.
- 수신 객체 지정 람다를 사용하면 람다 안에서 미리 정해둔 수신 객체의 메서드를 직접 호출할 수 있다.
- 표준 라이브러리 `with` 함수를 사용하면 어떤 객체에 대한 참조를 반복해서 언급하지 않고 그 객체의 메서드를 호출할 수 있다. `apply`를 사용하면 어떤 객체라도 빌더 스타일의 API를 사용해 생성하고 초기화할 수 있다.
'Kotlin' 카테고리의 다른 글
| Java와 다른 Kotlin의 인터페이스 (0) | 2026.01.09 |
|---|---|
| [Kotlin in action 2/e] 코틀린 타입 시스템 (0) | 2026.01.07 |
| [Kotlin in Action 2/e] 클래스, 객체, 인터페이스 (0) | 2025.12.30 |
| [Kotlin in Action 2/e] 함수 정의와 호출 (1) | 2025.12.30 |
| [Kotlin in Action 2/e] 코틀린의 기초 (0) | 2025.12.12 |