시스템아 미안해
Chapter 6: Class design - Item 46: Avoid member extensions 본문
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? = ...
// ...
}
멤버 확장 함수는 피하는 것이 원칙이지만, 이것이 최선의 선택인 상황이라면 사용할 수 있습니다.