시스템아 미안해
Chapter 1: Safety - Item 1: Limit mutability 본문
왜 우리는 Java, JavaScript, C++ 대신 Kotlin을 프로젝트에 사용하기로 결정할까요?
개발자 입장에서의 답은 보통 Kotlin이 많은 유용한 기능을 갖춘 현대적인 언어이기 때문입니다.
비즈니스 관점에서의 답은 대개 Kotlin이 안전한 언어라는 점입니다. 즉, 언어 설계 자체로 인해 오류가 발생할 가능성이 더 낮다는 의미입니다.
개발 경험이 전혀 없더라도, 애플리케이션이 갑자기 종료되거나 한 시간 동안 장바구니에 상품을 담은 뒤 결제가 되지 않는 웹사이트를 마주하면 누구나 불쾌함을 느끼게 됩니다.
이처럼 크래시가 줄어들면 사용자와 개발자 모두의 삶의 질이 개선되며, 이는 곧 상당한 비즈니스 가치를 만들어냅니다.
안전성은 우리에게 매우 중요하며, Kotlin은 실제로 매우 안전한 언어입니다.
다만, 완전히 안전해지기 위해서는 개발자의 올바른 사용이 반드시 필요합니다.
이 장에서는 Kotlin에서 안전성을 높이기 위한 가장 중요한 모범 사례(best practices)를 다룰 것입니다.
Kotlin의 기능들이 어떻게 안전성을 촉진하는지, 그리고 이를 어떻게 올바르게 사용해야 하는지를 살펴봅니다.
이 장에 포함된 각 항목의 공통된 목적은 하나입니다.
오류가 발생할 가능성이 더 낮은 코드를 작성하는 것입니다.
Item 1: Limit mutability
Kotlin에서는 프로그램을 모듈로 설계하며, 각 모듈은 클래스, 객체, 함수, 타입 별칭, 최상위 속성 등 다양한 종류의 요소들로 구성됩니다. 이러한 요소들 중 일부는 상태(state)를 가질 수 있습니다. 예를 들어, read-write var 속성이나 mutable 객체를 가지는 경우입니다:
var a = 10
var list: MutableList<Int> = mutableListOf()
요소가 상태를 가지면, 그 동작 방식은 사용 방법뿐만 아니라 **이력(history)**에도 의존하게 됩니다. 상태를 가진 클래스의 전형적인 예는 일정 금액의 잔액을 가진 은행 계좌입니다:
class BankAccount {
var balance = 0.0
private set
fun deposit(depositAmount: Double) {
balance += depositAmount
}
@Throws(InsufficientFunds::class)
fun withdraw(withdrawAmount: Double) {
if (balance < withdrawAmount) {
throw InsufficientFunds()
}
balance -= withdrawAmount
}
}
class InsufficientFunds : Exception()
val account = BankAccount()
println(account.balance) // 0.0
account.deposit(100.0)
println(account.balance) // 100.0
account.withdraw(50.0)
println(account.balance) // 50.0
여기서 BankAccount는 이 계좌에 얼마나 많은 돈이 들어 있는지를 나타내는 **상태(state)**를 가지고 있습니다.
상태를 유지하는 것은 양날의 검과 같습니다.
한편으로는 시간이 지남에 따라 변화하는 요소를 표현할 수 있게 해주기 때문에 매우 유용합니다.
하지만 다른 한편으로는 상태 관리는 어렵습니다. 그 이유는 다음과 같습니다.
- 변경 지점이 많은 프로그램은 이해하고 디버깅하기가 더 어렵다.
각 변경(mutation) 사이의 관계를 이해해야 하며, 변경이 많아질수록 상태가 어떻게 바뀌어 왔는지를 추적하기가 점점 더 어려워집니다.
서로 의존하는 변경 지점이 많은 클래스는 이해하기도, 수정하기도 매우 힘든 경우가 많습니다.
이는 특히 예상치 못한 상황이나 오류가 발생했을 때 더욱 큰 문제가 됩니다. - 가변성(mutability)은 코드에 대한 추론을 어렵게 만든다.
불변(immutable) 객체의 상태는 명확하지만, 가변 상태는 훨씬 이해하기 어렵습니다.
값이 언제든지 바뀔 수 있기 때문에, 방금 전에 확인했더라도 이미 변경되었을 가능성이 있습니다. - 가변 상태는 멀티스레드 프로그램에서 적절한 동기화를 요구한다.
모든 변경은 잠재적인 충돌 지점이 됩니다.
이 부분은 다음 항목에서 더 자세히 다루겠지만, 지금은 공유 상태를 관리하는 일이 어렵다는 점만 짚고 넘어가겠습니다. - 가변 요소는 테스트하기가 더 어렵다.
가능한 모든 상태를 테스트해야 하며, 가변성이 많아질수록 확인해야 할 상태의 수 역시 늘어납니다.
더 나아가, 동일한 객체나 파일 안에 변경 지점이 많아질수록 테스트해야 할 상태의 수는 보통 기하급수적으로 증가합니다.
이는 가능한 모든 상태 조합을 고려해야 하기 때문입니다. - 상태가 변경되면, 다른 클래스들이 이 변경을 인지해야 하는 경우가 많다.
예를 들어, 정렬된 리스트에 가변 요소를 추가했을 때 그 요소의 값이 바뀌면, 리스트를 다시 정렬해야 합니다.
이처럼 가변성이 가진 단점은 매우 많아서, 아예 상태 변경을 허용하지 않는 언어들도 존재합니다.
이런 언어들을 순수 함수형 언어라고 하며, 대표적인 예가 Haskell입니다.
하지만 이러한 언어들은 가변성이 극도로 제한되어 있기 때문에, 주류 애플리케이션 개발에서는 잘 사용되지 않습니다.
현실 세계의 시스템 상태를 표현하는 데 있어 가변 상태는 매우 유용한 수단입니다.
저자는 가변성을 사용하는 것을 권장하지만, 실질적인 가치를 제공하는 경우에만 사용해야 한다고 말합니다.
가능하다면 가변성은 제한하는 편이 더 좋습니다.
다행히도 Kotlin은 가변성을 제한하는 데 매우 좋은 지원을 제공하는 언어입니다.
Limiting mutability in Kotlin
Kotlin은 mutability를 제한하도록 설계되었습니다. immutable 객체를 만들거나 속성을 immutable하게 유지하기 쉽습니다. 이는 이 언어의 많은 기능과 특성의 결과이며, 가장 중요한 것들은:
- Read-only 속성 val
- Mutable과 read-only collection의 분리
- Data class의 copy
이들을 하나씩 살펴보겠습니다.
Read-only properties
Kotlin에서는 각 속성을 read-only val("value"처럼) 또는 read-write var("variable"처럼)로 만들 수 있습니다. Read-only(val) 속성은 새 값으로 설정할 수 없습니다:
val a = 10
a = 20 // ERROR
다만 read-only 속성이 반드시 immutable한 것은 아니며 final도 아닙니다. read-only 속성은 mutable 객체를 가질 수 있습니다.
val list = mutableListOf(1, 2, 3)
list.add(4)
print(list) // [1, 2, 3, 4]
Read-only 속성은 다른 속성에 의존하는 custom getter를 사용하여 정의할 수도 있습니다:
var name: String = "Marcin"
var surname: String = "Moskała"
val fullName
get() = "$name $surname"
fun main() {
println(fullName) // Marcin Moskała
name = "Maja"
println(fullName) // Maja Moskała
}
위 예제에서 val이 변경되는 값을 반환하는 것은 custom getter를 정의했기 때문인데, 값을 요청할 때마다 호출됩니다.
fun calculate(): Int {
print("Calculating... ")
return 42
}
val fizz = calculate() // Calculating...
val buzz
get() = calculate()
fun main() {
print(fizz) // 42
print(fizz) // 42
print(buzz) // Calculating... 42
print(buzz) // Calculating... 42
}
Kotlin에서 프로퍼티는 기본적으로 캡슐화되어 있으며,
**커스텀 접근자(getter와 setter)**를 가질 수 있다는 이 특성은 Kotlin에서 매우 중요합니다.
이 덕분에 API를 정의하거나 변경할 때 큰 유연성을 확보할 수 있습니다.
이 내용은 Item 15: 프로퍼티는 동작이 아니라 상태를 표현해야 한다에서 자세히 설명될 것입니다.
다만 여기서의 핵심 아이디어는 다음과 같습니다.
val은 내부적으로 getter만 존재하기 때문에 변경 지점(mutation point)을 제공하지 않습니다.
반면 var는 getter와 setter를 모두 가지는 구조입니다.
바로 이 이유 때문에 val은 var로 오버라이드할 수 있습니다:
interface Element {
val active: Boolean
}
class ActualElement : Element {
override var active: Boolean = false
}
읽기 전용인 val 프로퍼티의 값은 변경될 수 있습니다.
하지만 이러한 프로퍼티는 **변경 지점(mutation point)**을 제공하지 않으며,
프로그램을 동기화하거나 로직을 추론할 때 문제가 되는 주된 원인은 바로 이 변경 지점입니다.
이 때문에 우리는 일반적으로 var보다 val을 선호합니다.
val이 곧 **불변(immutable)**을 의미하는 것은 아니라는 점을 기억해야 합니다.
val은 getter나 delegate로 정의될 수도 있습니다.
이러한 특성 덕분에, 기존의 final 프로퍼티를 getter로 표현되는 프로퍼티로 바꾸는 데 더 많은 자유를 가질 수 있습니다.
하지만 더 복잡한 방식이 필요하지 않다면,
final 프로퍼티를 정의하는 편이 좋습니다.
이 경우 값이 정의 위치 바로 옆에 명시되기 때문에 코드 추론이 훨씬 쉬워집니다.
또한 Kotlin에서는 이러한 프로퍼티가 더 잘 지원됩니다.
예를 들어, **스마트 캐스트(smart cast)**가 가능합니다:
val name: String? = "Márton"
val surname: String = "Braun"
val fullName: String?
get() = name?.let { "$it $surname" }
val fullName2: String? = name?.let { "$it $surname" }
fun main() {
if (fullName != null) {
println(fullName.length) // ERROR
}
if (fullName2 != null) {
println(fullName2.length) // 12
}
}
fullName은 getter로 정의되어 있기 때문에 스마트 캐스트가 불가능합니다.
검사(check) 시점과 실제로 사용할 때 서로 다른 값을 반환할 수 있기 때문입니다
(예를 들어, 다른 스레드에서 name 값을 변경하는 경우).
지역 범위를 벗어난(non-local) 프로퍼티는
final이며 커스텀 getter가 없는 경우에만 스마트 캐스트가 가능합니다.
Separation between mutable and read-only collections
Kotlin은 읽기·쓰기 가능한 프로퍼티와 읽기 전용 프로퍼티를 구분하듯이,
읽기·쓰기 컬렉션과 읽기 전용 컬렉션도 명확히 분리합니다.
이는 컬렉션 계층 구조를 설계하는 방식 덕분에 가능합니다.
Kotlin의 컬렉션 계층 구조를 나타낸 다이어그램을 살펴보면 이해하기 쉽습니다.
왼쪽에는 Iterable, Collection, Set, List 인터페이스가 있으며,
이들은 모두 **읽기 전용(read-only)**입니다.
즉, 컬렉션을 변경할 수 있는 메서드를 전혀 제공하지 않습니다.
반면 오른쪽에는 MutableIterable, MutableCollection, MutableSet, MutableList 인터페이스가 있으며,
이들은 모두 가변(mutable) 컬렉션을 나타냅니다.
각 가변 인터페이스는 대응되는 읽기 전용 인터페이스를 상속하고,
컬렉션을 변경할 수 있는 메서드들을 추가로 제공합니다.
이 구조는 프로퍼티의 동작 방식과 매우 유사합니다.
읽기 전용 프로퍼티는 getter만을 의미하고,
읽기·쓰기 프로퍼티는 getter와 setter를 모두 가진다는 점과 같은 원리입니다.

읽기 전용 컬렉션이 반드시 **불변(immutable)**인 것은 아닙니다.
대부분의 경우 실제로는 **가변(mutable)**이지만,
읽기 전용 인터페이스 뒤에 감춰져 있기 때문에 변경할 수 없을 뿐입니다.
예를 들어, Iterable<T>.map이나 Iterable<T>.filter 함수는
실제로는 **가변 리스트인 ArrayList**를 생성하지만,
반환 타입은 **읽기 전용 인터페이스인 List**로 노출합니다.
아래 코드에서는 표준 라이브러리(stdlib)에 있는
Iterable<T>.map의 단순화된 구현을 확인할 수 있습니다.
inline fun <T, R> Iterable<T>.map(
transformation: (T) -> R
): List<R> {
val list = ArrayList<R>()
for (elem in this) {
list.add(transformation(elem))
}
return list
}
이러한 컬렉션 인터페이스를 **완전한 불변(immutable)**이 아니라
**읽기 전용(read-only)**으로 설계한 선택은 매우 중요합니다.
이 방식은 훨씬 더 큰 유연성을 제공하기 때문입니다.
내부적으로는, 해당 인터페이스를 만족하기만 하면
어떤 실제 컬렉션이든 반환할 수 있습니다.
따라서 플랫폼에 특화된 컬렉션을 사용하는 것도 가능합니다.
이 접근 방식의 안전성은
불변 컬렉션을 사용하는 경우와 거의 동일한 수준에 가깝습니다.
유일한 위험 요소는 개발자가 다운캐스팅을 통해 “시스템을 해킹”하려고 할 때입니다.
이는 Kotlin 프로젝트에서는 절대 허용되어서는 안 되는 행위입니다.
우리는 읽기 전용으로 리스트를 반환했다면,
그 리스트가 오직 읽기 용도로만 사용될 것이라고 신뢰할 수 있어야 합니다.
이것은 일종의 **계약(contract)**입니다.
이에 대한 자세한 내용은 Part 2에서 더 다룰 예정입니다.
컬렉션을 다운캐스팅하는 행위는
인터페이스가 보장하는 계약을 깨뜨릴 뿐만 아니라,
추상화가 아닌 구현에 의존하게 만들고(이는 우리가 지양해야 할 방식입니다),
보안적으로도 안전하지 않으며 예기치 못한 결과를 초래할 수 있습니다.
다음 코드를 살펴보겠습니다:
val list = listOf(1, 2, 3)
// DON'T DO THIS!
if (list is MutableList) {
list.add(4)
}
이 연산의 결과는 컴파일 대상 플랫폼에 따라 달라집니다.
JVM에서는 listOf가
Arrays.ArrayList의 인스턴스를 반환하는데,
이 클래스는 Java의 List 인터페이스를 구현하고 있으며
add, set과 같은 메서드를 가지고 있습니다.
그래서 Kotlin에서는 MutableList 인터페이스로 매핑됩니다.
하지만 Arrays.ArrayList는 실제로 add를 비롯한
일부 객체 변경(mutation) 연산을 구현하지 않습니다.
이 때문에 해당 코드를 실행하면
UnsupportedOperationException이 발생합니다.
플랫폼이 달라지면, 동일한 코드라도
전혀 다른 결과를 낼 수 있습니다.
더 나아가, 1년 뒤에 이 코드가 어떻게 동작할지는 아무도 보장할 수 없습니다.
내부에서 사용되는 컬렉션 구현이 바뀔 수도 있고,
Kotlin으로 구현된 완전히 불변인 컬렉션으로 교체될 수도 있으며,
이 경우 MutableList 자체를 전혀 구현하지 않을 수도 있습니다.
즉, 아무것도 보장되지 않습니다.
이러한 이유로,
읽기 전용 컬렉션을 가변 컬렉션으로 다운캐스팅하는 행위는
Kotlin에서 절대 해서는 안 됩니다.
읽기 전용 컬렉션을 가변 컬렉션으로 변환해야 한다면,
List.toMutableList() 함수를 사용해야 합니다.
이 함수는 새로운 복사본을 생성하며,
그 이후에는 안전하게 수정할 수 있습니다:
val list = listOf(1, 2, 3)
val mutableList = list.toMutableList()
mutableList.add(4)
이 방법은 어떤 계약도 위반하지 않으며, 더 안전합니다. 외부에서 우리가 노출하는 List로 수정되지 않는다고 느낄 수 있기 때문입니다.
Copy in data classes
String이나 Int처럼 내부 상태가 변하지 않는 객체, 즉 불변(immutable) 객체를 선호해야 하는 이유는 많습니다.
앞서 설명한 “가변성을 최소화해야 하는 일반적인 이유” 외에도,
불변 객체는 다음과 같은 고유한 장점을 가지고 있습니다.
- 객체가 생성된 이후 상태가 변하지 않기 때문에 이해하고 추론하기가 쉽다.
- 불변성은 병렬 처리(parallelization)를 훨씬 쉽게 만든다.
공유 객체 간의 충돌이 발생하지 않기 때문이다. - 불변 객체에 대한 참조는 캐시할 수 있다.
객체가 변경되지 않기 때문에 캐시된 값이 무효화될 일이 없다. - 방어적 복사(defensive copy)가 필요 없다.
불변 객체를 복사해야 하는 경우에도 깊은 복사(deep copy)를 할 필요가 없다. - 불변 객체는 다른 객체를 구성하기에 최적의 재료다.
가변 객체든 불변 객체든 모두 만들 수 있으며,
어디까지 가변성을 허용할지 명확하게 결정할 수 있다.
또한 불변 객체를 대상으로 작업하는 것이 훨씬 수월하다. - 불변 객체는 Set에 추가하거나 Map의 키로 사용할 수 있다.
반면 가변 객체는 이런 용도로 사용해서는 안 된다.
이는 Kotlin/JVM에서 이 두 컬렉션이 내부적으로
**해시 테이블(hash table)**을 사용하기 때문이다.
이미 해시 테이블에 분류된 요소를 변경하면,
해당 요소의 해시 기반 위치가 더 이상 올바르지 않게 되어
다시 찾을 수 없게 될 수 있다.
이 문제는 Item 43: hashCode의 계약을 준수하라에서 자세히 설명될 예정이다.
정렬된 컬렉션에서도 이와 유사한 문제가 발생한다.
val names: SortedSet<FullName> = TreeSet()
val person = FullName("AAA", "AAA")
names.add(person)
names.add(FullName("Jordan", "Hansen"))
names.add(FullName("David", "Blanc"))
print(s) // [AAA AAA, David Blanc, Jordan Hansen]
print(person in names) // true
person.name = "ZZZ"
print(names) // [ZZZ AAA, David Blanc, Jordan Hansen]
print(person in names) // false
마지막으로 확인했을 때,
그 사람은 분명 이 Set 안에 있음에도 불구하고 컬렉션은 false를 반환했습니다.
해당 객체가 잘못된 위치에 놓여 있었기 때문에 찾을 수 없었던 것입니다.
이처럼 보시다시피, 가변 객체는 더 위험하고 예측하기 어렵습니다.
반면 불변 객체의 가장 큰 문제는,
데이터가 변경되어야 하는 경우가 실제로 존재한다는 점입니다.
이에 대한 해결책은 간단합니다.
불변 객체는 원하는 변경 사항이 적용된 “복사본”을 생성하는 메서드를 제공해야 합니다.
예를 들어 Int는 불변 객체이며,
plus, minus와 같은 여러 메서드를 가지고 있습니다.
이 메서드들은 Int 자체를 수정하지 않고,
연산 결과를 담은 새로운 Int를 반환합니다.
Iterable 역시 읽기 전용이며,
map이나 filter 같은 컬렉션 처리 함수들은
원본 컬렉션을 수정하지 않고 새로운 컬렉션을 반환합니다.
이 원리는 우리가 만드는 불변 객체에도 그대로 적용할 수 있습니다.
예를 들어, 불변 클래스 User가 있고
그중 surname을 변경할 필요가 있다고 가정해봅시다.
이 경우 withSurname 메서드를 제공하여,
특정 프로퍼티만 변경된 새로운 복사본을 생성하도록 할 수 있습니다:
class User(
val name: String,
val surname: String
) {
fun withSurname(surname: String) = User(name, surname)
}
var user = User("Maja", "Markiewicz")
user = user.withSurname("Moskała")
print(user) // User(name=Maja, surname=Moskała)
이러한 함수를 직접 작성하는 것은 가능하지만,
모든 프로퍼티마다 하나씩 만들어야 한다면 상당히 번거로운 작업이 됩니다.
그래서 **data 변경자(modifier)**가 등장합니다.
data 변경자가 자동으로 생성해주는 메서드 중 하나가 바로 **copy**입니다.
copy 메서드는 기본적으로
기존 인스턴스의 주 생성자(primary constructor) 프로퍼티 값들을 그대로 사용하여
새로운 인스턴스를 생성합니다.
필요하다면 새로운 값도 지정할 수 있습니다.
copy를 비롯해 data 변경자가 생성해주는 다른 메서드들에 대해서는
Item 37: 데이터 묶음을 표현할 때는 data 변경자를 사용하라에서 자세히 다룰 예정입니다.
아래는 이 방식이 어떻게 동작하는지를 보여주는 간단한 예제입니다:
data class User(
val name: String,
val surname: String
)
var user = User("Maja", "Markiewicz")
user = user.copy(surname = "Moskała")
print(user) // User(name=Maja, surname=Moskała)
이 우아하고 범용적인 해결책은
데이터 모델 클래스를 **불변(immutable)**으로 만드는 것을 가능하게 합니다.
이 방식은 단순히 가변 객체를 사용하는 것보다 성능 면에서는 덜 효율적일 수 있지만,
그만큼 더 안전하며,
앞서 언급한 불변 객체의 모든 장점을 그대로 누릴 수 있습니다.
따라서 기본 선택(default)으로 이 방식을 사용하는 것이 바람직합니다.
Different kinds of mutation points
변경 가능한(mutating) list를 표현해야 한다고 가정해 봅시다. 두 가지 방법으로 이를 달성할 수 있습니다: mutable collection을 사용하거나 read-write 속성을 사용하는 방법입니다:
val list1: MutableList<Int> = mutableListOf()
var list2: List<Int> = listOf()
두 속성 모두 수정할 수 있지만, 다른 방식으로:
list1.add(1)
list2 = list2 + 1
이 두 가지 방법 모두 plus-assign 연산자로 대체할 수 있지만, 각각은 다른 동작으로 번역됩니다:
list1 += 1 // Translates to list1.plusAssign(1)
list2 += 1 // Translates to list2 = list2.plus(1)
이 두 가지 방식은 모두 문제없는 방법이며, 각각 장점과 단점이 있습니다.
두 방식 모두 상태가 실제로 바뀌는 지점은 하나뿐이지만,
그 변경이 일어나는 위치는 서로 다릅니다.
첫 번째 방식에서는
상태 변경이 리스트 객체 내부에서 일어납니다.
만약 동시성을 고려해 설계된 컬렉션을 사용하고 있다면,
멀티스레드 환경에서도
그 컬렉션이 내부적으로 제공하는 동기화 기능을 믿고 사용할 수 있습니다.
두 번째 방식에서는
컬렉션 자체는 바뀌지 않고,
프로퍼티가 가리키는 리스트 참조만 교체됩니다.
이 경우 동기화는 개발자가 직접 처리해야 하지만,
상태 변경이 오직 하나의 프로퍼티에서만 발생하기 때문에
구조적으로는 더 안전하고, 코드 흐름을 이해하기도 쉽습니다.
다만, 이 방식 역시
동기화를 제대로 하지 않으면
동시에 여러 변경이 발생할 때
일부 요소가 누락되는 문제가 생길 수 있다는 점은 주의해야 합니다.
var list = listOf<Int>()
for (i in 1..1000) {
thread {
list = list + i
}
}
Thread.sleep(1000)
print(list.size) // 아마도 1000이 아닐 것,
// 매번 다른 숫자, 예를 들어 911
가변 리스트를 사용하는 대신
가변 프로퍼티(var) 를 사용하면,
커스텀 setter를 정의하거나
delegate(내부적으로 setter를 사용하는)를 통해
이 프로퍼티가 언제, 어떻게 변경되는지 추적할 수 있습니다.
예를 들어,
observable delegate를 사용하면
리스트가 변경될 때마다 그 변화를 로그로 남길 수 있습니다.
var names by observable(listOf<String>()) { _, old, new ->
println("Names changed from $old to $new")
}
names += "Fabio"
// Names changed from [] to [Fabio]
names += "Bill"
// Names changed from [Fabio] to [Fabio, Bill]
이와 같은 기능을 가변 컬렉션 자체에서 구현하려면,
관찰 가능한(observable) 전용 컬렉션 구현체가 필요합니다.
반면, 가변 프로퍼티에 읽기 전용 컬렉션을 담는 방식을 사용하면,
객체를 변경하는 메서드가 여러 개 존재하는 대신
setter 하나만 존재하게 되므로 변경을 제어하기가 훨씬 쉽습니다.
또한 이 setter를 private으로 제한할 수도 있습니다:
var announcements = listOf<Announcement>()
private set
요약하자면, 가변 컬렉션을 사용하는 방식은 성능 면에서 약간 더 빠를 수 있지만,
가변 프로퍼티를 사용하는 방식은 객체가 어떻게 변경되는지를 더 잘 제어할 수 있습니다.
그리고 주의해야 할 점은,
가장 나쁜 해결책은 가변 프로퍼티와 가변 컬렉션을 동시에 사용하는 경우라는 것입니다:
// Don't do that
var list3 = mutableListOf<Int>()
일반적인 원칙은 상태를 변경할 수 있는 불필요한 경로를 만들지 말아야 한다는 것입니다.
상태를 변경할 수 있는 모든 방법은 비용입니다.
모든 변경 지점(mutation point)은 이해하고 유지보수해야 할 대상이 됩니다.
따라서 우리는 가변성을 가능한 한 제한하는 방향을 선호합니다.