Theme:

코틀린 코드를 보다 보면 .let { }, .apply { } 같은 코드가 끝없이 나온다. 처음엔 "이게 뭔 차이지?" 싶어서 혼란스러웠는데, 결국 두 가지만 기억하면 된다 — 수신 객체를 뭘로 참조하냐(this vs it), 그리고 뭘 반환하냐(수신 객체 vs 람다 결과). 여기에 확장 함수의 원리까지 알면 코틀린의 핵심을 꿰뚫을 수 있다.

확장 함수 — 클래스를 수정하지 않고 기능 추가

확장 함수는 기존 클래스에 새 메서드를 추가하는 것처럼 보이게 해주는 기능이다. 실제로 클래스를 변경하는 건 아니다.

KOTLIN
// String에 확장 함수 추가
fun String.addExclamation(): String {
    return this + "!"
}

println("Hello".addExclamation())   // Hello!

내부 동작 원리 — 실은 정적 메서드

면접에서 가장 자주 나오는 질문이다. 확장 함수는 컴파일 시 정적 메서드로 변환된다.

KOTLIN
// 코틀린
fun String.addExclamation(): String {
    return this + "!"
}

이 코드는 컴파일되면 대략 이런 자바 코드가 된다.

JAVA
// 자바로 디컴파일
public static String addExclamation(String $this) {
    return $this + "!";
}

수신 객체(this)가 첫 번째 파라미터로 들어가는 정적 메서드일 뿐이다. 이 사실에서 몇 가지 중요한 특성이 나온다.

확장 함수의 특성과 한계

KOTLIN
class Secret {
    private val password = "1234"
    internal val code = "5678"
}

// 확장 함수에서 private 멤버 접근 불가
fun Secret.hack(): String {
    // return password   // 컴파일 에러! private 접근 불가
    return code          // internal은 같은 모듈이면 OK
}
  • private/protected 멤버에 접근 불가 — 실제로 클래스 외부에 있으니까
  • 멤버 함수와 이름이 겹치면 멤버 함수가 우선 — 확장 함수가 무시됨
  • 정적 디스패치 — 런타임 타입이 아니라 컴파일 타임 타입 기준으로 호출
KOTLIN
open class Animal
class Dog : Animal()

fun Animal.speak() = "동물"
fun Dog.speak() = "멍멍"

val animal: Animal = Dog()
println(animal.speak())    // "동물" — 컴파일 타임 타입(Animal) 기준!

이 정적 디스패치 특성은 면접에서 까다로운 질문으로 자주 나온다.

실무에서 유용한 확장 함수 예시

KOTLIN
// 날짜 포맷팅
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종 — 비교표부터 보자

스코프 함수를 처음 공부할 때 가장 혼란스러운 건 "뭘 써야 하지?"였다. 결론부터 말하면 두 가지 기준으로 구분한다.

핵심 비교표

함수수신 객체 참조반환값주요 용도
letit람다 결과null 검사, 변환
runthis람다 결과객체 설정 + 결과 계산
applythis수신 객체객체 초기화 (빌더)
alsoit수신 객체부수 효과 (로깅, 검증)
withthis람다 결과이미 있는 객체에 여러 작업

외우는 팁

  • this로 참조: run, apply, with (멤버처럼 접근)
  • it으로 참조: let, also (파라미터처럼 접근)
  • 수신 객체 반환: apply, also (체이닝에 유리)
  • 람다 결과 반환: let, run, with (변환에 유리)

let — null 검사와 변환의 왕

let은 수신 객체를 it으로 참조하고, 람다의 결과를 반환한다.

null 검사 패턴

KOTLIN
val name: String? = getUserName()

// null이 아닐 때만 실행
name?.let { validName ->
    println("이름: $validName")
    saveToDatabase(validName)
}

// 변환 + 기본값
val greeting = name?.let { "안녕, $it" } ?: "이름 없음"

변환 패턴

KOTLIN
val numbers = listOf(1, 2, 3)
val result = numbers
    .map { it * 2 }
    .let { doubled -> "결과: $doubled" }

println(result)   // 결과: [2, 4, 6]

run — 설정 + 결과 계산

run은 수신 객체를 this로 참조하고, 람다의 결과를 반환한다.

KOTLIN
// 객체 설정 후 결과 반환
val result = StringBuilder().run {
    append("Hello")
    append(", ")
    append("Kotlin")
    toString()             // 이 값이 반환됨
}
println(result)            // Hello, Kotlin

수신 객체 없는 run

KOTLIN
// 지역 스코프 생성 용도로도 사용
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로 참조하고, 수신 객체 자체를 반환한다. 빌더 패턴처럼 객체를 설정할 때 쓴다.

KOTLIN
val user = User().apply {
    name = "심정훈"          // this.name = "심정훈"
    age = 28                // this.age = 28
    email = "test@email.com"
}
// user가 그대로 반환됨

실무 패턴 — RecyclerView, Intent 등

KOTLIN
// 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으로 참조하고, 수신 객체 자체를 반환한다. 체이닝 중간에 로깅이나 검증 같은 부수 효과를 넣을 때 쓴다.

KOTLIN
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 — 언제 뭘 쓰나?

KOTLIN
// 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는 다른 스코프 함수와 달리 확장 함수가 아니라 일반 함수다. 이미 생성된 객체에 여러 작업을 할 때 사용한다.

KOTLIN
val user = getUser()

val description = with(user) {
    // this로 참조
    println("이름: $name")
    println("나이: $age")
    "사용자: $name ($age살)"     // 반환값
}

with vs run

KOTLIN
// 거의 같은 동작
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을 쓰는 게 안전하다.

실무 선택 가이드

어떤 스코프 함수를 써야 할지 빠르게 결정하는 플로우다.

PLAINTEXT
1. null 검사가 필요한가?
   └─ Yes → let (?.let { })

2. 객체를 초기화/설정하는가?
   └─ Yes → apply

3. 부수 효과(로깅, 검증)를 추가하는가?
   └─ Yes → also

4. 객체에서 결과를 계산하는가?
   ├─ 이미 있는 객체 → with 또는 run
   └─ Nullable 객체 → run (?.run { })

스코프 함수 남용 주의

KOTLIN
// 나쁜 예 — 스코프 함수 남용으로 가독성 저하
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를 만들기도 한다.

KOTLIN
// 확장 함수로 유틸 정의
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"
        }
        ?: "유효하지 않은 이메일"
}

takeIftakeUnless도 스코프 함수처럼 유용한데, 조건에 따라 수신 객체를 반환하거나 null을 반환한다.

KOTLIN
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종의 차이"와 "확장 함수가 실제로 어떻게 컴파일되느냐"가 단골 질문이다. 비교표를 그릴 수 있으면 충분하고, 확장 함수가 정적 메서드라는 사실을 알면 정적 디스패치 문제까지 자연스럽게 설명할 수 있다.

댓글 로딩 중...