스코프 함수

스코프 함수 (Scope functions)

Kotlin 표준 라이브러리에는 한 가지 목적을 위해 존재하는 함수들이 몇 개 있어요. 바로 객체의 컨텍스트 안에서 코드 블록을 실행하는 거예요. 이런 함수를 람다 표현식과 함께 객체에서 호출하면, 임시 스코프가 만들어져요. 이 스코프 안에서는 객체를 이름 없이 접근할 수 있죠. 이런 함수들을 스코프 함수(scope functions) 라고 불러요. 모두 다섯 가지예요: let, run, with, apply, also.

기본적으로 이 함수들은 모두 똑같은 일을 해요. 객체에 대해 코드 블록을 실행하죠. 차이가 나는 건 그 객체가 블록 안에서 어떻게 제공되는지, 그리고 전체 표현식의 결과가 무엇인지예요.

출처: Kotlin 공식 문서

본문

스코프 함수를 쓰는 전형적인 예를 볼게요.

data class Person(var name: String, var age: Int, var city: String) {
    fun moveTo(newCity: String) { city = newCity }
    fun incrementAge() { age++ }
}

fun main() {
//sampleStart
    Person("Alice", 20, "Amsterdam").let {
        println(it)
        it.moveTo("London")
        it.incrementAge()
        println(it)
    }
//sampleEnd
}

let 없이 같은 코드를 쓰려면 새 변수를 만들고 쓸 때마다 그 이름을 반복해야 해요.

data class Person(var name: String, var age: Int, var city: String) {
    fun moveTo(newCity: String) { city = newCity }
    fun incrementAge() { age++ }
}

fun main() {
//sampleStart
    val alice = Person("Alice", 20, "Amsterdam")
    println(alice)
    alice.moveTo("London")
    alice.incrementAge()
    println(alice)
//sampleEnd
}

스코프 함수는 새로운 기술적 능력을 추가해 주지 않아요. 하지만 코드를 더 간결하고 읽기 좋게 만들어 줘요.

여러 스코프 함수가 비슷해서, 상황에 맞는 함수를 고르는 게 까다로울 수 있어요. 선택은 주로 의도프로젝트에서의 일관된 사용에 달려 있어요. 아래에서 스코프 함수의 차이점과 사용 관례를 자세히 설명할게요.

함수 선택 (Function selection)

목적에 맞는 스코프 함수를 고르는 데 도움이 되도록, 다섯 함수의 핵심 차이를 표로 정리해 봤어요.

함수 객체 참조 반환 값 확장 함수 여부
let it 람다 결과 Yes
run this 람다 결과 Yes
run (비확장) - 람다 결과 No: 컨텍스트 객체 없이 호출
with this 람다 결과 No: 컨텍스트 객체를 인자로 받음
apply this 컨텍스트 객체 Yes
also it 컨텍스트 객체 Yes

각 함수에 대한 자세한 내용은 아래 전용 섹션에서 다룰게요.

용도에 따른 간단한 선택 가이드는 이래요.

  • 널이 아닌 객체에 람다 실행하기: let
  • 표현식을 지역 스코프의 변수로 도입하기: let
  • 객체 구성(configuration): apply
  • 객체 구성 + 결과 계산: run
  • 표현식이 필요한 위치에서 여러 문 실행: 비확장 run
  • 부수 효과(additional effects): also
  • 객체에 대한 함수 호출 묶기: with

여러 스코프 함수의 사용 사례가 겹치므로, 프로젝트나 팀의 특정 관례에 따라 어떤 함수를 쓸지 고르면 돼요.

스코프 함수가 코드를 간결하게 해 주긴 하지만 과용은 피해야 해요. 읽기 어려워지고 오류로 이어질 수 있거든요. 또 스코프 함수를 중첩하는 것도 피하고, 체이닝할 때는 조심하길 권해요. 현재 컨텍스트 객체나 this/it의 값이 뭔지 헷갈리기 쉬우니까요.

차이점 (Distinctions)

스코프 함수는 성격이 비슷해서 차이점을 정확히 이해하는 게 중요해요. 각 스코프 함수 사이에는 두 가지 주요 차이가 있어요.

  • 컨텍스트 객체를 참조하는 방식
  • 반환 값

컨텍스트 객체: this 또는 it

스코프 함수에 전달된 람다 안에서는 컨텍스트 객체가 실제 이름 대신 짧은 참조로 제공돼요. 각 스코프 함수는 두 가지 방식 중 하나로 컨텍스트 객체를 참조해요. 람다 리시버(receiver)(this)로 참조하거나, 람다 인자(it)로 참조하죠. 두 방식 모두 같은 능력을 제공하지만, 각각의 장단점이 있어서 사용 사례별로 권장안을 설명할게요.

fun main() {
    val str = "Hello"
    // this
    str.run {
        println("The string's length: $length")
        //println("The string's length: ${this.length}") // 같은 일을 해요
    }

    // it
    str.let {
        println("The string's length is ${it.length}")
    }
}
this

run, with, apply는 컨텍스트 객체를 람다 리시버로 참조해요. this 키워드를 쓰는 거죠. 그래서 이들의 람다 안에서는 객체가 일반 클래스 함수에서처럼 사용 가능해요.

대부분의 경우 리시버 객체의 멤버에 접근할 때 this를 생략할 수 있어서 코드가 짧아져요. 반면 this를 생략하면 리시버 멤버와 외부 객체·함수를 구분하기 어려워질 수 있어요. 그래서 컨텍스트 객체를 리시버(this)로 두는 것은, 객체의 멤버 위주로 작동하는 람다(함수 호출이나 프로퍼티 값 대입)에 권장돼요.

data class Person(var name: String, var age: Int = 0, var city: String = "")

fun main() {
//sampleStart
    val adam = Person("Adam").apply { 
        age = 20                       // this.age = 20과 같음
        city = "London"
    }
    println(adam)
//sampleEnd
}
it

반면 letalso는 컨텍스트 객체를 람다 인자로 참조해요. 인자 이름을 지정하지 않으면 암시적 기본 이름인 it으로 객체에 접근해요. itthis보다 짧고, it이 들어간 표현식은 대개 읽기 쉬워요.

하지만 객체의 함수나 프로퍼티를 호출할 때는 this처럼 객체가 암시적으로 제공되지 않아요. 그래서 컨텍스트 객체를 주로 함수 호출의 인자로 사용할 때는 it으로 접근하는 게 좋아요. 코드 블록 안에 변수가 여럿 있을 때도 it이 나아요.

import kotlin.random.Random

fun writeToLog(message: String) {
    println("INFO: $message")
}

fun main() {
//sampleStart
    fun getRandomInt(): Int {
        return Random.nextInt(100).also {
            writeToLog("getRandomInt() generated value $it")
        }
    }

    val i = getRandomInt()
    println(i)
//sampleEnd
}

아래 예제는 컨텍스트 객체를 이름(value)을 붙인 람다 인자로 참조하는 모습을 보여줘요.

import kotlin.random.Random

fun writeToLog(message: String) {
    println("INFO: $message")
}

fun main() {
//sampleStart
    fun getRandomInt(): Int {
        return Random.nextInt(100).also { value ->
            writeToLog("getRandomInt() generated value $value")
        }
    }

    val i = getRandomInt()
    println(i)
//sampleEnd
}

반환 값 (Return value)

스코프 함수는 돌려주는 결과가 달라요.

  • applyalso는 컨텍스트 객체를 반환해요.
  • let, run, with는 람다 결과를 반환해요.

코드에서 다음에 무엇을 하려는지에 따라 원하는 반환 값을 신중히 고려해야 해요. 이게 어떤 스코프 함수를 쓸지 고르는 데 도움이 되거든요.

컨텍스트 객체 (Context object)

applyalso의 반환 값은 컨텍스트 객체 자체예요. 그래서 이들은 호출 체인에서 사이드 스텝(side steps) 처럼 끼워 넣을 수 있어요. 같은 객체에 함수 호출을 하나씩 계속 이어 나갈 수 있죠.

fun main() {
//sampleStart
    val numberList = mutableListOf<Double>()
    numberList.also { println("Populating the list") }
        .apply {
            add(2.71)
            add(3.14)
            add(1.0)
        }
        .also { println("Sorting the list") }
        .sort()
//sampleEnd
    println(numberList)
}

또한 컨텍스트 객체를 반환하는 함수의 return 문에서도 사용할 수 있어요.

import kotlin.random.Random

fun writeToLog(message: String) {
    println("INFO: $message")
}

fun main() {
//sampleStart
    fun getRandomInt(): Int {
        return Random.nextInt(100).also {
            writeToLog("getRandomInt() generated value $it")
        }
    }

    val i = getRandomInt()
//sampleEnd
}
람다 결과 (Lambda result)

let, run, with는 람다 결과를 반환해요. 그래서 결과를 변수에 대입하거나, 결과에 연산을 체이닝하는 등에 쓸 수 있어요.

fun main() {
//sampleStart
    val numbers = mutableListOf("one", "two", "three")
    val countEndsWithE = numbers.run { 
        add("four")
        add("five")
        count { it.endsWith("e") }
    }
    println("There are $countEndsWithE elements that end with e.")
//sampleEnd
}

추가로 반환 값을 무시하고, 로컬 변수를 위한 임시 스코프를 만들 목적으로 스코프 함수를 쓸 수도 있어요.

fun main() {
//sampleStart
    val numbers = mutableListOf("one", "two", "three")
    with(numbers) {
        val firstItem = first()
        val lastItem = last()        
        println("First item: $firstItem, last item: $lastItem")
    }
//sampleEnd
}

함수들 (Functions)

사용 사례에 맞는 스코프 함수를 고르도록, 각 함수를 자세히 설명하고 사용 권장안을 제공할게요. 기술적으로 스코프 함수는 많은 경우 서로 바꿔 쓸 수 있어서, 예제는 각 함수의 사용 관례를 보여줘요.

let

  • 컨텍스트 객체가 인자(it)로 제공돼요.
  • 반환 값은 람다 결과예요.

let은 호출 체인의 결과에 함수 하나 이상을 호출할 때 쓸 수 있어요. 예를 들어 다음 코드는 컬렉션에 대한 두 연산의 결과를 출력해요.

fun main() {
//sampleStart
    val numbers = mutableListOf("one", "two", "three", "four", "five")
    val resultList = numbers.map { it.length }.filter { it > 3 }
    println(resultList)    
//sampleEnd
}

let을 쓰면 위 예제를, 리스트 연산의 결과를 변수에 대입하지 않는 방식으로 다시 쓸 수 있어요.

fun main() {
//sampleStart
    val numbers = mutableListOf("one", "two", "three", "four", "five")
    numbers.map { it.length }.filter { it > 3 }.let { 
        println(it)
        // 필요하면 더 많은 함수 호출
    } 
//sampleEnd
}

let에 전달하는 코드 블록이 it을 인자로 받는 함수 하나만 담고 있다면, 람다 인자 대신 메서드 참조(::)를 쓸 수 있어요.

fun main() {
//sampleStart
    val numbers = mutableListOf("one", "two", "three", "four", "five")
    numbers.map { it.length }.filter { it > 3 }.let(::println)
//sampleEnd
}

let은 널이 아닌 값을 담은 코드 블록을 실행할 때 자주 사용돼요. 널 가능 객체에 대해 작업하려면 그 객체에 안전 호출 연산자 ?.를 쓰고, 그 다음 let에 실행할 작업을 람다로 넣어 주면 돼요.

fun processNonNullString(str: String) {}

fun main() {
//sampleStart
    val str: String? = "Hello"   
    //processNonNullString(str)       // 컴파일 오류: str은 null일 수 있음
    val length = str?.let { 
        println("let() called on $it")        
        processNonNullString(it)      // OK: '?.let { }' 안에서 'it'은 null이 아님
        it.length
    }
//sampleEnd
}

let을 써서 스코프가 제한된 로컬 변수를 도입해 코드를 읽기 쉽게 만들 수도 있어요. 컨텍스트 객체에 새 변수를 정의하려면, 그 이름을 람다 인자로 지정해서 기본 it 대신 쓰면 돼요.

fun main() {
//sampleStart
    val numbers = listOf("one", "two", "three", "four")
    val modifiedFirstItem = numbers.first().let { firstItem ->
        println("The first item of the list is '$firstItem'")
        if (firstItem.length >= 5) firstItem else "!" + firstItem + "!"
    }.uppercase()
    println("First item after modifications: '$modifiedFirstItem'")
//sampleEnd
}

with

  • 컨텍스트 객체가 리시버(this)로 제공돼요.
  • 반환 값은 람다 결과예요.

with는 확장 함수가 아니라서 컨텍스트 객체가 인자로 전달돼요. 하지만 람다 안에서는 리시버(this)로 제공되죠.

반환 결과를 사용할 필요 없이 컨텍스트 객체에 함수를 호출할 때 with를 쓰는 걸 권장해요. 코드에서 with는 " 이 객체로, 다음을 하라"처럼 읽으면 돼요.

fun main() {
//sampleStart
    val numbers = mutableListOf("one", "two", "three")
    with(numbers) {
        println("'with' is called with argument $this")
        println("It contains $size elements")
    }
//sampleEnd
}

값을 계산할 때 프로퍼티나 함수를 사용하는 헬퍼 객체를 도입하는 데도 with를 쓸 수 있어요.

fun main() {
//sampleStart
    val numbers = mutableListOf("one", "two", "three")
    val firstAndLast = with(numbers) {
        "The first element is ${first()}," +
        " the last element is ${last()}"
    }
    println(firstAndLast)
//sampleEnd
}

run

  • 컨텍스트 객체가 리시버(this)로 제공돼요.
  • 반환 값은 람다 결과예요.

runwith와 같은 일을 하지만 확장 함수로 구현되어 있어요. 그래서 let처럼 컨텍스트 객체에서 점 표기법으로 호출할 수 있죠.

run은 람다가 객체를 초기화하면서 동시에 반환 값을 계산할 때 유용해요.

class MultiportService(var url: String, var port: Int) {
    fun prepareRequest(): String = "Default request"
    fun query(request: String): String = "Result for query '$request'"
}

fun main() {
//sampleStart
    val service = MultiportService("https://example.kotlinlang.org", 80)

    val result = service.run {
        port = 8080
        query(prepareRequest() + " to port $port")
    }

    // let() 함수로 쓴 같은 코드:
    val letResult = service.let {
        it.port = 8080
        it.query(it.prepareRequest() + " to port ${it.port}")
    }
//sampleEnd
    println(result)
    println(letResult)
}

run을 비확장 함수로 호출할 수도 있어요. 비확장 run은 컨텍스트 객체가 없지만 여전히 람다 결과를 반환해요. 비확장 run은 표현식이 필요한 위치에서 여러 문을 실행할 수 있게 해 줘요. 코드에서 비확장 run은 " 코드 블록을 실행하고 결과를 계산하라"처럼 읽으면 돼요.

fun main() {
//sampleStart
    val hexNumberRegex = run {
        val digits = "0-9"
        val hexDigits = "A-Fa-f"
        val sign = "+-"

        Regex("[$sign]?[$digits$hexDigits]+")
    }

    for (match in hexNumberRegex.findAll("+123 -FFFF !%*& 88 XYZ")) {
        println(match.value)
    }
//sampleEnd
}

apply

  • 컨텍스트 객체가 리시버(this)로 제공돼요.
  • 반환 값은 객체 자체예요.

apply는 컨텍스트 객체 자체를 반환하므로, 값을 반환하지 않고 주로 리시버 객체의 멤버에 대해 작동하는 코드 블록에 쓰는 걸 권장해요. apply의 가장 흔한 사용 사례는 객체 구성(configuration)이에요. 이런 호출은 " 객체에 다음 대입들을 적용하라"로 읽을 수 있어요.

data class Person(var name: String, var age: Int = 0, var city: String = "")

fun main() {
//sampleStart
    val adam = Person("Adam").apply {
        age = 32
        city = "London"        
    }
    println(adam)
//sampleEnd
}

apply의 또 다른 사용 사례는 더 복잡한 처리를 위해 apply를 여러 호출 체인에 포함시키는 거예요.

also

  • 컨텍스트 객체가 인자(it)로 제공돼요.
  • 반환 값은 객체 자체예요.

also는 컨텍스트 객체를 인자로 받는 어떤 동작을 수행할 때 유용해요. 객체의 프로퍼티나 함수가 아니라 객체 자체에 대한 참조가 필요한 동작, 또는 바깥 스코프의 this 참조를 가리고 싶지 않을 때 also를 쓰면 돼요.

코드에서 also를 보면 " 그리고 객체로 다음을 함께 하라"처럼 읽으면 돼요.

fun main() {
//sampleStart
    val numbers = mutableListOf("one", "two", "three")
    numbers
        .also { println("The list elements before adding new one: $it") }
        .add("four")
//sampleEnd
}

takeIf와 takeUnless

스코프 함수 외에도 표준 라이브러리에는 takeIftakeUnless가 있어요. 이 함수들은 호출 체인 안에 객체 상태에 대한 검사를 넣을 수 있게 해 줘요.

객체에서 predicate와 함께 호출하면, takeIf는 주어진 predicate를 만족하면 그 객체를 반환하고, 그렇지 않으면 null을 반환해요. 즉 takeIf는 단일 객체에 대한 필터링 함수예요.

takeUnlesstakeIf의 반대 로직이에요. predicate와 함께 호출하면, 주어진 predicate를 만족하면 null을 반환하고, 그렇지 않으면 객체를 반환해요.

takeIftakeUnless를 쓸 때 객체는 람다 인자(it)로 제공돼요.

import kotlin.random.*

fun main() {
//sampleStart
    val number = Random.nextInt(100)

    val evenOrNull = number.takeIf { it % 2 == 0 }
    val oddOrNull = number.takeUnless { it % 2 == 0 }
    println("even: $evenOrNull, odd: $oddOrNull")
//sampleEnd
}

takeIftakeUnless 뒤에 다른 함수를 체이닝할 때는 널 검사 없이 그냥 쓰면 안 돼요. 반환 값이 널 가능이기 때문에 null 검사를 하거나 안전 호출(?.)을 써야 해요.

fun main() {
//sampleStart
    val str = "Hello"
    val caps = str.takeIf { it.isNotEmpty() }?.uppercase()
   //val caps = str.takeIf { it.isNotEmpty() }.uppercase() //컴파일 오류
    println(caps)
//sampleEnd
}

takeIftakeUnless는 스코프 함수와 함께 쓸 때 특히 유용해요. 예를 들어 takeIf·takeUnlesslet과 함께 체이닝해서, 주어진 predicate를 만족하는 객체에 대해서만 코드 블록을 실행할 수 있어요. 이렇게 하려면 객체에 takeIf를 호출하고 그다음 let을 안전 호출(?.)로 호출하면 돼요. predicate를 만족하지 않는 객체에 대해서는 takeIfnull을 반환하므로 let이 호출되지 않아요.

fun main() {
//sampleStart
    fun displaySubstringPosition(input: String, sub: String) {
        input.indexOf(sub).takeIf { it >= 0 }?.let {
            println("The substring $sub is found in $input.")
            println("Its start position is $it.")
        }
    }

    displaySubstringPosition("010000011", "11")
    displaySubstringPosition("010000011", "12")
//sampleEnd
}

비교를 위해, 같은 함수를 takeIf나 스코프 함수 없이 쓴 예제를 볼게요.

fun main() {
//sampleStart
    fun displaySubstringPosition(input: String, sub: String) {
        val index = input.indexOf(sub)
        if (index >= 0) {
            println("The substring $sub is found in $input.")
            println("Its start position is $index.")
        }
    }

    displaySubstringPosition("010000011", "11")
    displaySubstringPosition("010000011", "12")
//sampleEnd
}

더 알아보기 (Learn more)

  • 옵트인 요구사항 — 실험적 API를 쓸 때 적용되는 규칙을 설명해요.
  • 시간 측정measureTimeMillis 같은 표준 라이브러리의 시간 측정 함수를 소개해요.