Kotlin의 Scope Functions: let, run, apply, also, with
1. 개요
Kotlin에는 매우 비슷해 보이는 다섯 개의 함수가 있습니다: let, run, with, apply, also. 이들은 모두 객체에서 코드 블록을 실행하지만, 객체에 접근하는 방식과 함수가 반환하는 값이 다릅니다. 많은 사람들이 아무렇게나 하나를 선택하고, 코드는 여전히 실행되지만 읽기가 더 어려워집니다.
이 글에서는 scope function을 구분하는 두 가지 기준을 설명하고 각 함수를 구체적인 예제로 보여줍니다. 그 다음 테스트를 작성해 동작을 검증하고 자주 하는 실수를 봅니다. 읽고 나면 각 상황에 맞는 함수를 선택할 수 있을 것입니다.
2. 두 가지 구분 기준
모든 scope function은 lambda를 받아 context object 위에서 그 lambda를 호출합니다. 당신은 두 가지 질문에만 답하면 됩니다:
- lambda 내에서 객체에
this(receiver)를 통해 접근하는가, 아니면it(매개변수)를 통해 접근하는가? - 함수가 lambda의 결과를 반환하는가, 아니면 객체 자체를 반환하는가?
| 함수 | Context object | 반환 값 | Extension인가? |
|---|---|---|---|
let |
it |
lambda의 결과 | 예 |
run |
this |
lambda의 결과 | 예 (extension이 아닌 버전도 있음) |
with |
this |
lambda의 결과 | 아니오, object가 매개변수 |
apply |
this |
객체 자체 | 예 |
also |
it |
객체 자체 | 예 |
빠르게 기억하기 위해 쌍으로 생각할 수 있습니다:
apply와also는 객체를 반환하므로, 객체를 설정하거나 호출 체인 중간에 부작용을 삽입할 때 적합합니다.let,run,with는 결과를 반환하므로, 객체를 다른 값으로 변환할 때 적합합니다.
아래 예제들은 몇 가지 간단한 클래스를 공유합니다:
data class Customer(val name: String, val email: String?)
class Order(val id: String) {
val items = mutableListOf<Double>()
var discountPercent = 0
}
class Invoice {
var number: String = ""
var amount: Double = 0.0
var paid: Boolean = false
}
3. let: null 값 처리 및 변환
let은 객체를 lambda에 it이라는 이름으로 전달하고 lambda의 결과를 반환합니다. 가장 일반적인 사용법은 safe call ?.과 조합해서 값이 null이 아닐 때만 코드를 실행하는 것입니다:
fun sendWelcome(customer: Customer) {
customer.email?.let { email ->
println("환영 이메일을 $email로 보냅니다")
}
}
fun main() {
sendWelcome(Customer("An", "an@example.com"))
sendWelcome(Customer("Bình", null))
}
환영 이메일을 an@example.com로 보냅니다
고객 Bình의 경우 email은 null입니다. ?. 표현식이 여기서 멈추고 lambda는 실행되지 않습니다. lambda 내에서 email의 타입은 String?이 아니라 String이므로 추가 확인 없이 사용할 수 있습니다. 여기서 매개변수 이름을 it 대신 email로 지정했으므로 코드를 더 쉽게 읽을 수 있습니다.
let은 또한 중간 변수를 만들지 않고 값을 변환하려고 할 때 유용합니다:
val domain = Customer("An", "an@example.com").email
?.let { it.substringAfter("@") }
?: "알 수 없음"
println(domain) // example.com
4. run: 객체 기반 계산
run은 let과 비슷하지만, 객체에 this를 통해 접근한다는 차이가 있습니다. this는 생략할 수 있으므로 객체의 속성과 메서드를 직접 호출할 수 있습니다:
val order = Order("ORD-42").apply {
items += listOf(100_000.0, 50_000.0)
discountPercent = 10
}
val total = order.run {
val subtotal = items.sum()
subtotal - subtotal * discountPercent / 100
}
println(total) // 135000.0
run 블록 내에서 items와 discountPercent는 order의 속성입니다. 반환 값은 마지막 표현식으로, 할인 후 금액입니다.
run은 extension이 아닌 버전도 있습니다. 여러 문장을 한데 모아 값을 생성할 때 사용하며, 임시 변수가 외부 scope로 새어 나가지 않습니다:
val connectionUrl = run {
val host = System.getenv("DB_HOST") ?: "localhost"
val port = System.getenv("DB_PORT") ?: "5432"
"jdbc:postgresql://$host:$port/shop"
}
이 블록 이후 host와 port는 더 이상 존재하지 않습니다. connectionUrl만 남습니다.
5. with: 같은 객체에 여러 호출 묶기
with는 run처럼 작동하지만 extension이 아닙니다. 객체는 첫 번째 매개변수로 전달됩니다:
val summary = with(order) {
"주문 $id: ${items.size}개 상품, 할인 $discountPercent%"
}
println(summary)
주문 ORD-42: 2개 상품, 할인 10%
의미상으로 with(order) { ... }는 “이 order로 하다”라고 읽습니다. 이 함수는 객체가 확실히 null이 아니고 여러 멤버를 호출해야 할 때 적합합니다. 객체가 null일 수 있다면 order?.run { ... }을 사용해야 합니다. with는 ?.과 조합할 수 없기 때문입니다.
6. apply: 객체 설정
apply는 this를 통해 객체에 접근하고 객체 자체를 반환합니다. 이것은 객체를 하나의 표현식에서 초기화하고 설정하는 관용적 방법입니다:
val invoice = Invoice().apply {
number = "INV-001"
amount = 250_000.0
paid = false
}
println("${invoice.number} - ${invoice.amount}") // INV-001 - 250000.0
lambda의 결과는 무시되고 apply는 항상 새로 만든 Invoice를 반환합니다. Spring에서 bean을 설정할 때처럼 Java 클래스에서 setter를 사용할 때 자주 apply를 만날 것입니다:
@Bean
fun restTemplate(): RestTemplate = RestTemplate().apply {
requestFactory = SimpleClientHttpRequestFactory().apply {
setConnectTimeout(2_000)
setReadTimeout(5_000)
}
}
7. also: 호출 체인을 유지하면서 부작용 추가
also는 it을 통해 객체에 접근하고 객체 자체도 반환합니다. 이 함수는 로깅, 검증, 메트릭 호출과 같이 “틈새 작업”에 적합합니다. 이러한 작업들이 메인 흐름을 변경하지 않을 때입니다:
fun createOrder(id: String): Order =
Order(id).also { println("주문 ${it.id}이(가) 생성되었습니다") }
fun main() {
val order = createOrder("ORD-7")
.also { it.items += 99_000.0 }
.also { println("상품 개수: ${it.items.size}") }
}
주문 ORD-7이(가) 생성되었습니다
상품 개수: 1
객체에 it을 통해 접근하므로, 외부 클래스의 this를 혼동하지 않고도 사용할 수 있습니다. 이것이 lambda가 주로 객체를 사용하고 설정하지 않을 때 also가 apply보다 읽기 쉬운 이유입니다.
한 가지 팁은 also를 사용해 두 변수를 교환하는 것입니다:
var a = 1
var b = 2
a = b.also { b = a }
println("$a $b") // 2 1
표현식 b.also { ... }는 b의 이전 값(2)을 반환하고, lambda는 a에 대한 할당이 발생하기 전에 b = a(1)을 할당합니다.
8. 어느 함수를 선택할까요?
다음 규칙을 사용할 수 있습니다:
- 값이
null이 아닐 때 코드를 실행하거나 값을 변환해야 할 때:?.let사용. - 객체의 여러 멤버로부터 값을 계산해야 할 때:
run사용, 또는 객체가 확실히null이 아니면with사용. - 여러 문장을 하나의 표현식으로 모아야 할 때: receiver가 없는
run { }사용. - 객체를 초기화하거나 설정해야 할 때:
apply사용. - 로깅, 검증 또는 객체를 유지하면서 부작용을 추가해야 할 때:
also사용.
9. 동작 검증
반환 값을 올바르게 이해했는지 확인하려면 몇 가지 테스트를 작성하세요. 먼저 pom.xml에 dependency를 추가합니다:
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>2.0.21</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Spring Boot 3.x 프로젝트인 경우 spring-boot-starter-test에 JUnit 5와 AssertJ가 이미 포함되어 있으므로 이 두 라이브러리를 별도로 선언할 필요가 없습니다.
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class ScopeFunctionsTest {
@Test
fun `apply와 also는 객체 자체를 반환한다`() {
val invoice = Invoice()
val fromApply = invoice.apply { amount = 100.0 }
val fromAlso = invoice.also { it.paid = true }
assertThat(fromApply).isSameAs(invoice)
assertThat(fromAlso).isSameAs(invoice)
assertThat(invoice.amount).isEqualTo(100.0)
assertThat(invoice.paid).isTrue()
}
@Test
fun `let run with는 lambda의 결과를 반환한다`() {
val order = Order("ORD-1").apply { items += listOf(10.0, 20.0) }
val count: Int = order.let { it.items.size }
val sum: Double = order.run { items.sum() }
val label: String = with(order) { "Order $id" }
assertThat(count).isEqualTo(2)
assertThat(sum).isEqualTo(30.0)
assertThat(label).isEqualTo("Order ORD-1")
}
@Test
fun `값이 null일 때 let이 실행되지 않는다`() {
var called = false
Customer("Bình", null).email?.let { called = true }
assertThat(called).isFalse()
}
}
첫 번째 테스트는 isSameAs를 사용하여 apply와 also가 사본이 아닌 원래 참조를 반환하는지 확인합니다. 두 번째 테스트는 각 함수가 lambda의 결과를 반환한다는 것을 명확히 하기 위해 명시적 타입(Int, Double, String)을 선언합니다. 세 번째 테스트는 ?.let이 null 값을 건너뛴다는 것을 확인합니다.
10. 자주 하는 실수
10.1. apply 내에서 this가 가려진다
apply 내에서는 가장 가까운 receiver가 우선됩니다. 따라서 외부 클래스에서 같은 이름의 property가 가려집니다:
class InvoiceFactory(private val amount: Double) {
fun create() = Invoice().apply {
amount = amount // Invoice.amount를 자신에게 할당하면 결과는 여전히 0.0입니다
}
}
두 가지 해결 방법이 있습니다. 첫 번째는 this@InvoiceFactory.amount로 receiver를 명시하는 것입니다. 두 번째는 also로 전환하는 것입니다:
fun create() = Invoice().also { it.amount = amount }
10.2. ?.let { } ?: ... 함정
많은 사람들이 x?.let { ... } ?: fallback을 if/else와 같은 의미로 작성합니다. 이 방법은 동등하지 않습니다. lambda가 null을 반환하면 ?: 분기도 실행됩니다:
val cache = mapOf<String, String>()
val email: String? = "an@example.com"
email?.let { cache[it] } ?: println("이메일이 없습니다")
이메일이 없습니다
여기서 email은 null이 아니지만 cache[it]은 null을 반환하므로, 메시지는 여전히 출력됩니다. 명확한 분기가 필요하면 if (email != null) ... else ...를 사용하세요.
10.3. 너무 많이 중첩된다
scope function을 중첩할 때 it과 this가 여러 개체를 가리키므로 독자는 각각이 무엇인지 다시 확인해야 합니다:
// 읽기 어렵습니다
order.let { o -> o.items.firstOrNull()?.let { price -> price.run { this * 2 } } }
주의: 한 레벨 이상 중첩하면 안 됩니다. 중첩이 필요하면
it대신 명시적인 매개변수 이름을 사용하세요. 더 좋은 것은 지역 변수나 별도의 함수로 분리하는 것입니다.
10.4. 사용할 수 있다는 이유만으로 scope function을 사용한다
val name = customer.let { it.name }은 val name = customer.name보다 나을 게 없습니다. scope function은 임시 변수를 제거하거나, null을 처리하거나, 설정 코드를 결합할 때 유용합니다. 이러한 작업을 하지 않으면 사용하지 마세요.
11. 결론
Kotlin의 다섯 scope function은 오직 두 가지 점에서만 다릅니다: 객체에 this 또는 it으로 접근하는 것, 그리고 함수가 객체를 반환하는가 또는 lambda의 결과를 반환하는가입니다. 여기서 당신은 적절한 사용법을 얻습니다: 설정을 위해 apply, 부작용을 추가하기 위해 also, null 값을 처리하고 변환하기 위해 let, 객체를 기반으로 계산하기 위해 run과 with. 또한 this가 가려지는 것, ?.let ?: 함정, 너무 많은 중첩에 주의해야 합니다. 마지막으로 scope function의 목표는 코드를 읽기 더 쉽게 만드는 것이지, 어떤 대가를 치르더라도 짧게 만드는 것이 아니라는 점을 기억하세요.