아이템 1. 가변성을 제한하라

코틀린은 모듈을 통해 프로그램을 설계한다. 모듈엔 다음과 같은 요소가 있다.

  • 클래스

  • 객체

  • 함수

  • 타입 별칭(type alias)

  • 탑레벨(top-level) 프로퍼티

위 요소들은 var나 mutable 객체를 사용해 상태를 가질 수 있다. 일반적으로 상태가 없는 요소들은 사용 방법만 알면 되지만 상태를 가지면 상태를 추적해야하기 때문에 **이력(history)**에도 의존하게 된다.

상태를 가질경우 단점

  1. 디버깅 하기 힘들다. 상태를 추적하며 디버깅 해야한다.

  2. 코드의 실행을 추론하기 어렵다. 시점에따라 값이 달라져서 이전 값을 알아야 현재 값을 예측할 수 있다.

  3. 멀티스레드인 경우 동기화가 필요하다. 변경이 일어나는 부분에서 충돌이 발생할 수 있다.

  4. 테스트하기 어렵다. 모든 상태를 테스트해야 한다.

  5. 상태 변경을 다른 곳에 알려야 하는 경우가 있다. 정렬된 리스트에 요소를 추가하면 재정렬해야한다.

멀티스레드로 var 프로퍼티 값을 수정하는 경우 충돌에 의해 일부 연산이 수행되지 않는다.

var num = 0
for (i in 1..1000) {
  thread {
    Thread.sleep(10)
    num += 1
  }
}
Thread.sleep(5000)
print(num) // 실행할 때마다 다른 숫자 출력

코투린을 사용해도 더 적은 스레드를 사용하지만 여전히 문제가 발생한다.

suspend fun main() {
  var num = 0
  coroutineScope {
    for (i in 1..1000) {
      launch {
        delay(10)
        num += 1
      }
    }
  }
  print(num) // 실행할 때마다 다른 숫자 출력
}

이 경우 동기화를 추가로 구현해야 하고 이마저도 가변인 부분이 많다면 어려워진다. 변할 수 있는 부분은 줄일수록 좋다.

하스켈(Haskell)과 같은 순수 함수형 언어는 가변을 완전히 제한한다. 반면에 이러한 제약으로 인해 프로그램을 작성하기 어려워서 주류로 사용되진 않는다.

코틀린에서 가변성 제한하기

코틀린은 가변을 제한할 수 있게 설계되어 불변(immutable) 객체를 만들거나 프로퍼티 변경을 막는것이 편리하다.

  • 읽기 전용 프로퍼티(val)

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

  • 데이터 클래스의 copy

읽기 전용 프로퍼티(val)

val로 선언된 값은 일반적인 방법으로 값이 변하지 않는다. 하지만 완전한 불변은 아니다. mutable 객체를 선언하면 내부적으로는 변할 수 있다.

val list = mutableListOf(1,2,3)
list.add(4)

print(list) // [1, 2, 3, 4]

또 가변 프로퍼티(var)를 활용한 게터로 정의한 경우 가변 프로퍼티의 변경으로 인해 값이 변할 수 있다.

var name = "홍"
var surname = "길동"
val fullName
	get() = "$name $surname"

fun main() {
  println(fullName) // 홍길동
  name = "김"
  println(fullName) // 김길동
}

val도 변경 될 수 있긴 하지만, 프로퍼티 레퍼런스 자체를 변경할 수 없기 때문에 동기화 문제를 줄일 수 있다. 또한 스마트 캐스트 등 추가적인 기능도 활용할 수 있다. 일반적으로 var보다 val을 사용한다.

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

프로퍼티처럼 컬렉션도 읽고 쓸 수 있는 컬렉션과 읽기 전용 컬렉션으로 구분된다. 이는 컬렉션 계층이 설계된 방식 덕분이다.

Iterable, Collection, Set, List 인터페이스는 읽기 전용이다. 반면 MutableIterable, MutableCollection, MutableSet, MutableMap 인터페이스는 읽기 전용 인터페이스를 상속받아서 변경 메서드를 추가한 것이다.

읽기 전용 컬렉션이 진짜 불변은 아니지만 인터페이스가 이를 지원하지 않기 때문에 변경할 수 없다. 코틀린 내부적으로 mutable한 컬렉션을 immutable하게 보이게 해서 안정성을 얻을 수 있는 것이다.

하지만 다음과 같이 다운캐스팅해서 사용하는 경우 문제가 생길 수 있다.

val list = listOf(1, 2, 3)

if (list is MutableList) {
  list.add(4)
}

JVM 환경에서 listOf는 자바의 Array.ArrayList를 반환하고 자바의 List는 add, set 같은 메서드를 제공해서 코틀린의 MutableList로 변경할 수 있다. 하지만 Arrays.ArrayList는 이러한 연산을 지원하지 않아서 UnsupportedOperationException이 발생한다. 따라서 읽기 전용 컬렉션을 mutable로 변경해야한다면 복제 메소드인 list.toMutableList를 사용해야 한다.

val list = listOf(1, 2, 3)

val mutableList = list.toMutableList()
mutableList.add(4)

이렇게 하면 기존의 객체는 여전히 immutable이기 때문에 안전하다.

데이터 클래스의 copy

immutable 객체를 사용하는 경우 장점

  1. 한번 정의된 상태가 유지되므로 코드를 이해하기 쉽다.

  2. 충돌이 없어서 병렬 처리를 해도 안전하다.

  3. 참조가 변경되지 않아서 쉽게 캐시할 수 있다.

  4. 방어적 복사본(defensive copy)을 만들 필요가 없다. 또한 깊은 복사를 하지 않아도 된다.

  5. 다른 객체를 만들 때 활용하기 좋다. 또한 실행을 쉽게 예측할 수 있다.

  6. Set, Map의 key로 사용할 수 있다. 내부적으로 해시 테이블을 사용하고 값을 기반으로 버킷을 결정하는데, mutable하게 값이 변경되면 해시 테이블 내부에서 요소를 찾을 수 없게 된다.

    val names = TreeSet<FullName>()
    val person = FullName("홍", "길동")
    names.add(person)
    names.add(FullName("홍", "길동2"))
    names.add(FullName("홍", "길동3"))
    
    print(names) // [홍 길동, 홍 길동2, 홍 길동3]
    print(person in names) // true
    
    person.name = "김"
    print(names) // [김 길동, 홍 길동2, 홍 길동3]
    print(person in names) // false

반면 immutable 객체는 변경할 수 없다는 단점이 있다. Int의 plus, minus, Iterable의 map, filter처럼 우리가 만드는 객체도 상태 변경 대신 자신을 수정한 새로운 객체를 리턴해야한다.

class User(
	val name: String,
	val surname: String
) {
  fun withSurname(surname: String) = User(name, surname)
}

var user = User("홍", "길동")
user = user.withSurname("길동2")
print(user) // User(name=홍, surname=길동2)

모든 프로퍼티마다 이런 함수를 만드는 것은 번거롭기 때문에 data 클래스를 사용한다. data 클래스는 copy 메서드를 만들어준다.

data class User(
	val name: String,
	val surname: String
)

var user = User("홍", "길동")
user = user.copy(surname = "길동2")
print(user) // User(name=홍, surname=길동2)

다른 종류의 변경 가능 지점

2가지 방법으로 변경할 수 있는 리스트를 만들 수 있다.

val list1 = mutableListOf()
val list2 = listOf()

각각 다른 방법으로 변경한다.

list1.add(1)
list2 = list2 + 1

list1은 리스트 구현 내부에 변경 가능 지점이 있어서 멀티스레드의 경우 내부적으로 동기화가 되었는지 알 수 없다. 반면 list2는 프로퍼티 자체가 변경 가능 지점이라서 안정성이 더 좋다.

mutable 리스트 대신 mutable 프로퍼티를 사용하면 사용자 정의 세터나 델리게이트를 사용해 변경을 추적할 수 있다.

var names by Delegates.observable(listOf<String>()) { 
  _, old, new -> print("$old to $new")
}

mutable 컬렉션은 변경을 추적하려면 추가적인 구현이 필요하다. 따라서 mutable 프로퍼티에 읽기 전용 컬렉션을 넣어 사용하는 것이 좋다. 이렇게 하면 변경이 필요한 경우 사용자 정의 세터를 사용하고, 변경이 필요 없으면 private으로 만들면 되기 때문이다.

var names = listOf<String>()
	private set

mutable 프로퍼티와 mutable 컬렉션을 모두 사용하는 것은 최악이다.

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

mutable 객체를 외부에 노출하는 것은 위험하다.

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

loadAll을 사용해 private 상태의 storedUser를 수정할 수 있다. 따라서 다음과 같이 처리할 수 있다.

  1. mutable 객체를 복제해서 리턴

    class UserRepository {
      private val storedUser = mutableMapOf<Int, String>()
      
      fun loadAll(): MutableMap<Int, String> {
        return storedUser.copy() // data class의 copy 사용
      }
    }
  2. 읽기 전용 슈퍼타입으로 업캐스트해서 리턴

    class UserRepository {
      private val storedUser = mutableMapOf<Int, String>()
      
      fun loadAll(): Map<Int, String> { // Map 타입으로 리턴
        return storedUser
      }
    }

정리

  • var 보다 val을 사용한다.

  • mutable 프로퍼티보다 immutable 프로퍼티를 사용한다.

  • mutable 객체와 클래스보다 immutable 객체와 클래스를 사용한다.

  • 변경이 필요한 대상을 만들어야 한다면 data class로 만들고 copy를 활용한다.

  • 컬렉션에 상태를 저장해야 한다면 mutable 컬렉션 보다 읽기 전용 컬렉션을 사용한다.

  • 변경 지점을 적절하게 설계하고 불필요한 변경 지점을 만들지 않는다.

  • mutable 객체를 외부에 노출하지 않는다.

예외사항으로 효율성이 필요한 경우엔 mutable 객체를 사용하는 것이 좋다.

Last updated