코틀린 클린코드 (6) – extension function과 scope function

이 글의 목차 숨기기
코틀린 클린코드 6편 extension function과 scope function 사용법
코틀린 클린코드 6편 extension function과 scope function 사용법

1편에서는 코틀린 클린코드의 기준을 잡았습니다. 2편에서는 이름 짓기를 다뤘고, 3편에서는 함수 설계를 정리했습니다. 4편에서는 null safety를 살펴봤습니다. 5편에서는 data class와 sealed class로 모델링을 다뤘습니다.

이번 6편에서는 많은 개발자가 좋아하면서도, 동시에 자주 남용하는 문법을 다룹니다. 바로 extension functionscope function입니다.

이 둘은 코틀린다운 코드를 만들 때 아주 강력합니다. 잘 쓰면 코드가 짧아집니다. 의도도 또렷해집니다. 반복도 줄어듭니다.

하지만 잘못 쓰면 정반대가 됩니다. 함수가 어디서 왔는지 감이 안 잡힙니다. 현재 문맥의 객체가 무엇인지 헷갈립니다. thisit가 겹치면서 읽는 속도가 크게 떨어집니다.

클린코드 관점에서 중요한 기준은 하나입니다. 코드를 짧게 만드는 것보다, 읽는 사람이 한 번에 이해할 수 있게 만드는 것이 먼저입니다.

이번 글에서는 문법 설명만 하지 않겠습니다. 언제 extension function이 좋은지, 언제 일반 함수가 더 나은지, letrun을 어떻게 구분하면 좋은지, 왜 scope function 중첩이 위험한지까지 실무 기준으로 정리하겠습니다.

시리즈 전체 흐름이 궁금하시다면 코틀린 클린코드 허브 페이지를 먼저 읽어보세요. 앞선 글이 필요하시다면 1편, 2편, 3편, 4편, 5편도 함께 보시면 좋습니다.

왜 extension function과 scope function이 자주 문제를 만들까요?

두 문법 모두 표현을 줄여주는 도구입니다. 그래서 처음에는 좋아 보입니다.

예를 들어 아래 코드는 분명 코틀린답게 보입니다.


val nickname = user
    ?.let { it.nickname.trim() }
    ?.also { logger.info("nickname = $it") }
    ?.takeIf { it.length >= 2 }
    ?: return

짧습니다. 체이닝도 자연스러워 보입니다. 하지만 읽는 사람 입장에서는 잠깐 멈추게 됩니다.

  • 지금 it가 무엇일까요?
  • 로그를 찍는 단계와 값 변환 단계가 명확히 구분될까요?
  • 여기서 정말 scope function이 필요한 걸까요?

코틀린 공식 문서도 scope function은 새로운 기능을 주는 것이 아니라 코드를 더 간결하고 읽기 좋게 만들 수 있는 도구라고 설명합니다. 동시에 과도하게 쓰거나 중첩하면 읽기 어렵고, 현재 문맥의 객체와 this, it 값을 헷갈리게 만들 수 있다고 경고합니다.

즉, 이 문법의 핵심은 “많이 쓰는 것”이 아니라 “정확한 자리에만 쓰는 것”입니다.

extension function의 기본 개념

코틀린 공식 문서 기준으로 extension은 상속이나 Decorator 같은 패턴 없이 기존 클래스나 인터페이스에 새로운 기능을 붙여서 호출할 수 있게 해주는 문법입니다.

중요한 점이 하나 있습니다. extension은 클래스를 진짜로 수정하지 않습니다. 멤버를 추가하는 것도 아닙니다. 다만 그렇게 보이게 호출할 수 있게 해주는 문법입니다.


fun String.maskEmail(): String {
    val visiblePrefix = take(3)
    return "$visiblePrefix***"
}

val masked = "mango@example.com".maskEmail()

호출 형태만 보면 String의 멤버 함수처럼 보입니다. 하지만 실제로는 기존 String 클래스에 메서드가 추가된 것이 아닙니다.

이 특성을 이해해야 extension function을 과신하지 않게 됩니다.

receiver를 중심으로 읽어야 합니다

extension function은 항상 어떤 receiver를 기준으로 동작합니다. 위 예제에서는 String이 receiver 타입입니다.

그래서 extension function은 “이 함수가 주로 어떤 객체를 다루는가”가 분명할 때 좋습니다.


fun String.removeWhitespace(): String =
    replace(" ", "")

fun List<Int>.averageOrZero(): Double =
    if (isEmpty()) 0.0 else average()

이런 함수는 receiver가 분명합니다. 읽는 사람도 함수를 보자마자 “문자열에 대한 동작이구나”, “리스트에 대한 동작이구나”를 바로 이해할 수 있습니다.

extension function이 읽기 좋을 때

모든 헬퍼 함수를 extension으로 바꿀 필요는 없습니다. 다만 아래 상황에서는 특히 잘 맞습니다.

1. 함수가 특정 타입을 중심으로 동작할 때

기존 헬퍼 함수가 사실상 한 객체를 계속 첫 번째 인자로 받는다면 extension으로 바꾸는 편이 더 자연스럽습니다.


fun toDisplayName(user: User): String =
    "${user.name} (${user.email})"

data class User(
    val name: String,
    val email: String,
)

이 코드는 틀리지 않습니다. 하지만 함수의 중심이 User라는 점이 이름에서 한 번, 매개변수에서 한 번 반복됩니다.


fun User.toDisplayName(): String =
    "$name ($email)"

이제 호출하는 쪽이 더 자연스러워집니다.


val displayName = user.toDisplayName()

2. 제3자 라이브러리 타입에 작은 의미를 부여할 때

수정할 수 없는 타입에 프로젝트 문맥에 맞는 동작을 붙이고 싶을 때도 좋습니다.


fun String.toSlug(): String =
    lowercase()
        .trim()
        .replace(" ", "-")

val slug = "Kotlin Clean Code".toSlug()

이런 함수는 프로젝트 안에서 자주 쓰입니다. 호출 형태도 읽기 쉽습니다.

3. 컬렉션이나 값 객체에 도메인 표현을 붙일 때

이름만 봐도 의미가 살아나는 extension은 가독성을 크게 높입니다.


data class Order(
    val amount: Int,
)

fun List<Order>.totalAmount(): Int =
    sumOf { it.amount }

val totalAmount = orders.totalAmount()

sumOf { it.amount }를 매번 쓰는 것보다, 도메인 의미를 담은 이름 하나가 더 잘 읽히는 경우가 많습니다.

extension function에서 자주 하는 오해

1. extension function은 동적 디스패치가 아닙니다

코틀린 공식 문서 기준으로 extension function은 정적으로 해석됩니다. 즉, 실제 인스턴스 타입이 아니라 선언된 타입을 기준으로 어떤 extension을 호출할지 결정합니다.


open class Animal
class Dog : Animal()

fun Animal.sound(): String = "animal"
fun Dog.sound(): String = "dog"

fun printSound(animal: Animal) {
    println(animal.sound())
}

printSound(Dog()) // animal

Dog()를 넘겼는데도 결과는 animal입니다. 매개변수 타입이 Animal로 선언되어 있기 때문입니다.

이 특성을 모르면 “왜 더 구체적인 extension이 안 불리지?”라는 혼란이 생깁니다.

2. 같은 시그니처의 멤버 함수가 있으면 멤버가 우선합니다

extension은 멤버 함수처럼 보이지만, 실제 우선순위에서는 멤버 함수가 더 강합니다.


class UserFormatter {
    fun format(): String = "member"
}

fun UserFormatter.format(): String = "extension"

val formatter = UserFormatter()
println(formatter.format()) // member

같은 이름, 같은 형태라면 extension이 아니라 멤버가 호출됩니다. 그래서 라이브러리 타입에 extension을 추가할 때는 기존 멤버와 이름이 충돌하지 않는지 먼저 보는 습관이 중요합니다.

3. extension property는 상태를 저장하지 못합니다

extension property도 편리합니다. 하지만 클래스에 실제 필드를 추가하는 것은 아닙니다. 그래서 backing field를 가질 수 없습니다.


data class User(
    val firstName: String,
    val lastName: String,
)

val User.fullName: String
    get() = "$firstName $lastName"

이런 계산형 프로퍼티는 좋습니다. 반면 아래처럼 값을 저장하려고 하면 맞지 않습니다.


// 컴파일되지 않는 예시
// var User.loginCount: Int = 0

extension property는 저장이 아니라 계산된 접근에 가깝다고 이해하는 편이 안전합니다.

4. nullable receiver는 편리하지만, 책임을 숨기면 안 됩니다

코틀린은 nullable receiver에 대한 extension도 허용합니다. 이 기능은 유용합니다. 다만 null 처리 책임을 지나치게 숨기면 코드가 오히려 덜 분명해질 수 있습니다.


fun String?.orDefault(defaultValue: String): String =
    if (this == null) defaultValue else this

val nickname: String? = null
println(nickname.orDefault("익명"))

이 함수는 괜찮습니다. 이름만 봐도 null을 어떻게 처리하는지 드러나기 때문입니다.

반대로 이름이 모호한 extension으로 null 처리 규칙을 숨기면, 호출하는 쪽이 실제 동작을 추측해야 합니다. 그런 함수는 피하는 편이 낫습니다.

extension function은 어디에 두는 것이 좋을까요?

이 부분은 실무에서 특히 중요합니다. extension function은 문법보다 배치가 더 중요할 때가 많습니다.

코틀린 코딩 컨벤션은 관련 있는 선언을 같은 소스 파일에 두는 것을 권장합니다. 그리고 어떤 클래스의 모든 사용자에게 의미 있는 extension은 그 클래스와 같은 파일에 두고, 특정 클라이언트에게만 의미가 있는 extension은 그 클라이언트 코드 근처에 두라고 안내합니다. 반대로 어떤 클래스의 모든 extension을 한 파일에 몰아넣기 위한 전용 파일을 만드는 방식은 피하라고 설명합니다.

즉, 이런 식은 좋지 않습니다.


// StringExtensions.kt
// ListExtensions.kt
// UserExtensions.kt
// Extensions.kt

이런 파일은 처음에는 편합니다. 하지만 시간이 지나면 잡동사니 창고가 됩니다. 함수 발견도 어려워집니다.

좋은 배치 예시

모든 호출자에게 공통으로 의미가 있다면 타입 근처에 둡니다.


// User.kt
data class User(
    val id: Long,
    val name: String,
    val email: String,
)

fun User.toDisplayName(): String =
    "$name ($email)"

특정 화면이나 특정 모듈에서만 쓴다면 그 코드 근처에 두는 편이 좋습니다.


private fun User.toRowItem(): UserRowItem =
    UserRowItem(
        id = id,
        title = name,
        subtitle = email,
    )

visibility도 넓게 열 필요가 없습니다. 코틀린 코딩 컨벤션은 API 오염을 줄이기 위해 extension visibility를 가능한 좁게 제한하라고 권장합니다. 그래서 파일 내부에서만 쓴다면 private top-level extension이 좋고, 정말 짧은 보조 로직이면 local extension도 괜찮습니다.


fun render(users: List<User>): List<String> {
    fun User.toLabel(): String = "$name <$email>"
    return users.map { it.toLabel() }
}

scope function의 기본 개념

이제 scope function을 보겠습니다. 코틀린 공식 문서 기준으로 scope function은 어떤 객체를 문맥 객체로 두고, 그 블록 안에서 이름을 줄여 접근하게 해주는 함수입니다. let, run, with, apply, also가 여기에 속합니다.

중요한 사실은 이것입니다. scope function은 새로운 기술적 능력을 추가하지 않습니다. 다만 임시 스코프를 만들고 표현을 더 짧고 읽기 좋게 만들 수 있는 도구입니다.

그래서 선택 기준도 복잡하지 않습니다. 아래 두 가지만 먼저 보면 됩니다.

  • 문맥 객체를 this처럼 쓸 것인가, it처럼 쓸 것인가
  • 최종 결과로 원래 객체를 돌려받을 것인가, 람다 결과를 돌려받을 것인가

scope function은 “무조건 코틀린답게 보이는 문법”이 아닙니다. 어떤 객체가 현재 문맥인지 더 잘 보일 때만 써야 합니다.

let, run, apply, also, with를 고르는 기준

다섯 개를 한 번에 외우려 하면 복잡합니다. 실무에서는 목적별로 기억하는 편이 훨씬 쉽습니다.

let: null 처리와 짧은 변환에 좋습니다

let은 문맥 객체를 it로 받습니다. 그리고 람다 결과를 반환합니다.

그래서 nullable 값을 안전하게 다루거나, 어떤 값을 다른 값으로 바꿔서 바로 이어서 쓰는 상황에 잘 맞습니다.


val email: String? = user.email

val normalizedEmail = email?.let {
    it.trim().lowercase()
}

이 예제에서는 it가 인자처럼 보이기 때문에, “email을 받아 가공한다”는 흐름이 잘 보입니다.

다만 간단한 한 줄까지 모두 let으로 감싸면 오히려 불필요한 스코프가 생깁니다.


// 아쉬운 예시
val name = user.name.let { it.trim() }

// 더 단순한 예시
val name = user.name.trim()

run: 객체를 다루면서 결과를 계산할 때 좋습니다

run은 문맥 객체를 this로 받습니다. 그리고 람다 결과를 반환합니다.

그래서 객체의 멤버를 여러 번 다루면서 마지막에 어떤 결과를 계산해 돌려주고 싶을 때 잘 맞습니다.


data class SignupForm(
    val email: String,
    val password: String,
)

val isValid = form.run {
    email.contains("@") && password.length >= 8
}

이 경우에는 this를 생략해도 현재 관심사가 form 하나라는 점이 잘 드러납니다.

객체 설정과 결과 계산이 동시에 필요할 때도 run이 잘 맞습니다.


val message = StringBuilder().run {
    append("안녕하세요, ")
    append(user.name)
    append("님")
    toString()
}

참고로 run에는 객체 없이 호출하는 형태도 있습니다. 표현식이 필요한 자리에서 여러 문장을 묶고 싶을 때 유용합니다.


val normalizedKeyword = run {
    val raw = input.trim()
    raw.lowercase()
}

apply: 객체 초기화에 가장 잘 맞습니다

apply는 문맥 객체를 this로 받습니다. 반환값은 람다 결과가 아니라 원래 객체입니다.

그래서 생성 직후 여러 프로퍼티를 채우는 초기화 패턴에 잘 맞습니다.


val request = CreateUserRequest().apply {
    name = "mango"
    email = "mango@example.com"
    age = 30
}

이 패턴은 정말 자주 씁니다. 다만 초기화 블록 안에 조건 분기와 비즈니스 로직까지 몰아넣으면 금방 읽기 어려워집니다.


// 좋지 않은 예시
val request = CreateUserRequest().apply {
    name = input.name.trim()

    if (input.isAdmin) {
        role = "ADMIN"
        sendAuditLog(input.name)
    } else {
        role = "USER"
    }

    if (input.age < 20) {
        throw IllegalArgumentException("미성년자는 가입할 수 없습니다.")
    }
}

이런 로직은 apply 밖으로 빼는 편이 좋습니다. apply는 설정이 중심일 때 가장 잘 읽힙니다.

also: 부수 효과를 붙일 때 좋습니다

also는 문맥 객체를 it로 받습니다. 그리고 원래 객체를 그대로 반환합니다.

그래서 로깅, 검증, 추적 같은 추가 행동을 끼워 넣을 때 좋습니다.


val user = userRepository.findById(id)
    .also { logger.info("조회된 사용자 id = ${it.id}") }

핵심은 본 흐름을 바꾸지 않는다는 점입니다. 그래서 also 안에서는 객체를 크게 변형하기보다, 부수 효과를 짧게 넣는 편이 더 읽기 좋습니다.

with: 이미 있는 객체에 대한 호출을 묶을 때 좋습니다

with는 extension function이 아닙니다. 객체를 인자로 받고, 블록 안에서 this처럼 다루게 해줍니다. 결과는 람다 결과입니다.

이미 가지고 있는 객체를 여러 번 호출해야 할 때 자연스럽습니다.


val summary = with(user) {
    "$name / $email / ${createdAt.year}"
}

user를 계속 반복하지 않아도 됩니다. 다만 현재 문맥이 명확할 때만 써야 합니다. 블록이 길어지면 누가 this인지 금방 흐려집니다.

아주 짧게 정리하면

  • let: null 처리, 짧은 변환
  • run: 객체를 중심으로 작업하고 결과 계산
  • apply: 객체 초기화
  • also: 부수 효과 추가
  • with: 이미 있는 객체에 대한 호출 묶기

이 정도만 기억해도 대부분의 상황은 충분합니다.

scope function이 가독성을 해치는 순간

1. 중첩되기 시작하면 바로 위험 신호입니다

코틀린 공식 문서도 중첩과 과도한 체이닝을 주의하라고 말합니다. 이유는 단순합니다. 현재 객체가 누군지 금방 헷갈리기 때문입니다.


val result = user?.let { user ->
    user.profile?.run {
        address?.also {
            logger.info("city = ${it.city}")
        }?.let {
            "${user.name} - ${it.city}"
        }
    }
}

동작은 할 수 있습니다. 하지만 독자는 변수와 문맥을 계속 추적해야 합니다.

이럴 때는 이름 있는 변수로 풀어 쓰는 편이 더 낫습니다.


val profile = user?.profile ?: return null
val address = profile.address ?: return null

logger.info("city = ${address.city}")

val result = "${user.name} - ${address.city}

2. this 생략이 오히려 모호함을 만들 때

run, apply, with에서는 this를 생략할 수 있습니다. 그래서 짧아집니다. 하지만 외부 변수 이름과 섞이면 어느 쪽 멤버인지 한 번에 안 보일 수 있습니다.


class UserEditor(
    private val name: String,
) {
    fun update(user: User) {
        user.apply {
            if (name.isBlank()) {
                throw IllegalArgumentException("이름은 비어 있을 수 없습니다.")
            }

            nickname = name
        }
    }
}

이 코드에서 nameUserEditor의 것인지, User의 것인지 잠깐 멈추게 됩니다.

이럴 때는 명시적으로 적는 편이 낫습니다.


class UserEditor(
    private val name: String,
) {
    fun update(user: User) {
        user.apply {
            if (this@UserEditor.name.isBlank()) {
                throw IllegalArgumentException("이름은 비어 있을 수 없습니다.")
            }

            nickname = this@UserEditor.name
        }
    }
}

혹은 더 단순하게 apply를 아예 쓰지 않는 편이 낫습니다.

3. 한 줄을 코틀린답게 보이게 하려는 욕심

간단한 로직까지 모두 scope function으로 연결하면, 코드가 멋있어 보일 수는 있어도 읽기 쉬워지지는 않습니다.


val title = post
    .also { validate(it) }
    .run { name.trim() }
    .let { it.uppercase() }

이 코드는 각 단계의 역할을 이해해야 전체 의미가 보입니다. 오히려 아래처럼 나누는 편이 더 빠르게 읽힙니다.


validate(post)

val normalizedTitle = post.name.trim()
val title = normalizedTitle.uppercase()

extension function과 scope function을 함께 쓸 때의 기준

이 둘을 함께 쓰면 아주 깔끔한 코드가 나올 수 있습니다. 다만 각 단계의 역할이 분명해야 합니다.

예를 들어 extension function은 의미 있는 변환에 쓰고, scope function은 문맥 제어에만 쓰면 좋습니다.


data class User(
    val id: Long,
    val name: String,
    val email: String,
)

fun User.toSummary(): String =
    "$name ($email)"

val summary = user
    ?.also { logger.info("user loaded: ${it.id}") }
    ?.toSummary()
    ?: "사용자 정보 없음"

흐름이 분명합니다.

  • also는 로그를 남깁니다.
  • toSummary()는 의미 있는 변환을 합니다.
  • 마지막 Elvis는 null 대체값을 줍니다.

반대로 extension과 scope function이 서로 역할을 침범하면 읽기 어려워집니다.


fun User.logAndToSummary(logger: Logger): String {
    logger.info("user loaded: $id")
    return "$name ($email)"
}

val summary = user?.logAndToSummary(logger) ?: "사용자 정보 없음"

이 함수는 변환과 로깅을 동시에 합니다. 이름도 길어지고, 책임도 섞입니다. 이런 경우는 extension과 scope function의 장점을 함께 살리지 못합니다.

실무 가이드라인

1. extension function은 “타입의 자연스러운 언어”처럼 들릴 때만 만드세요

user.toDisplayName(), orders.totalAmount() 같은 이름은 자연스럽습니다. 반면 user.processForScreenAndCache() 같은 이름은 이미 책임이 섞였다는 신호일 수 있습니다.

2. scope function은 목적이 한눈에 보일 때만 쓰세요

apply는 초기화, also는 부수 효과, let은 짧은 변환처럼 목적이 바로 보이면 좋습니다. 반대로 “일단 줄여 보자”는 마음으로 고르면 대개 읽기 어려워집니다.

3. 한 블록 안에서 문맥 객체를 너무 오래 붙잡지 마세요

블록이 길어질수록 thisit는 설명력이 떨어집니다. 세 줄, 네 줄 정도는 괜찮습니다. 하지만 조건 분기와 예외 처리까지 들어가기 시작하면 일반 코드로 풀어 쓰는 편이 낫습니다.

4. 확장 함수 파일을 잡동사니 창고로 만들지 마세요

StringUtils.kt, UserExtensions.kt, CommonExtensions.kt 같은 파일은 시간이 지나면 검색성과 응집도를 동시에 떨어뜨립니다. 확장 함수는 관련 타입이나 관련 기능 근처에 둬야 읽기 좋습니다.

5. public extension은 API라는 사실을 잊지 마세요

한 번 공개된 extension은 팀 전체가 의존할 수 있습니다. 그래서 이름은 더 신중해야 합니다. 코딩 컨벤션이 visibility를 가능한 좁게 제한하라고 권장하는 이유도 여기에 있습니다.

체크리스트

  • 이 extension function은 특정 타입을 중심으로 자연스럽게 읽히나요?
  • 일반 함수보다 extension function이 더 분명한가요?
  • 같은 이름의 멤버 함수와 충돌하지 않나요?
  • 이 scope function은 목적이 바로 드러나나요?
  • thisit가 한 번에 이해되나요?
  • 중첩된 scope function 때문에 현재 문맥이 흐려지지 않나요?
  • 한 줄로 줄인 코드가 정말 더 읽기 쉬운가요?
  • extension function의 위치와 visibility가 적절한가요?

마무리

extension function과 scope function은 코틀린의 큰 장점입니다. 잘 쓰면 코드가 부드럽게 읽힙니다. 중복도 줄어듭니다. 프로젝트 문맥도 더 선명하게 드러납니다.

하지만 이 문법의 가치는 “짧음” 자체에 있지 않습니다. 읽는 사람이 더 빨리 이해하는가에 있습니다.

정리하면 이렇게 기억하시면 좋습니다.

  • extension function은 타입의 의미를 선명하게 만들 때 사용합니다.
  • scope function은 문맥을 잠깐 정리할 때만 사용합니다.
  • 둘 다 과하면 바로 가독성을 해칩니다.

다음 편에서는 컬렉션과 람다 클린코드를 다뤄보겠습니다. map, filter, forEach, 체이닝 길이, 중간 변수 도입 기준까지 실무 관점에서 정리해보겠습니다.

함께보면 좋은 글