코틀린다운 표현력은 어디서 오는가
코틀린 코드를 읽다 보면 자바보다 문장이 짧고 자연스럽게 느껴지는 경우가 많습니다. 그 이유는 단순히 문법이 간결해서만은 아닙니다. 코틀린은 기존 타입에 새 기능을 붙이고, 자주 쓰는 작업을 자연스러운 문장처럼 표현할 수 있는 도구를 제공합니다.
이 장에서 다룰 핵심 도구는 다음 네 가지입니다.
확장 함수
확장 프로퍼티
스코프 함수:
apply,also,let,run,with연산자 오버로딩과 중위 함수
이 기능들의 목표는 같습니다. 문제 영역의 코드를 더 읽기 쉽게 만드는 것입니다.
예를 들어 주문 목록에서 특정 상품을 찾는 코드를 다음처럼 쓸 수 있다면 어떨까요?
val bookLine = order["BOOK-001"]
if ("BOOK-001" in order) {
println(order.totalPrice)
}이 코드는 내부 구현을 자세히 몰라도 의도를 짐작할 수 있습니다.
order["BOOK-001"]: 주문에서 상품 코드로 항목을 찾는다."BOOK-001" in order: 주문에 해당 상품이 포함되어 있는지 확인한다.order.totalPrice: 주문의 총액을 구한다.
코틀린은 이런 표현을 직접 만들 수 있게 해 줍니다. 다만 표현력이 높아진다는 것은 동시에 오용할 위험도 커진다는 뜻입니다. 이 장의 목표는 문법을 외우는 것이 아니라, 언제 쓰면 코드가 좋아지고 언제 피해야 하는지 판단하는 기준을 익히는 것입니다.

확장 함수: 기존 타입에 새 함수처럼 붙이기
확장 함수는 이미 존재하는 타입에 함수를 추가한 것처럼 사용할 수 있는 기능입니다.
가장 단순한 예부터 보겠습니다.
fun String.firstChar(): Char {
return this[0]
}
fun main() {
val name = "Kotlin"
println(name.firstChar()) // K
}String 클래스는 우리가 만든 클래스가 아닙니다. 표준 라이브러리에 있는 타입입니다. 그런데 name.firstChar()처럼 마치 String에 원래 있던 함수처럼 호출할 수 있습니다.
확장 함수의 기본 형태는 다음과 같습니다.
fun 확장할타입.함수이름(매개변수): 반환타입 {
// this는 확장 대상 객체를 가리킨다
}여기서 String.firstChar의 String을 수신 객체 타입이라고 합니다. 함수 본문 안의 this는 실제로 이 함수를 호출한 문자열 객체를 가리킵니다.
fun String.firstChar(): Char {
return this[0]
}위 코드는 다음처럼 더 짧게 쓸 수도 있습니다.
fun String.firstChar(): Char = this[0]확장 함수 안에서는 this를 생략할 수 있습니다.
fun String.firstChar(): Char = get(0)다만 처음에는 this를 명시하는 편이 이해하기 쉽습니다.
확장 함수는 실제 클래스를 수정하지 않는다
확장 함수는 강력하지만, 오해하면 안 되는 점이 있습니다.
확장 함수는 실제 클래스에 멤버를 추가하지 않습니다.
즉, String.firstChar()를 정의했다고 해서 String 클래스의 내부 구조가 바뀌는 것은 아닙니다. 컴파일러가 name.firstChar()라는 호출을 보고 적절한 함수 호출로 연결해 줄 뿐입니다.
그래서 확장 함수는 클래스의 private 멤버에 접근할 수 없습니다.
class User(private val password: String) {
val name: String = "guest"
}
fun User.maskedPassword(): String {
// return password // 컴파일 오류
return ""
}password는 User 내부에서만 접근할 수 있는 값입니다. 확장 함수는 겉으로는 멤버 함수처럼 보이지만 실제 멤버 함수가 아니기 때문에 private 값에 접근할 수 없습니다.
이 차이는 중요합니다.
확장 함수는 다음과 같은 상황에서 유용합니다.
외부 라이브러리 타입에 편의 기능을 추가하고 싶을 때
기존 클래스를 직접 수정하기 어렵거나 수정하고 싶지 않을 때
특정 프로젝트에서만 필요한 읽기 쉬운 표현을 만들고 싶을 때
여러 곳에서 반복되는 변환 로직을 한곳에 모으고 싶을 때
예를 들어 문자열이 간단한 이메일 형태인지 확인하는 함수를 만들 수 있습니다.
fun String.isSimpleEmail(): Boolean {
return contains("@") && contains(".")
}
fun main() {
println("[email protected]".isSimpleEmail()) // true
println("hello".isSimpleEmail()) // false
}정교한 이메일 검증은 더 복잡하지만, 예제의 의도는 확장 함수의 사용 방식입니다. 문자열에 대해 자주 수행하는 판단을 String 옆에 자연스럽게 붙일 수 있습니다.
멤버 함수와 이름이 같으면 어떻게 될까
확장 함수는 실제 멤버가 아닙니다. 그렇다면 기존 멤버 함수와 같은 이름의 확장 함수를 만들면 어떻게 될까요?
class Message {
fun text(): String = "member"
}
fun Message.text(): String = "extension"
fun main() {
val message = Message()
println(message.text()) // member
}결과는 member입니다.
멤버 함수와 확장 함수의 이름과 매개변수가 같으면 멤버 함수가 우선합니다.
이 규칙은 자연스럽습니다. 클래스가 원래 제공하는 기능이 외부에서 덧붙인 기능보다 우선해야 하기 때문입니다.
따라서 확장 함수로 기존 멤버 함수를 덮어쓸 수는 없습니다. 확장 함수는 타입의 기능을 보완하는 도구이지, 타입의 원래 동작을 바꾸는 도구가 아닙니다.
확장 함수는 정적으로 결정된다
확장 함수는 다형성처럼 동작하지 않습니다. 이 점도 중요합니다.
다음 예제를 보겠습니다.
open class User
class PremiumUser : User()
fun User.label(): String = "일반 사용자"
fun PremiumUser.label(): String = "프리미엄 사용자"
fun printLabel(user: User) {
println(user.label())
}
fun main() {
val user: User = PremiumUser()
printLabel(user) // 일반 사용자
}실제 객체는 PremiumUser입니다. 하지만 printLabel 함수의 매개변수 타입은 User입니다. 확장 함수는 실행 시점의 실제 객체 타입이 아니라, 컴파일 시점에 보이는 타입을 기준으로 선택됩니다.
그래서 PremiumUser.label()이 아니라 User.label()이 호출됩니다.
반면 멤버 함수의 오버라이딩은 실제 객체 타입에 따라 결정됩니다.
open class Account {
open fun label(): String = "일반 계정"
}
class VipAccount : Account() {
override fun label(): String = "VIP 계정"
}
fun printLabel(account: Account) {
println(account.label())
}
fun main() {
val account: Account = VipAccount()
printLabel(account) // VIP 계정
}이 차이를 기억해야 합니다.
오버라이딩된 멤버 함수: 실행 시점의 실제 객체 타입을 따른다.
확장 함수: 컴파일 시점의 변수 타입을 따른다.
따라서 다형성이 필요한 핵심 동작은 확장 함수보다 멤버 함수나 인터페이스로 설계하는 편이 좋습니다.
널이 될 수 있는 타입에도 확장할 수 있다
코틀린의 확장 함수는 널이 될 수 있는 타입에도 정의할 수 있습니다.
fun String?.orDefault(defaultValue: String): String {
return this ?: defaultValue
}
fun main() {
val name: String? = null
println(name.orDefault("이름 없음")) // 이름 없음
}수신 객체 타입이 String?입니다. 그래서 name이 null이어도 orDefault를 호출할 수 있습니다.
다만 함수 본문에서는 this가 널일 수 있으므로 안전하게 처리해야 합니다.
fun String?.lengthOrZero(): Int {
return this?.length ?: 0
}이런 방식은 널 처리 코드를 반복해서 쓰지 않게 해 줍니다.
val nickname: String? = null
val length = nickname.lengthOrZero()주의할 점도 있습니다. 널 처리를 너무 많이 확장 함수로 숨기면, 실제로 값이 없다는 사실이 코드에서 잘 보이지 않을 수 있습니다. 널이 중요한 의미를 가진다면 ?:, let, 명시적인 조건문으로 드러내는 편이 더 낫습니다.
확장 프로퍼티: 값처럼 읽히는 계산 만들기
확장 함수와 비슷하게 확장 프로퍼티도 만들 수 있습니다.
val String.lastChar: Char
get() = this[length - 1]
fun main() {
val language = "Kotlin"
println(language.lastChar) // n
}lastChar()가 아니라 lastChar처럼 값으로 읽습니다.
확장 프로퍼티는 다음과 같은 경우에 적합합니다.
매개변수가 필요 없다.
계산 결과가 객체의 자연스러운 속성처럼 느껴진다.
호출할 때마다 값을 계산해도 의미가 어색하지 않다.
예를 들어 문자열의 단어 목록을 구하는 확장 프로퍼티를 만들 수 있습니다.
val String.words: List<String>
get() = trim()
.split(" ")
.filter { it.isNotBlank() }
fun main() {
val sentence = "Kotlin makes code concise"
println(sentence.words) // [Kotlin, makes, code, concise]
}하지만 확장 프로퍼티도 실제 클래스에 필드를 추가하지 않습니다. 즉, 값을 저장할 수 있는 backing field가 없습니다.
다음 코드는 불가능합니다.
// 컴파일 오류
// val String.cachedWords: List<String> = emptyList()확장 프로퍼티는 반드시 get()을 통해 값을 계산해야 합니다.
val String.wordCount: Int
get() = words.size확장 프로퍼티는 값처럼 보이기 때문에, 비용이 큰 작업에는 조심해서 사용해야 합니다. order.totalPrice처럼 간단한 합계 계산은 자연스럽지만, 내부에서 네트워크 요청이나 데이터베이스 조회가 일어난다면 읽는 사람을 놀라게 만들 수 있습니다.
좋은 프로퍼티는 가볍고 예측 가능해야 합니다.
확장 함수는 어디에 두어야 할까
확장 함수는 보통 최상위 함수로 작성합니다.
package com.example.text
fun String.firstChar(): Char = this[0]다른 파일에서는 import해서 사용할 수 있습니다.
import com.example.text.firstChar
fun main() {
println("Kotlin".firstChar())
}프로젝트가 커질수록 확장 함수를 어디에 둘지 정하는 것이 중요합니다.
추천 기준은 다음과 같습니다.
특정 타입과 관련된 확장 함수는 타입의 역할에 가까운 패키지에 둔다.
특정 화면이나 기능에서만 쓰는 확장 함수는 그 기능 근처에 둔다.
전역적으로 쓰기 쉬운 이름은 신중하게 선택한다.
String,Int,List처럼 넓은 타입에 확장할 때는 특히 조심한다.
예를 들어 String.toDate() 같은 이름은 편해 보이지만 위험할 수 있습니다. 날짜 형식이 프로젝트마다 다를 수 있기 때문입니다.
fun String.toDate(): LocalDate // 어떤 형식일까?더 명확하게 쓰는 편이 좋습니다.
fun String.toIsoDate(): LocalDate
fun String.toKoreanDate(): LocalDate확장 함수는 코드를 짧게 만들지만, 이름이 모호하면 오히려 이해를 방해합니다.
스코프 함수: 객체를 다루는 다섯 가지 방식
코틀린 표준 라이브러리에는 객체를 특정 범위 안에서 다루기 위한 함수들이 있습니다. 대표적으로 다음 다섯 가지가 있습니다.
letrunwithapplyalso
이 함수들을 보통 스코프 함수라고 부릅니다. 스코프 함수는 새로운 기능을 만드는 도구라기보다, 객체를 다루는 코드를 더 읽기 좋게 정리하는 도구입니다.
처음 보면 모두 비슷해 보입니다.
user.let { ... }
user.run { ... }
with(user) { ... }
user.apply { ... }
user.also { ... }차이는 크게 두 가지입니다.
첫째, 람다 안에서 객체를 무엇으로 부르는가입니다.
this로 사용:run,with,applyit으로 사용:let,also
둘째, 무엇을 반환하는가입니다.
람다 결과를 반환:
let,run,with원래 객체를 반환:
apply,also
정리하면 다음과 같습니다.
| 함수 | 객체 참조 | 반환값 | 주로 쓰는 상황 |
|---|---|---|---|
let | it | 람다 결과 | 널이 아닌 경우 실행, 값 변환 |
run | this | 람다 결과 | 객체를 사용해 결과 계산 |
with | this | 람다 결과 | 이미 있는 객체로 여러 작업 수행 |
apply | this | 원래 객체 | 객체 설정, 초기화 |
also | it | 원래 객체 | 로그, 검증, 부가 작업 |
표를 외울 필요는 없습니다. 중요한 기준은 두 가지입니다.
이 블록의 결과로 새 값을 얻고 싶은가?
아니면 원래 객체를 계속 넘기고 싶은가?
let: 널 체크와 값 변환에 어울린다
let은 객체를 it으로 받아서 람다의 마지막 값을 반환합니다.
널이 아닐 때만 작업하고 싶을 때 자주 사용합니다.
val email: String? = "[email protected]"
val domain = email?.let {
it.substringAfter("@")
}
println(domain) // example.comemail이 널이면 let 블록은 실행되지 않고 domain은 널이 됩니다.
val email: String? = null
val domain = email?.let {
it.substringAfter("@")
}
println(domain) // nulllet은 값을 다른 값으로 변환할 때도 자연스럽습니다.
val lengthMessage = "Kotlin".let {
"문자열 길이: ${it.length}"
}
println(lengthMessage) // 문자열 길이: 6let을 쓸 때의 핵심은 이것입니다.
이 객체를 이용해 다른 값을 만들고 싶다면 let을 고려합니다.
다만 it이 여러 겹으로 중첩되면 코드를 읽기 어려워집니다.
user?.let {
it.address?.let {
it.city?.let {
println(it)
}
}
}이런 코드는 피하는 편이 좋습니다. 이름을 붙이면 훨씬 낫습니다.
user?.let { currentUser ->
currentUser.address?.let { address ->
address.city?.let { city ->
println(city)
}
}
}더 나아가 조건문이나 별도 함수가 더 읽기 쉬울 수도 있습니다.
apply: 객체를 설정하고 그대로 돌려준다
apply는 객체를 설정할 때 자주 사용합니다. 람다 안에서는 객체를 this로 다루고, 마지막에는 원래 객체를 반환합니다.
class UserProfile {
var name: String = ""
var email: String = ""
var age: Int = 0
}
val profile = UserProfile().apply {
name = "지민"
email = "[email protected]"
age = 28
}apply를 쓰지 않으면 다음처럼 작성해야 합니다.
val profile = UserProfile()
profile.name = "지민"
profile.email = "[email protected]"
profile.age = 28두 코드 모두 맞습니다. 하지만 객체를 만들고 여러 속성을 설정하는 흐름에서는 apply가 더 한 덩어리로 읽힙니다.
apply의 기준은 간단합니다.
객체를 설정한 뒤 그 객체 자체가 필요하다면 apply를 사용합니다.
주의할 점은 apply 안의 this입니다. 바깥에도 this가 있는 클래스 내부에서 apply를 쓰면 어느 객체의 속성인지 헷갈릴 수 있습니다.
class Screen {
var title: String = "기본 제목"
fun createButton(): Button {
return Button().apply {
title = "저장" // Button의 title인지 Screen의 title인지 확인해야 한다
}
}
}
class Button {
var title: String = ""
}이럴 때는 구조를 단순하게 만들거나, 필요한 경우 명시적인 이름을 사용해 혼동을 줄여야 합니다.
also: 부가 작업을 끼워 넣는다
also는 원래 객체를 그대로 반환한다는 점에서 apply와 비슷합니다. 하지만 람다 안에서 객체를 it으로 받습니다.
주로 로그를 남기거나, 값을 확인하거나, 체인 중간에 부가 작업을 넣을 때 사용합니다.
val numbers = listOf(1, 2, 3, 4)
.also { println("원본: $it") }
.filter { it % 2 == 0 }
.also { println("짝수만: $it") }
println(numbers) // [2, 4]also는 이렇게 읽으면 됩니다.
이 객체에 대해 추가로 이것도 하고, 객체 자체는 계속 흘려보낸다.
검증에도 사용할 수 있습니다.
val name = "Kotlin".also {
require(it.isNotBlank()) { "이름은 비어 있을 수 없습니다." }
}하지만 also 안에서 중요한 상태 변경을 많이 하면 코드 흐름이 숨겨질 수 있습니다.
val user = loadUser()
.also { it.loginCount++ }
.also { repository.save(it) }이 코드가 항상 나쁜 것은 아닙니다. 다만 also는 보조적인 작업에 어울립니다. 핵심 동작이라면 명시적인 함수 호출로 드러내는 편이 좋습니다.
val user = loadUser()
user.increaseLoginCount()
repository.save(user)짧은 체인보다 분명한 흐름이 더 중요한 경우가 많습니다.
run과 with: 객체를 사용해 결과를 만든다
run은 객체를 this로 다루고, 람다의 마지막 값을 반환합니다.
val summary = user.run {
"$name <$email>"
}let과 비슷하지만 it 대신 this를 사용합니다. 객체의 멤버를 여러 번 사용할 때는 run이 더 자연스럽습니다.
class User(
val name: String,
val email: String,
val age: Int
)
val user = User("지민", "[email protected]", 28)
val description = user.run {
"$name / $email / ${age}세"
}with는 호출 형태가 조금 다릅니다.
val description = with(user) {
"$name / $email / ${age}세"
}with는 확장 함수가 아니라 일반 함수입니다. 객체를 인자로 넘기고, 블록 안에서 그 객체를 this로 사용합니다.
with는 이미 만들어진 객체를 대상으로 여러 값을 읽거나 여러 작업을 묶을 때 어울립니다.
val report = StringBuilder()
with(report) {
appendLine("사용자 보고서")
appendLine("이름: ${user.name}")
appendLine("이메일: ${user.email}")
}
println(report.toString())run과 with의 기준은 다음처럼 잡으면 됩니다.
객체.run { ... }: 객체에서 출발해 결과를 만들고 싶을 때with(객체) { ... }: 이미 있는 객체를 블록 안에서 반복해서 사용할 때
둘의 차이는 크지 않습니다. 팀에서 선호하는 스타일이 있다면 일관성을 따르는 것이 더 중요합니다.
스코프 함수는 적게 쓸수록 강하다
스코프 함수는 코드를 짧게 만들어 줍니다. 하지만 많이 중첩하면 오히려 읽기 어려워집니다.
val result = user?.let {
it.address?.run {
city.also {
println(it)
}
}
}이 코드는 짧지만 한눈에 들어오지 않습니다. it, this, 반환값이 섞여 있기 때문입니다.
이럴 때는 단계별로 나누는 편이 좋습니다.
val address = user?.address
val city = address?.city
if (city != null) {
println(city)
}코틀린다운 코드는 무조건 짧은 코드가 아닙니다.
코틀린다운 코드는 의도가 잘 읽히는 코드입니다.
스코프 함수를 선택할 때는 다음 질문을 해 보세요.
이 블록이 값을 변환하는가? →
let,run,with원래 객체를 그대로 반환해야 하는가? →
apply,also객체의 멤버를 많이 쓰는가? →
run,with,apply객체를 이름으로 드러내는 편이 나은가? →
let,also중첩이 생기는가? → 별도 변수나 함수로 나누기
연산자 오버로딩: 자연스러운 문법 제공하기
코틀린에서는 특정 이름의 함수를 operator로 정의하면 연산자 문법을 사용할 수 있습니다.
예를 들어 + 연산자는 plus 함수와 연결됩니다.
data class Point(val x: Int, val y: Int)
operator fun Point.plus(other: Point): Point {
return Point(x + other.x, y + other.y)
}
fun main() {
val p1 = Point(1, 2)
val p2 = Point(3, 4)
println(p1 + p2) // Point(x=4, y=6)
}p1 + p2는 실제로 다음 호출과 비슷하게 해석됩니다.
p1.plus(p2)단, 아무 함수나 연산자로 만들 수 있는 것은 아닙니다. 코틀린이 정해 둔 함수 이름만 연산자와 연결할 수 있습니다.
대표적인 연결은 다음과 같습니다.
| 문법 | 함수 이름 |
|---|---|
a + b | a.plus(b) |
a - b | a.minus(b) |
a * b | a.times(b) |
a / b | a.div(b) |
a % b | a.rem(b) |
a[i] | a.get(i) |
a[i] = v | a.set(i, v) |
x in a | a.contains(x) |
a() | a.invoke() |
a > b, a <= b | a.compareTo(b) |
연산자 오버로딩은 코드를 짧게 만드는 기능이 아닙니다. 도메인에서 자연스러운 표현을 코드에 제공하는 기능입니다.
예를 들어 좌표끼리 더하는 것은 자연스럽습니다.
Point(1, 2) + Point(3, 4)금액끼리 더하는 것도 자연스럽습니다.
Money(10_000, "KRW") + Money(5_000, "KRW")하지만 사용자를 더한다거나, 주문을 나눈다거나, 문자열을 이상한 규칙으로 곱하는 것은 읽는 사람을 혼란스럽게 만들 수 있습니다.
연산자는 의미가 예상 가능할 때만 사용해야 합니다.
plus: 더하기의 의미를 설계하기
금액 타입을 예로 들어 보겠습니다.
data class Money(
val amount: Long,
val currency: String
) {
operator fun plus(other: Money): Money {
require(currency == other.currency) {
"서로 다른 통화는 더할 수 없습니다."
}
return Money(
amount = amount + other.amount,
currency = currency
)
}
}
fun main() {
val price = Money(10_000, "KRW")
val shippingFee = Money(3_000, "KRW")
println(price + shippingFee) // Money(amount=13000, currency=KRW)
}이 코드는 price.plus(shippingFee)보다 price + shippingFee가 더 자연스럽습니다. 금액의 덧셈은 우리가 이미 알고 있는 개념과 잘 맞기 때문입니다.
다만 같은 +라도 설계 원칙이 필요합니다.
좋은 plus는 보통 다음 성질을 가집니다.
결과를 예측할 수 있다.
원본 객체를 갑자기 변경하지 않는다.
이름 그대로 더하는 의미에 가깝다.
실패할 수 있는 조건이 있다면 명확히 검사한다.
위 예제에서는 서로 다른 통화를 더하려고 하면 예외를 발생시킵니다.
val won = Money(10_000, "KRW")
val dollar = Money(10, "USD")
// won + dollar // 예외 발생이런 검사가 없다면 잘못된 금액이 조용히 만들어질 수 있습니다. 연산자 문법이 편하더라도 내부 규칙은 엄격해야 합니다.
get과 contains: 괄호보다 읽기 쉬운 접근 만들기
get을 연산자로 정의하면 대괄호 문법을 사용할 수 있습니다.
data class OrderLine(
val productId: String,
val quantity: Int
)
data class Order(
val lines: List<OrderLine>
)
operator fun Order.get(productId: String): OrderLine? {
return lines.find { it.productId == productId }
}
fun main() {
val order = Order(
lines = listOf(
OrderLine("BOOK-001", 2),
OrderLine("PEN-001", 5)
)
)
val line = order["BOOK-001"]
println(line) // OrderLine(productId=BOOK-001, quantity=2)
}order.get("BOOK-001")보다 order["BOOK-001"]가 더 간결하고 자연스럽습니다. 주문을 상품 코드로 조회한다는 의미가 분명하기 때문입니다.
이번에는 contains를 보겠습니다.
operator fun Order.contains(productId: String): Boolean {
return lines.any { it.productId == productId }
}
fun main() {
val order = Order(
lines = listOf(
OrderLine("BOOK-001", 2),
OrderLine("PEN-001", 5)
)
)
if ("BOOK-001" in order) {
println("주문에 책이 포함되어 있습니다.")
}
}"BOOK-001" in order는 order.contains("BOOK-001")와 연결됩니다.
이 표현은 영어 문장처럼 읽힙니다.
BOOK-001이order안에 있는가?
이처럼 연산자 오버로딩은 조건문을 더 자연스럽게 만들 수 있습니다.
compareTo: 비교 가능한 타입 만들기
compareTo를 정의하면 비교 연산자를 사용할 수 있습니다.
data class Version(
val major: Int,
val minor: Int
) {
operator fun compareTo(other: Version): Int {
return when {
major != other.major -> major - other.major
else -> minor - other.minor
}
}
}
fun main() {
val current = Version(2, 1)
val required = Version(2, 0)
if (current >= required) {
println("사용할 수 있는 버전입니다.")
}
}current >= required는 내부적으로 current.compareTo(required) >= 0과 비슷하게 처리됩니다.
compareTo의 반환값 규칙은 다음과 같습니다.
음수: 왼쪽 값이 더 작다.
0: 두 값이 같다.
양수: 왼쪽 값이 더 크다.
비교 연산자를 제공할 때는 정렬 기준이 분명해야 합니다. 버전, 날짜, 크기, 우선순위처럼 대소 관계가 자연스러운 타입에 어울립니다.
반대로 대소 관계가 애매한 타입에는 억지로 비교 연산자를 넣지 않는 편이 좋습니다. 예를 들어 사용자 두 명 중 누가 더 큰지, 주문 두 개 중 무엇이 더 큰지는 기준이 여러 가지일 수 있습니다. 이런 경우에는 compareByTotalPrice, compareByCreatedAt처럼 기준을 이름으로 드러내는 편이 안전합니다.
invoke: 객체를 함수처럼 호출하기
invoke를 정의하면 객체를 함수처럼 호출할 수 있습니다.
class TaxCalculator(
private val rate: Double
) {
operator fun invoke(price: Long): Long {
return (price * rate).toLong()
}
}
fun main() {
val tax = TaxCalculator(0.1)
println(tax(10_000)) // 1000
}tax(10_000)은 tax.invoke(10_000)과 연결됩니다.
이 문법은 다음과 같은 객체에 어울립니다.
계산기
규칙 객체
조건 검사기
함수처럼 동작하는 전략 객체
예를 들어 할인 정책을 객체로 만들 수 있습니다.
class DiscountPolicy(
private val minimumPrice: Long,
private val discountAmount: Long
) {
operator fun invoke(price: Long): Long {
return if (price >= minimumPrice) {
price - discountAmount
} else {
price
}
}
}
fun main() {
val discount = DiscountPolicy(
minimumPrice = 50_000,
discountAmount = 5_000
)
println(discount(60_000)) // 55000
}다만 invoke는 과하게 쓰면 객체의 역할이 숨겨질 수 있습니다. discount.applyTo(60_000)이 더 명확한 상황도 있습니다.
함수처럼 호출하는 것이 정말 자연스러운지 확인한 뒤 사용해야 합니다.
연산자 오버로딩은 확장 함수로도 가능하다
연산자 함수는 멤버 함수로도 만들 수 있고, 확장 함수로도 만들 수 있습니다.
data class Cart(
val productIds: List<String>
)
operator fun Cart.plus(productId: String): Cart {
return Cart(productIds + productId)
}
fun main() {
val cart = Cart(emptyList()) + "BOOK-001" + "PEN-001"
println(cart)
}이 방식은 이미 존재하는 타입을 수정하지 않고도 연산자 문법을 제공할 수 있다는 장점이 있습니다.
하지만 핵심 동작이라면 클래스 내부의 멤버 함수로 두는 편이 더 나을 수 있습니다. 확장 함수는 타입 외부에 있기 때문에, 기능이 여러 파일에 흩어지면 찾기 어려워집니다.
기준은 다음과 같습니다.
타입의 본질적인 기능인가? → 멤버 함수 고려
특정 모듈에서만 필요한 편의 기능인가? → 확장 함수 고려
외부 라이브러리 타입이라 수정할 수 없는가? → 확장 함수 고려
연산자 오버로딩은 API의 얼굴을 바꿉니다. 그래서 일반 확장 함수보다 더 신중하게 설계해야 합니다.
중위 함수: 점과 괄호를 줄여 문장처럼 만들기
중위 함수는 점과 괄호 없이 호출할 수 있는 함수입니다.
대표적인 예는 표준 라이브러리의 to입니다.
val pair = "language" to "Kotlin"이 코드는 다음과 같습니다.
val pair = "language".to("Kotlin")중위 함수를 만들려면 infix 키워드를 붙입니다.
infix fun Int.percentOf(total: Int): Int {
return total * this / 100
}
fun main() {
val discount = 10 percentOf 50_000
println(discount) // 5000
}10 percentOf 50_000은 10.percentOf(50_000)과 같습니다.
중위 함수에는 조건이 있습니다.
멤버 함수이거나 확장 함수여야 한다.
매개변수가 정확히 하나여야 한다.
vararg매개변수를 사용할 수 없다.기본값을 가진 매개변수로 여러 호출 형태를 만들 수 없다.
중위 함수는 문장처럼 읽히는 표현에 적합합니다.
data class User(
val name: String,
val roles: Set<String>
)
infix fun User.hasRole(role: String): Boolean {
return role in roles
}
fun main() {
val user = User("지민", setOf("ADMIN", "WRITER"))
if (user hasRole "ADMIN") {
println("관리자입니다.")
}
}user hasRole "ADMIN"은 자연스럽게 읽힙니다.
하지만 모든 함수를 중위 함수로 만들 필요는 없습니다.
// 과한 예
infix fun String.merge(other: String): String = this + other"A" merge "B"는 처음 보는 사람에게 오히려 낯설 수 있습니다. 그냥 "A" + "B" 또는 "A".plus("B")가 더 익숙합니다.
중위 함수는 다음 조건을 만족할 때 좋습니다.
왼쪽 값과 오른쪽 값의 관계가 분명하다.
함수 이름이 동사나 관계처럼 자연스럽게 읽힌다.
일반 함수 호출보다 의미가 더 잘 드러난다.
복잡한 식 안에 섞이지 않는다.
복잡한 연산과 함께 쓰면 괄호를 추가하는 편이 안전합니다.
val discount = (10 percentOf 50_000) + 1_000읽는 사람이 우선순위를 고민하지 않도록 만드는 것이 좋습니다.
작은 도메인 API 만들기
지금까지 배운 기능을 하나의 예제로 묶어 보겠습니다. 주문 도메인을 간단히 모델링해 보겠습니다.
data class Money(
val amount: Long,
val currency: String
) {
operator fun plus(other: Money): Money {
require(currency == other.currency) {
"통화가 다르면 더할 수 없습니다."
}
return Money(amount + other.amount, currency)
}
operator fun times(quantity: Int): Money {
return Money(amount * quantity, currency)
}
}
data class OrderLine(
val productId: String,
val price: Money,
val quantity: Int
)
data class Order(
val lines: List<OrderLine> = emptyList()
)
operator fun Order.plus(line: OrderLine): Order {
return copy(lines = lines + line)
}
operator fun Order.get(productId: String): OrderLine? {
return lines.find { it.productId == productId }
}
operator fun Order.contains(productId: String): Boolean {
return lines.any { it.productId == productId }
}
val Order.totalPrice: Money
get() = lines.fold(Money(0, "KRW")) { total, line ->
total + (line.price * line.quantity)
}
infix fun Order.hasProduct(productId: String): Boolean {
return productId in this
}이제 사용하는 코드를 보겠습니다.
fun main() {
val order = Order()
.plus(OrderLine("BOOK-001", Money(15_000, "KRW"), 2))
.plus(OrderLine("PEN-001", Money(1_000, "KRW"), 3))
println(order["BOOK-001"])
if ("PEN-001" in order) {
println("펜이 포함되어 있습니다.")
}
if (order hasProduct "BOOK-001") {
println("책이 포함되어 있습니다.")
}
println(order.totalPrice) // Money(amount=33000, currency=KRW)
}이 코드는 여러 코틀린 기능을 사용합니다.
Money + Money: 금액 덧셈Money * Int: 수량만큼 금액 계산Order + OrderLine: 주문에 항목 추가order[productId]: 상품 코드로 주문 항목 조회productId in order: 주문 포함 여부 확인order.totalPrice: 주문 총액 계산order hasProduct productId: 문장처럼 읽히는 조건
여기서 중요한 점은 문법을 많이 썼다는 사실이 아닙니다. 각 표현이 도메인의 의미와 잘 맞는다는 점입니다.
다만 이 예제에서도 개선할 점은 있습니다.
val Order.totalPrice: Money
get() = lines.fold(Money(0, "KRW")) { total, line ->
total + (line.price * line.quantity)
}이 코드는 기본 통화를 KRW로 고정합니다. 실제 서비스에서는 주문의 통화를 별도 속성으로 가져야 할 수 있습니다. 확장 프로퍼티가 편하다고 해서 도메인 규칙을 단순화해 버리면 안 됩니다.
표현력은 정확한 모델 위에서만 힘을 가집니다.
무분별한 확장이 위험한 이유
확장 함수는 편리합니다. 그래서 처음에는 자꾸 만들고 싶어집니다.
fun String.isValid(): Boolean = isNotBlank()
fun Int.isValid(): Boolean = this > 0
fun User.isValid(): Boolean = name.isNotBlank()하지만 이런 이름은 금방 문제가 됩니다.
isValid가 무엇을 검증하는지 문맥 없이는 알 수 없습니다. 문자열의 유효성, 숫자의 유효성, 사용자의 유효성은 모두 기준이 다릅니다.
더 구체적인 이름이 낫습니다.
fun String.isNotBlankName(): Boolean = isNotBlank()
fun Int.isPositiveQuantity(): Boolean = this > 0
fun User.hasRequiredProfile(): Boolean = name.isNotBlank()확장 함수의 위험은 크게 다섯 가지입니다.
첫째, 기능이 흩어집니다.
클래스 안에 있는 멤버 함수는 찾기 쉽습니다. 하지만 확장 함수는 여러 파일과 패키지에 흩어질 수 있습니다. 프로젝트가 커지면 같은 타입에 대한 확장 함수가 어디에 있는지 찾기 어려워집니다.
둘째, 이름 충돌이 생길 수 있습니다.
다른 패키지에서 같은 이름의 확장 함수를 가져오면 어떤 함수를 써야 할지 모호해질 수 있습니다.
import com.example.text.firstChar
import com.other.text.firstChar이런 경우 컴파일러가 모호하다고 알려 주거나, 호출 위치에서 명확히 구분해야 합니다. 필요하면 import alias를 사용할 수 있습니다.
import com.example.text.firstChar as firstCharacter셋째, 너무 넓은 타입에 확장하면 영향 범위가 커집니다.
Any, String, List, Map 같은 타입은 거의 모든 코드에서 사용됩니다. 여기에 모호한 확장 함수를 많이 붙이면 전역 문법처럼 퍼져 버립니다.
fun Any.debug(): String = toString()간단해 보이지만, 모든 객체에 debug()가 생긴 것처럼 보입니다. 이런 확장은 팀 전체가 동의한 규칙이 아니라면 피하는 편이 좋습니다.
넷째, 비용이 숨겨질 수 있습니다.
val User.purchaseHistory: List<Order>
get() = orderRepository.findByUserId(id) // 좋지 않은 예프로퍼티를 읽는 것처럼 보이는데 실제로 데이터베이스 조회가 일어난다면 읽는 사람이 예측하기 어렵습니다. 비용이 큰 작업은 함수로 드러내는 편이 낫습니다.
fun User.loadPurchaseHistory(): List<Order>다섯째, 연산자와 중위 함수는 의미를 과장할 수 있습니다.
user + role
order - coupon
product invoke discount팀원 모두가 즉시 의미를 이해할 수 없다면 일반 함수가 더 좋습니다.
user.addRole(role)
order.removeCoupon(coupon)
product.applyDiscount(discount)짧은 코드가 항상 좋은 코드는 아닙니다.
이름 충돌을 피하는 실전 기준
확장 함수의 이름을 정할 때는 다음 기준을 사용해 보세요.
첫째, 넓은 타입일수록 이름을 구체적으로 짓습니다.
// 모호함
fun String.toDate(): LocalDate
// 더 명확함
fun String.toIsoLocalDate(): LocalDate둘째, 프로젝트의 도메인 용어를 사용합니다.
fun Order.isPaid(): Boolean
fun Order.isCancelable(): Boolean
fun User.canWritePost(): Boolean셋째, 변환 함수는 결과 타입이나 형식을 드러냅니다.
fun Money.toDisplayText(): String
fun User.toProfileResponse(): ProfileResponse넷째, 표준 라이브러리와 비슷한 이름을 함부로 만들지 않습니다.
map, filter, flatMap, groupBy 같은 이름은 이미 강한 의미를 가지고 있습니다. 다른 의미로 사용하면 혼란을 줍니다.
다섯째, 같은 타입에 대한 확장 함수는 가능한 한 한곳에 모읍니다.
예를 들어 주문 관련 확장 함수는 order 패키지나 OrderExtensions.kt 같은 파일에 모으면 찾기 쉽습니다. 단, 파일 이름에만 의존하기보다 패키지 구조가 더 중요합니다.
package com.example.order
val Order.totalPrice: Money
get() = ...
fun Order.isCancelable(): Boolean = ...확장 함수는 숨은 전역 함수에 가깝습니다. 그래서 위치와 이름이 API 설계의 일부가 됩니다.
확장 함수와 멤버 함수 중 무엇을 선택할까
기능을 만들 때마다 고민할 수 있습니다.
"이걸 클래스 안에 넣을까, 확장 함수로 만들까?"
간단한 기준은 다음과 같습니다.
멤버 함수가 어울리는 경우입니다.
타입의 핵심 책임이다.
private 상태를 사용해야 한다.
하위 클래스에서 오버라이딩해야 한다.
객체의 불변식과 강하게 연결된다.
API 사용자에게 반드시 보여야 하는 기능이다.
확장 함수가 어울리는 경우입니다.
기존 타입을 수정할 수 없다.
특정 모듈에서만 필요한 편의 기능이다.
여러 타입을 조합한 변환 로직이다.
핵심 책임은 아니지만 자주 쓰인다.
널 처리나 포맷팅처럼 주변 기능에 가깝다.
예를 들어 Money.plus는 금액의 핵심 동작이므로 멤버 함수가 어울립니다.
data class Money(val amount: Long, val currency: String) {
operator fun plus(other: Money): Money = ...
}반면 Money.toDisplayText()는 화면 표시 형식에 가까우므로 확장 함수가 더 나을 수 있습니다.
fun Money.toDisplayText(): String {
return "${amount}원"
}도메인 핵심과 표현 계층의 편의 기능을 분리하면 코드가 더 오래 유지됩니다.
표현력을 높이는 체크리스트
확장 함수, 스코프 함수, 연산자 오버로딩, 중위 함수는 모두 코드를 보기 좋게 만들 수 있습니다. 하지만 기준 없이 사용하면 코드가 어려워집니다.
다음 체크리스트를 기억하세요.
확장 함수를 만들기 전에 묻습니다.
이 기능은 정말 이 타입 옆에 있을 때 자연스러운가?
이름만 보고 동작을 예상할 수 있는가?
너무 넓은 타입에 붙이는 것은 아닌가?
멤버 함수가 더 적절하지 않은가?
같은 기능이 이미 표준 라이브러리나 프로젝트 안에 있지 않은가?
확장 프로퍼티를 만들기 전에 묻습니다.
값처럼 읽혀도 어색하지 않은가?
계산 비용이 작고 예측 가능한가?
상태를 저장한다고 오해할 여지는 없는가?
스코프 함수를 쓸 때 묻습니다.
반환값이 무엇인지 바로 알 수 있는가?
it과this가 섞여 혼란스럽지 않은가?중첩을 줄일 수 있는가?
일반 변수나 함수로 나누는 편이 더 읽기 쉽지 않은가?
연산자 오버로딩을 만들기 전에 묻습니다.
이 연산자가 도메인에서 자연스러운가?
기존 연산자의 기대를 깨지 않는가?
원본 객체를 갑자기 변경하지 않는가?
일반 함수 이름이 더 명확하지 않은가?
중위 함수를 만들기 전에 묻습니다.
문장처럼 읽히는가?
매개변수 하나로 의미가 충분한가?
복잡한 식에서 헷갈리지 않는가?
이 질문들에 자신 있게 답할 수 있다면, 코틀린의 표현력은 코드의 강점이 됩니다.
마무리
이번 장에서는 코틀린의 표현력을 높이는 기능들을 살펴보았습니다.
확장 함수와 확장 프로퍼티는 기존 타입을 수정하지 않고도 새 기능을 붙일 수 있게 해 줍니다. 하지만 실제 클래스가 바뀌는 것은 아니며, private 멤버에 접근하거나 다형적으로 오버라이딩되는 것도 아닙니다.
스코프 함수는 객체를 다루는 코드를 간결하게 정리해 줍니다. let, run, with, apply, also는 비슷해 보이지만 객체를 참조하는 방식과 반환값이 다릅니다. 이 차이를 이해하면 상황에 맞게 고를 수 있습니다.
연산자 오버로딩은 +, [], in 같은 자연스러운 문법을 직접 제공하게 해 줍니다. 중위 함수는 user hasRole "ADMIN"처럼 문장에 가까운 표현을 만들 수 있습니다.
하지만 이 모든 기능의 목적은 문법 과시가 아닙니다.
좋은 코틀린 코드는 짧은 코드가 아니라, 문제의 의미가 잘 드러나는 코드입니다.
표현력이 높아질수록 설계 책임도 커집니다. 이름을 신중하게 고르고, 도메인에 맞는 문법만 제공하세요. 그러면 코틀린 코드는 단순히 실행되는 명령이 아니라, 문제를 설명하는 문장이 됩니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.