시스템아 미안해

Chapter 6: Class design - Item 46: Avoid member extensions 본문

책/Effective Kotlin

Chapter 6: Class design - Item 46: Avoid member extensions

if else 2026. 3. 9. 15:24

어떤 클래스에 extension 함수를 정의하더라도, 그것이 해당 클래스의 멤버로 실제로 추가되는 것은 아닙니다.

 

extension 함수는 단지 첫 번째 인자로 전달되는 객체를 기반으로 호출되는 특별한 형태의 함수일 뿐입니다. 이때 그 첫 번째 객체를 receiver(수신 객체) 라고 부릅니다.

 

내부적으로 보면 extension 함수는 일반 함수로 컴파일되며, receiver 객체가 첫 번째 파라미터로 전달되는 형태로 변환됩니다.

 

예를 들어 다음과 같은 함수가 있다고 가정해 보겠습니다.

fun String.isPhoneNumber(): Boolean =
    length == 7 && all { it.isDigit() }

내부적으로 다음과 같이 컴파일됩니다.

fun isPhoneNumber(`this`: String): Boolean =
    `this`.length == 7 && `this`.all { it.isDigit() }

확장 함수가 이렇게 구현되는 방식의 결과로, 인터페이스 안에 멤버 확장을 정의하거나 클래스 안에 확장 함수를 정의하는 것이 가능합니다.

interface PhoneBook {
    fun String.isPhoneNumber(): Boolean
}

class Fizz : PhoneBook {
    override fun String.isPhoneNumber(): Boolean =
        this.length == 7 && this.all { it.isDigit() }
}

가능하긴 하지만, 멤버 extension을 정의하는 것은 일반적으로 피해야 할 좋은 이유들이 있습니다.
(DSL을 정의하는 경우는 예외입니다.)

 

특히 가시성(visibility)을 제한하려는 목적(PhoneBook 전용 함수) 만으로 extension을 멤버로 정의하는 것은 피해야 합니다.

 
class PhoneBookIncorrect {

    fun verify(number: String): Boolean {
        require(number.isPhoneNumber())
        // ...
    }

    // Bad practice, do not do this
    fun String.isPhoneNumber(): Boolean =
        this.length == 7 && this.all { it.isDigit() }
}

그 이유 중 하나는 멤버 extension 함수가 실제로 가시성(visibility)을 제한하지 않기 때문입니다.

단지 extension 함수를 사용하는 과정을 더 복잡하게 만들 뿐입니다.
왜냐하면 사용자는 extension receiver와 dispatch receiver를 모두 제공해야 하기 때문입니다.

PhoneBookIncorrect().apply {
    "1234567890".isPhoneNumber()
}

확장 함수의 가시성을 제한하려면 멤버로 만드는 것이 아니라, 가시성 한정자를 사용해야 합니다.

class PhoneBook {

    fun verify(number: String): Boolean {
        require(number.isPhoneNumber())
        // ...
    }

    // ...
}

// This is how we limit extension functions visibility
private fun String.isPhoneNumber(): Boolean =
    this.length == 7 && this.all { it.isDigit() }

어떤 함수가 클래스의 상태(state)를 사용해야 하기 때문에 멤버 함수여야 하지만, 확장 함수처럼 호출하고 싶다면 let을 사용하는 방법을 고려할 수 있습니다.

 
class PhoneBook(
    private val phoneNumberVerifier: PhoneNumberVerifier
) {
    fun verify(number: String): Boolean {
        require(number.let(::isPhoneNumber))
        // ...
    }

    // ...

    private fun isPhoneNumber(number: String): Boolean =
        phoneNumberVerifier.verify(number)
}

멤버 확장 함수를 피해야 하는 이유

멤버 확장 함수를 피해야 하는 구체적인 이유는 다음과 같습니다.

함수 레퍼런스를 사용할 수 없습니다.

val ref = String::isPhoneNumber
val str = "1234567890"
val boundedRef = str::isPhoneNumber

val refX = PhoneBookIncorrect::isPhoneNumber // ERROR
val book = PhoneBookIncorrect()
val boundedRefX = book::isPhoneNumber // ERROR

두 수신자에 대한 암묵적 접근이 혼란스러울 수 있습니다.

class A {
    val a = 10
}
class B {
    val a = 20
    val b = 30

    fun A.test() = a + b // Is it 40 or 50?
}

확장이 수신자를 수정하거나 참조할 때, 확장 수신자를 수정하는 것인지 아니면 확장이 정의된 클래스(디스패치 수신자)를 수정하는 것인지 명확하지 않습니다.

class A {
    //...
}
class B {
    //...

    fun A.update() ... // Does it update A or B?
}

경험이 적은 개발자에게는 멤버 확장 함수가 직관에 반하거나 낯설게 느껴질 수 있습니다.


피하되, 금지하지는 않는다

이 규칙이 모든 경우에 적용되는 것은 아닙니다.
멤버 extension을 사용하는 것이 적절한 상황도 분명 존재합니다.

 

가장 대표적인 예는 DSL 빌더를 정의할 때입니다.
(참고: Item 34 — 복잡한 객체 생성을 위해 DSL 정의를 고려하라)

 

또한 특정 스코프(scope)를 나타내는 객체에서 호출되는 함수를 정의해야 할 때도 멤버 extension이 유용합니다.

 

예를 들어 다음과 같은 경우가 있습니다.

  • produce 함수를 사용해 Channel을 생성하는 멤버 함수
  • TestApplicationEngine에서 엔드포인트를 호출하는 통합 테스트 함수를 인터페이스 안에 정의하는 경우
    (이 패턴은 Kotlin 백엔드 통합 테스트에서 자주 사용되는 방식이며, “Traits for testing in Kotlin” 글에서도 설명된 바 있습니다.)

이러한 경우에는 단순한 취향이나 스타일 때문이 아니라, 스코프를 정의하는 것이 실제로 의미 있는 역할을 하기 때문에 멤버 extension을 사용하는 것입니다.

 

(여기서 productOrders는 순히 CoroutineScope만 있으면 되는 게 아니라,
OrderUseCase가 가진 상태나 의존성(api)도 필요함)

class OrderUseCase(
    // ...
) {
    // ...

    private fun CoroutineScope.produceOrders() =
        produce<Order> {
            var page = 0
            do {
                val orders = api
                    .requestOrders(page = page++)
                    .orEmpty()
                for (order in orders) send(order)
            } while (orders.isNotEmpty())
        }
}

interface UserApiTrait {

    fun TestApplicationEngine.requestRegisterUser(
        token: String,
        request: RegisterUserRequest
    ): UserJson? = ...

    fun TestApplicationEngine.requestGetUserSelf(
        token: String
    ): UserJson? = ...

    // ...
}

멤버 확장 함수는 피하는 것이 원칙이지만, 이것이 최선의 선택인 상황이라면 사용할 수 있습니다.