한 줄 정의
가변 상태는 변경 지점마다 이해·동기화·테스트 비용이 붙는 부채이므로,
val·읽기 전용 컬렉션·data class의copy로 기본을 불변으로 두고 변경 지점을 의도한 한 곳으로 좁혀야 합니다.
쉽게 말하면
팀 문서를 공유 링크 로 나눠 주는 상황을 떠올리면 됩니다. 문서 하나를 팀 전체에 “편집 가능” 링크로 뿌리면 누가 언제 어느 줄을 바꿨는지 알려면 히스토리를 계속 뒤져야 하고, 두 사람이 같은 줄을 동시에 고치면 내용이 깨집니다. 검토해야 할 경우의 수도 링크를 받은 사람 수만큼 불어납니다.
그래서 기본은 “보기 전용” 링크로 주고(val, 읽기 전용 컬렉션), 고칠 일이 있으면 사본을 떠서 고친 뒤 그 사본을 새로 공유합니다(copy). 정말 실시간으로 바뀌어야 하는 문서라면 편집자를 한 명만 두고 그 사람만 고치게 합니다(변경 지점 하나, private set). 그러면 변경 로그도, 잠금도, 검증도 그 한 사람에게만 붙이면 됩니다.
보기 전용 링크를 받은 사람이 URL을 손봐 편집 모드로 들어가는 것이 다운캐스팅 입니다. 원본이 실제로는 편집 가능한 문서라도, 보기 전용으로 준 것은 “읽기만 하라”는 약속이므로 이를 깨면 그 약속 위에 세운 모든 코드가 함께 무너집니다.
왜 중요한가?
상태를 가진다는 것은 양날의 검입니다. 시간에 따라 변하는 것을 표현할 수 있다는 장점 뒤에, 변경 지점이 늘어날수록 커지는 다섯 가지 비용이 따라옵니다.
| 가변 상태의 비용 | 왜 그런가 |
|---|---|
| 이해·디버깅이 어렵다 | 가변 대상들 사이의 관계를 알아야 하고, 서로 의존하는 변경 지점이 많은 클래스는 예기치 않은 상황에서 특히 추적이 어렵습니다 |
| 추론이 어렵다 | 언제든 바뀔 수 있으므로 “방금 확인한 값”이 이미 다른 값일 수 있습니다 |
| 동기화가 필요하다 | 멀티스레드에서 모든 변화는 잠재적 충돌입니다 |
| 테스트가 어렵다 | 확인해야 할 상태 조합이 변경 지점 수에 따라 기하급수적으로 늘어납니다 |
| 변경을 전파해야 한다 | 정렬된 리스트에 가변 요소를 넣으면 그 요소가 바뀔 때마다 리스트를 다시 정렬해야 합니다 |
하스켈처럼 상태 변경을 아예 금지하는 순수 함수형 언어도 있지만 주류가 되지는 못했습니다. 가변 상태는 실제 시스템을 표현하는 데 너무 유용해서 “금지”는 답이 아니고, 정말 합당한 이유가 있을 때만 쓰는 것 이 답입니다. 코틀린은 이 제한을 언어 차원에서 잘 지원합니다.
핵심 내용
코틀린이 가변성을 제한하는 도구는 크게 세 가지입니다. 읽기 전용 프로퍼티 val, 가변 컬렉션과 읽기 전용 컬렉션의 구분, 데이터 클래스의 copy입니다.
읽기 전용 프로퍼티 val
val은 재할당 불가일 뿐, 불변이 아니다
val은 값을 새로 설정할 수 없다는 뜻이지 값이 변하지 않는다는 뜻이 아닙니다. 가변 객체를 담을 수도 있고, 다른 프로퍼티에 의존하는 사용자 정의 게터를 가질 수도 있습니다.
val list = mutableListOf(1, 2, 3)
list.add(4) // val이지만 내용은 바뀝니다
var name: String = "Marcin"
var surname: String = "Moskała"
val fullName
get() = "$name $surname" // name이 바뀌면 fullName도 달라집니다게터가 있는 val은 읽을 때마다 계산되므로, 초기화 때 한 번 계산되는 일반 val과 동작이 다릅니다.
| 선언 | 계산 시점 | print() 두 번 호출 시 |
|---|---|---|
val fizz = calculate() | 초기화 때 한 번 | ”Calculating…” 한 번 |
val buzz get() = calculate() | 읽을 때마다 | ”Calculating…” 두 번 |
val은 변경 지점을 제공하지 않는다
핵심은 val이 내부적으로 게터일 뿐 이라는 점입니다. var는 게터이자 세터입니다. 그래서 인터페이스의 val을 구현 클래스에서 var로 오버라이드할 수 있습니다.
interface Element {
val active: Boolean
}
class ActualElement : Element {
override var active: Boolean = false
}val의 값이 변할 수는 있어도 프로퍼티 자체가 변경 지점을 열어 주지는 않습니다. 변경 지점은 동기화와 추론을 어렵게 만드는 주범이므로, 일반적으로 var보다 val을 선호합니다.
그래도 단순한 final 프로퍼티가 낫다
복잡하게 쓸 이유가 없다면 정의 옆에 값이 명시된 val(재할당 불가라는 뜻의 final이며, 코틀린 키워드 final과는 다릅니다)을 쓰는 것이 좋습니다. 이해하기 쉽고, 코틀린이 스마트 캐스트 같은 지원을 더 해 줍니다.
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은 게터를 쓰기 때문에 null 검사와 사용 사이에 다른 값이 반환될 수 있습니다(예를 들어 다른 스레드가 name을 바꿨을 때). 그래서 컴파일러가 스마트 캐스트를 거부합니다. 지역 변수가 아닌 프로퍼티는 final이면서 사용자 정의 게터가 없을 때만 스마트 캐스트가 됩니다.
가변 컬렉션과 읽기 전용 컬렉션의 구분
계층 구조
프로퍼티를 val/var로 구분한 것과 같은 방식으로, 컬렉션도 읽기 전용 인터페이스와 가변 인터페이스로 나뉩니다. 가변 인터페이스는 각각 상응하는 읽기 전용 인터페이스를 상속해 변경 메서드를 추가합니다.
flowchart BT I[Iterable] C[Collection] --> I S[Set] --> C L[List] --> C MI[MutableIterable] --> I MC[MutableCollection] --> C MC --> MI MS[MutableSet] --> S MS --> MC ML[MutableList] --> L ML --> MC AL[java.util.ArrayList] --> ML HS[java.util.HashSet] --> MS classDef ro fill:#e8e8e8,stroke:#555,color:#111 classDef mu fill:#ffffff,stroke:#555,color:#111 classDef impl fill:#222222,stroke:#222222,color:#fff class I,C,S,L ro class MI,MC,MS,ML mu class AL,HS impl
회색이 읽기 전용 인터페이스, 흰색이 가변 인터페이스, 검은색이 코틀린/JVM에서 실제로 쓰이는 구현체입니다.
읽기 전용은 불변이 아니다, 그래서 다운캐스팅 금지
읽기 전용 컬렉션이 반드시 변경 불가능한 것은 아닙니다. 예를 들어 Iterable<T>.map은 내부에서 ArrayList를 만들어 채운 뒤 읽기 전용 인터페이스 List로 반환합니다. 실제 객체는 가변이지만 읽기 전용 인터페이스 뒤에 숨어 있어 변경할 수 없는 것입니다.
컬렉션을 진짜 불변으로 만드는 대신 인터페이스만 읽기 전용으로 설계 한 이유는 유연성입니다. 반환하는 컬렉션의 실제 형태와 상관없이 인터페이스만 구현하면 되므로 플랫폼별로 특화된 컬렉션을 쓸 수 있습니다. 안전성은 불변 컬렉션과 비슷하고, 유일한 위험은 개발자가 다운캐스팅으로 이 규약을 깨는 것입니다.
val list = listOf(1, 2, 3)
// 이렇게 하지 마세요!
if (list is MutableList) {
list.add(4)
}이 코드는 JVM에서 UnsupportedOperationException을 던집니다
JVM에서
listOf는 자바의Arrays.ArrayList를 반환합니다. 자바List인터페이스에는add·set이 있어 코틀린에서는MutableList로 보이지만,Arrays.ArrayList는 그 연산을 구현하지 않습니다. 다른 플랫폼에서는 결과가 다르고, 1년 뒤에는MutableList를 구현하지 않는 완전 불변 컬렉션으로 바뀌어 있을 수도 있습니다. 아무것도 보장되지 않습니다.
다운캐스팅은 규약 위반이면서 추상화가 아닌 구현에 의존 하는 행위입니다. 읽기 전용 컬렉션을 가변으로 바꿔야 한다면 toMutableList()로 사본을 만들어야 합니다. 이 방식은 어떤 규약도 깨지 않고, List로 반환된 원본이 외부에서 수정되지 않는다는 확신을 유지해 줍니다.
val list = listOf(1, 2, 3)
val mutableList = list.toMutableList()
mutableList.add(4)데이터 클래스의 copy
불변 객체가 그 자체로 주는 이점
String이나 Int처럼 내부 상태를 바꾸지 않는 불변 객체 는 앞서 본 “가변성이 낮은 것을 선호하는 이유” 외에도 자체 이점이 있습니다.
| 이점 | 의미 |
|---|---|
| 추론이 쉽다 | 한 번 생성되면 상태가 그대로입니다 |
| 병렬화가 쉽다 | 공유해도 충돌이 없습니다 |
| 캐싱할 수 있다 | 참조가 가리키는 값이 변하지 않습니다 |
| 방어적 복사가 불필요하다 | 깊은 복사를 할 이유가 없습니다 |
| 다른 객체의 재료로 완벽하다 | 가변·불변 어느 쪽을 구성할 때도 변경 허용 위치를 설계자가 결정할 수 있습니다 |
Set 요소·Map 키로 쓸 수 있다 | 가변 객체는 이 용도로 쓰면 안 됩니다 |
마지막 항목이 가장 실전적인 함정입니다. 해시 테이블이든 정렬 컬렉션이든, 컬렉션은 요소를 넣을 때의 값 으로 위치를 정합니다. 넣은 뒤 요소를 바꾸면 위치가 어긋나 컬렉션 안에 있어도 찾을 수 없습니다.
val names: SortedSet<FullName> = TreeSet()
val person = FullName("AAA", "AAA")
names.add(person)
names.add(FullName("Jordan", "Hansen"))
names.add(FullName("David", "Blanc"))
print(names) // [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변경은 새 사본으로
불변 객체의 가장 큰 문제는 데이터가 때때로 바뀌어야 한다는 점입니다. 해법은 변경 사항을 반영한 새 사본을 반환하는 메서드 입니다. Int의 plus·minus, Iterable의 map·filter가 모두 원본을 두고 새 값을 만드는 방식입니다.
프로퍼티마다 withSurname 같은 메서드를 손으로 만들 수도 있지만, data 한정자가 이 일을 대신하는 copy를 만들어 줍니다. copy는 기본 생성자의 모든 프로퍼티가 같은 새 인스턴스를 만들되, 지정한 프로퍼티만 새 값으로 바꿉니다.
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)가변 객체보다 효율은 떨어지지만 더 안전하고 불변 객체의 모든 장점을 갖기 때문에, 데이터 모델 클래스는 기본적으로 이 방식을 선택해야 합니다.
다른 종류의 변경 지점
변경 가능한 리스트를 표현하는 방법은 두 가지입니다. 가변 컬렉션을 쓰거나, 읽기 전용 컬렉션을 var 프로퍼티에 담는 것입니다.
val list1: MutableList<Int> = mutableListOf() // 가변 컬렉션
var list2: List<Int> = listOf() // 가변 프로퍼티
list1 += 1 // list1.plusAssign(1) — 컬렉션 내부가 바뀝니다
list2 += 1 // 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 같은 매번 다른 숫자가변 프로퍼티의 진짜 장점은 변경을 한 곳에서 제어 할 수 있다는 점입니다. 사용자 정의 세터나 observable 위임으로 모든 변경을 추적할 수 있고, 세터 하나만 private으로 막으면 외부 변경을 차단할 수 있습니다. 가변 컬렉션으로 같은 일을 하려면 원소 변화를 관찰하는 컬렉션을 직접 구현해야 합니다.
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]
var announcements = listOf<Announcement>()
private set // 변경 지점을 클래스 안으로 가둡니다가장 나쁜 선택은 가변 프로퍼티에 가변 컬렉션을 담아 변경 지점을 둘로 만드는 것입니다.
// 이렇게 하지 마세요. 변경 지점이 둘입니다
var list3 = mutableListOf<Int>()기본 규칙
모든 가변 상태는 비용이며, 모든 변경 지점은 이해하고 유지 관리해야 할 대상입니다. 이 아이템이 남기는 규칙은 다음과 같습니다.
var보다val을 선호합니다- 가변 프로퍼티보다 불변 프로퍼티, 가변 객체와 클래스보다 불변 객체와 클래스를 선호합니다
- 불변 객체를 변경해야 한다면
data클래스로 만들고copy를 사용합니다 - 상태를 저장해야 한다면 가변 컬렉션보다 읽기 전용 컬렉션을 선호합니다
- 변경 지점을 의도적으로 설계하고 불필요한 변경 지점을 만들지 않습니다
예외는 성능입니다. 코드의 성능이 정말 중요한 부분에서는 가변 객체가 더 효율적일 수 있지만, 그 부분에서만 써야 하고 멀티스레딩 환경이라면 더 많이 주의해야 합니다. 기본 원칙은 여전히 가변성 제한입니다.
비교 / 트레이드오프
val + MutableList | var + List | var + MutableList | |
|---|---|---|---|
| 변경 지점 | 컬렉션 구현체 내부 | 프로퍼티 세터 하나 | 둘 (최악) |
| 동기화 | 컬렉션 자체가 지원해야 함 | 세터 한 곳에서 직접 구현 | 양쪽 모두 필요 |
| 변경 추적 | 관찰 가능한 컬렉션을 직접 구현 | observable 위임·사용자 정의 세터로 간단 | 어느 쪽도 완전히 못 잡음 |
| 외부 변경 차단 | 참조가 노출되면 막을 수 없음 | private set 한 줄 | 불가 |
| 성능 | 제자리 변경으로 더 빠름 | 변경마다 새 컬렉션 생성 | 빠르지만 의미 없음 |
내 생각
- JPA 엔티티는 예외, 그 밖은 전부
val입니다. 코틀린과 JPA를 함께 쓰면 엔티티는 지연 로딩과 더티 체킹 때문에var와 가변 컬렉션이 불가피하지만, 요청·응답 DTO와 도메인 이벤트, 설정 객체는data class+val이 기본값이어야 합니다. 변경이 필요한 곳을 엔티티 한 층으로 가두는 것이 “변경 지점 설계”를 실무로 옮긴 형태입니다. List<T>반환 규약은 자바 경계에서 깨집니다. 코틀린 안에서는 읽기 전용 인터페이스가 약속을 지켜 주지만, 자바 코드는 같은 객체를java.util.List로 받아add를 부를 수 있습니다. 자바 모듈이나 외부 라이브러리에 넘기는 컬렉션은Collections.unmodifiableList로 감싸거나 사본을 넘겨야 합니다.- 가변 객체를 해시 컬렉션 키로 쓰는 버그는 JPA에서 그대로 재현됩니다.
equals/hashCode에 DB가 채워 주는 id를 포함하면persist전후로 해시가 바뀌어HashSet에서 사라집니다. 식별자는 불변 비즈니스 키로 잡거나, 해시 컬렉션에 넣는 객체는 불변으로 만드는 것이 정석입니다. var+private set은 동기화 훅을 한 곳에 모아 줍니다. 변경 지점이 세터 하나라는 것은 잠금·검증·이벤트 발행·로깅을 그 자리에만 붙이면 된다는 뜻입니다. 책의 스레드 예제처럼 경합이 있다면AtomicReference의updateAndGet으로 읽기 전용 컬렉션을 교체하면 잠금 없이도 손실을 막을 수 있습니다.
관련 개념
- Ch06 동시성, 데이터가 꼬이기 전에 잡아야 한다 — 스레드 간 데이터를 공유할 때 복제본이나 불변 값을 넘기는 이유