들어가기 전에...
1장 안정성
자바, 자바스크립트, C++가 아니라 코틀린이 요즘 트렌드 언어가 된 계기이자 특징은 코틀린의 안정성(Safety) 때문이다.
코틀린은 다양한 설계 지원을 통해서 어플리케이션의 잠재적인 오류를 줄여준다. 크래시(crash)가 적으면 사용자와 개발자 모두에게 좋고, 상당한 비즈니스 가치를 제공한다.
안정성은 매우 중요하다. 코톨린은 정말 안전한 언어지만, 정말로 안전하게 사용하려면 개발자가 뒷받침을 해야 한다. 해당 장에서 다루는 내용의 기본적인 목적은 '오류가 덜 발생하는 코드를 만드는 것'이다.
이제 본론으로 들어가서!
아이템 1. 가변성을 제한하라
코틀린은 모듈로 프로그램을 설계한다. 여기서의 모듈은 클래스, 객체, 함수, type alias, top-level property등 다양한 요소로 구성된다.
위 요소 중 일부는 상태(state)를 가질 수 있고 이러한 상태는 R/W가 가능한 프러퍼티(read-write property) var를 사용하거나, mutable 객체를 사용함으로써 이루어진다.
상태(state)를 가진다는 것은 코드의 흐름, 시간의 변화에 따른 변하는 요소를 표현할 수 있다는 점에서 유용할 수 있지만! 상태를 적절하게 관리하는게 매우 어렵고 사실상 안쓰는 것을 권장한다. 그 이유들은 다음과 같다.
- state를 권장하지 않는 이유 -
1. 프로그램을 이해하고 디버그하기 힘들어진다.
- 상태를 갖는 변수들의 관계를 이해해야 하며, 이들을 추적하고 수정하기 힘들어진다. 하나의 변수를 수정하였을 때 연관 관계에 있는 다른 코드들이 무너질 가능성과 위험성이 크다.
2. 가변성(mutability)이 있으면, 코드의 실행을 추론하기 어려워진다.
- state를 가지는 변수들은 시점에 따라 값이 달라질 수 있으며, 현재 어떤 값을 갖고 있는지 알아야 코드의 실행을 예측할 수 있다. 또한 특정 시점에서 확인한 값이 동일하게 유지된다는 보장이 없다.
3. 멀티스레드 프로그램일 때는 적절한 동기화가 필요하다.
- 변경이 일어나는 모든 부분에서 충돌이 발생할 수 있다. 코틀린에서는 코루틴(Coroutine)을 제공하여 특정 스레드의 관여를 지정할 수 있기에 동기화를 통한 충돌에 관련된 문제를 줄일 수 있지만...근본적인 해결 방법이 아니다.
4. 테스트하기 어렵다.
- 모든 상태를 테스트해야 하므로, 변경이 많으면 많을수록 더 많은 조합을 테스트해야 한다.
5. 상태 변경이 일어날 때, 이러한 변경을 다른 부분에 알려야 하는 경우가 있다.
- 예를 들어, 정렬되어 있는 리스트에 가변 요소를 추가한다면, 요소에 변경이 일어날 때마다 리스트 전체를 다시 정렬해야 한다.
state에 대한 가변성(mutability)에 대한 근본적인 해결 방법은 가능한 가변성을 사용하지 않는 것이다. 하지만 변경이 일어나야 하는 곳이 없을 수 없다. 신중하고 확실하게 결정하고 사용하자.
코틀린은 가변성을 제한할 수 있게 설계되어 있다. 읽기 전용 프로퍼티(val), 가변 컬렉션과 읽기 전용 컬렉션 구분하기, 데이터 클래스의 copy 등 다양한 방식으로 가변성을 제한할 수 있다.
가변성(mutability) 제한하는 다양한 방법
1. 읽기 전용 프로퍼티(val)
코틀린은 val을 사용하여 읽기 전용 프로퍼티를 만들 수 있다. 이렇게 선언한 프로퍼티는 마치 value 처럼 동작하며, 일반적으로는 값이 변경되지 않는다.
하지만 읽기 전용 프로퍼티가 완전히 변경 불가능한 것은 아니다. val 프로퍼티는 var 프로퍼티를 가질 수 있으며 이러한 경우에는 내부 값의 변경에 따라 변할 수 있다.
코틀린의 프로퍼티는 기본적으로 캡슐화되어 있고, 추가적으로 사용자 정의 접근자 게터(getter)와 세터(setter)를 가질 수 있다. 이러한 특성으로 코틀린은 API를 변경하거나 정의할 때 굉장히 유연하다. (이에 대한 자세한 내용은 아이템16: 프로퍼티는 동작이 아니라 상태를 나타내야 한다'에서 확인 가능)
추가적으로 var은 게터와 세터를 모두 제공하지만, val은 변경이 불가능하므로 게터만 제공한다. 그래서 val을 var로 오버라이드 할 수 있다.
interface Element{
val active: Boolean
}
class ActualElement: Element {
override var active: Boolean = false
}
읽기 전용 프로퍼티 val의 값은 변경될 수 있지만, 프로퍼티 레퍼런스 자체를 변경할 수는 없으므로 동기화 문제는 해결할 수 있다. 그래서 일반적으로 var 보다 val을 많이 사용한다.
(코드리뷰 커멘트 중 하나가 우선적 val을 사용하라 였다. 사실 개발 환경 IDE가 변경에 대한 트래킹을 지속적으로 하고 있기에 val을 변경하려면 오류를, 변경되지 않는 var을 val로 변경하라 권장해주기도 한다. 그렇기에 후에 어떻게 사용될지 모르는 프로퍼티라면 우선적으로 val로 선언하자)
2. 가변 컬렉션과 읽기 전용 컬렉션 구분하기
var과 val 처럼 컬렉션도 가변과 불변으로 구분된다. 예를 들면 Iterable, Collection, Set, List와 MutableIterable, MutableCollection, MutableSet, MutableList 으로 구분된다. 크게 두 분류의 컬렉션은 기본적으로 불변 컬렉션으로 구현되어 있으며 가변 컬렉션은 이를 상속 받아서 변경을 위한 메소드를 추가한 형식이다.
읽기 전용 컬렉션이 내부의 값은 변경할 수 없다는 의미는 아니다. 대부분의 경우에는 변경할 수 있다. 하지만 읽기 전용 인터페이스가 이를 지원하지 않으므로 변경할 수 없다.
저서 내 예를 통해 확인해보면 다음과 같다.
예를 들어 Iterable<T>.map과 Iterable<T>.filter 함수는 ArrayList를 리턴합니다. ArrayList는 변경할 수 있는 리스트입니다. 다음 코드는 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)하게 만들지 않고, 읽기 전용으로 설계한 것은 굉장히 중요한 부분입니다. 이로 인해서 더 많은 자유를 얻을 수 있습니다. 내부적으로 인터페이스를 사용하고 있으므로, 실제 컬렉션을 리턴할 수 있습니다. 따라서 플랫폼 고유의 컬렉션을 사용할 수 있습니다.
내부적으로는 immutable하지 않은 컬렉션을 외부적으로는 immutable하게 보이게 만들어서 안정성을 얻는 케이스이다. 그렇기에 컬렉션 다운캐스팅을 하게되면 문제가 발생한다. 예를 들면..
val lsit = listOf(1,2,3)
if (list is MutableList) {
list.add(4)
}
위와 같은 코드는 플랫폼에 따라 다른 결과가 나오게 된다. JVM에서 listOf는 자바의 List인터페이스를 구현한 Array.ArrayList 인스턴스를 리턴한다. 자바의 List 인터페이스는 add와 set같은 메소드를 제공한다. 이는 코틀린의 MutableList로 변경할 수 있다. 하지만 Arrays.ArrayList는 이러한 연산을 구현하고 있지 않다. 따라서 UnsupportedOperationException과 같은 문제가 발생한다.
컬렉션 다운캐스팅은 추상화를 무시하는 행위이며 이런 코드는 안전하지 않고, 예측하지 못한 결과를 불러일으킨다. 따라서 읽기 전용에서 mutable로 변경해야 한다면, 복제(copy)를 통해서 새로운 mutable 컬렉션을 만드는 list.toMutableList를 사용해야 한다.
3. 데이터 클래스의 copy
Immutable 객체를 사용하면, 다음과 같은 장점이 있다.
1. 한 번 정의된 상태가 유지되므로, 코드를 이해하기 쉽다.
2. immutable 객체는 공유했을 때도 충돌이 따로 이루어지지 않으므로, 병렬 처리를 안전하게 할 수 있다.
3. immutable 객체에 대한 참조는 변경되지 않으므로, 쉽게 캐시할 수 있다.
4. immutable 객체는 방어적 복사본(defensive copy)를 만들 필요가 없다.
5. immutable 객체는 다른 객체를 만들 때 활용하기 좋다.
6. immutable 객체는 set 또는 map의 key로 사용할 수 있다.
immutable 객체는 기본적으로 변경 불가능하므로 내부적으로 변경 값에 대한 새로운 객체를 반환하게끔 구현해야 한다. 예를 들어 Int와 같은 immutable 객체는 내부적으로 plus, minus와 같은 메소드를 사용하여 자신의 변경된 값에 대하여 새로운 객체를 반환하는 형식을 택하고 있다. 사용자 정의 객체도 마찬가지다. 다음과 같은 예로...
class User(
val name: String,
val surName: String
) {
fun withSurname(surname: String) = User(name, surname)
}
하지만 위와 같이 모든 프로퍼티에 대하여 새로운 객체를 반환하는 메소드를 정의하는 것은 비효율적이기에 코틀린은 data class를 제공한다. data 한정자는 copy라는 이름의 메소드를 만들어주고 이를 활용하면, 모든 기본 생성자 프로퍼티가 같은 새로운 객체를 만들어 낼 수 있다.
변경할 수 있다는 측면만 보면 mutable 객체가 더 좋아 보이지만, 이렇게 데이터 모델 클래스를 만들어 immutable 객체로 만드는 것이 더 많은 장점을 가지므로, 기본적으로 이렇게 만드는 것이 더 좋다.
다른 종류의 변경 가능 지점과 변경 가능 지점 노출하지 말기
1. 다른 종류의 변경 가능 지점
변경할 수 있는 리스트를 만들어야 하는 경우에는,
val list1 : MutableList<Int> = mutableListOf()
var list2: List<Int> = listOf()
와 같이 두 개의 방법을 택할 수 있고 위 두개는 처리하는 방식이 다르다. 멀티스레드 환경에서는 내부적으로 어떻게 이루어질지 모르는 list1방식보다는 list2 방식이 안정성이 더 좋다고 할 수 있다. (각 장단점이 존재한다)
하지만 var list3 = mutableListOf<Int>() 와 같이 프로퍼티와 컬렉션 둘 다 변경 가능한 지점으로 만드는 것은 최악이다.
2. 변경 가능 지점 노출하지 말기
state를 나타내는 mutable 객체를 외부에 노출하는 것은 굉장히 위험하다.
data class User(val name: String)
class UserRepository {
private val storedUsers: MutableMap<Int, String> =
mutableMapOf()
fun loadAll(): Mutablemap<Int, String> {
return storedUsers
}
//...
}
loadAll()을 사용해서 private 상태인 UserRepository를 수정할 수 있다.
val userRepository = UserRepository()
val storedUsers = userRepository.loadAll()
storeUsers[4] = "Kirill"
//...
print(userRepository.loadAll())
이러한 코드는 돌발적인 수정이 일어날 때 위험할 수 있다.
이를 처리하는 방법은 두 가지다. 첫 번째는 리턴되는 mutable 객체를 복제하는 것. 이를 방어적 복제라고 부른다. 이때 data 한정자로 만들어지는 copy 메소드를 활용하면 좋다. 두 번째는 무조건적 가변성 제한이다. 컬렉션은 객체를 읽기 전용 슈퍼타입으로 업캐스트하여 제한할 수도 있다.
해당 포스팅은 이펙티브 코틀린 을 참고하여 작성하였습니다.
'Kotlin > 이펙티브 코틀린(Effective Kotlin)' 카테고리의 다른 글
[Item 05] 예외를 활용해 코드에 제한을 걸어라 (0) | 2023.05.02 |
---|---|
[Item 04] inferred 타입으로 리턴하지 말라 (0) | 2023.04.30 |
[Item 02] 변수의 스코프를 최소화하라 (0) | 2023.04.25 |