확장 함수로 기존 타입에 새 기능 더하기

확장 함수로 기존 타입에 새 기능 더하기

Kotlin의 **확장(extension)**은 클래스나 인터페이스에 상속이나 Decorator 같은 디자인 패턴 없이 새 기능을 더하게 해줘요. 직접 수정할 수 없는 서드파티 라이브러리를 다룰 때 특히 유용하죠. 한 번 정의하면 마치 원래 그 클래스나 인터페이스의 멤버인 것처럼 호출할 수 있어요.

확장의 가장 흔한 형태는 확장 함수확장 프로퍼티예요. 중요한 점은 확장이 실제로 클래스나 인터페이스를 수정하지 않는다는 거예요. 새 멤버를 추가하는 게 아니라, 같은 문법으로 호출할 수 있는 새 함수나 접근 가능한 새 프로퍼티를 만드는 것뿐이죠.

이 페이지에서는 확장 함수를 만드는 방법부터, 수신자 타입, 널 허용 수신자, 멤버로 선언된 확장과 가시성까지 확장의 전반을 살펴볼게요.

출처: Extensions — Kotlin 공식 문서

수신자와 확장 함수 만들기

확장은 항상 **수신자(receiver)**에 대해 호출돼요. 수신자는 확장 대상 클래스나 인터페이스와 같은 타입이어야 해요. 사용할 때는 수신자 뒤에 .을 붙이고 함수나 프로퍼티 이름을 쓰면 돼요.

예를 들어 표준 라이브러리의 .appendLine() 확장 함수는 StringBuilder 클래스를 확장해요. 이 경우 수신자는 StringBuilder 인스턴스고 수신자 타입도 StringBuilder죠.

fun main() {
    // builder는 StringBuilder의 인스턴스
    val builder = StringBuilder()
    // builder에서 .appendLine() 확장 함수 호출
    builder.appendLine("Hello")
        .appendLine()
        .appendLine("World")
    println(builder.toString()) // Hello
                                //
                                // World
}

자신만의 확장 함수를 만들기 전에, 찾는 기능이 이미 Kotlin 표준 라이브러리에 있는지 먼저 확인해 보세요. 표준 라이브러리는 많은 유용한 확장 함수를 제공해요.

직접 확장 함수를 만들려면 이름 앞에 수신자 타입과 .을 붙이면 돼요. 아래 예시에서 .truncate() 함수는 String 클래스를 확장하므로 수신자 타입은 String이에요.

fun String.truncate(maxLength: Int): String {
    return if (this.length <= maxLength) this else take(maxLength - 3) + "..."
}

fun main() {
    val shortUsername = "KotlinFan42"
    val longUsername = "JetBrainsLoverForever"
    println("Short username: ${shortUsername.truncate(15)}") // KotlinFan42
    println("Long username: ${longUsername.truncate(15)}")  // JetBrainsLov...
}

.truncate()는 호출된 문자열을 maxLength 인자만큼 잘라내고 줄임표 ...를 붙여요. 문자열이 maxLength보다 짧으면 원래 문자열을 그대로 돌려주죠.

다음 예시의 .displayInfo()User 인터페이스를 확장해요. 인터페이스에 확장을 정의하면 그 인터페이스를 구현하는 모든 타입에 기능을 한 번만 추가할 수 있어서 편리해요.

interface User {
    val name: String
    val email: String
}

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

// User 인터페이스의 프로퍼티를 상속·구현
class RegularUser(override val name: String, override val email: String) : User

fun main() {
    val user = RegularUser("Alice", "[email protected]")
    println(user.displayInfo()) // User(name=Alice, [email protected])
}

이번엔 Map<String, Int>를 확장하는 .mostVoted() 함수를 볼게요.

fun Map<String, Int>.mostVoted(): String? {
    return maxByOrNull { (key, value) -> value }?.key
}

fun main() {
    val poll = mapOf(
        "Cats" to 37,
        "Dogs" to 58,
        "Birds" to 22
    )
    println("Top choice: ${poll.mostVoted()}") // Dogs
}

.mostVoted()는 호출된 맵의 키-값 쌍을 순회하고 maxByOrNull()로 가장 높은 값을 가진 쌍의 키를 반환해요. 맵이 비어 있으면 maxByOrNull()null을 반환하므로, .mostVoted()는 안전 호출 ?.maxByOrNull()이 널이 아닐 때만 key에 접근해요.

제네릭 확장 함수

제네릭 확장 함수를 만들려면 함수 이름 앞에 제네릭 타입 매개변수를 선언하면 돼요. 그러면 수신자 타입 표현식에서 그 타입을 쓸 수 있어요. 아래 예시의 .endpoints()List<T>를 확장하며 T는 어떤 타입이든 될 수 있어요.

fun <T> List<T>.endpoints(): Pair<T, T> {
    return first() to last()
}

fun main() {
    val cities = listOf("Paris", "London", "Berlin", "Prague")
    val temperatures = listOf(21.0, 19.5, 22.3)

    val cityEndpoints = cities.endpoints()
    val tempEndpoints = temperatures.endpoints()

    println("First and last cities: $cityEndpoints")         // (Paris, Prague)
    println("First and last temperatures: $tempEndpoints")   // (21.0, 22.3)
}

.endpoints()는 호출된 리스트의 첫 요소와 마지막 요소를 담은 Pair를 반환해요. 본문 안에서 first()last() 함수를 호출하고, to 중위 함수로 그 반환값을 Pair로 합쳐요. 제네릭에 대한 더 자세한 내용은 generic functions를 참고하세요.

널 허용 수신자

확장 함수는 널 허용 수신자 타입으로도 정의할 수 있어요. 그러면 값이 null인 변수에서도 그 함수를 호출할 수 있어요. 수신자가 null이면 확장 함수 안의 thisnull이 돼요. 함수 내부에서 널 처리를 올바르게 해야 하죠. 예를 들어 본문 안에서 this == null 검사나 안전 호출 ?., 엘비스 연산자 ?:를 쓰면 돼요.

아래 예시에서는 .toString()을 호출할 때 null 검사를 하지 않아도 돼요. 검사가 확장 함수 안에서 이미 일어나니까요.

fun main() {
    // 널 허용 Any에 대한 확장 함수
    fun Any?.toString(): String {
        if (this == null) return "null"
        // 널 검사 후에는 `this`가 널 불가 Any로 스마트 캐스트됨
        // 그래서 이 호출은 일반 toString() 함수로 해석됨
        return toString()
    }

    val number: Int? = 42
    val nothing: Any? = null
    println(number.toString()) // 42
    println(nothing.toString()) // null
}

확장 함수일까 멤버 함수일까?

확장 함수와 멤버 함수 호출은 표기법이 같으니, 컴파일러는 어떤 걸 쓸지 어떻게 알까요? 확장 함수는 정적으로 디스패치돼요. 즉 컴파일러가 컴파일 시점에 수신자 타입을 기준으로 호출할 함수를 결정해요.

fun main() {
    open class Shape
    class Rectangle: Shape()

    fun Shape.getName() = "Shape"
    fun Rectangle.getName() = "Rectangle"

    fun printClassName(shape: Shape) {
        println(shape.getName())
    }

    printClassName(Rectangle()) // Shape
}

이 예시에서 shape 매개변수는 Shape 타입으로 선언됐으므로 컴파일러는 Shape.getName() 확장 함수를 호출해요. 확장 함수는 정적으로 해석되기 때문에, 실제 인스턴스가 아니라 선언된 타입을 기준으로 함수를 고르죠. 그래서 Rectangle 인스턴스를 넘겨도 .getName()Shape.getName()으로 해석돼요.

클래스에 멤버 함수가 있고, 확장 함수가 같은 수신자 타입·같은 이름·호환되는 인자를 가진다면 멤버 함수가 우선해요.

fun main() {
    class Example {
        fun printFunctionType() { println("Member function") }
    }

    fun Example.printFunctionType() { println("Extension function") }

    Example().printFunctionType() // Member function
}

다만 확장 함수는 같은 이름이지만 다른 시그니처를 가진 멤버 함수를 오버로드할 수 있어요.

fun main() {
    class Example {
        fun printFunctionType() { println("Member function") }
    }

    // 같은 이름이지만 다른 시그니처
    fun Example.printFunctionType(index: Int) { println("Extension function #$index") }

    Example().printFunctionType(1) // Extension function #1
}

Int.printFunctionType()에 전달됐으므로 컴파일러는 시그니처가 일치하는 확장 함수를 선택해요. 인자를 받지 않는 멤버 함수는 무시되죠.

익명 확장 함수

확장 함수는 이름을 붙이지 않고 정의할 수도 있어요. 전역 네임스페이스를 어지럽히지 않으려 하거나, 어떤 확장 동작을 매개변수로 넘겨야 할 때 유용해요.

예를 들어 데이터 클래스에 운송비를 계산하는 일회성 함수를 이름 없이 확장한다고 해볼게요.

fun main() {
    data class Order(val weight: Double)

    val calculateShipping = fun Order.(rate: Double): Double = this.weight * rate

    val order = Order(2.5)
    val cost = order.calculateShipping(3.0)
    println("Shipping cost: $cost") // Shipping cost: 7.5
}

확장 동작을 매개변수로 넘기려면 타입 표기가 붙은 람다 표현식을 쓰면 돼요. 예를 들어 이름 있는 함수 없이 숫자가 범위 안에 있는지 확인한다고 해볼게요.

fun main() {
    val isInRange: Int.(min: Int, max: Int) -> Boolean = { min, max -> this in min..max }

    println(5.isInRange(1, 10))  // true
    println(20.isInRange(1, 10)) // false
}

isInRange 변수는 Int.(min: Int, max: Int) -> Boolean 타입의 함수를 담아요. Int 클래스에 대한 확장 함수 타입으로, minmax 매개변수를 받아 Boolean을 반환하죠. 람다 본문 { min, max -> this in min..max }는 함수가 호출된 Int 값이 minmax 사이의 범위에 드는지 확인해요. 더 자세한 내용은 람다 표현식과 익명 함수를 참고하세요.

확장 프로퍼티

Kotlin은 **확장 프로퍼티(extension property)**도 지원해요. 데이터 변환이나 UI 표시 도우미를 만들 때 사용하는 클래스를 어지럽히지 않고 유용하죠. 확장 프로퍼티를 만들려면 확장할 클래스 이름 뒤에 .과 프로퍼티 이름을 쓰면 돼요.

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

// 이메일 스타일 사용자 이름을 얻는 확장 프로퍼티
val User.emailUsername: String
    get() = "${firstName.lowercase()}.${lastName.lowercase()}"

fun main() {
    val user = User("Mickey", "Mouse")
    // 확장 프로퍼티 호출
    println("Generated email username: ${user.emailUsername}") // Generated email username: mickey.mouse
}

확장은 실제로 클래스에 멤버를 추가하지 않으므로, 확장 프로퍼티가 백킹 필드(backing field)를 가질 효율적인 방법이 없어요. 그래서 확장 프로퍼티에는 초기화(initializer)가 허용되지 않아요. getter와 setter를 명시적으로 제공해서만 그 동작을 정의할 수 있어요.

data class House(val streetName: String)

// getter와 setter가 없어서 컴파일되지 않음
// var House.number = 1 // 오류: 확장 프로퍼티에는 초기화가 허용되지 않음

// 컴파일 성공
val houseNumbers = mutableMapOf<House, Int>()

var House.number: Int
    get() = houseNumbers[this] ?: 1
    set(value) {
        println("Setting house number for ${this.streetName} to $value")
        houseNumbers[this] = value
    }

fun main() {
    val house = House("Maple Street")
    // 기본값 표시
    println("Default number: ${house.number} ${house.streetName}") // Default number: 1 Maple Street

    house.number = 99 // Setting house number for Maple Street to 99

    // 갱신된 값 표시
    println("Updated number: ${house.number} ${house.streetName}") // Updated number: 99 Maple Street
}

이 예시에서 getter는 엘비스 연산자를 써서 houseNumbers 맵에 집 번호가 있으면 반환하고 없으면 1을 반환해요. getter와 setter 작성법은 Custom getters and setters를 참고하세요.

컴패니언 객체 확장

클래스가 컴패니언 객체(companion object)를 정의한다면 컴패니언 객체에 대한 확장 함수와 프로퍼티를 정의할 수도 있어요. 컴패니언 객체의 일반 멤버처럼 클래스 이름만 한정자로 써서 호출할 수 있어요. 컴파일러는 컴패니언 객체를 기본적으로 Companion이라는 이름으로 부르죠.

class Logger {
    companion object { }
}

fun Logger.Companion.logStartupMessage() {
    println("Application started.")
}

fun main() {
    Logger.logStartupMessage() // Application started.
}

멤버로 선언된 확장

한 클래스 안에서 다른 클래스에 대한 확장을 선언할 수도 있어요. 이런 확장은 암시적 수신자가 여러 개예요. 암시적 수신자는 this로 한정하지 않고 멤버에 접근할 수 있는 객체를 말해요.

  • 확장을 선언한 클래스는 **디스패치 수신자(dispatch receiver)**예요.
  • 확장 함수의 수신자 타입은 **확장 수신자(extension receiver)**예요.

다음 예시에서 Connection 클래스는 Host 클래스에 대한 확장 함수 printConnectionString()을 가져요.

class Host(val hostname: String) {
    fun printHostname() { print(hostname) }
}

class Connection(val host: Host, val port: Int) {
    fun printPort() { print(port) }

    // Host는 확장 수신자
    fun Host.printConnectionString() {
        // Host.printHostname() 호출
        printHostname()
        print(":")
        // Connection.printPort() 호출
        // Connection은 디스패치 수신자
        printPort()
    }

    fun connect() {
        /*...*/
        // 확장 함수 호출
        host.printConnectionString()
    }
}

fun main() {
    Connection(Host("kotl.in"), 443).connect() // kotl.in:443

    // Connection 밖에서는 확장 함수를 쓸 수 없어서 오류 발생
    // Host("kotl.in").printConnectionString() // Unresolved reference 'printConnectionString'
}

이 예시는 printConnectionString()Connection 클래스 안에 선언하므로 Connection이 디스패치 수신자예요. 확장 함수의 수신자 타입은 Host 클래스이므로 Host가 확장 수신자죠.

디스패치 수신자와 확장 수신자가 같은 이름의 멤버를 가질 경우, 확장 수신자의 멤버가 우선해요. 디스패치 수신자에 명시적으로 접근하려면 한정된 this 문법을 쓰면 돼요.

class Connection {
    fun Host.getConnectionString() {
        // Host.toString() 호출
        toString()
        // Connection.toString() 호출
        [email protected]()
    }
}

멤버 확장 오버라이딩

멤버 확장은 open으로 선언하고 서브클래스에서 오버라이드할 수 있어요. 각 서브클래스에서 확장의 동작을 커스터마이즈하고 싶을 때 유용하죠. 컴파일러는 수신자 타입마다 다르게 처리해요.

수신자 타입 해석 시점 디스패치 타입
디스패치 수신자 런타임 가상(virtual)
확장 수신자 컴파일 시점 정적(static)

다음 예시에서 User 클래스는 open이고 Admin 클래스가 이를 상속해요. NotificationSenderUserAdmin 둘 다에 대한 sendNotification() 확장 함수를 정의하고, SpecialNotificationSender가 이를 오버라이드해요.

open class User
class Admin : User()

open class NotificationSender {
    open fun User.sendNotification() {
        println("Sending user notification from normal sender")
    }
    open fun Admin.sendNotification() {
        println("Sending admin notification from normal sender")
    }

    fun notify(user: User) {
        user.sendNotification()
    }
}

class SpecialNotificationSender : NotificationSender() {
    override fun User.sendNotification() {
        println("Sending user notification from special sender")
    }
    override fun Admin.sendNotification() {
        println("Sending admin notification from special sender")
    }
}

fun main() {
    // 디스패치 수신자는 NotificationSender
    // 확장 수신자는 User
    // NotificationSender의 User.sendNotification()로 해석
    NotificationSender().notify(User()) // Sending user notification from normal sender

    // 디스패치 수신자는 SpecialNotificationSender
    // 확장 수신자는 User
    // SpecialNotificationSender의 User.sendNotification()로 해석
    SpecialNotificationSender().notify(User()) // Sending user notification from special sender

    // 디스패치 수신자는 SpecialNotificationSender
    // 확장 수신자는 User가 아니라 Admin
    // notify() 함수는 user를 User 타입으로 선언
    // SpecialNotificationSender의 User.sendNotification()로 정적으로 해석
    SpecialNotificationSender().notify(Admin()) // Sending user notification from special sender
}

디스패치 수신자는 가상 디스패치로 런타임에 해석돼서 main() 함수에서 동작을 따라가기 쉬워요. 놀라울 수 있는 점은 Admin 인스턴스로 notify()를 호출할 때, 확장 수신자는 정적으로 해석되므로 컴파일러가 선언된 타입(user: User) 기준으로 확장을 고른다는 거예요.

가시성과 확장의 범위

확장은 같은 스코프에서 선언된 일반 함수와 동일한 가시성 수식어(visibility modifiers)를 사용해요. 다른 클래스의 멤버로 선언된 확장도 마찬가지예요.

파일 최상위에 선언된 확장은 같은 파일의 다른 private 최상위 선언에 접근할 수 있어요.

// File: StringUtils.kt
private fun removeWhitespace(input: String): String {
    return input.replace("\\s".toRegex(), "")
}

fun String.cleaned(): String {
    return removeWhitespace(this)
}

fun main() {
    val rawEmail = " user @example. com "
    val cleaned = rawEmail.cleaned()

    println("Raw: '$rawEmail'")       // Raw: ' user @example. com '
    println("Cleaned: '$cleaned'")    // Cleaned: '[email protected]'
    println("Looks like an email: ${cleaned.contains("@") && cleaned.contains(".")}") // Looks like an email: true
}

그리고 확장이 자신의 수신자 타입 밖에 선언되면, 수신자의 private 또는 protected 멤버에 접근할 수 없어요.

class User(private val password: String) {
    fun isLoggedIn(): Boolean = true
    fun passwordLength(): Int = password.length
}

// 클래스 밖에 선언된 확장
fun User.isSecure(): Boolean {
    // password는 private라 접근 불가:
    // return password.length >= 8
    // 대신 공개 멤버에 의존:
    return passwordLength() >= 8 && isLoggedIn()
}

fun main() {
    val user = User("supersecret")
    println("Is user secure: ${user.isSecure()}") // Is user secure: true
}

확장이 internal로 표시되면 자신의 모듈(module) 안에서만 접근할 수 있어요.

// Networking module
// JsonParser.kt
internal fun String.parseJson(): Map<String, Any> {
    return mapOf("fakeKey" to "fakeValue")
}

대부분 확장은 패키지 바로 아래인 최상위에 정의해요.

package org.example.declarations

fun List<String>.getLongestString() { /*...*/ }

선언 패키지 밖에서 확장을 쓰려면 호출하는 곳에서 import 하면 돼요.

package org.example.usage

import org.example.declarations.getLongestString

fun main() {
    val list = listOf("red", "green", "blue")
    list.getLongestString()
}

더 알아보기

  • 확장 함수에서 쓰는 람다와 함수 타입은 고차 함수와 람다에서 살펴볼 수 있어요.
  • 확장 수신자와 연결된 this 문법은 this 표현식 문서를 참고하세요.
  • 수신자가 있는 람다의 실전 활용은 타입 안전 빌더에서 확인해요.