- 시작하며 -
만약 잘 넘어지는 아이가 뛰어놀고 있다면 가장 간단하게 넘어져 다치지 않게 만들 수 있는 방법이 무엇일까요? 저는 우선 아이를 우선 멈춰 새 울 것 같습니다. 멈춰 새운다면 돌아다니지 않으니 다치지 않을 것입니다. 코드도 이와 비슷한 점이 있습니다. 첫 Chapter인 안정성에서는 안정성이 있는 코드를 짜서 잠재적 오류를 배제하라고 합니다. 코드 또한 안전하게 만들기 위해서는 가장 간단하게 가변성을 제한한다면 예측가능한 상황으로 만들 수 있을 것입니다.
- 본문 -
코틀린의 요소 중 일부는 var 또는 mutable을 사용하여 상태를 가질 수 있습니다. 이 요소가 작다면 예측가능한 패턴만 있겠지만 그 수가 많다면 생길 수 있는 패턴이 기하급수적으로 늘어날 것입니다. 패턴이 많으면 무슨 문제가 생기게 될까요?
- 많은 패턴들을 추적하기 힘들어지니 이해하고 디버그 하는 것이 어려워집니다.
- 디버그가 어려워지면 수정도 힘들어지니 당연히 잠재적 오류가 늘어납니다.
- 가변성이 있으면, 코드의 실행을 추론하기 어렵습니다.
- 현재 어떤 값이 있는지 알아야 코드의 실행을 예측할 수 있지만 시점에 따라 값이 달라지니 또한 그값이 계속 동일하게 유지된다는것을 확신하기 어려우니 결과를 예측할수 없어 코드를 짜기 어려워집니다.
- 멀티 스레드에서는 동기화가 필요합니다.
- 변경이 일어나는 부분은 충돌이 발생할 수 있습니다.
- 많은 패턴들을 하나하나 테스트해야 하니 테스트가 어려워집니다.
- 상태 변경이 일어날 때 이러한 변경을 다른 부분에 알려야 하는 경우가 있습니다.
이 말들을 요약하자면 가변요소가 많아질수록 일관성 문제랑 복잡성이 증가한다는 것입니다. 그렇다면 가변성을 제한하려면 어떻게 해야 할까요? 그 방법은 의외로 쉽습니다. var 대신 val을 사용하거나 mutable 대신 immutable을 사용하여 요소들을 불변으로 만들면 되는 것입니다. 이 방법 들을 더 자세히 봐봅시다.
읽기 전용 프로퍼티(val)
코틀린에서 변수는 쉽게 나눈다면 var(Variable) 형태로 만들어진 변수와 val(Value) 형태로 만들어진 변수로 나눌 수 있습니다. val 형태의 변수로 만든다면 일반적인 방법으로는 값이 변하지 않습니다.
val a = 10
a = 20 // 오류
읽기 전용 프로퍼티가 완전히 변경 불가능한 것은 아닙니다. 아래 코드처럼 mutable 객체를 담고 있다면, 내부적으로 변할 수 있습니다.
val list = mutableListOf(0,1,2,3)
list.add(4)
print(list) // [0, 1, 2, 3, 4]
읽기 전용 프로퍼티는 다른 프로퍼티를 활용하는 사용자 정의 게터로도 정의할 수 있습니다. var 프로퍼티를 사용하는 val 프로퍼티는 var 프로퍼티가 변할 때 변할 수 있습니다. 또한 var은 게터와 세터를 모두 제공하지만, val은 변경이 불가능하므로 게터만 제공한다. 그래서 val을 var로 오버로드 할 수도 있습니다. 대신 그 반대인 var을 val로 오버로드는 할 수 없습니다.
interface Element {
val active: Boolean
var unActivive: Boolean
}
class ActualElement : Element {
override var active: Boolean = false
//error!!
//override val unActive: Boolean = false
}
var name: String = "CheolSu"
var surName: String = "Lee"
val fullName
get() = "$surName $name"
fun main() {
println(fullName) // Lee CheolSu
name = "Kim"
println(fullName) // Kim CheolSu
}
위 2가지 예시처럼 val은 변경될 수 있기는 하지만, 프로퍼티 레퍼런스 자체를 변경할 수는 없으므로 동기화 문제들을 줄일 수 있습니다. 그래서 일반적으로는 var 보다는 val을 많이 사용합니다. 만약 완전히 변경할 필요가 없다면 final 프로퍼티를 사용하는 것이 좋습니다.
val은 정의 옆에 상태가 바로 적히므로, 코드의 실행을 예측하는 것이 훨씬 간단하며, 스마트 캐스트 등의 추가적인 기능을 활용할 수 있습니다.
var name: String = "CheolSu"
var surName: String = "Lee"
val fullName: String?
get() = name?.let { "$it $surName" }
val fullName2: String? = name?.let { "$it $surName" }
fun main() {
if (fullName != null) {
println(fullName.length) // 오류, Smart cast to 'String' is impossible, because 'fullName' is a property that has open or custom getter
}
if (fullName2 != null) {
println(fullName2.length) // 11(Lee CheolSu)
}
}
가변 컬렉션과 읽기 전용 컬렉션 구분하기
위의 그림처럼 코틀린에서는 읽고 쓸 수 있는 컬렉션과 읽기 전용 컬렉션으로 구분됩니다. 기본적으로 읽고 쓸 수 있는 컬렉션들은 읽기전용 컬렉션의 인터페이스 상속받아, 변경을 위한 메서드를 추가한 것입니다.
val list = listOf(1, 2, 3)
// 이렇게 하지 마세요! 아래의 코드처럼 사용필요.
if (list is MutableList) {
list.add(4)
}
코틀린에서 읽기 전용 컬렉션을 mutable 컬렉션으로 다운 캐스팅 하면 안 됩니다. 읽기 전용에서 mutable로 변경해야 한다면, 복제를 통해서 새로운 mutable 컬렉션을 만드는 list.toMutableList를 활용해야 합니다.
val list = listOf(1, 2, 3)
val mutableList = list.toMutableList()
mutable.add(4)
이처럼 복사로 mutable 컬렉션으로 만든다면 기존의 객체는 여전히 immutable이기 때문에 기존의 데이터들을 안전하게 보호할 수 있습니다. scope함수인 filter 나 map같은 경우에도 위함수와 비슷하게 함수안에서 ArrayList로 mutable 이라는 빈값을 만들고 for문을 통해 넣어주고 List<T>로 반환하기때문에 기존의 데이터들은 보호할수 있습니다.
데이터 클래스의 copy
immutable 객체의 사용 시 장점
- 한 번 정의된 상태가 유지되므로, 코드를 이해하기 쉽습니다
- immutable 객체는 공유했을 때도 충돌이 따로 이루어지지 않으므로, 병렬처리를 안전하게 할 수 있습니다
- immutable 객체에 대한 참조는 변경되지 않으므로, 쉽게 캐시할 수 있습니다
- immutable 객체는 방어적 복사본을 만들 필요가 없습니다
- immutable 객체는 다른 객체를 만들 때 활용하기 좋습니다
- immutable 객체는 ‘set’ 또는 ‘map의 키’로 사용할 수 있다. 참고로 mutable 객체는 이러한 것으로 사용할 수 없다. 해시 테이블은 처음 요소를 넣을 때 요소의 값을 기반으로 버킷을 결정하기 때문에, 변경이 일어나면 내부에서 요소를 찾을 수 없게 되어 버립니다.
val names: SortedSet<FullName> = TreeSet()
val person = FullName("AAA", "AAA")
names.add(person)
names.add(FullName("BBB", "BBB")
names.add(FullName("CCC", "CCC")
print(s) // [AAA AAA, BBB BBB, CCC CCC]
print(person in names) // true
person.name = "ZZZ"
print(names) // [ZZZ AAA, BBB BBB, CCC CCC]
print(person in names) // false
mutable 객체는 예측하기 어려우며 위험하다는 단점이 있습니다. 반면 immutable 객체는 변경할 수 없다는 단점이 있습니다. 따라서 immutable 객체는 자신의 일부를 수정한 새로운 객체를 만들어 내는 메서드를 가져야 합니다. 예를 들어 Int 같은 경우 plus나 minus 메서드를 사용한다면 수정한 새로운 Int를 리턴할 수 있습니다. 우리가 만들게 되는 custom immutable 객체에서도 자신을 수정한 새로운 객체를 만들 수 있게 만들어야 합니다.
class User(val name: String, val surname: String) {
fun withSurname(surname: String) = User(name, usrname)
}
var user = User("AAA", "BBB")
user = user.withSurname("CCC")
print(user) // User(name="AAA", surname="CCC")
data 클래스 같은 경우 copy메서드를 지원하기 때문에 위처럼 함수를 따로 지정할 필요는 없습니다.
data class User (val name:String, val surname: String)
var user = User("AAA", "BBB")
user = user.copy("CCC")
print(user) // User(name="AAA", surname="CCC")
다른 종류의 변경 가능 지점
만약 변경할 수 있는 리스트를 만든다고 한다면 두 가지 선택지가 있습니다.
- mutable 컬렉션 만들기
- var로 읽고 쓸 수 있는 프로퍼티 만들기
val list1: MutableList<Int> = mutableListOf()
var list2: List<Int> = listOf()
lsit1.add(1)
list2 = list2 + 1
list1 += 1 // list1.plusAssign(1)로 변경
list2 += 1 // list2 = list2.plus(1)로 변경
두가지 모두 정삭적을 동작하지만 변경가능지접이 다릅니다.
mutable 컬렉션 만들기의 경우..
구체적인 리스트 구현 내부에 변경 가능 지점이 있어, 멀티스레드 처리가 이루어질 경우, 내부적으로 적절한 동기화가 되어있는지 알 수 없어 위험합니다.
fun main() {
val list = mutableListOf<Int>()
// 1000개의 값을 동시에 추가하는 스레드 생성
val threads = List(1000) {
thread {
for (i in 0 until 1000) {
list.add(i)
}
}
}
// 모든 스레드의 실행이 종료될 때까지 대기
threads.forEach { it.join() }
println("최종 크기: ${list.size}")
}
위 코드는 1000개의 스레드를 생성하고 각각의 스레드에서 0부터 999까지의 값을 mutableList에 추가하는 예시입니다. 이 예시에서는 여러 스레드가 동시에 동일한 mutableList에 접근하고 수정하기 때문에 동기화 문제가 발생할 수 있습니다.
실행 결과는 실행할 때마다 달라질 수 있지만, 예상 결과는 1,000,000보다 작은 값이 출력되어야 합니다. 그 이유는 mutableList가 여러 스레드에서 동시에 수정되기 때문에 스레드 간의 경쟁 조건(race condition)이 발생하고, 값을 정확하게 누적하지 못할 수 있기 때문입니다.
var로 읽고 쓸 수 있는 프로퍼티 만들기의 경우..
프로퍼티 자체가 변경 가능 지점이라 멀티스레드 처리의 안정성이 더 좋습니다.
var list = listOf<Int>()
for (i in 1..1000) {
thread {
list = list + i
}
}
Thread.sleep(1000)
print(list.size) // 1000이 안됩니다.
mutable 리스트 대신 mutable프로퍼티를 사용하는 형태는 사용자 정의 세터를 활용해서 변경을 추적할 수 있습니다.
var names by Delegates.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 names2 = listOf<String>()
private set// 이처럼 private set으로 외부에 수정하는것을 막을수도 있습니다.
프로퍼티와 컬렉션을 모두 변경 가능한 지점으로 만드는 건 최악의 방식입니다.
var list3 = mutableListOf<Int>()
변경 가능 지점 노출하지 말기
결국 가변성 이라는 것은 변경가능 지점이 있기 때문에 가변성이라는 변수가 생기는 것 같습니다.
data class User(val name: String)
class UserRepository {
private val storedUsers: MuatableMap<Int, String> = mutableMapOf()
fun loadAll(): MutableMap<Int,String> {
return storedUsers
}
//...
}
// loadAll을 사용해서 private
val userRepository = UserRepository()
val storedUsers = userRepository.loadAll()
storedUsers[4] = "Test"
//...
print(userRepository.loadAll()) // {4=Test}
위의 코드 같은 경우 UserRepository라는 코드에서 storedUsers라는 private 한 mutable 객체를 loadAll이라는 함수로 이 mutable 객체를 어떠한 처리도 하지 않고 바로 반환해 버립니다. 이경우 밖에 변경가능 지점을 만들어줬기 때문에 storedUsers가 private 한 객체라도 수정을 할 수 있습니다. 이러한 위험을 처리하는 방법은 의외로 간단합니다. 반환하는 함수를 복사하거나 변경할 수 없는 immutable 한 객체로 만들어버리면 됩니다.
class UserHolder {
private val user: MutableUser()
fun get(): MutableUser {
return user.copy()
//...
}
class UserRepository {
private val storedUsers: MuatableMap<Int, String> = mutableMapOf()
fun loadAll(): Map<Int,String> {
return storedUsers
}
//...
}
- 마무리 -
이번 편에서 하고 싶은 말은 말 그대로 가변성을 줄이라는 것입니다. mutable 하여 얻을 수 있는 장점도 있겠지만 안정적인 서비스와 유지보수처럼 immutable 해서 얻을 수 있는 장점이 더 많기 때문에 이러한 부분을 최대한 줄여 보자라는 것입니다.
'코틀린 > Effective Kotlin' 카테고리의 다른 글
[Effective Kotlin] 5. 안정성 - 예외를 활용해 코드에 제한을 걸어라 (0) | 2023.07.19 |
---|---|
[Effective Kotlin] 4. 안정성 - inferred 타입으로 리턴하지 말라 (0) | 2023.07.17 |
[Effective Kotlin] 3. 안정성 - 최대한 플랫폼 타입을 사용하지 말라 (0) | 2023.07.14 |
[Effective Kotlin] 2. 안정성 - 변수의 스코프를 최소화 하라 (0) | 2023.07.11 |
[Effectiv Kotlin] 책소개및 클린코드의 정의 (0) | 2023.06.12 |