Kotlin 사고방식 (3/7)

이전 편: [Kotlin] 2. 타입 시스템 마스터

다음 편: [Kotlin] 4. 컬렉션의 모든 것

Kotlin 사고방식 시리즈 (3/7)

이전 편: 2 타입 시스템 마스터

다음 편: 4 컬렉션의 모든 것

Java에서 함수는 항상 클래스에 속해야 한다. 독립적으로 존재할 수 없고, 변수에 담을 수도 없고, 다른 함수에 직접 전달할 수도 없다. Java 8에서 람다가 추가됐지만, 실제로는 함수형 인터페이스(Predicate, Function, Consumer)의 인스턴스를 만드는 것이지 함수 자체를 다루는 것은 아니다.

Kotlin에서 함수는 일급 시민(first-class citizen)이다. 변수에 담을 수 있고, 다른 함수에 인자로 넘길 수 있고, 함수가 함수를 반환할 수도 있다. 이것은 단순한 문법 차이가 아니라, 코드를 설계하는 방식 자체를 바꾸는 패러다임 전환이다.

이 문서에서는 Kotlin의 함수 타입 시스템을 깊이 이해하고, 실무에서 어떻게 활용하는지 다룬다.

함수 타입

Kotlin에서 함수는 타입을 가진다. IntString처럼, 함수도 변수의 타입이 될 수 있다.

val greet: (String) -> String = { name -> "안녕, $name" }
  • (String) -> String — 이것이 함수 타입이다. "String을 받아서 String을 반환하는 함수."
  • { name -> "안녕, $name" } — 이것이 함수의 값(람다)이다.

Java에서 "함수를 변수에 담는다"고 하면 Function<String, String>이라는 인터페이스의 인스턴스를 만드는 것이다. Kotlin에서는 함수 타입이 언어에 내장되어 있어서 별도 인터페이스가 필요 없다.

함수 타입은 다양한 형태를 가질 수 있다.

val action: () -> Unit                         // 파라미터 없음, 반환 없음
val predicate: (Int) -> Boolean                // Int를 받아 Boolean 반환
val transform: (String, Int) -> String         // 두 개의 파라미터
val nullable: ((String) -> Int)?               // 함수 자체가 nullable

함수 타입도 nullable이 될 수 있다는 점이 중요하다. ((String) -> Int)?는 "이 함수가 아예 없을 수도 있다"는 뜻으로, 콜백이 선택사항일 때 유용하다.


고차 함수

함수를 파라미터로 받거나 반환하는 함수를 고차 함수(higher-order function)라 한다. Kotlin 표준 라이브러리의 filter, map, sortedBy 등이 모두 고차 함수다.

직접 만들면 이렇다.

fun <T> List<T>.filterAndLog(
    tag: String,
    predicate: (T) -> Boolean
): List<T> {
    val result = filter(predicate)
    println("[$tag] ${size}개 중 ${result.size}개 통과")
    return result
}
  • predicate: (T) -> Boolean — 필터 조건을 함수로 받는다.
  • 호출하는 쪽에서 로직을 주입할 수 있다.
val published = posts.filterAndLog("발행 필터") { it.isPublished }

고차 함수를 쓰면 "무엇을 할 것인가"와 "어떻게 할 것인가"를 분리할 수 있다. filterAndLog는 "필터링 + 로깅"이라는 흐름(어떻게)을 담당하고, 구체적인 조건(무엇을)은 호출하는 쪽이 람다로 전달한다.

Java에서 이걸 하려면 Predicate<T> 인터페이스를 파라미터로 받아야 하는데, Kotlin은 함수 타입이 있으니까 인터페이스 선언 없이도 가능하다.


람다 vs 익명 함수

Kotlin에서 "이름 없는 함수"를 표현하는 방법은 두 가지다. 람다와 익명 함수.

// 람다
val double = { x: Int -> x * 2 }

// 익명 함수
val double = fun(x: Int): Int { return x * 2 }

대부분의 상황에서는 람다를 쓴다. 익명 함수가 필요한 유일한 경우가 있는데, return의 동작이 다를 때다.

fun findFirst(posts: List<Post>): Post? {
    posts.forEach { post ->
        if (post.isPublished) return post   // 람다의 return은 findFirst 함수를 종료시킨다!
    }
    return null
}

람다 안의 return람다가 아니라 바깥 함수를 종료시킨다. 이것을 non-local return이라 한다. 람다만 종료하고 싶으면 레이블을 써야 한다.

posts.forEach { post ->
    if (post.isPublished) return@forEach   // 이 람다만 종료 (continue 같은 효과)
}

익명 함수에서는 return이 항상 해당 함수만 종료시킨다.

posts.forEach(fun(post) {
    if (post.isPublished) return   // 이 익명 함수만 종료
})

실무에서 익명 함수를 쓰는 경우는 드물다. 대부분 람다 + 레이블로 해결되고, non-local return을 의도적으로 쓰는 경우도 많다.


클로저

람다는 자신이 정의된 스코프의 변수를 캡처할 수 있다. 이것을 클로저(closure)라 한다.

fun createCounter(): () -> Int {
    var count = 0
    return { ++count }
}

val counter = createCounter()
println(counter())  // 1
println(counter())  // 2
println(counter())  // 3

createCounter가 끝난 후에도 람다가 count를 기억하고 있다. 람다가 외부 변수를 "닫아서(close over)" 가지고 있기 때문에 클로저라 부른다.

Java의 람다도 외부 변수를 캡처하지만, effectively final이어야 한다는 제약이 있다. 값을 변경할 수 없다.

// Java — 컴파일 에러!
int count = 0;
Runnable r = () -> count++;  // Variable used in lambda should be effectively final

Kotlin은 이 제약이 없다. 람다 안에서 외부의 var 변수를 자유롭게 수정할 수 있다. 내부적으로는 Ref 객체로 감싸서 처리한다.

클로저는 실무에서 다음과 같은 패턴에 쓰인다.

fun createValidator(rules: List<(String) -> Boolean>): (String) -> Boolean {
    return { input ->
        rules.all { rule -> rule(input) }  // rules를 클로저로 캡처
    }
}

val isValid = createValidator(
    listOf(
        { it.isNotBlank() },
        { it.length <= 200 },
        { !it.contains("<script>") }
    )
)
isValid("정상적인 제목")  // true

함수를 반환하는 함수에서, 반환되는 함수가 매개변수나 지역 변수를 캡처하는 패턴이다.


inline 함수

고차 함수는 편리하지만 비용이 있다. 람다를 전달할 때마다 함수 객체가 생성되고, 클로저를 쓰면 캡처한 변수도 함께 할당된다.

// 이 코드가 호출될 때마다 람다 객체가 하나 생성된다
posts.filter { it.isPublished }

대부분의 경우 이 비용은 무시할 수 있다. 하지만 성능이 중요한 코드나, 루프 안에서 반복 호출되는 함수에서는 무시할 수 없을 수 있다.

inline 키워드를 붙이면 함수 본문과 람다를 호출 지점에 복사해서, 함수 객체 생성을 없앤다.

inline fun <T> List<T>.customFilter(predicate: (T) -> Boolean): List<T> {
    val result = mutableListOf<T>()
    for (item in this) {
        if (predicate(item)) result.add(item)
    }
    return result
}

컴파일 후에는 이렇게 된다(개념적으로).

// customFilter { it.isPublished } 호출 시 실제로 생성되는 코드
val result = mutableListOf<Post>()
for (item in posts) {
    if (item.isPublished) result.add(item)  // 람다가 인라인됨
}

함수 호출이 사라지고, 람다 객체도 사라진다. 마치 직접 반복문을 쓴 것과 동일한 바이트코드가 된다.

Kotlin 표준 라이브러리의 filter, map, let, apply 등이 전부 inline으로 선언되어 있다. 그래서 이 함수들을 써도 성능 오버헤드가 없다.

직접 inline을 붙여야 할 때

표준 라이브러리 함수들은 이미 inline이니까 신경 쓸 필요 없다. 직접 만든 고차 함수에 inline을 붙이는 건, 그 함수가 정말 자주 호출되고 성능이 중요할 때만 고려하자. 무분별하게 붙이면 바이트코드 크기가 늘어난다.


noinline과 crossinline

inline 함수의 모든 람다 파라미터는 기본적으로 인라인된다. 하지만 람다를 변수에 저장하거나, 다른 non-inline 함수에 넘겨야 할 때는 인라인할 수 없다.

inline fun doSomething(
    inlined: () -> Unit,
    noinline stored: () -> Unit   // 이 람다는 인라인하지 않음
) {
    inlined()
    saveLater(stored)  // 저장해야 하니까 인라인 불가
}
  • noinline — 해당 람다만 인라인하지 않는다. 함수 객체가 생성된다.

crossinline은 다른 문제를 해결한다. 인라인 람다 안에서 non-local return을 금지할 때 쓴다.

inline fun runOnThread(crossinline action: () -> Unit) {
    Thread {
        action()  // 다른 스레드에서 실행되므로 non-local return이 위험하다
    }.start()
}
  • crossinline — 람다는 인라인하되, non-local return은 금지한다.
  • 람다가 다른 실행 컨텍스트(스레드, 콜백 등)에서 호출될 때 필요하다.

실무에서 noinlinecrossinline을 직접 쓸 일은 많지 않다. 라이브러리를 만들거나, 커스텀 DSL을 설계할 때 주로 필요하다.


수신 객체가 있는 함수 타입

Kotlin의 강력한 기능 중 하나는 수신 객체가 있는 함수 타입(function type with receiver)이다. 확장 함수의 람다 버전이라고 생각하면 된다.

// 일반 함수 타입
val greet: (String) -> String = { name -> "안녕, $name" }

// 수신 객체가 있는 함수 타입
val greet: String.() -> String = { "안녕, $this" }
  • String.() -> String — "String을 수신 객체로 받아서 String을 반환하는 함수."
  • 람다 안에서 this가 수신 객체를 가리킨다.

이것이 왜 중요하냐면, Kotlin DSL의 근간이기 때문이다. apply, run 같은 스코프 함수도 이 메커니즘으로 동작한다.

// apply의 실제 시그니처 (간략화)
inline fun <T> T.apply(block: T.() -> Unit): T {
    block()     // this = T 객체
    return this
}

apply에 전달하는 람다 안에서 this.title = "..." 대신 title = "..."으로 쓸 수 있는 이유가, 람다의 수신 객체가 T이기 때문이다.

이 개념은 6 DSL 설계에서 실전적으로 활용한다.


심화 분석

함수 참조

이미 정의된 함수를 람다 대신 넘길 때 함수 참조(function reference)를 쓸 수 있다.

fun isPublished(post: Post): Boolean = post.isPublished

// 람다로 전달
posts.filter { isPublished(it) }

// 함수 참조로 전달
posts.filter(::isPublished)
  • ::함수명으로 함수 참조를 만든다.
  • 멤버 함수 참조는 Post::isPublished 형태.

함수 참조가 람다보다 나은 경우는, 이미 이름이 있는 함수를 그대로 전달할 때다. 이름이 의도를 표현하므로 가독성이 좋다.

typealias와 함수 타입

함수 타입이 복잡해지면 typealias로 이름을 붙여서 가독성을 높일 수 있다.

typealias Predicate<T> = (T) -> Boolean
typealias Mapper<T, R> = (T) -> R
typealias EventHandler = (Event) -> Unit

fun <T> List<T>.filterWith(predicate: Predicate<T>): List<T> =
    filter(predicate)

typealias는 새 타입을 만드는 게 아니라 별칭을 붙이는 것이다. 컴파일 후에는 원래 타입과 동일하다. 하지만 코드를 읽는 사람에게 의도를 전달하는 데 큰 도움이 된다.


자주 하는 실수

non-local return을 모르고 사용

fun process(posts: List<Post>) {
    posts.forEach {
        if (it.isPublished) return   // process 함수 전체가 종료된다!
    }
    println("여기는 실행되지 않을 수 있다")
}

forEach 안의 returnforEach만 끝내는 게 아니라 process 전체를 끝낸다. 의도한 거라면 괜찮지만, 대부분 return@forEach를 쓰려고 한 것이다. filter + forEach로 바꾸거나, 일반 for 루프를 쓰는 것이 더 명확할 수 있다.

inline 남용

// 불필요한 inline
inline fun add(a: Int, b: Int) = a + b  // 람다 파라미터가 없으므로 inline 의미 없음

inline람다 파라미터가 있는 함수에서 의미가 있다. 단순 함수에 붙이면 바이트코드만 커지고 이점이 없다.

함수 타입과 SAM 변환 혼동

Java의 함수형 인터페이스를 Kotlin에서 쓸 때는 SAM 변환이 자동으로 된다.

// Java의 Runnable을 람다로 전달 — OK (SAM 변환)
Thread { println("hello") }

하지만 Kotlin의 인터페이스는 SAM 변환이 자동으로 안 된다. fun interface로 선언해야 한다.

// Kotlin 인터페이스 — SAM 변환 안 됨
interface MyCallback {
    fun onResult(value: String)
}

// fun interface로 선언하면 SAM 변환 가능
fun interface MyCallback {
    fun onResult(value: String)
}

setCallback { result -> println(result) }  // OK