코틀린의 Scope 함수를 정리하고 학습한 뒤 각각에 어떤 상황에서 scope 함수를 쓰는것이 좋을지 알아보겠습니다.
Scope 함수란?
Scope 함수(Scope Function) 는 객체를 더 간결하고 읽기 쉽게 다루기 위해,
객체의 컨텍스트(Context) 안에서 람다 블록을 실행하도록 도와주는 Kotlin 표준 라이브러리의 함수입니다.
스코프 함수를 사용한 예를 들면서 한번 볼게요.
val person = Person()
person.name = "Tom"
person.age = 30
person.introduce()
// Scope 함수 적용
val person = Person().apply {
name = "Tom"
age = 30
introduce()
}
매번 person.을 써야 해서 코드가 길고 보기 불편하죠.
이걸 한 번에 묶어서 표현할 수 있는 게 바로 Scope 함수예요.
💡 추가로 알아보면 좋을 것
- 고차 함수(Higher-Order Functions)란?
함수를 인자로 받거나, 함수를 반환하는 함수
즉, 함수를 데이터처럼 다루는 개념fun operate(x: Int, y: Int, operation: (Int, Int) -> Int): Int { // 함수 전달 return operation(x, y) }- 람다 표현식(lambda expression )이란?
이름이 없는 함수(익명 함수, anonymous function)
간단한 함수를 한 줄로 표현하는 문법fun main() { val result = operate(3, 5) { a, b -> a + b } // { paramter -> body } 형식 println(result) // 8 }- 수신 객체란?
수신 객체 타입: 확장함수를 가지게 되는 클래스의 이름
수신 객체: 확장 함수를 호출하는 클래스의 객체
스코프 함수에는 let, run, apply, also, with 가 있어요.
각각은 유사한 기능을 수행하지만 함수 정의와 구현에 각각이 차이가 있어 다르게 사용됩니다.
| 함수(Function) | 객체 참조(Object Reference) | 결과 값(Return Value) | 사용 사례(Common Use Case) |
|---|---|---|---|
| let | it | 람다 마지막행(Lambda result) | null 처리 또는 값 변환 |
| run | this | 람다 마지막행(Lambda result) | 계산 또는 초기화 |
| apply | this | 수신 객체(Object itself, 객체 자체) | 객체 구성 |
| also | it | 수신 객체(Object itself, 객체 자체) | side effect 추가 |
| with | this | 람다 마지막행(Lambda result) | 그룹화 작업 |
inline fun <T, R> with(receiver: T, block: T.() -> R): R { // 수신객체 지정 람다
return receiver.block()
}
inline fun <T> T.also(block: (T) -> Unit): T { // 람다 파라미터
block(this)
return this
}
inline fun <T> T.apply(block: T.() -> Unit): T { // 수신객체 지정 람다
block()
return this
}
inline fun <T, R> T.let(block: (T) -> R): R { // 람다 파라미터
return block(this)
}
inline fun <T, R> T.run(block: T.() -> R): R { // 수신객체 지정 람다
return block()
}Kotlin의 Scope 함수(let, apply, run, also, with)는 객체를 블록 안에서 사용할 수 있는 공통점이 있습니다.
그런데 블록 안에서 그 객체를 부르는 방식(객체 참조)이 두 가지로 나뉘어요.
1️⃣ this — 수신 객체 지정 람다 (Receiver Lambda)
this는 생략 가능위와 같은 특징들로 this 는 주로 함수를 호출하거나 내부에서 프로퍼티를 접근하는 경우 유용합니다.
val person = Person().apply {
name = "안드콩" // this.name
age = 25 // this.age
introduce() // this.introduce()
}
여기서 this 는 Person 가리키게 됩니다. apply 블로 안에서 프로퍼티를 수정하거나 메소드를 바로 호출이 가능하게합니다.
2️⃣ it — 람다 인자 (Lambda Argument)
it은 생략 불가능val person = Person("안드콩", 25)
person.also { it ->
println("이름: ${it.name}, 나이: ${it.age}")
}
val person = Person("안드콩", 25)
person.also { value ->
println("이름: ${value.name}, 나이: ${value.age}")
}
여기서 it 는 person 을 가리키게 됩니다.
그전에 하나 정리하고 가자면 ~
apply also 는 (수신)객체 자체를 반환합니다.let run with 는 람다 결과(람다의 마지막 행) 을 반환합니다.let은 nullable 객체를 처리하거나 값을 변환하는 데 주로 사용합니다.
객체를 it 로 참조하고 람다의 결과를 반환합니다.
3가지 정도의 경우에 사용합니다.
@Composable
fun ProfileScreen(user: User?) { // nullable 처리
user?.let {
Text("이름: ${it.name}")
} ?: Text("사용자 정보가 없습니다.")
}
val nicknameLength = user?.let { it.nickname.length } ?: 0 // 값 변환
getPersonDao().let { dao -> // 지역 스코프 제한
dao.insert(Person("안드콩", 25))
}
run은 this를 사용하여 객체를 참조하고 람다의 결과를 반환합니다.
객체를 초기화하거나 값을 계산하는 데 적합합니다.
val service = MultiportService("https://example.kotlinlang.org", 80)
// 값 초기화 run
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}")
}
이 예제는 값을 초기화 하는 코드입니다. let 으로도 충분히 표현 가능하지만, run 을 사용한 부분이 훨씬 깔끔해 보입니다.
참고로 run 은 확장 함수가 아닌 run 단독으로 쓰일경우 함수로도 사용가능합니다. 이경우 여전히 마지막 줄의 값을 반환합니다. 주로, 계산을 특정 범위 내에서 사용하고 싶을때 사용하면 유용합니다.
val hexNumberRegex = run {
val digits = "0-9"
val hexDigits = "A-Fa-f"
val sign = "+-"
Regex("[$sign]?[$digits$hexDigits]+")
}
apply는 객체를 this로 참조하고 객체 자체를 반환합니다.
어떤 객체의 인스턴스를 생성과 동시에, 변수에 담기 전 초기화를 할 때 주로 사용합니다.
val user = User().apply {
name = "Alice"
age = 25
}
also는 객체를 it로 참조하고 객체 자체를 반환합니다. 로깅 또는 유효성 검사와 같은 Side Effect 처리을 위해 설계되었습니다.
class Book(author: Person) {
val author = author.also {
requireNotNull(it.age)
print(it.name)
}
}
with는 객체를 가져와 this로 참조하고 람다의 결과를 반환합니다. 그룹화 작업에 적합합니다.
Non-nullable (Null 이 될수 없는) 수신 객체 이고 결과가 필요하지 않은 경우에만 with 를 사용합니다.
val person: Person = getPerson()
with(person) {
print(name)
print(age)
}
사실 이외에도 takeIf, takeUnless 등등.. 몇개가 더 있는데요. 이부분은 공식문서를 참고하시면 좋을거 같습니다 !!
Kotlin 공식 문서에서는 이렇게 말합니다
범위 함수는 코드를 간결하게 만들지만,
가독성을 떨어뜨리거나 혼동을 줄 수 있으므로 과도한 사용은 피해야 한다.특히 여러 Scope 함수를 중첩하거나 연쇄 호출(chain) 하는 경우
컨텍스트 객체를 헷갈리기 쉬우니 주의하라.
기본적으로 중첩해서 사용하는 경우는 가독성을 해치기에 지양하라고 이야기 하고 있습니다.
특히 this 의 경우, (수신)객체가 암시적으로 전달되기에, 이를 사용하는 apply, run, with 는 중첩해서는 안됩니다.
also 와 let 을 중첩할 경우에는 it 를 사용해서는 안됩니다. 대신 명식적인 이름을 제공해 코드상 이름이 혼동되지 않게 합니다.
바른 예)
val greeting = userRepository.getUser()
?.also { println("데이터 로드 성공: ${it.name}") }
?.let { user -> "안녕하세요, ${user.name}님!" }
?: "사용자 정보를 불러올 수 없습니다."
다음과 같이 각각의 스코프 함수의 특성에 맞게 적절히 체이닝 하는 것은 좋아보입니다.
스코프 함수를 공부하다 보면 “굳이 이렇게까지 사용할 필요가 있을까?”라는 의문이 들 수 있습니다.
하지만 다양한 스코프 함수의 의도를 이해해두면 다른 사람이 작성한 코드를 읽는 데 큰 도움이 되고 상황에 맞게 적절히 사용했을 때 훨씬 간결하고 코틀린스러운 코드를 작성할 수 있습니다.
공식 문서에서도 다음과 같이 말합니다.
서로 다른 스코프 함수는 사용 사례가 겹칠 수 있으며, 팀이나 프로젝트에서 정한 규칙에 따라 적절한 함수를 선택해 사용할 수 있습니다.
즉, 스코프 함수는 “무조건 이렇게 써야 한다”는 정답이 있는 것이 아니라,
어떤 목적을 가지고 이 스코프 함수를 선택했는지 명확하다면 팀 내 컨벤션에 따라 통일감 있게 사용하는 것이 가장 중요합니다.
올바른 사용 목적을 기반으로 일관성 있게 적용한다면 스코프 함수는 분명 코드 품질을 높이는 데 기여할 수 있습니다.
다음의 링크를 참고했습니다.
Scope functions 코틀린 공식문서
Extensions 코틀린 공식문서
고차함수와 람다 코틀린 공식문서