[Effective Kotlin] 아이템 1. 가변성을 제한하라

Jimin Lim·2023년 7월 12일
0

Effective Kotlin

목록 보기
1/39
post-thumbnail

아이템 1

가변성을 제한하라

read-write property인 var 혹은 mutable 객체를 가진다면 상태를 가질 수 있다.
이러한 방식으로 상태를 가진다면, history에도 의존하게 된다.

✅ 가변성 문제점

class BankAccount {
    var balance = 0.0
        private set

    fun deposit(depositAmount: Double) {
        balance += depositAmount
    }
    
    fun withdraw(withdrawAmount: Double) {
        if (balance < withdrawAmount) {
            throw InsufficientFunds()
        }
        balance -= withdrawAmount
    }
}

class InsufficientFunds : Exception()
class Item1 {
    @Test
    fun test() {
        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
    }
}

위와 같이 balance 를 var로 지정하고, 값을 시간의 변화에 따라서 변하는 요소를 표현하도록 할 수 있다.

하지만 이렇게 작성한다면, 아래와 같은 문제점이 존재해 상태를 적절히 관리하기 어렵다.

1. 디버그하기 힘듦
: 이러한 상태를 갖는 부분들의 관계를 이해해야하고, 상태 변경이 많아진다면 이를 추적하기 힘들다.
2. 코드의 실행 추론이 어려워짐
: 시점에 따라 값이 달라지므로 한 시점에 확인한 값이 계속 동일하게 유지된다고 확신할 수 없음
3. 멀티스레드 프로그램에서 적절한 동기화 필요
4. 테스트 어려움
: 모든 상태 테스트 필요
5. 상태 변경시 다른 부분에 알려야 하는 경우 존재
: 예를 들어 정렬되어 있는 리스트에 가변 요소를 추가한다면 요소에 변경이 일어날 때마다 리스트 전체를 다시 정렬해야 함

✅ 멀티스레드 환경에서의 문제

    @Test
    fun multiThreadTest1() {
        var num = 0
        for (i in 1..1000) {
            thread {
                Thread.sleep(10)
                num += 1
            }
        }
        Thread.sleep(5000)
        print(num) // 1000이 아닐 확률 높음
    }

위와 같이 작성한다면 동시성 문제로, 1000이 아닐 확률이 높다. 또한 코루틴을 사용한다면 더 적은 스레드가 관여되므로 충돌 문제는 줄어들지만, 사라지는 것은 아니다. (코루틴은 한 스레드로 여러 루틴 실행)

또한 책에서는 synchronized를 이용해서 동시성 제어 방법을 제시함

✅ 가변성 제한

코틀린은 이러한 문제를 해결하기 위해 immutable하게 코드를 작성할 수 있도록 설계되어있다. 예로는 아래와 같다.

  1. 읽기 전용 프로퍼티 (val)
  2. 가변 컬렉션과 읽기 전용 컬렉션 구분
  3. 데이터 클래스의 copy

🔗 읽기 전용 프로퍼티 val

  • val 을 이용해 할당 한 후, 값을 변경할 수 없다.
  • 읽기 전용 프로퍼티가 mutable 객체를 담고 있다면 내부적으로는 변할 수 있다.(예- list.add 연산시 값 추가 가능)

또한 아래와 같이 var (name) 프로퍼티를 사용하는 val (fullname)은 변경될 수 있다.

class Person {
    var name: String = "leah"
    var surname = "lim"

    val fullName
        get() = "$name.$surname"
}

    @Test
    fun immutableTest() {
        val person = Person()
        println(person.fullName) //leah.lim
        person.name = "lea"
        println(person.fullName) //lea.lim
    }
  • 추가적으로 var 은 getter, setter 모두 제공하고 val은 getter만 제공하므로, val을 var로 오버라이드할 수 있다.

val의 값은 변경될 수 있기는 하지만, 레퍼런스 자체는 변경할 수 없어 동기화 문제를 줄일 수 있다. 따라서 일반적으로 var보다는 val을 많이 사용한다.

//getter 
val fullName: String?
    get() = "$name.$surname"
//불변 프로퍼티 
val fullName: String? = "$name.$surname"

위와 같이 getter를 사용하는 경우, 값을 사용하는 시점 다른 결과가 나오므로 null checking 후 스마트캐스트를 사용할 수 없다. 하지만 아래와 같이 쓰이는 경우 스마트 캐스트할 수 있다.

🔗 가변 컬렉션과 읽기 전용 컬렉션 구분

mutable이 붙은 인터페이스는 읽기 전용 인터페이스를 상속받아 변경을 위한 메서드를 추가한다.

그렇다고 읽기 전용 컬렉션이 내부의 값을 변경할 수 없다는 의미는 아니다.
예를 들어, Iterable<T>.mapIterable<T>.filter 함수는 변경할 수 있는 리스트인 ArrayList를 리턴한다. 내부적으로는 immutable하지 않은 컬렉션을 외부적으로는 immutable하게 만든다는 것이다.

이러한 코틀린의 특성은 다운캐스팅(부모가 자식으로 구체화)을 할 때 문제가 발생한다.

 val list = listOf(1, 2, 3)

//다운 캐스팅 금지 
if (list is MutableList) {
    list.add(4)
}
  
//복제 사용
val mutableList = list.toMutableList()

리스트를 읽기 전용으로 리턴하면, 이를 읽기 전용으로만 사용해야 한다. 만약 읽기 전용에서 Mutable로 변경해야 한다면, 복제를 통해 새로운 mutable을 만들도록 해야한다.

🔗 데이터 클래스의 Copy

일반적으로 사용하는 String, Int는 내부 상태를 변경하지 않는 immutable 객체를 사용한다. 불변 객체를 사용하면 다음과 같은 장점이 있다.

  1. 한 번 정의된 객체가 유지되므로 코드를 이해하기 쉽다.
  2. immutable 객체는 공유했을 때도 충돌이 따로 이루어지지 않아 병렬처리를 안전하게 할 수 있다.
  3. immutable에 대한 참조는 변경되지 않아 쉽게 캐시할 수 있다.
  4. immutable 객체는 방어적 복사본을 만들 필요가 없다. 또 깊은 복사를 따로하지 않아도 된다.
  5. immutable 객체는 다른 객체(불변 or 가변)를 만들 때 활용하기 좋다.
  6. immutable 객체는 Set, Map의 키로 사용할 수 있다.
    mutable 객체를 키로 지정하면, 값을 중간에 변경하면 해당 Set, Map에서 해당 객체를 찾을 수 없다.

immutable 객체는 변경할 수 없기에, 자신의 일부를 수정한 새로운 객체를 만들어 내는 메서드를 가져야 한다.
코틀린에서는 이러한 복제하는 기능을 data 클래스로 제공해 준다.

val user = User2("leah", "lim")
user.copy(surname = "lee") //data 클래스에서 제공하는 copy 사용 
println(user)

✅ 다른 종류의 변경 가능 지점

변경할 수 있는 리스트를 만들어야 한다면, (1) mutable 컬렉션, (2) var로 정의 하는 방법이 있다.

val list1: MutableList<Int> = mutableListOf() //(1)
var list2: List<Int> = listOf() //(2)

(1)의 경우, list1.plusAssign(1)으로 변경되는데, 이는 멀티스레드 처리가 이루어진다면 적절한 동기화가 되어있는지 알 수 없어 위험하다.

(2)는 list2 = list2.plus(1)로 변경되며, 프로퍼티가 변경지점이기에 멀티스레드 처리의 안정성이 더 좋다고 할 수 있다. 또, Delegates.observable을 사용해 변경이 있을때 로그를 출력할 수 있어 변경을 추적할 수 있다. (1번 방식도 set을 이용해추적할 수 있지만, 상대적으로 구현 복잡)

(1), (2) 섞는건 최악이다..

✅ 변경 가능 지점 노출하지 말기

class UserRepository {
    private val storedUsers: MutableMap<Int, String> = mutableMapOf()
    
    fun loadAll() = storedUsers
}

외부에서 변경이 가능하기에 mutable 객체를 직접 노출하지 말아야 한다.

만약 노출이 필요하다면 data클래스의 copy를 이용해 복제하는 방식을 사용하거나, 읽기 전용으로 업캐스팅 해야한다.

✅ 정리

  • var보다는 val
  • mutable 보다 immutable
  • 변경이 필요하다면 data 클래스로 만들어 copy사용하도록
  • 불필요한 변경 지점은 만들지 않도록
  • mutable은 노출 x
profile
💻 ☕️ 🏝 🍑 🍹 🏊‍♀️

0개의 댓글