확장 함수와 스코프 함수 — let, run, apply, also, with
코틀린 코드를 보다 보면
.let { },.apply { }같은 코드가 끝없이 나온다. 처음엔 "이게 뭔 차이지?" 싶어서 혼란스러웠는데, 결국 두 가지만 기억하면 된다 — 수신 객체를 뭘로 참조하냐(this vs it), 그리고 뭘 반환하냐(수신 객체 vs 람다 결과). 여기에 확장 함수의 원리까지 알면 코틀린의 핵심을 꿰뚫을 수 있다.
확장 함수 — 클래스를 수정하지 않고 기능 추가
확장 함수는 기존 클래스에 새 메서드를 추가하는 것처럼 보이게 해주는 기능이다. 실제로 클래스를 변경하는 건 아니다.
// String에 확장 함수 추가
fun String.addExclamation(): String {
return this + "!"
}
println("Hello".addExclamation()) // Hello!
내부 동작 원리 — 실은 정적 메서드
면접에서 가장 자주 나오는 질문이다. 확장 함수는 컴파일 시 정적 메서드로 변환된다.
// 코틀린
fun String.addExclamation(): String {
return this + "!"
}
이 코드는 컴파일되면 대략 이런 자바 코드가 된다.
// 자바로 디컴파일
public static String addExclamation(String $this) {
return $this + "!";
}
수신 객체(this)가 첫 번째 파라미터로 들어가는 정적 메서드일 뿐이다. 이 사실에서 몇 가지 중요한 특성이 나온다.
확장 함수의 특성과 한계
class Secret {
private val password = "1234"
internal val code = "5678"
}
// 확장 함수에서 private 멤버 접근 불가
fun Secret.hack(): String {
// return password // 컴파일 에러! private 접근 불가
return code // internal은 같은 모듈이면 OK
}
- private/protected 멤버에 접근 불가 — 실제로 클래스 외부에 있으니까
- 멤버 함수와 이름이 겹치면 멤버 함수가 우선 — 확장 함수가 무시됨
- 정적 디스패치 — 런타임 타입이 아니라 컴파일 타임 타입 기준으로 호출
open class Animal
class Dog : Animal()
fun Animal.speak() = "동물"
fun Dog.speak() = "멍멍"
val animal: Animal = Dog()
println(animal.speak()) // "동물" — 컴파일 타임 타입(Animal) 기준!
이 정적 디스패치 특성은 면접에서 까다로운 질문으로 자주 나온다.
실무에서 유용한 확장 함수 예시
// 날짜 포맷팅
fun LocalDateTime.toKoreanString(): String {
return this.format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH:mm"))
}
// 컬렉션 유틸
fun <T> List<T>.secondOrNull(): T? {
return if (size >= 2) this[1] else null
}
// 로깅
fun Any.logDebug(message: String) {
println("[DEBUG][${this::class.simpleName}] $message")
}
스코프 함수 5종 — 비교표부터 보자
스코프 함수를 처음 공부할 때 가장 혼란스러운 건 "뭘 써야 하지?"였다. 결론부터 말하면 두 가지 기준으로 구분한다.
핵심 비교표
| 함수 | 수신 객체 참조 | 반환값 | 주요 용도 |
|---|---|---|---|
| let | it | 람다 결과 | null 검사, 변환 |
| run | this | 람다 결과 | 객체 설정 + 결과 계산 |
| apply | this | 수신 객체 | 객체 초기화 (빌더) |
| also | it | 수신 객체 | 부수 효과 (로깅, 검증) |
| with | this | 람다 결과 | 이미 있는 객체에 여러 작업 |
외우는 팁
- this로 참조: run, apply, with (멤버처럼 접근)
- it으로 참조: let, also (파라미터처럼 접근)
- 수신 객체 반환: apply, also (체이닝에 유리)
- 람다 결과 반환: let, run, with (변환에 유리)
let — null 검사와 변환의 왕
let은 수신 객체를 it으로 참조하고, 람다의 결과를 반환한다.
null 검사 패턴
val name: String? = getUserName()
// null이 아닐 때만 실행
name?.let { validName ->
println("이름: $validName")
saveToDatabase(validName)
}
// 변환 + 기본값
val greeting = name?.let { "안녕, $it" } ?: "이름 없음"
변환 패턴
val numbers = listOf(1, 2, 3)
val result = numbers
.map { it * 2 }
.let { doubled -> "결과: $doubled" }
println(result) // 결과: [2, 4, 6]
run — 설정 + 결과 계산
run은 수신 객체를 this로 참조하고, 람다의 결과를 반환한다.
// 객체 설정 후 결과 반환
val result = StringBuilder().run {
append("Hello")
append(", ")
append("Kotlin")
toString() // 이 값이 반환됨
}
println(result) // Hello, Kotlin
수신 객체 없는 run
// 지역 스코프 생성 용도로도 사용
val hexColor = run {
val red = 255
val green = 128
val blue = 0
String.format("#%02X%02X%02X", red, green, blue)
}
println(hexColor) // #FF8000
apply — 객체 초기화의 정석
apply는 수신 객체를 this로 참조하고, 수신 객체 자체를 반환한다. 빌더 패턴처럼 객체를 설정할 때 쓴다.
val user = User().apply {
name = "심정훈" // this.name = "심정훈"
age = 28 // this.age = 28
email = "test@email.com"
}
// user가 그대로 반환됨
실무 패턴 — RecyclerView, Intent 등
// Android에서 자주 보이는 패턴
val intent = Intent(context, DetailActivity::class.java).apply {
putExtra("USER_ID", userId)
putExtra("FROM", "main")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// HTTP 요청 빌드
val request = Request.Builder().apply {
url("https://api.example.com/users")
header("Authorization", "Bearer $token")
get()
}.build()
apply는 this를 사용하므로 프로퍼티나 메서드를 바로 호출할 수 있어서 초기화 코드가 깔끔해진다.
also — 부수 효과 전문가
also는 수신 객체를 it으로 참조하고, 수신 객체 자체를 반환한다. 체이닝 중간에 로깅이나 검증 같은 부수 효과를 넣을 때 쓴다.
val numbers = mutableListOf(1, 2, 3)
.also { println("초기 리스트: $it") } // 로깅
.also { require(it.isNotEmpty()) { "비어있음" } } // 검증
// 체이닝 중간 디버깅
val result = fetchData()
.also { logger.info("원본 데이터: $it") }
.map { transform(it) }
.also { logger.info("변환된 데이터: $it") }
.filter { it.isValid }
apply vs also — 언제 뭘 쓰나?
// apply — 객체 설정 (this 사용)
val user = User().apply {
name = "심정훈" // this.name
age = 28 // this.age
}
// also — 부수 효과 (it 사용)
val user = User("심정훈", 28).also {
logger.info("사용자 생성: ${it.name}") // it으로 참조
analytics.track("user_created")
}
공부하다 보니 이 구분이 제일 많이 헷갈렸는데, 설정은 apply, 관찰은 also로 외우면 편하다.
with — 이미 있는 객체에 여러 작업
with는 다른 스코프 함수와 달리 확장 함수가 아니라 일반 함수다. 이미 생성된 객체에 여러 작업을 할 때 사용한다.
val user = getUser()
val description = with(user) {
// this로 참조
println("이름: $name")
println("나이: $age")
"사용자: $name ($age살)" // 반환값
}
with vs run
// 거의 같은 동작
val result1 = with(user) { "$name, $age" }
val result2 = user.run { "$name, $age" }
// 차이: with는 null 안전하지 않음
val nullableUser: User? = null
// with(nullableUser) { ... } // 컴파일 에러 또는 NPE
nullableUser?.run { ... } // 안전 호출 가능
Nullable 객체에는 run을 쓰는 게 안전하다.
실무 선택 가이드
어떤 스코프 함수를 써야 할지 빠르게 결정하는 플로우다.
1. null 검사가 필요한가?
└─ Yes → let (?.let { })
2. 객체를 초기화/설정하는가?
└─ Yes → apply
3. 부수 효과(로깅, 검증)를 추가하는가?
└─ Yes → also
4. 객체에서 결과를 계산하는가?
├─ 이미 있는 객체 → with 또는 run
└─ Nullable 객체 → run (?.run { })
스코프 함수 남용 주의
// 나쁜 예 — 스코프 함수 남용으로 가독성 저하
val result = data?.let { d ->
d.process().run {
this.also { println(it) }.let { processed ->
processed.apply { flag = true }
}
}
}
// 좋은 예 — 적절히 분리
val processed = data?.let { it.process() } ?: return
println(processed)
processed.flag = true
스코프 함수를 3단계 이상 중첩하면 오히려 가독성이 떨어진다. 코드 리뷰에서 자주 지적받는 부분이니 주의하자.
확장 함수 + 스코프 함수 조합
실무에서는 확장 함수와 스코프 함수를 조합해서 DSL 같은 깔끔한 API를 만들기도 한다.
// 확장 함수로 유틸 정의
fun String.isValidEmail(): Boolean {
return matches(Regex("^[A-Za-z0-9+_.-]+@(.+)\$"))
}
// 스코프 함수와 조합
fun processRegistration(email: String?): String {
return email
?.takeIf { it.isValidEmail() } // 조건 필터
?.let { validEmail ->
registerUser(validEmail)
"등록 완료: $validEmail"
}
?: "유효하지 않은 이메일"
}
takeIf와 takeUnless도 스코프 함수처럼 유용한데, 조건에 따라 수신 객체를 반환하거나 null을 반환한다.
val number = 42
val even = number.takeIf { it % 2 == 0 } // 42 (조건 충족)
val odd = number.takeUnless { it % 2 == 0 } // null (조건 미충족)
정리
확장 함수와 스코프 함수는 코틀린의 표현력을 높이는 핵심 도구다.
- 확장 함수: 컴파일 시 정적 메서드로 변환, private 멤버 접근 불가, 정적 디스패치
- let:
it+ 람다 결과 반환 → null 검사, 변환 - run:
this+ 람다 결과 반환 → 설정 + 결과 계산 - apply:
this+ 수신 객체 반환 → 객체 초기화 - also:
it+ 수신 객체 반환 → 부수 효과 (로깅, 검증) - with:
this+ 람다 결과 반환 → 이미 있는 객체에 여러 작업
면접에서는 "스코프 함수 5종의 차이"와 "확장 함수가 실제로 어떻게 컴파일되느냐"가 단골 질문이다. 비교표를 그릴 수 있으면 충분하고, 확장 함수가 정적 메서드라는 사실을 알면 정적 디스패치 문제까지 자연스럽게 설명할 수 있다.