Kotlin – Scope Function

코틀린을 배우기 위해서 인프런에서 강의를 구매하고 코틀린과 친해지고 기본기를 다지기 위해서 공부하는 중이다. 글 내용은 Kotlin의 Scope Function이고 최태현님의 자바 개발자를 위한 코틀린 입문(Java to Kotlin Starter Guide) 강의에 소금을 조금 친 내용이다

코드를 간결하게 해주는 Scope Function

코틀린의 Scope Function은 일시적인 영역을 형성하는 함수로, 람다를 통해 코드를 간결하게 만들거나 메서드 체이닝에 활용된다. 객체를 this 또는 it으로 참조하여 코드 블록을 실행하며, 람다의 반환값 또는 객체 자체를 반환할 수 있어 다양한 상황에 유용하게 사용된다

Scope Function의 종류 및 활용

Scope Function은 크게 람다 결과 반환과 객체 자체 반환의 두 가지 기준으로 나눌 수 있으며, 객체 참조 방식에 따라 it (단일 파라미터 람다) 또는 this (수신 객체 지정 람다)를 사용한다

함수객체 참조반환값주요 용도
letit람다 결과Non-null 값 처리,
메서드 체인 결과 전달,
일회성 변수
runthis람다 결과객체 초기화 및 결과 계산,
복잡한 람다 로직 실행
applythis객체 자체객체 설정 및 초기화
(빌더 패턴)
alsoit객체 자체부수 효과 (로깅, 디버깅),
객체 검증
withthis람다 결과객체 변환 시 코드 간소화
(확장 함수 아님)

let: Non-null 처리 및 체이닝

  • let 함수는 주로 null이 아닐 때만 코드 블록을 실행하거나, 메서드 체이닝 중간에 값을 변환하여 다음 단계로 전달할 때 유용하다
class Person(val name: String, val age: Int)

fun printPersonLet(person: Person?) {
    person?.let { // person이 null이 아닐 때만 실행
        println(it.name) // it = person
        println(it.age)
    }
}

fun main() {
    val numbers = listOf("one", "two", "three", "four")
    val modifiedFirstItem = numbers.first()
        .let { firstItem -> // 일회성 지역 변수
            if (firstItem.length >= 5) firstItem else "!$firstItem!"
        }.uppercase() // 람다 결과 반환
    println(modifiedFirstItem) // !ONE!
}
시그니처: public inline fun <T, R> T.let(block: (T) -> R): R
  • T 타입의 확장 함수로, (T) -> R 타입의 람다 함수를 받아 람다 실행 결과 R을 반환한다. 람다 내에서 호출 객체는 it으로 접근한다

run: 객체 초기화 및 반환값 계산

  • run 함수는 객체를 초기화하면서 동시에 특정 로직을 수행하고 그 결과를 반환할 때 사용된다. this를 사용하여 객체의 멤버에 직접 접근할 수 있다
// 가상의 PersonRepository
object PersonRepository {
    fun save(person: Person): Person {
        println("Saving ${person.name}")
        return person
    }
}

fun main() {
    val person = Person("혁", 100).run(PersonRepository::save) // 객체 생성과 동시에 저장
    // 또는 추가 설정과 함께
    val anotherPerson = Person("은", 30).run {
        // this는 생략 가능
        println("Setting hobby for $name")
        // this.hobby = "독서" // Person 클래스에 hobby 프로퍼티가 있다면
        PersonRepository.save(this)
    }
    println(person.name) // 혁
}
시그니처: public inline fun <T, R> T.run(block: T.() -> R): R
  • T 타입의 확장 함수로, T.() -> R 타입의 람다 함수(수신 객체 지정 람다)를 받아 람다 실행 결과 R을 반환한다. 람다 내에서 호출 객체는 this로 접근하며 생략 가능하다

apply: 객체 설정 및 초기화

apply 함수는 객체의 속성을 설정하거나 초기화한 후, 객체 자체를 반환할 때 사용된다. 빌더 패턴과 유사하게 객체를 연속적으로 설정할 때 매우 유용하다

class Product(var name: String = "", var price: Int = 0, var category: String = "")

fun createProduct(): Product {
    return Product().apply {
        name = "Laptop" // this 생략
        price = 1200
        category = "Electronics"
    }
}

fun main() {
    val myProduct = createProduct()
    println("${myProduct.name}, ${myProduct.price}, ${myProduct.category}")
    // Laptop, 1200, Electronics
}
시그니처: public inline fun <T> T.apply(block: T.() -> Unit): T
  • T 타입의 확장 함수로, T.() -> Unit 타입의 람다 함수를 받아 객체 자체 T를 반환한다. run과 마찬가지로 this로 접근한다

also: 부수 효과 (로깅, 디버깅)

also 함수는 객체 자체를 반환하면서, 객체에 대한 부수 효과 (side effect)를 수행할 떄 주로 사용된다. 객체의 값을 변경하지 않고 중간에 로깅이나 디버깅 등의 추가 작업을 삽입할 때 유용하다

fun main() {
    val numbers = mutableListOf("one", "two", "three")
        .also { println("Original list: $it") } // 로깅
        .also { it.add("four") } // 리스트 자체를 반환
        .also { println("Modified list: $it") } // 로깅
    println("Final list: $numbers")
    // Original list: [one, two, three]
    // Modified list: [one, two, three, four]
    // Final list: [one, two, three, four]
}
시그니처: public inline fun <T> T.also(block: (T) -> Unit): T
  • T 타입의 확장 함수로, (T) -> Unit 타입의 람다 함수를 받아 객체 자체 T를 반환한다. let과 마찬가지로 it으로 접근한다

with: 객체 변환 시 코드 간소화

with 함수는 다른 Scope Function들과 달리 확장 함수가 아니다. 인자로 객체를 받고 람다를 실행하여 람다의 결과를 반환한다. 주로 객체의 여러 프로퍼티를 참조하여 다른 객체로 변환하거나, 한 객체의 여러 멤버에 접근해야 할 때 코드 중복을 줄이는 데 사용된다

data class PersonDto(val name: String, val age: Int)

fun convertPersonToDto(person: Person): PersonDto {
    return with(person) {
        PersonDto(
            name = name, // this.name (생략 가능)
            age = age    // this.age (생략 가능)
        )
    }
}

fun main() {
    val person = Person("혁", 100)
    val personDto = convertPersonToDto(person)
    println("${personDto.name}, ${personDto.age}") // 혁, 100
}
시그니처: public inline fun <T, R> with(receiver: T, block: T.() -> R): R
  • receiver 객체를 T 타입으로 받고, T.() -> R 타입의 람다 함수(수신 객체 지정 람다)를 실행한 후 람다 결과 R을 반환한다. 람다 내에서 receiver 객체는 this로 접근한다

Scope Function 사용 시 고려사항

Scope Function은 코드를 간결하게 만들지만, 과도하게 사용하거나 적절하지 않은 상황에 사용하면 오히려 가독성을 해치고 복잡성을 증가시킬 수 있다

class View {
    fun showPerson(person: Person) = println("Showing person: ${person.name}")
    fun showError() = println("Showing error")
}

// 기존 방식 (1번 코드)
if (person != null && person.isAdult) {
    view.showPerson(person)
} else {
    view.showError()
}

// Scope function 활용 (2번 코드)
val view = View()
val person: Person? = Person("Alice", 25)
person?.takeIf { it.age >= 18 } // isAdult 대신 age >= 18 조건 사용
    ?.let(view::showPerson)
    ?: view.showError()

// 2번 코드의 문제점: showPerson이 Unit이 아닌 다른 값을 반환하고,
// 그 값이 null이 될 가능성이 있다면 ?: 이후의 코드가 의도치 않게 실행될 수 있음.
// 가독성도 Kotlin에 익숙하지 않은 개발자에게는 더 낮을 수 있음.

핵심 판단 기준

  • 명확성과 단순성: 코드가 더 명확하고 이해하기 쉬워지는지 판단한다. 간단한 if-else가 더 직관적인 경우도 많다
  • 복잡성: 여러 Scope Function을 체이닝하면 코드의 흐름을 파악하기 어려워질 수 있다
  • 팀 컨벤션: 팀원들의 Kotlin 숙련도와 선호도를 고려하여 일관된 컨벤션을 따르는 것이 중요하다
  • 디버깅 용이성: 복잡한 체이닝은 디버깅을 어렵게 만들 수 있다

출처 – 인프런 강의 중 자바 개발자를 위한 코틀린 입문(Java to Kotlin Starter Guide)