ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [ Effective Kotlin ] Chapter 1 - Safety
    Kotlin 2023. 6. 9. 12:34

    Item 1 : Limit mutability

    Kotlin은 '불변 객체'와 '가변 객체'를 구분하여 상태를 관리할 수 있다. 이 중 '가변 객체'를 통해 상태를 관리하는 것은 양날의 검과 같다.

     

    '가변 객체'는 시간이 지남에 따라 변하는 '상태를 표현하는 것이 간편'하여 유용하다는 장점이 있지만, 다음과 같은 이유로 상태 관리가 어려워진다는 단점이 있다.

    • 상태의 변화를 추적해야 하므로 코드를 이해하고 디버그 하는 것이 어려움.
    • 상태가 언제든지 변경될 수 있기에 로직의 추론이 어려움.
    • 멀티 스레드 환경에서 동기화 메커니즘이 없는 경우, 상태의 변동성은 잠재적인 충돌을 발생함.
    • 변경 가능한 모든 상태를 테스트해야 하기에 테스트가 어려움.
    • 상태가 변경되면 상태를 사용하고 있던 요소들에게 변경 사항을 반드시 알려야 함.

    그럼에도, 상태 변경을 통해 실제 시스템의 상태를 나타내는 것은 유용하므로, 가변성을 최대한 제한하고 변경점을 최소화하는 것이 중요하다. Kotlin은 이런 가변성을 쉽게 제한할 수 있도록 설계되었고, 이를 지원하는 다양한 기능과 특성을 제공한다.

    Read-only `val`

    `val`은 재할당을 허용하지 않지만, 반드시 불변 값을 갖거나 최종적인 값을 의미하진 않는다. 또한 '가변 객체를 보유'할 수 있고, 다른 프로퍼티를 의존한 뒤 'custom getter'를 통해 정의될 수 있다.

    val a = 10
    a = 20 // Error
    
    val list = mutableListOf(1, 2, 3)
    list.add(4) // OK
    
    
    var name: String = "Java"
    var surname: String = "Hello"
    val fullName: String
        get() = "$name $surname"
    
    fun main() {
        println(fullName) // Java Hello
        name = "Kotlin"
        println(fullName) // Kotlin Hello
    }

     

    위와 같이 `val`은 참조의 변경이 불가능한 대신에 'custom getter' or 'delegate'를 통해 참조 객체의 내부 상태를 변경할 수 있다. 여기서 중요한 점은 '참조의 변경이 불가능'하다는 점이다. 즉, 변경점을 제공하지 않는다.

     

    앞서 말한 것처럼, 변경점은 동기화나 프로그램을 추론할 때 문제의 원인이 될 수 있기에 개발자들은 `var` 보다 `val`을 선호한다. 만약, 상태 변경이 필요하지 않다면, 고정된 값을 명확하게 정의하여 final 프로퍼티로 정의하는 것이 좋다 : `val name = "yongsuk"`

     

    'Mutable Collection'과 'Read-only Collection'의 분리

    Collection 구조

     

    Kotlin에서 컬렉션 계층 구조는 위와 같이 설계되어 있기에 'Read-only Collection'을 지원한다.

     

    하늘색 박스들은 'Read-only Collection'으로 수정을 허용하는 메서드를 제공하지 않는다. 반면, 보라색 박스들은 'Mutable Collection'으로 'Read-only Collection'을 확장하고, 수정을 허용하는 메서드를 제공한다. 이는 Kotlin 프로퍼티가 작동하는 방식과 유사하며, val은 'getter'만을 포함하고, var은 'getter'와 'setter'를 포함한다.

     

    'Read-only Collection'도 val의 'getter' 방식과 동일하게 내부적으로 변경이 가능하지만, 외부에서는 변경이 불가능하다. 예를 들어, Iterable<T>.mapArrayList를 생성하여 작업을 수행하고, 서브 타입인 'Read-only Collection'인 List로 반환하여 외부에서 변경이 불가능하도록 한다.

    inline fun <T, R> Iterable<T>.map(
        transform: (T) -> R
    ): List<R> {
        val list = ArrayList<R>()
        for (item in this@map) {
            list.add(transform(item))
        }
        return list
    }

     

     

    위와 같이 'Read-only Collection'은 많은 유연성을 제공한다. 예를 들어, 메모리 사용이 중요한 모바일 앱의 경우 ArrayList를 통해 작업을 수행하고, 'Read-only Collection'으로 반환할 수 있다. 그리고 빠른 Insert/Delete 작업이 필요한 서버 사이드는 LinkedList를 통해 작업을 수행할 수 있다.

     

    주의할 점은 개발자가 의도적으로 'Read-only Collection'을 'Down-casting'하여 불변성을 우회하려는 시도 해서는 안된다.이는 모든 Kotlin 개발자들의 'contract'로써 지켜져야 한다.

    // Don't do this
    val list = listOf(1, 2, 3)
    
    if (list is MutableList) {
        list.add(4)
    }
    위 코드 중 `listOf()`는 JVM 환경에서는 `ArrayList`를 반환하기에, 주의해야 함.
    Java의 `List`는 Kotlin의 `MutableList`와 같이 다를 수 있는것처럼 보이지만, 
    `ArrayList`는 `MutableList`의 모든 연산을 지원하지 않고 '다르기에' 주의애야 함.

     

    만약 'Read-only Collection'을 'Mutable Collection'로 변환하고 싶다면, toMutableList()를 사용하여 새로운 객체로 복사하여 사용하자.

    val list = listOf(1, 2, 3)
    
    val mutableList = list.toMutableList()
    mutableList.add(4)

     

    Copy in data classes

    String, Int와 같이 내부 상태가 변하지 않는 불변 객체를 선호하는 이유는 다음과 같다.

    • 한 번 생성된 후 변경되지 않으므로, 프로그램 추론이 쉬움.
    • 불변 객체는 공유되어도 상태가 변하지 않아, 병렬 프로그래밍 시 'Race-condition', 'Deadlock' 등 문제를 방지할 수 있음.
    • 상태가 변경되지 않기에, 불변 객체의 참조를 안전하게 캐싱하여 여러 곳에서 재사용이 가능함.
    • 복사할 때 원본 객체의 상태가 변하지 않으므로, 'Defensive copy', 'Deep copy' 등 필요하지 않음.
    • 상태가 변경되지 않기에, 불변 객체를 기반으로 다른 객체를 구성하기 좋음
    • Set에 추가하거나 Map의 키로 사용할 수 있음.
    val names: SortedSet<FullName> = TreeSet()
    val person = FullName("AAA", "AAA")
    names.add(person)
    names.add(FullName("BBB", "BBB"))
    names.add(FullName("CCC", "CCC"))
    
    print(names) // [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, because person is at incorrect position

     

    불변 객체에 데이터 변경이 필요한 경우, 객체 내부의 변경이 아닌 데이터 변경 후의 새로운 객체를 생성하는 메서드를 사용해야 한다. 예를 들어, Int에서 plus, minus 메서드는 연산 후 새로운 Int를 반환하는 것처럼 말이다.

    class User(
        val name: String,
        val surname: String
    ) {
        fun withSurname(surname: String): User = User(name, surname)
    }
    
    // usage
    var user = User("yongsuk", "park")
    user = user.withSurname("kim")
    print(user) // User(name=yongsuk, surname=kim)

     

    그러나 위와 같은 작업은 모든 프로퍼티에 대해 수행하는 경우 번거로운 작업이 될 수 있기에, data classcopy()를 사용하는 것이 좋다. copy()는 기존 객체의 상태를 기본값으로, 변경하고자 하는 특정 프로퍼티만 새로운 값으로 지정하여 새로운 객체로 생성할 수 있다.

    data class User(
        val name: String,
        val surname: String
    )
    
    // usage
    var user = User("yongsuk", "park")
    user = user.copy(surname = "kim")
    print(user) // User(name = yongsuk, surname = kim)

     

    Different kinds of mutation points

    변경이 가능한 목록이 필요하면 'Mutable Collection' 또는 var을 통해 만들 수 있다.

    val list: MutableList<Int> = mutableListOf()
    var list2: List<Int> = listOf()
    
    list.add(1)
    list2 = list2 + 1


    만약 목록을 변경할 때 'plus-assign'을 사용하여 변경한다면, 각각 다른 행동으로 동작된다.

    list1 += 1 // Translates to list1.plusAssign(1)
    list2 += 1 // Translates to list2 = list2.plus(1)


    'Mutable Collection'은 리스트 구현체에서 변화가 일어나고, 구현이 직관적이 쉽다.

    var은 해당 프로퍼티 자체를 단일 변경점으로 갖고 있기에 해당 속성에 대한 접근을 제어함으로써 동기화 관리가 수월하다. 또한 var은 'custom setter' 또는 'Delegate'를 사용하여 프로퍼티에 대한 변경을 추적할 수 있다.

    var names by Delegates.observable(listOf<String>()) { _, old, new ->
        println("Names changed from $old to $new")
    }
    
    names += "Yongsuk" // Names changed from [] to [Yongsuk]
    names += "Minsu" // Names changed from [Yongsuk] to [Yongsuk, Minsu]


    그럼에도 위 2가지 방법은 가변 상태를 가지고 있어 멀티 스레드 환경에서 동기화 문제가 있기에 별도로 동기화 메커니즘을 구현해서 사용해야 한다. 또한 상태를 변경하는 모든 방식은 비용이 되기에 일반적인 경우에는 불필요한 상태 변경 가능성을 제거하는 것이 좋다.


    Item 2 : Minimize the scope of variables

    상태를 정의할 때 변수와 프로퍼티의 범위를 최소화하는 것이 좋으며, 다음 두 가지 방법으로 이를 수행할 수 있다.

    • 프로퍼티 대신 로컬 변수 사용
    • 가능한 최소 범위의 변수 사용 (e.g : 변수가 loop 내에서만 사용된다면, loop 내에서 정의)

    이와 같이 변수와 프로퍼티 범위를 최소화하면 다음과 같은 이점이 있다.

    • 해당 변수가 영향을 미치는 코드의 양이 줄어들어, 코드 복잡성을 줄이고 오류 가능성을 낮출 수 있다.
    • 다른 개발자와 협업 시, 공통으로 사용되는 프로퍼티의 목적을 추론하기 쉬워진다.

    변수를 val 또는 var 중 어떤 것으로 선언하든, 개발자는 항상 변수가 정의될 때 초기화되는 것을 선호해야 하며, 이는 if, when, try-catch, ?:와 표현식을 사용하여 지원된다.

    val user: User =
        if (hasValue) {
            getValue()
        } else {
            User()
        }
    
    fun updateWeather(degrees: Int) {
        val (description, color) = when {
            degrees < 5 -> "cold" to Color.BLUE
            degrees < 23 -> "mild" to Color.YELLOW
            else -> "hot" to Color.RED
        }
    }

     

    Capturing

    'loop' 내에서만 변수를 사용된다면 'loop' 내에서 변수를 정의하는 것이 좋다. 예를 들어, 'Sequence builder'를 사용하여 에라토스테네스의 체 알고리즘을 구현하여 소수를 찾는 방식은 다음과 같다.

    val primes: Sequence<Int> = sequence {
        var numbers = generateSequence(2) { it + 1 }
    
        while (true) {
            val prime = numbers.first()
            yield(prime)
            numbers = numbers.drop(1).filter { it % prime != 0 }
        }
    }
    
    print(primes.take(10).toList()) // [2,3,5,7,11,13,17,19,23,29]

     

    그러나, 이를 'loop' 외부 범위에서 변수를 정의하여 사용하면 다음과 같이 올바르지 못한 구현이 될 수 있다.

    val primes: Sequence<Int> = sequence {
        var numbers = generateSequence(2) { it + 1 }
    
        var prime: Int
        while (true) {
            prime = numbers.first()
            yield(prime)
            numbers = numbers.drop(1).filter { it % prime != 0 }
        }
    }
    
    print(primes.take(10).toList()) // [2, 3, 5, 6, 7, 8, 9, 10, 11, 12]

     

    이는, prime 변수가 외부 범위에서 '캡처'됨과 'Sequence'의 게으른 연산들으로 문제가 발생된 것이다. 게으른 연산은 'Sequence'가 실제로 평가되어야 할 때만 연산을 수행함을 의미하는데, 이때 연산들이 지연되고 쌓이게 되면서 prime 값이 변경될 때마다 filter { it % prime != 0 } 연산에 영향을 주게 되어 잘못된 결과를 반환하게 된다.

    // numbers = [ 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, ... ]
    
    // Loop #1
    prime = numbers.first() // numbers first '2'
    yield(prime) // primes add '2'
    // `drop(1).filter { ... } ` operations are lazy, so they are not executed yet 
    
    // Loop #2
    // 실제 numbers 시퀀스가 평가 되어야 할 때 == first(), toList(), take() 등이 호출될 때
    prime = numbers
        .drop(1).filter { it % 2 != 0 } // drop '2' and filter '{ 3 % 2 != 0 }'
        .first() // numbers first '3'
    
    yield(prime) // primes add '3'
    // `drop(1).filter { ... } ` operations are lazy, so they are not executed yet
    
    // Loop #3
    // `prime` 변수가 반복문 외부에 캡처되었기 때문에, 이전 prime 값에 영향을 받음
    prime = numbers
        .drop(1).filter { it % 3 != 0 } // drop '2' and filter '3'
        .drop(1).filter { it % 3 != 0 } // drop '4' and filter '{ 5 % 3 != 0 }'
        .first() // numbers first '5'
    
    yield(prime) // primes add '5'

     

    이와 같은 이유로 개발자들은 의도되지 않은 캡처링의 위험성을 인지해야 하고, 이를 방지하기 위해 변수의 범위를 최소화하는 것이 좋다.


    Item 3 : Eliminate platform types as soon as possible

    Kotlin의 'Null-Safety'는 'NPE'을 방지하며 '타입 시스템'을 통해 변수가 'null'이 될 수 있는지 명시적으로 처리한다.

     

    만약 Java와 같이 'Null-Safety'가 확실하지 않은 언어와 상호작용 시, 'Null-safety'를 보장할 수 없게 된다. 그럼에도 Kotlin은 Java의 @Nullable@NotNull 어노테이션을 통해, 'Nullability'에 대한 정보를 얻을 수 있다.

    • @Nullable : 'Nullable' 타입으로 간주하고, String?으로 해석
    • @NotNull : 'Non-nullable' 타입으로 간주하고, String으로 해석

    Java에서는 모든 타입이 'Nullable' 타입이지만, 개발자 입장에서는 객체가 절대 'null'이 될 수 없다는 것을 확신할 수 있는 경우 Kotlin에서는 'Non-null assertion !!'을 사용하여 해당 타입을 명시적으로 'Non-Nullable' 타입으로 나타낼 수 있다.

     

    그럼에도, Java에서의 제네릭 타입과 @Nullable 또는 @NotNull 어노테이션이 없는 타입을 다룰 때, Kotlin에서는 기본적으로 'Nullable' 타입으로 간주하기에, 'Non-null assertion'을 수행하고 'null'을 필터링하는 작업으로 프로그램 안전성을 보장해야 한다.

    val users: List<List<User>> = UserRepo().groupedUsers!!.map { it!!.filterNotNull() }

     

    하지만, 이와 같은 작업은 번거로운 작업이기에, Kotlin에서는 Java와 같은 언어와 상호작용 시 발생하는 'Nullability' 문제를 해결하기 위해 'Platform Type'이라는 개념을 도입했다.

     

    Platform Type

    'Platform Type'은 상호작용하는 언어에서 가져온 타입의 'Nullability'가 명확하지 않을 때 사용하는 특별한 타입으로, String!과 같이 타입 뒤에 !를 붙여 표기한다. 그러나 이 표기법은 코드 내 직접적으로 표기가 불가능하고, 변수나 프로퍼티에 할당될 때 추론되어 사용해야 한다. 'Platform Type'은 'Nullable'과 'Non-Nullable' 중 의도하는 타입으로 선택할 수 있다.

    val repo = UserRepo()
    val user1 = repo.user           // Type of user1 is User!
    val user2: User = repo.user     // Type of user2 is User
    val user3: User? = repo.user    // Type of user3 is User?

     

    그럼에도, 'Non-nullable' 타입으로 가정한 것이 시간이 지나 'Nullable' 타입으로 변경될 수 있는 위험성이 존재하기에, 상호작용하는 Java 코드 일부를 제어할 수 있다면, 가능한 @Nullable@NotNull 어노테이션을 적극적으로 명시하는 것이 좋다.

     

    Kotlin 코드에서 'Platform type'을 다룰 때, 명시적으로 'Nullable', 'Non-Nullable' 타입으로 변환하는 것이 좋다. 이는 안정성을 높이고 예기치 못한 'NPE'를 방지하는데 도움이 되는데, 다음 두 코드의 동작 차이를 보자.

    // Java
    public class JavaClass {
        public String getValue() {
            return null;
        }
    }
    
    // Kotlin
    fun statedType() {
        val value: String = JavaClass().value // NPE Error
        print(value.length)
    }
    
    fun platformType() {
        val value = JavaClass().value
        print(value.length) // NPE Error
    }

     

    statedType()과 같이 타입을 명시적으로 작성했을 때, 'Nullable' 타입을 잘못 받고 있음이 명확해지고, 컴파일러가 경고를 통해 알려주기에 예상치 못한 'NPE'를 방지할 수 있다.

     

    반대로 platformType()과 같이 명시적으로 타입을 작성하지 않는 경우, 'Platform Type'으로 지정된 변수는 'Nullable' 타입과 'Non-Nullable' 타입이 될 수 있기에 '허용' 된다.

     

    그 후 value.length와 같이 'Non-Nullable' 타입으로 사용하려고 하면, 값이 존재하지 않기에 'NPE'가 발생한다. 이와 같이 언제, 어느 곳에서 사용할지 모르기에 런타임 에러로 발생될 확률이 높고 코드가 복잡해지면 그 원인을 찾기 어려울 수 있다.

     

    이와 같은 이유로 'Platform Type'은 'Nullability'가 명확하지 않기에 사용을 지향하고, 사용할 때는 명시적으로 'Nullable' or 'Non-Nullable' 타입으로 변환하는 것이 좋다.


    Item 4: Do not expose inferred types

    Kotlin의 'inferred type'은 개발자가 타입을 명시하지 않아도, 컴파일러가 자동으로 적절하게 타입을 결정해 주는 기능이다.

     

    변수에 값을 할당할 때, 컴파일러는 오른쪽 항의 정확한 타입을 변수의 타입으로 추론한다. 이는 할당된 값이 인터페이스나 상위 타입의 클래스를 구현하더라도, 실제 할당된 객체 타입을 기준으로 타입이 결정됨을 의미한다.

    open class Animal
    class Zebra : Animal()
    
    fun main() {
        var animal = Zebra()
        animal = Animal() // Error: Type mismatch
    }

     

    만약 'inferred type'이 제한적인 요소가 많다고 느끼면, 타입을 명시적으로 선언하여 이를 해결할 수 있다.

    var animal: Animal = Zebra() // Specify type explicitly
    animal = Animal() // OK

     

    그러나, 라이브러리나 다른 모듈에서 타입을 명시적으로 제어할 수 없는 경우에 'inferred type'의 노출은 위험한 일이 될 수 있다.
    예를 들어, 다음과 같은 시나리오를 생각해 보자.

    1. 자동차를 생산하기 위한 공장을 나타내는 인터페이스가 있음.
      • interface CarFactory { fun produce(): Car }
    2. 대부분의 공장은 아무런 요청이 없는 경우, 기본적인 자동차를 생산함.
      • val DEFAULT_CAR: Car = Sonata()
    3. 그리고 DEFAULT_CAR는 어차피 Car 타입임이 확실하기에, 명시적으로 선언되어 있던 타입을 생략함.
      • interface CarFactory { fun produce(): DEFAULT_CAR }
    4. 시간이 지나. 나중에 어떤 개발자가 다른 코드를 보지 않고, DEFAULT_CAR만을 보고 해당 타입을 추론할 수 있다고 결정해 버림.
      • val DEFAULT_CAR = Sonata()
    5. 이후, 모든 자동차 공장은 Car가 아닌, Sonata만을 생산하게 되는 문제가 발생함.

    이와 같이 CarFactory 직접 제어할 수 있다면, 해당 문제를 쉽게 발견하여 수정할 수 있지만, 이들이 라이브러리나 외부 모듈의 일부라면, 해당 문제를 발견하고 수정하는 것은 불가능에 가깝고, 따로 문의하여 시간과 비용이 많이 들게 된다.


    Item 5 : Specify your expectations on arguments and state

    코드 내 특정 조건이나 기대사항을 명시할 때 가능한 먼저 명시하는 것이 좋으며, Kotlin에서는 아래 방법을 통해 이를 지원한다.

    • require : 'Argument'에 대한 기대사항을 명시하는 보편적인 방법
    • check : '상태'에 대한 기대사항을 명시하는 보편적인 방법
    • assert : 어떤 것이 'true'인지 확인하는 방법, JVM 환경의 Unit Test에서 사용
    • return or throw와 함께 사용하는 'Elvis 연산자(?:)'

    위 방식들은 전통적인 문서화 방법을 대체하지 못하지만, 그럼에도 다음과 같은 장점을 가지고 있다.

    • 코드 자체에 기대사항이 명확하기에 문서를 꼼꼼하게 읽지 않은 개발자도 기대사항을 쉽게 파악할 수 있다.
    • 조건이 만족되지 않으면 예외를 발생시켜, 잠재적인 버그나 예상치 못한 행동으로 이어질 수 있는 문제들을 사전에 방지한다. 특히 상태가 변경되기 전에 예외를 발생시켜, 부분 상태 변경으로 인한 복잡성 문제를 방지하여 상태 안정성을 높일 수 있다.
    • 일정 부분을 코드가 자체적으로 조건을 확인하고 유효성을 검증하기에 'Unit Test' 필요성을 줄일 수 있다.
    • 위 방법들 모두 'smart-casting'과 함께 잘 작동하여, 명시적인 타입 캐스팅을 피할 수 있다.

    require는 조건이 만족되지 않았을 때 IllegalArgumentException을 발생시킨다. 또한 함수 시작 부분에 위치하여, 잘못된 'Argument'로 발생되는 문제를 함수 내부 깊숙이 전파되지 않도록 막는다. 결과적으로 require는 기대사항을 충족하는 'Argument'로 호출됨을 보장해 주는 역할을 한다.

     

    check는 조건이 만족되지 않았을 때 IllegalStateException을 발생시킨다. 일반적으로 require 검증 후 check 검증을 바로 수행하거나, 함수가 여러 단계를 거치며 각 단계에서 특정 상태임을 검증해야 하는 경우에도 사용되어 함수의 중간이나 끝 부분에도 위치할 수 있다.

     

    기본적으로 assert는 Kotlin/JVM에서만 활성화되어 있고, JVM의 -ea(enable assertions) 옵션을 사용하여 명시적으로 활성화하지 않으면 무시된다. 그렇기에 assert는 '단위 테스트'의 일부로 간주되며, 'production 환경'에서 어떠한 오류도 발생시키지 않는다.

     

    contract를 통해 requirecheck는 검사 후 반환이 되면 그들의 조건이 '참'이라는 것을 명시한다.

    public inline fun require(value: Boolean): Unit {
        contract { returns() implies value }
        require(value) { "Failed requirement." }
    }

     

    위와 같이 검사하여 컴파일러에게 '참'으로 인식된 것들은 'smart-casting'과 잘 호환되는데, 아래와 같이 'smart-casting'을 통해 outfit의 타입이 Dress 타입임이 검증되면, 컴파일러는 outfitDress 타입임을 인지하고, 이후 코드에서 outfitDress 타입으로 취급한다.

    fun changeDress(person: Person) {
       require(person.outfit is Dress)
       val dress: Dress = person.outfit
    }

     

    이러한 특징은 null을 검증할 때에도 유용하며, requireNotNull()checkNotNull()을 통해 변수를 검증하여 'unpack'으로 사용할 수 있다.

    class Person(val email: String?)
    fun validateEmail(email: String) { /* ... */ }
    
    fun sendEmail(person: Person) {
       val email = requireNotNull(person.email)
       validateEmail(email)
       // ...
    }
    
    fun sendEmail(person: Person) {
       requireNotNull(person.email)
       validateEmail(person.email)
       // ...
    }

     

    또한 throw or return에 'Elvis 연산(?:)'을 사용하여 개발자에게 유연성을 제공할 수 있다.

    class Person(val email: String?)
    
    fun sendEmail(person: Person) {
        val email: String = person.email ?: return
    }
    
    fun sendEmail(person: Person) {
       val email = user.email ?: run {
          Log.e("Email required")
          return
       }
    }

    Item 6 : Prefer standard errors to custom ones

    표준 라이브러리에 정의되어 있지 않은 예외는 사용자가 직접 정의하여 적합한 예외를 던지는 것이 좋다. (e.g : Json 파싱 중 잘못된 형식인 경우 JsonParsingException) 그 외 표준 라이브러리에서 지원되는 예외를 사용하도록 하자.

    • IllegalArgumentException : 함수에 전달된 'Argument'가 기대사항과 다를 때
    • IllegalStateException : 함수의 '상태'가 기대사항과 다를 때
    • IndexOutOfBoundsException : 컬렉션 요소에 접근하려 할 때 유효하지 않은 인덱스가 사용될 때
    • ConcurrentModificationException : 컬렉션이나 공유 리소스를 동시에 수정할 때
    • UnsupportedOperationException : 특정 메서드가 클래스에 존재하지만 실제로 구현되어 있지 않았거나 지원되지 않을 때
    • NoSuchElementException : 컬렉션에서 요소를 요청했지만, 해당 요소가 존재하지 않을 때

    Item 7 : Prefer null or Failure result when the lack of result is possible

    때때로 함수에서 원하는 결과를 얻을 수 없는 상황이 생길 수 있으며, 이를 처리하는 2가지 메커니즘이 존재한다.

    • null / sealed class 반환
    • Throw an exception

    Exception(이하 예외)는 정보 전달 목적으로 사용되는 것이 아닌, 예외 조건에 부합될 때 사용되어야 하며 그 이유는 아래와 같다.

    • 예외가 전파되는 방식은 대부분 가독성이 떨어지기에, 예외 조건에 부합될 때 사용되어야 한다.
    • 컴파일러가 예외 처리를 강제하지 않아, 예외 조건을 알 수 없는 경우가 많기에, 예외 조건이 명확할 때 사용되어야 한다.
    • JVM 환경에서 예외는 예외적인 상황을 위해 설계되어, 예외 처리 시 여러 비용들이 발생하기에 명시적인 테스트들('if', 'when' 등 조건문)만큼 효율적이지 않아, 예외 조건에 부합될 때 사용되어야 한다.
    • 'try-catch' 블록 안에 코드를 배치하면 컴파일러가 수행하는 최적화를 방해하므로, 예외 조건에 부합될 때 사용되어야 한다.

    반면, null or Failure은 명시적이기에 예상 가능한 오류를 처리하는데 적합하다.

    • 오류가 예상될 때, null or Failure 반환
    • 오류가 예상되지 않을 때, 예외 throw
    sealed class Result<out T> {
        data class Success<out T>(val result: T) : Result<T>()
        data class Failure(val throwable: Throwable) : Result<Nothing>()
    }
    
    class JsonParsingException : Exception()
    
    inline fun <reified T> String.readObjectOrNull(): T? {
        // ...
        if (incorrectSign) {
            return null
        }
        // ...
        return result
    }
    
    inline fun <reified T> String.readObject(): Result<T> {
        // ...
        if (incorrectSign) {
            return Failure(JsonParsingException())
        }
        // ...
        return Success(result)
    }

     

    위와 같은 방식으로 처리되는 오류는 다루기 쉽고, 오류 상황을 쉽게 인지할 수 있다. Kotlin에서는 null을 처리하기 위해 'Safe-call' or 'Elvis 연산'을 지원하고, Result와 같은 'Union type'의 경우 when을 통해 쉽게 처리할 수 있게 지원한다.

    val age = userText.readObjectOrNull<Person>()?.age ?: -1
    
    val age =
        when (val person = userText.readObject<Person>()) {
            is Success -> person.age
            is Failure -> -1
        }

     

    만약 try-catch 만을 사용하여 예외 처리를 했을 때, 잘못하여 예외를 놓치게 되면 앱 전체가 중단될 위험이 있다. 반면, null or sealed class는 명시적인 처리가 필요하지만 앱을 중단하지 않는다. 이런 이유로, null or sealed class를 사용하여 오류를 처리하는 방식이 try-catch 보다 더 효율적이고, 명시적이다. 추가로, sealed class는 계속해서 서브 클래스를 확장할 수 있기에 정보를 계속해서 추가해야 하는 경우 유용하다. 그렇지 않은 경우에는 null을 사용하는 것이 좋다.


    Item 8 : Handle nulls properly

    null은 프로퍼티와 함수에서 특정한 의미를 지닌다.

    • 프로퍼티에서 null은 값이 설정되지 않았거나, 제거되었음을 의미한다.
    • 함수에서 null을 반환하는 것은 어떤 함수이냐에 따라 다른 의미를 가질 수 있다.
      • String.toIntOrNull()은 문자열이 정수로 변환될 수 없는 경우 null을 반환한다.
      • Interable<T>.firstOrNull(() -> Boolean)은 조건을 만족하는 첫 번째 요소를 반환하거나, 조건 만족 요소가 없는 경우 null을 반환한다.

    위와 같이 null을 사용할 때, 의미가 명확해야 하고 의미를 가지고 있기에 반드시 처리되어야 한다. 일반적으로 'Nullable 타입'을 처리하는 방법은 다음과 같다.

    • 'Safe-call(?.)', 'Smart-casting', 'Elvis 연산(?:)' 등 사용
    • Throw an error
    • 해당 프로퍼티나 함수를 리팩터링 하여 'Nullable 타입' → 'Non-Nullable 타입'으로 변환

    Handling nulls safely

    null을 안전하게 처리하는 방법은 'Safe-call', 'Smart-casting', 'Elvis 연산'을 사용하는 것이다.

    printer?.print()                        // Safe-call
    if (printer != null) printer.print()    // Smart-casting
    
    // Elvis operator
    val printerName1 = printer?.name ?: "Unknown"
    val printerName2 = printer?.name ?: throw IllegalStateException("Printer is not set")
    val printerName3 = printer?.name ?: return

     

    위 예시의 'Safe-call'과 'Smart-casting'은 printer가 'not-null'일 때 호출된다.

    'Elvis 연산'은 'Nullable 타입'에 기본 값을 제공하여 null을 처리하는 방식이다.

     

    추가로, Kotlin에서 많은 객체들은 확장 함수를 통해 null에 대한 기본 값을 제공할 수 있고, 이를 통해 'Nullable 타입'을 'Non-nullable 타입'으로 변환할 수 있다. 예를 들어, '빈 컬렉션'을 제공하는 emptyList()와 같은 함수들이 있다.

    Throwing an error

    'Safe-call'과 'Smart-casting'을 통해 null을 처리할 때, 'Nullable 타입'이 실제로 null인 경우, 아무런 작업도 하지 않고 다음 라인으로 넘어간다. "아무런 작업을 하지 않아서 외부에 아무것도 알리지 않는다." 이런 방식은 오류의 근본적인 원인을 숨기고 문제 해결을 어렵게 만들 수 있다.

     

    이처럼 알려지지 않고 넘어가는 오류를 'Silent failure'이라 하며, 이를 해결하기 위해 예외를 발생시켜 개발자에게 알리는 것이 좋다. 예외를 발생시킬 때에는 throw, 'Not-null assertion(!!)', requireNotNull(), checkNotNull() 등을 사용할 수 있다.

    fun process(user: User) {
        requireNotNull(user.name)
    
        val context = checkNotNull(context)
        val networkService = getNetowkrService(context) ?: throw NoInternetConnection()
    
        networkService.getData { data, userData ->
            show(data!!, userData!!)
        }
    }

     

    The problems with the not-null assertion !!

    !!은 'Nullable 타입'을 처리하는 가장 간단한 방법으로, Java의 'NPE'가 발생하는 경우와 동일하게 동작한다.

     

    !!은 'Nullable 타입'이지만 null이 될 수 있다고 예상되지 않는 상황에서만 사용한다. 하지만, 현재 null이 아니더라도 미래에 null이 될 수 있음을 고려하지 않아서 미래에 발생할 수 있는 오류를 방지하지 못한다. 또한 !!을 사용하여 문제가 발생하면 일반적인 예외만 발생하여 오류에 대한 구체적인 정보를 얻을 수 없다.

     

    일반적으로 !!는 많은 팀들이 사용하지 못하게 강하게 정책을 채택하고 있고, 심지어 'Deteket'와 같은 정적 분석 도구에서도 !! 사용 시 오류를 발생시키도록 설정하는 경우도 있다.

     

    Avoiding meaningless nullability

    'Nullability'는 적절한 처리가 필요하며 이는 곧 비용이 발생함을 알 수 있다. 그렇기에 필요하지 않은 경우 'Nullability'를 피하는 것이 좋다. null은 중요한 메시지를 전달하는 용으로 사용되며, 개발자에게 의미가 없는 null로 보이는 상황은 피해야 한다. 그렇지 않으면, 개발자가 해당 null을 처리하기 위해 !!를 사용하거나, 'Null-safe' 처리를 반복하는 등 불필요하게 코드를 복잡하게 만들 수 있다.

     

    이로 인해, 의미가 없는 'Nullability'는 회피하는 게 좋으며, 이를 위한 방법은 다음과 같다.

    • 클래스의 경우 결과를 예상할 수 있는 상황들을 'sealed class'로 묶어 반환하거나, 값이 없는 경우 'nullable' 값으로 반환하는 함수를 제공 (e.g : List<T>getgetOrNull)
    • 프로퍼티가 사용 전에 확실히 초기화되는 경우 lateinit 또는 Delegate.notNull 사용
    • null 대신 빈 컬렉션을 반환 (e.g : emptyList(), emptySet(), emptyMap())
    • 'Nullable enum'과 'None enum' 값은 서로 다른 메시지를 전달함
      • 'Nullable enum'은 별도 처리가 필요한 메시지로 사용됨
      • 'None enum'은 'enum 정의'에 포함되어 있어 유효한 값 중 하나로 취급됨

    lateinit property and notNull delegate

    클래스 생성 중에 초기화할 수 없지만, 첫 사용 전에 반드시 초기화될 프로퍼티를 갖는 경우가 존재하며, 일반적으로 다음과 같이 사용되곤 한다.

    class UserRepoTest {
        private var userDao: UserDao? = null
    
        @Before
        fun set() {
            dao = fakeUserdao()
        }
    
        @Test
        fun test1() {
            dao!!.getUser()
        }
    }

     

    위와 같이 필요할 때마다 'Nullable 타입'을 'Non-nullable 타입'으로 캐스팅하는 것은 번거롭고 비효율적이다. 또한 테스트 환경과 같이 미리 설정될 것이 확정적인 경우 이는 무의미한 작업이 된다. 이럴 때는 lateinit을 사용하여 프로퍼티 초기화를 미룰 수 있다.

    class UserRepoTest {
        private lateinit var userDao: UserDao
    
        @Before
        fun set() {
            dao = fakeUserdao()
        }
    
        @Test
        fun test1() {
            dao.getUser()
        }
    }

     

    lateinit는 첫 사용 전에 반드시 초기화될 것이 확실할 때 사용해야 한다. 만약 초기화 전에 값에 접근하면, 예외가 발생되며 이는 로직에 문제가 있다는 것을 알 수 있어 오히려 문제를 해결할 수 있다.

     

    'Nullable 타입' 대신 lateinit 사용 시 얻는 이점은 다음과 같다.

    • 매번 'Nullable 타입' 프로퍼티를 'Non-nullable 타입'으로 "unpack" 할 필요가 없음
    • 미래에 의미 있는 상황을 나타내기 위해 'null'을 사용하는 경우, 쉽게 'Nullable 타입'으로 전환할 수 있음

    대신, lateinit은 한 번 초기화되면, 초기화되지 않은 상태로 되돌릴 수 없다.

     

    lateinit은 주로 클래스가 'lifecycle'을 가지고 있고, 'lifecycle' 초기 단계에서 초기화하여 사용한다.

    예를 들어, 'Android-Activity의 onCreate', 'iOS-UIViewController의 viewDidLoad' 등이 이에 해당한다.

     

    lateinit은 JVM에서 원시 타입으로 취급되는 Int, Long, Double, Boolean 타입에는 사용할 수 없다. 이런 경우, Delegate.notNull을 사용할 수 있다.

    class UserActivity: Activity() {
        private var userAge: Int by Delegates.notNull()
        private var isNotification: Boolean by Delegates.notNull()
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            userAge = intent.getIntExtra(USER_AGE_ARG, 0)
            isNotification = intent.getBooleanExtra(NOTIFICATION_ARG, false)
        }
    }

    Item 9 : Close resources with use

    'Kotlin/JVM'에서 사용하는 'Java 표준 라이브러리'에는 자동으로 닫히지 않는 리소스들이 있다. 이러한 리소스들은 더 이상 필요하지 않을 때 close를 직접 호출해야 한다.

    • InputStream, OutputStream
    • java.sql.Connection
    • java.io.Reader (FileReader, BufferedReader, CSSParser)
    • java.new.Socket, java.util.Scanner

    위 리소스들은 Closable 인터페이스 구현체이며, AutoClosable를 확장하고 있다. 만약 리소스 사용 후, close를 호출하지 않으면, 시스템이 리소스를 계속 유지하려 하면서 메모리 누수가 발생하게 된다. 'CG'의 경우에는 리소스에 대한 참조가 유지되지 않는 경우에만 리소스를 정리할 수 있으므로, 위와 같은 상황은 앱의 성능에 안좋은 영향을 준다.

     

    따라서 기존에는 이런 리소스 관리를 'try-finally' 안에 래핑하고 close를 호출하는 방식으로 사용했다.

    fun countCharactersInFile(path: String): Int {
        val reader = BufferedReader(FileReader(path))
    
        try {
            return reader.lineSequence().sumBy { it.length }
        } finally {
            reader.close()
        }
    }

     

    하지만 이 방법은 'try'와 'finally' 양쪽에서 오류가 발생하면 한쪽에서의 오류가 무시되는 문제가 발생한다. 이는 개발자들이 기대하는 방식과 다르다. 일반적으로 개발자들은 오류를 순차적으로 모두 확인하여 처리하길 원한다.

     

    위와 같은 한계점을 인지하고 표준 라이브러리에서는 use를 구현하여 해결 하였다.

    use는 리소스를 적절히 닫고 예외를 처리하는 데 사용되며, 모든 Closable 구현체에서 사용이 가능하다.

    fun countCharactersInFile(path: String): Int {
        BufferReader(FileReader(path)).use { reader ->
            return reader.lineSequence().sumOf { it.length }
        }
    }

     

    추가로, 파일을 1줄씩 읽는 일반적인 작업을 돕기 위해 표준 라이브러리에서 useLines를 제공한다.

     

    useLines는 파일의 각 줄을 시퀀스로 제공하고 모든 처리가 완료되면 자동으로 'reader'를 닫는다. 또한, useLines는 큰 파일을 처리할 때도 효율적인데, 그 이유는 필요에 따라 파일의 각 줄을 동적으로 읽고 메모리에는 한 줄만 유지하며 처리하기 떄문이다. 그러나, 메모리에 한 줄만 유지하기에 파일의 줄을 여러 번 순회하려면 파일을 여러 번 열어야 한다.

    fun countCharactersInFile(path: String): Int =
        File(path).useLines { lines ->
            lines.sumOf { it.length }
        }

    Item 10 : Write unit tests

    코드를 안전하게 만드는 효과적인 방법은 여러 종류의 테스트를 수행하는 것이다. 이 중 앱 테스트는 사용자 입장에서 앱이 올바르게 동작하는지를 테스트하는 것이며, 이러한 테스트에는 개발자가 아닌 충분한 수의 테스터 또는 테스트 엔지니어가 작성한 자동화 테스트로 수행될 수 있다.

     

    위와 같은 테스트는 소프트웨어 개발자 입장에서는 시스템의 구체적인 요소들이 제대로 동작하는지에 대한 확신을 얻지 못한다. 또한 개발 과정 중에는 잘못된 구현에 대한 피드백을 받을 수 없다. 이로 인해 개발자는 단위 테스트를 통해 구체적인 요소들이 제대로 동작하는지 확인할 수 있다. 단위 테스트를 통해 다음 사항을 확인해야 한다.

    • 일반적으로 구체적인 요소가 예상대로 동작되는지 확인
    • 비정상적인 동작이 예상되는 상황이나 과거에 발생했던 오류가 다시 발생하지 않는지 확인
    • 극단적인 상황으로 구체적인 요소를 테스트하여 확인하거나 인자 값을 잘못 전달하는 등의 오류 확인

    단위 테스트의 장점은 다음과 같다.

    • 단위 테스트를 지속적으로 축적함으로써, 새로운 기능의 추가나 수정으로 이미 개발된 기능에 오류 발생 유무를 쉽게 파악할 수 있다.
    • 제대로 테스트된 요소는 리팩토링을 더 쉽게 할 수 있고, 결과적으로 좋은 테스트를 거친 프로그램은 점점 더 좋아지는 코드를 만들 수 있다.
      반면, 테스트를 작성하지 않은 프로그램은 개발자들의 실수를 쉽게 허용하여 쉽게 오류가 날 수 있다.
    • 무언가 올바르게 동작하는지 수동으로 확인하는 것보다, 단위 테스트를 작성하여 확인하는 것이 훨씬 빠르다.
      단위 테스트의 빠른 피드백 루프는 개발을 더 빠르게 만들고, 버그를 빨리 찾을 수 있도록 도와주어 비용을 절감해 준다.

    단위 테스트의 단점을 꼽자면 다음과 같이 있을 수 있겠다.

    • 단위 테스트를 작성하는데 시간이 걸린다.
      • 하지만 장기적으로 보면, '좋은 단위 테스트'는 버그를 빠르게 찾고 디버깅하는 시간을 줄여, 개발 시간을 단축시킨다.
      • 또한, 단위 테스트를 실행하는 것이 수동 테스트나 다른 종류의 자동 테스트보다 훨씬 빠르다.
    • 테스트 가능한 코드로 만들기 위해 코드를 재구성해야 할 수도 있다.
      • 이런 재구성은 어려울 수 있지만, 더 좋은 아키텍처를 사용하도록 개발자를 유도하는 효과가 있다.
    • '좋은 단위 테스트'를 작성하는데 필요한 '기술과 이해'는 소프트웨어 개발과는 다른 기술이 필요하다.
      '나쁜 단위 테스트'는 도움이 되기보단 실제 문제를 감추거나 더 심각한 문제를 일으킬 수 있어 좋지 않다.
      이러한 이유로, 모든 개발자는 자신의 코드를 '좋은 단위 테스트'로 테스트할 수 있도록 공부를 해야 한다.

    가장 큰 챌린지는 효과적으로 단위 테스트를 수행하고, 이를 지원하는 코드를 작성하는데 필요한 기술을 배우는 것이다. 특히, 경험이 많은 개발자일수록 이런 기술을 배우고, 아래 목록과 같이 중요한 부분들은 반드시 단위 테스트를 작성해야 한다.

    • 복잡한 함수들
    • 시간이 지남에 따라 변경성이 높은 코드, 리팩토링이 필요한 코드
    • 비즈니스 로직
    • Open API
    • 문제가 빈번하게 발생하는 코드
    • Production 환경에서 발견되어 수정한 버그들
Designed by Tistory.