시스템아 미안해

Chapter 2: Readability - Item 11: 연산자의 의미는 함수 이름과 일치해야 합니다 (An operator’s meaning should be consistent with its function name) 본문

책/Effective Kotlin

Chapter 2: Readability - Item 11: 연산자의 의미는 함수 이름과 일치해야 합니다 (An operator’s meaning should be consistent with its function name)

if else 2026. 1. 23. 18:32

연산자 오버로딩은 매우 강력한 기능이며, 대부분의 강력한 기능이 그렇듯 위험성도 함께 가지고 있습니다.
프로그래밍에서는 큰 힘에는 그만큼 큰 책임이 따릅니다.

 

강사로서 저는 사람들이 연산자 오버로딩을 처음 접했을 때, 그 매력에 빠져 지나치게 사용하는 모습을 자주 보아 왔습니다.
예를 들어, 어떤 연습 문제에서는 숫자의 팩토리얼을 계산하는 함수를 만들어 보라는 과제가 주어집니다.

 
 
kotlin
fun Int.factorial(): Int = (1..this).product()

fun Iterable<Int>.product(): Int = 
    fold(1) { acc, i -> acc * i }

이 함수는 Int에 대한 확장 함수로 정의되었기 때문에, 사용법은 매우 편리합니다:

print(10 * 6.factorial()) // 7200

모든 수학자들은 팩토리얼을 나타내는 특별한 표기법이 있다는 사실을 알고 있습니다.
바로 숫자 뒤에 느낌표(!)를 붙이는 방식입니다.

10 * 6!

Kotlin에는 이러한 연산자를 직접 지원하지는 않습니다.
하지만 제 워크숍 참가자 중 한 명이 지적했듯이, 대신 not 연산자에 대해 연산자 오버로딩을 사용하는 방법은 존재합니다.

 

operator fun Int.not() = factorial()

print(10 * !6) // 7200

이렇게 구현할 수는 있지만, 과연 그렇게 해야 할까요?
가장 단순한 답은 아니오입니다.

 

함수 선언부만 읽어보아도 이 함수의 이름이 not라는 점을 알 수 있습니다.
이 이름이 암시하듯, 이 함수는 이런 용도로 사용되어서는 안 됩니다.
not은 숫자의 팩토리얼이 아니라 논리 연산을 의미합니다.
이와 같은 사용은 혼란을 주고 오해를 불러일으킬 수 있습니다.

 

Kotlin에서 모든 연산자는 실제로는 구체적인 이름을 가진 함수에 대한 문법적 설탕(syntactic sugar)에 불과합니다.
아래 표에 제시된 것처럼, 모든 연산자는 연산자 문법 대신 함수 호출 형태로도 항상 사용할 수 있습니다.
그렇다면 다음 코드는 함수 호출 형태로 작성하면 어떤 모습이 될까요?

 

 
kotlin
print(10 * 6.not()) // 7200

 

What each operator translates to in Kotlin.

Kotlin에서 각 연산자의 의미는 언제나 동일하게 유지됩니다.
이는 매우 중요한 설계 결정입니다.

 

Scala와 같은 일부 언어는 연산자 오버로딩에 사실상 제한이 없습니다.
이 정도의 자유도는 일부 개발자들에 의해 심각하게 오용되는 것으로 잘 알려져 있습니다.
의미 있는 함수명과 클래스명을 사용하고 있더라도, 처음 접하는 라이브러리의 코드를 읽는 일은 쉽지 않을 수 있습니다.

 

그런데 여기에 더해, 연산자마저 범주론(category theory)에 익숙한 개발자들만 아는

전혀 다른 의미로 사용된다고 상상해 보십시오.
이해하기는 훨씬 더 어려워질 것입니다.
각 연산자가 무엇을 의미하는지 개별적으로 이해하고, 특정 문맥에서의 의미를 기억한 뒤,
이 모든 것을 머릿속에 유지한 채로 전체 문장을 조합해서 이해해야 하기 때문입니다.

 

Kotlin에서는 각 연산자가 명확하고 고정된 의미를 가지기 때문에 이러한 문제가 발생하지 않습니다.
예를 들어, 다음과 같은 표현식을 보았을 때를 생각해 보겠습니다.

 
 
kotlin
x + y == z

이것이 다음과 동일하다는 것을 알고 있습니다:

 
 
kotlin
x.plus(y).equal(z)

또는 plus가 nullable 반환 타입을 선언하면 다음 코드가 될 수 있습니다:

 
 
kotlin
(x.plus(y))?.equal(z) ?: (z === null)

이 함수들은 모두 구체적인 이름을 가지고 있으며, 우리는 모든 함수가 그 이름이 암시하는 동작을 수행할 것이라고 기대합니다.
이러한 원칙은 각 연산자가 사용될 수 있는 범위를 크게 제한합니다.
not을 팩토리얼을 반환하도록 사용하는 것은 이 관례를 명백히 위반하는 것이며, 절대로 있어서는 안 됩니다.

 

다만 언급할 만한 점은, 이 규칙이 Kotlin 표준 라이브러리에서도 한 번 깨진 적이 있다는 사실입니다.
Kotlin stdlib에서는 Path에 대해 div 확장 함수를 정의하여, 다음과 같은 사용을 가능하게 합니다.

 
 
kotlin
val path = Path("A")
val path2 = path / "B"
println(path2) // Prints: A/B

명시적인 이름이 사용될 때는 그렇게 아름답지 않습니다. 이러한 확장은 Kotlin을 더 "수학적"으로 만들고, 코드를 읽기 쉽게 유지하고 싶다면 사용을 피하는 것을 제안합니다:

 
 
kotlin
val path = Path("A")
val path2 = path.div("B")
println(path2) // Prints: A/B

불명확한 경우 (Unclear cases)

가장 큰 문제는 어떤 사용 방식이 관례를 충족하는지 여부가 불분명할 때 발생합니다.
예를 들어, 함수를 세 번 곱한다는 것은 어떤 의미일까요?
어떤 사람들에게는 이것이 해당 함수를 세 번 반복해서 실행하는 또 다른 함수를 만든다는 의미로 명확하게 받아들여질 수 있습니다.

 
 
kotlin
operator fun Int.times(operation: () -> Unit): () -> Unit = 
    { repeat(this) { operation() } }

val tripledHello = 3 * { print("Hello") }

tripledHello() // Prints: HelloHelloHello

다른 사람들에게는 이 함수를 3번 호출하고 싶다는 것이 명확할 수 있습니다:

 
 
kotlin
operator fun Int.times(operation: () -> Unit) {
    repeat(this) { operation() }
}

3 * { print("Hello") } // Prints: HelloHelloHello

의미가 불명확할 때는 설명적인 확장 함수를 선호하는 것이 좋습니다. 사용을 연산자처럼 유지하려면 infix로 만들 수 있습니다:

 
 
kotlin
infix fun Int.timesRepeated(operation: () -> Unit) = {
    repeat(this) { operation() }
}

val tripledHello = 3 timesRepeated { print("Hello") }
tripledHello() // Prints: HelloHelloHello

때로는 최상위 함수를 대신 사용하는 것이 더 낫습니다. 함수를 3번 반복하는 것은 이미 구현되어 stdlib에 배포되었습니다:

 
 
kotlin
repeat(3) { print("Hello") } // Prints: HelloHelloHello

이 규칙을 깨는 것이 괜찮을 때는? (When is it fine to break this rule?)

연산자 오버로딩을 이상한 방식으로 사용해도 괜찮은 매우 중요한 경우가 하나 있습니다: Domain Specific Language(DSL)을 설계할 때입니다. 고전적인 HTML DSL 예제를 생각해보세요:

 
 
kotlin
body {
    div {
        +"Some text"
    }
}

요소에 텍스트를 추가하기 위해 String.unaryPlus를 사용한다는 것을 알 수 있습니다. 이것은 명백히 Domain Specific Language(DSL)의 일부이기 때문에 허용됩니다. 이 특정 컨텍스트에서는 다른 규칙이 적용됩니다.