한동안 안드로이드와 코틀린을 업무에 있어서 사용하면서 코틀린 내부 구현이나 동작을 자세하게 공부할 기회가 없었습니다.
그러다보니 제대로 공부하지 않고 감으로 사용하던 코드들이 많아졌고, 이 메서드를 왜 이렇게 사용한걸까 싶은 경우들이 많아졌습니다.
그래서!! 이번 포스팅에서는 정말 많이 사용하지만, 자세하게 공부하지는 않았었던 범위지정함수에 대해서 공식 문서를 번역해 보면서 알아보려고 합니다!
(의역 혹은 원문에는 없는 추가 코멘트들이 다수 포함될 수 있으니 참고 바랍니다!!)
더 원활한 이해를 위해 원문을 읽고 싶으신 분들을 위해 하단에 공식문서 링크도 함께 첨부드립니다~~ 😄
Scope functions | Kotlin
kotlinlang.org
목차
0. 범위지정함수가 뭔데?
코틀린에서의 범위지정함수는 람다 표현식을 통해 임시 스코프를 만들고 그 안에서 이름 없이 객체에 접근할 수 있도록 도와주는 함수를 말합니다.
let, run, with, apply, also 총 5종류가 존재하고, 모두 동일하게 내부 블록(람다 표현식)을 실행하도록 동작합니다.
각각은 블록 내에서 어떻게 객체를 이용할 수 있는지와 전체 표현식의 반환값이 무엇인지에 따라 구분됩니다.
이 부분은 가장 중요한 설명 부분이므로 단순히 의역하기보다는 보다 명확하게 설명하는게 좋을 것 같아서, 특별히 코틀린 공식 홈페이지의 원문을 가져왔습니다. (이 문단 뒤부터 원문은 생략합니다 ㅋㅋㅋ)
The Kotlin standard library contains several functions whose sole purpose is to execute a block of code within the context of an object. When you call such a function on an object with a lambda expression provided, it forms a temporary scope. In this scope, you can access the object without its name. Such functions are called scope functions. There are five of them: let, run, with, apply, and also.
Basically, these functions all perform the same action: execute a block of code on an object. What's different is how this object becomes available inside the block and what the result of the whole expression is.
아래 예시는 let이라는 범위지정함수를 사용한 예입니다.
물론 범위지정함수를 쓰지 않더라도 동일하게 동작하는 코드를 만들 수도 있기 때문에, 해당 케이스도 예시에 같이 포함시켰습니다.
// 범위지정함수 let 사용
Person("Alice", 20, "Amsterdam").let {
println(it)
it.moveTo("London")
it.incrementAge()
println(it)
}
// 범위지정함수 미사용
val alice = Person("Alice", 20, "Amsterdam")
println(alice)
alice.moveTo("London")
alice.incrementAge()
println(alice)
위 예시를 봤을 때 큰 차이가 느껴지지 않을 수도 있습니다.
하지만 let이 없는 아래 코드의 경우 alice라는 변수를 선언하고 이것을 반복해서 사용해야 한다는 것을 볼 수 있습니다.
즉 let을 사용함으로써 코드가 비교적 짧아지거나 가독성이 높아질 수 있다는 것을 확인할 수 있습니다.
5개의 범위지정함수 중에서 어떤 것을 써야하는 지는 딱히 정답이 정해져있는 것은 아닙니다. 개발자가 직접 판단해서 이 곳에는 이걸 쓰는게 더 좋은 것 같으면 쓰면 되는 것이죠.
이걸 위해서 범위지정함수가 어떤 차이가 있는지, 각각 어떤 방식으로 동작하는지에 대해서 보다 자세히 알아볼 필요가 있습니다!
1. 먼저 간단히 요약좀..
각각의 범위지정함수에 대해서 자세하게 알아보기 전에, 먼저 표를 통해 간단하게 차이점부터 알아보고 갑시다!
아래에서 더 자세히 다룰 예정이니, 여기서 짧게 다룬다고 너무 걱정하지 않으셔도 됩니다~
함수 | 객체 참조 | 반환값 | 확장함수 여부 |
let | it | 람다 결과값 | O |
run | this | 람다 결과값 | O |
run | - | 람다 결과값 | X (단독으로 사용 가능) |
with | this | 람다 결과값 | X (매개변수로 객체를 받음) |
apply | this | 컨텍스트 객체 | O |
also | it | 컨텍스트 객체 | O |
공식 문서에서는 일반적으로 사용되는 케이스에 대해서도 짧게 알려주고 있습니다.
- let : non-nullable한 객체에 대해서 람다 표현식을 실행하고 싶을 때 / 지역 스코프에서 표현식을 변수 형태로 사용하고 싶을 때
- apply : 객체를 정의할 때
- run : 객체를 정의하고 결과값을 연산해야할 때 / 표현식이 필요한 명령문을 실행할 때 (단독 사용 시)
- also : 추가적인 effect가 존재할 때 (side-effect의 그 effect입니다)
- with : 객체에서 함수 호출을 묶고싶을 때
반드시 위 상황에서만 써야하는 것은 아니고, 팀 내에 특정 컨벤션이 존재하거나 한다면 거기에 따라가도 좋습니다.
하지만 범위지정함수를 남발하게 되면 오히려 코드의 가독성을 해치거나 잘못 사용하게 되어 에러가 발생할 수도 있습니다.
그리고 중첩해서 범위지정함수를 사용하는 것도 코드의 이해도를 낮추거나, 범위지정함수가 대상으로 하는 객체, 혹은 스코프 내부에서 사용하는 this, it과 같은 객체 참조 요소에 대한 혼동을 줄 수 있으니 주의해야 합니다.
2. 대충 알았으니까 무슨 차이가 있는건데?
범위지정함수는 일반적으로 비슷하게 보이지만, 크게 객체 참조 방식과 반환값 두 가지 형태로 나눌 수 있습니다.
사진으로 미리 정리해보면 아래와 같습니다!

2.0. 객체 참조 방식: this / it
객체 참조 방식에 있어서 this를 쓰는 범위지정함수와 it을 쓰는 범위지정함수로 나뉩니다.
this의 경우 람다 수신자(객체 참조자), it은 람다 인수(람다 매개변수)라고 부릅니다.
아래에서 두 케이스에 대한 사용법과 장단점 등을 알아봅시다.
fun main() {
val str = "Hello"
// this를 사용하는 run
str.run {
println("The string's length: $length")
//println("The string's length: ${this.length}") // 이렇게도 가능!!
}
// it을 사용하는 let
str.let {
println("The string's length is ${it.length}")
}
}
👉 2.0.0. this
this를 사용하는 케이스는 run, with, apply가 있습니다.
세 가지 케이스에서는 람다 표현식 내부에서 일반적인 클래스 함수들처럼 객체를 사용할 수 있습니다.
val adam = Person("Adam").apply {
age = 20 // this.age = 20와 동일!!
city = "London"
}
println(adam)
👉 2.0.1. it
it을 사용하는 케이스는 let과 also가 있습니다.
두 케이스에서는 인수(매개변수) 형태로 객체를 참조할 수 있고, 그렇기 때문에 명시적으로 이름도 설정할 수 있습니다.
만약 명시적으로 이름을 설정하지 않으면 it 이라는 기본 이름으로 참조할 수 있습니다.
// 암시적(it)으로 인수를 설정했을 경우
fun getRandomInt(): Int {
return Random.nextInt(100).also {
writeToLog("getRandomInt() generated value $it")
}
}
val i = getRandomInt()
println(i)
// 명시적(value)으로 인수를 설정했을 경우
fun getRandomInt(): Int {
return Random.nextInt(100).also { value ->
writeToLog("getRandomInt() generated value $value")
}
}
val i = getRandomInt()
println(i)
it은 this보다 짧고, 가독성이 좋습니다.
하지만 this의 경우 객체의 함수나 속성값(property)를 사용할 때는 아예 생략해버릴 수 있다는 장점이 있습니다.
그러므로 함수의 인자로 들어가야 한다거나, 코드블럭 내에서 여러 변수를 사용해야 한다거나하는 케이스에서 it이 더 좋습니다.
2.1. 반환값: 객체 / 람다 표현식
반환값의 유무로도 범위지정함수를 구분할 수 있습니다.
apply와 also는 객체를 그대로 반환하고 let, run, with은 람다 표현식의 결과값을 반환합니다.
즉 let, run, with은 반환값이 없을 수도 있습니다.
그렇기 때문에 다음에 이 결과값으로 무엇을 할 지에 따라 사용하는 범위지정함수를 잘 결정해야 합니다.
👉 2.1.0. 객체 반환
apply와 also는 객체를 반환하기 때문에 chain 함수를 사용할 수도 있습니다.
아래 예시처럼 람다 표현식을 마치고 정렬을 한다거나.. 할 수 있는 거죠.
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()
아니면 object의 함수 반환문 형태로도 사용할 수 있습니다. (반환할 때 어떠한 동작을 추가한 다음 반환할 수 있습니다!!)
fun getRandomInt(): Int {
return Random.nextInt(100).also {
writeToLog("getRandomInt() generated value $it")
}
}
val i = getRandomInt()
👉 2.1.1. 람다 표현식 반환
let, run, with은 람다 표현식을 반환하기 때문에 따로 변수에 결과값을 할당해 주어야만 결과값을 이어서 사용할 수 있습니다.
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.")
아예 반환을 하지 않고 임시 스코프 형태로 사용할 수도 있습니다.
val numbers = mutableListOf("one", "two", "three")
with(numbers) {
val firstItem = first()
val lastItem = last()
println("First item: $firstItem, last item: $lastItem")
}
3. 각각의 함수들에 대해서 자세히 알려줘!
이제 5가지의 범위지정함수에 대해서 좀 더 디테일하게 뜯어봅시다.
3.0. let
let은 객체 참조는 it으로 가능하고, 람다 표현식을 반환합니다.
보통 하나 혹은 여러 함수들이 연속해서 동작할 때 사용합니다.
예를 들면 두 collections가 존재하고, 결과값을 출력해야 한다고 가정해보겠습니다.
val numbers = mutableListOf("one", "two", "three", "four", "five")
val resultList = numbers.map { it.length }.filter { it > 3 }
println(resultList)
let을 사용하면 resultList를 변수에 할당하지 않고도 똑같이 동작하는 코드를 만들 수 있습니다.
val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let {
println(it)
// 필요하면 여기에서 함수를 더 호출할 수도 있다!
}
만약 let 안에 하나의 함수만 들어가는 경우, 메서드 참조(::)를 사용해서 더 간단히 표현할 수도 있습니다.
val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let(::println)
그리고 let은 non-null값들을 담은 코드블럭을 실행할 때도 자주 사용됩니다.
non-null object에서만 실행되게 하기 위해 safe call operator(?.)를 사용할 수 있습니다.
val str: String? = "Hello"
// str은 String? 타입으로 null이 올 수 있어서 compilation error가 발생!!
// processNonNullString(str)
// 'it' 은 '?.let { }' 블럭 내에서 not-null을 보장받을 수 있다!!
val length = str?.let {
println("let() called on $it")
processNonNullString(it)
it.length
}
또한 제한된 스코프 안에서 변수를 선언해서 코드의 가독성을 높이는 데에 let을 사용할 수도 있습니다.
객체에 새로운 변수를 정의하기 위해서 람다 표현식의 인수(매개변수)에 이름을 부여하여 it 대신 그 이름으로 대신 사용할 수도 있습니다.
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'")
3.1. with
with은 객체 참조는 this로 가능하고, 람다 표현식을 반환합니다.
특이한 점이라면, 확장함수 형태로 사용하지 않습니다. 대신 객체를 인수(매개변수) 형태로 전달합니다.
with은 객체의 메서드를 사용하면서 반환값을 반환하지 않는 경우 사용하는 것을 추천합니다.
with은 코드상에서 "매개변수로 받은 객체와 함께, 람다 표현식을 실행합니다!" 로도 읽힐 수 있습니다.
val numbers = mutableListOf("one", "two", "three")
with(numbers) {
println("'with' is called with argument $this")
println("It contains $size elements")
}
with은 값을 연산하는데 사용되는 속성이나 함수를 가진 helper 객체를 선언하기 위해서도 사용됩니다.
val numbers = mutableListOf("one", "two", "three")
val firstAndLast = with(numbers) {
"The first element is ${first()}," +
" the last element is ${last()}"
}
println(firstAndLast)
3.2. run
run은 객체 참조는 this로 가능하고, 람다 표현식을 반환합니다.
그럼 with이랑 똑같은 게 아니냐?? 라고 생각할 수도 있겠지만, run은 확장함수 형태로 사용합니다.
그래서 let처럼 객체 뒤에 .run { } 과 같은 형태로 사용할 수 있습니다.
run은 람다 표현식 내에서 객체를 초기화하고 반환값을 연산해야 하는 경우 유용하게 사용될 수 있습니다.
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}")
}
또 특이한 점이라면, run은 확장함수가 아닌 형태로도 사용할 수 있습니다.
확장함수가 아닌 형태의 run은 부모 객체가 없지만, 람다 표현식의 반환값은 존재합니다.
그래서 표현식이 필요한 곳에서 여러 선언문을 포함한 블록을 실행할 수 있게 도와줍니다.
확장함수가 아닌 형태의 run은 코드 상에서 "코드 블럭을 run 하고 결과를 도출합니다!"로 읽힐 수 있습니다.
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)
}
3.3. apply
apply는 객체 참조는 this로 가능하고, 객체를 반환합니다.
객체를 그대로 반환하기 때문에, 객체의 멤버 변수를 주로 실행하면서 값을 반환하지 않는 케이스에 사용하는 것을 권장합니다.
가장 대표적으로 많이 쓰이는 케이스가 객체 구성(설정)입니다.
이러한 동작은 "객체에 다음과 같은 할당을 적용합니다!"로 읽힐 수 있습니다.
val adam = Person("Adam").apply {
age = 32
city = "London"
}
println(adam)
또 다른 사용 예시의 경우 복잡한 처리 과정을 위해 여러번 체이닝을 하는 것입니다.
(이건 원문에 따로 예시가 없어서 직접 만들었습니다!)
val adam = Person("Adam").plusAge().celebrate().apply {
status = "adult"
}
println(adam)
3.4. also
also는 객체 참조는 it으로 가능하고, 객체를 반환합니다.
also는 객체를 인수로 사용하는 일부 작업을 수행하는 데 유용합니다.
객체의 속성이나 함수가 아닌 객체 자체에 대한 참조가 필요하거나, 외부 스코프의 this 참조를 가리지 않으려는 경우 also를 사용하는 것을 권장합니다.
이러한 동작은 "그 객체로 다음의 작업을 수행합니다!"로 읽힐 수 있습니다.
val numbers = mutableListOf("one", "two", "three")
numbers
.also { println("The list elements before adding new one: $it") }
.add("four")
4. 번외로 알아보는 takeIf와 takeUnless
위에서 공부한 범위지정함수 외에도 표준 라이브러리에는 takeIf와 takeUnless라는 함수가 포함되어 있습니다.
이 두 함수를 통해 객체 상태를 검사할 수 있습니다.
takeIf는 객체의 확장함수 형태로 조건식과 함께 호출되었을 때 해당 조건을 만족하면 해당 객체를 반환하고, 만족하지 못하면 null을 반환합니다.
그래서 takeIf는 단일 객체를 위한 필터링 함수입니다.
takeUnless는 takeIf와 반대의 로직으로 동작합니다.
조건을 만족하면 null, 만족하지 않으면 객체를 반환합니다.
두 케이스 모두에서 객체는 람다 표현식 내에서 it으로 접근할 수 있습니다.
val number = Random.nextInt(100)
val evenOrNull = number.takeIf { it % 2 == 0 }
val oddOrNull = number.takeUnless { it % 2 == 0 }
println("even: $evenOrNull, odd: $oddOrNull")
❗ 여기서 한 가지 주의해야 하는 점이라면, takeIf나 takeUnless 뒤에 다른 함수를 체이닝할 때는
safe call operator(?.)을 사용하거나 null 체크를 하는 것을 잊지 마세요!!
(takeIf랑 takeUnless는 nullable한 반환값을 주니까!!!)
val str = "Hello"
val caps = str.takeIf { it.isNotEmpty() }?.uppercase()
// str이 empty면 null에 대해 .uppercase()를 할 수 없어서 compilation 에러가 발생한다!!
// val caps = str.takeIf { it.isNotEmpty() }.uppercase()
println(caps)
takeIf와 takeUnless는 범위지정함수와 결합할 때 굉장히 유용합니다.
예를 들어 takeIf, takeUnless와 let을 체이닝하면 특정 조건에 만족하는 객체에 대해서만 코드 블럭을 실행할 수 있습니다.
이걸 위해 takeIf를 호출한 이후 safe call operator(?.)와 함께 let을 호출하면 됩니다.
조건에 만족하지 않는 객체에 대해서는 takeIf가 null을 반환하게 되고 let이라는 범위지정함수의 람다 표현식은 실행되지 않겠죠?
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")
비교를 위해서 아래 예시는 takeIf와 범위지정함수를 사용하지 않으면 어떻게 되는지를 보여줍니다.
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")
최근댓글