한 줄 정의
코틀린의 연산자는 이름이 정해진 함수의 문법적 설탕이므로, 연산자를 오버로딩할 때는 그 함수 이름이 뜻하는 동작만 구현해야 합니다.
쉽게 말하면
Ctrl+C 는 어느 프로그램에서든 “복사”입니다. 어떤 앱이 Ctrl+C 를 “삭제”로 바꿔 두면 기능 자체는 멀쩡해도, 처음 쓰는 사람은 복사하려다 작업을 날립니다.
반대로 터미널에서는 Ctrl+C 가 “실행 중단”이지만 아무도 혼란스러워하지 않습니다. 그 환경 안에서는 다들 그렇게 쓴다는 합의가 있기 때문이고, 연산자 오버로딩에서 DSL이 바로 이런 예외입니다.
왜 중요한가?
코드를 읽는 사람은 연산자를 보는 순간 의미를 예측합니다. ! 는 부정, * 는 곱셈이라는 예측이 코드 전체에서 지켜지기 때문에 연산자가 섞인 표현식도 빠르게 읽힙니다.
이 예측이 한 곳이라도 깨지면 모든 연산자를 “혹시 여기서는 다른 뜻인가?” 의심하며 읽어야 합니다. 스칼라처럼 연산자 오버로딩을 제한 없이 허용하는 언어에서는 낯선 라이브러리 코드를 읽을 때 연산자 하나하나의 의미를 따로 외워 조합해야 하는데, 코틀린은 연산자마다 이름 있는 함수를 대응시켜 이 문제를 설계 단계에서 막았습니다.
핵심 내용
연산자 = 이름이 정해진 함수
코틀린의 모든 연산자는 구체적인 이름을 가진 함수로 번역됩니다.
| 분류 | 연산자 → 함수 |
|---|---|
| 단항 | +a → unaryPlus(), -a → unaryMinus(), !a → not() |
| 증감 | ++a → inc(), --a → dec() |
| 산술 | a+b → plus, a-b → minus, a*b → times, a/b → div |
| 범위 · 포함 | a..b → a.rangeTo(b), a in b → b.contains(a) |
| 복합 대입 | a+=b → plusAssign, -= *= /= 도 같은 규칙 |
| 동등 · 비교 | a==b → a.equals(b), a>b → a.compareTo(b) > 0 (< >= <= 도 동일) |
그래서 x + y == z 는 x.plus(y).equals(z) 이고, plus 가 널 가능 타입을 반환하면 (x.plus(y))?.equals(z) ?: (z === null) 이 됩니다. 연산자를 쓰는 것은 결국 이 함수들을 호출하는 것이고, 읽는 사람은 함수 이름이 말하는 대로 동작할 것 이라고 기대합니다.
이름을 배신하는 오버로딩
팩토리얼 확장 함수를 만든 뒤, 수학 표기 6! 흉내를 내려고 not 을 오버로딩하는 예입니다.
fun Int.factorial(): Int = (1..this).product()
fun Iterable<Int>.product(): Int =
fold(1) { acc, i -> acc * i }
operator fun Int.not() = factorial()
print(10 * !6) // 7200컴파일도 되고 결과도 맞지만 해서는 안 됩니다. !6 은 연산자 표기를 벗기면 6.not() 이고, not 은 팩토리얼이 아니라 논리 부정을 뜻하는 이름입니다. print(10 * 6.not()) 을 보고 팩토리얼을 떠올릴 사람은 없습니다.
표준 라이브러리의 예외
코틀린 표준 라이브러리조차
Path에div확장 함수를 정의해path / "B"로 경로를 잇게 했습니다. 연산자를 벗기면path.div("B")라는 어색한 코드가 되므로, 코틀린을 ‘마법 같게’ 만들어 주더라도 가독성을 위해 피하는 편이 좋습니다.
의미가 분명하지 않은 경우
더 큰 문제는 규칙을 지키는지조차 불분명할 때입니다. “함수를 세 배로 만든다(triple a function)“는 말을 코드로 옮기면 두 가지로 해석됩니다.
// 해석 1: 함수를 세 번 반복하는 새 함수를 만든다 → 결과는 () -> Unit
operator fun Int.times(operation: () -> Unit): () -> Unit =
{ repeat(this) { operation() } }
val tripledHello = 3 * { print("Hello") }
tripledHello() // 출력: HelloHelloHello
// 해석 2: 함수를 지금 세 번 호출한다 → 결과는 Unit
operator fun Int.times(operation: () -> Unit) {
repeat(this) { operation() }
}
3 * { print("Hello") } // 출력: HelloHelloHello같은 3 * { ... } 가 한쪽에서는 함수를 만들고 다른 쪽에서는 즉시 실행합니다. 이렇게 의미가 갈리면 연산자 대신 의미를 드러내는 이름의 함수를 씁니다. 연산자처럼 쓰고 싶다면 infix 함수로, 아니면 최상위 함수로 충분합니다.
infix fun Int.timesRepeated(operation: () -> Unit) = {
repeat(this) { operation() }
}
val tripledHello = 3 timesRepeated { print("Hello") }
tripledHello() // 출력: HelloHelloHello
repeat(3) { print("Hello") } // 표준 라이브러리의 최상위 함수규칙을 어겨도 되는 경우: DSL
DSL(Domain Specific Language)을 설계할 때는 연산자를 원래 의미와 다르게 써도 됩니다. 고전적인 HTML DSL에서 +"Some text" 는 String.unaryPlus 로 요소에 텍스트를 추가합니다.
body {
div {
+"Some text"
}
}DSL 안이라는 맥락이 명확하므로 읽는 사람도 이 예외를 자연스럽게 받아들입니다.
비교 / 트레이드오프
| 선택지 | 쓸 때 | 예 |
|---|---|---|
| 연산자 오버로딩 | 함수 이름의 의미와 동작이 정확히 일치할 때, 또는 DSL 안 | 값 객체의 plus, HTML DSL의 unaryPlus |
infix 함수 | 연산자 같은 문법은 원하지만 의미를 이름으로 밝혀야 할 때 | 3 timesRepeated { } |
| 일반 · 최상위 함수 | 기본값. 의미가 조금이라도 모호할 때 | 6.factorial(), repeat(3) { } |
내 생각
- 값 객체의
plus는 교과서적인 좋은 사례입니다.Money나Quantity에plus·minus를 정의하면total + fee가 “더하기”라는 이름 그대로 동작하므로, 서비스 코드의add()호출 체인보다 읽기 쉬워집니다. compareTo를 오버로딩하면><가 따라옵니다. 등급·버전처럼 순서가 자명한 타입에만Comparable을 구현하고, 정렬 기준이 여러 개인 엔티티는Comparator로 밖에서 주입하는 편이 연산자 의미를 흐리지 않습니다.- 백엔드에서 만나는 연산자·infix 남용의 대부분은 DSL입니다. Exposed의
Users.age greater 18, Kotest의result shouldBe 42처럼 라이브러리 경계 안에서는 허용되는 문법이라, 같은 스타일을 도메인 코드로 끌고 나오지 않는 것이 선을 지키는 방법입니다.
관련 개념
- 아이템 10 가독성을 목표로 설계하라 —
String.invoke와andinfix로 만든"A" { "B" } and "C"가 바로 연산자 의미를 위배한 반례이고, 이 아이템은 그 컨벤션을 연산자 전반의 규칙으로 정리한 것입니다