시배's Android

Effective Kotlin | 1장. 안정성 본문

Book/Effective Kotlin

Effective Kotlin | 1장. 안정성

si8ae 2024. 2. 5. 20:53

가변성을 제한하라

상태를 적잘하게 관리해야한다.

  • 프로그램을 이해하고 디버그하기 힘들어집니다.
  • 가변성이 있으면, 코드의 실행을 추론하기 어려워집니다.
  • 멀티스레드 프로그램일 때는 적절한 동기화가 필요합니다.
  • 테스트하기 어렵습니다.
  • 상태변경이 일어날 때, 이러한 변경을 다른 부분에 알려야 하는 경우가 있습니다.
val lock = Any()
var num = 0
for ( i 1..1000) {
    thread {
        Thread.sleep(10)
        synchronized(lock) {
            num += 1
        }
    }
}
Thread.sleep(1000)
print(num)

가변성은 생각보다 단점이 많아서 이를 완전하게 제한하는 프로그래밍 언어도 있습니다. 바로 순수 함수형 언어입니다.

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

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

읽기 전용 프로퍼티가 완전히 변경 불가능한 것은 아니라는 것을 주의하기 바랍니다.
읽기 전용 프로퍼티가 mutable 객체를 담고 있다면, 내부적으로 변할 수 있습니다.

var name : String = "Marcin"
var surname : String = "Moskala"
val fullName = 
    get() = "$name $surname"
  • 코틀린의 프로터니는 기본적으로 캡슐화되어 있고, 추가적으로 사용자 정의 접근자(getter)와 세터(setter)를 가질 수 있습니다.
  • var은 게터와 세터를 모두 제공하지만, val은 변경이 불가능하므로 게터만 제공합니다. 그래서 val은 var로 오버라이드할 수 있습니다.
  • val은 읽기 전용 프로퍼티지만, 변경할 수 없음을 의미하는 것은 아니라는 것을 기억하기 바랍니다.
val name : String? = "Marton"
val surname : String = "Braun"

val fullName : String?
    get() = name?.let { "$it $surname" }
val fullName2 : String? = name?.let {$it $surname"}

fun main() {
    if(fullName != null){
        println(fullName.length) // 오류
    }

    if(fullName2 != null) {
        println(fullName2.length)
    }
}

fullName은 게터로 정의했으므로 스마트 캐스트할 수 없습니다.
게터를 활용하므로, 값을 사용하는 시점의 name에 따라서 다른 결과가 나올 수 있기 때문입니다.
fullName2처럼 지역 변수가 아닌 프로퍼티가 final이고, 사용자 정의 게터를 갖지 않을 경우 스마트 캐스트할 수 있습니다.

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

  • Iterable, Collection, Set, List 인터페이스는 읽기 전용입니다.
  • MutableIterable, MutableCollection, MutableSet, MutableList 인터페이스는 읽고 쓸 수 있는 컬렉션입니다.
  • 컬렉션 다운캐스팅은 이러한 계약을 위반하고, 추상화를 무시하는 행위입니다.

데이터 클래스의 copy

immutable 객체를 사용하면, 다음과 같은 장점이 있습니다.

  • 한 번 정의된 상태가 유지되므로, 코드를 이해하기 쉽습니다.
  • immutable 객체는 공유했을 때도 충돌이 따로 이루어지지 않으므로, 병렬처리를 안전하게 할 수 있습니다.
  • immutable 객체에 대한 참조는 변경되지 않으므로, 쉽게 캐시할 수 있습니다.
  • immutable 객체는 방어적 복사본을 만들 필요가 없습니다.
  • immutable 객체는 다른 객체를 만들 때 활용하기 좋습니다. 또한 immutable 객체는 실행을 더 쉽게 예측할 수 있습니다.
  • immutable 객체는 세트 또는 맵의 키로 사용할 수 있습니다.

다른 종류의 변경 가능 지점

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

첫 번째 코드는 쿠제적인 리스트 구현 내부에 변경 가능 지점이 있습니다. 멀티스레드 처리가 이루어질 경우, 내부적으로 적절한 동기화가 되어 있는지 확실하게 알 수 없으므로 위험합니다.
두 번쨰 코드는 프로퍼티 자체가 변경 가능 지점입니다. 따라서 멀티스레드 처리의 안정성이 더 좋다고 할 수 있습니다.

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

class UserHolder {
    private val user : MutableUser()

    fun get() : MutableUser {
        return user.copy()
    }
}

리턴되는 mutable 객체를 복제하는 것입니다. 방어적 복제라고 부릅니다. 이때 data 한정자로 만들어지는 copy 메서드를 활용하면 좋습니다.

data class User(val name : String)

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

    fun loadAll() : Map<Int, String> {
        return sortedUsers
    }
}

컬렉션은 객체를 읽기 전용 슈퍼타입으로 업캐스트하여 가변성을 제한할 수도 있습니다.

변수의 스코프를 최소화하라

  • 프로퍼티보다는 지역 변수를 사용하는 것이 좋습니다.
  • 최대한 좁은 스코프를 갖게 변수를 사용합니다.
val a = 1
fun fizz() {
    val b = 2
    print(a + b)
}
val buzz = {
    val c = 3
    print(a + c)
}
// 이 위치에서는 a를 사용할 수 있지만, b와 c는 사용할 수 없습니다.
  • 참고로 스코프 내부에 스코프가 있을 수도 있습니다.
  • 최대한 변수는 스코프를 좁게 설정하는 것이 좋습니다.
  • 프로그램을 추적하고 관리하기 쉽기 때문입니다.

캡처링

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 }
    }
}

prime이라는 변수를 캡처했기 때문에 실행결과가 이상하게 나옵니다.
반복문 내부에서 filter를 활용해서 prime으로 나눌 수 있는 숫자를 필터링합니다.
그런데 시퀀스를 활용하므로 필터링이 지연됩니다.
따라서 최종적인 prime 값으로만 필터링된 것입니다.

최대한 플랫폼 타입을 사용하지 말라

  • 코틀린은 자바 등의 다른 프로그래밍 언어에서 넘어온 타입들을 특수하게 다룹니다.
  • 이러한 타입을 플랫폼 타입이라고 부릅니다.
  • 플랫폼 타입은 String!처럼 타입 이름 뒤에 ! 기호를 붙여서 표기합니다.
  • 문제는 null이 아니라고 생각되는 것이 null일 가능성이 있으므로, 여전히 위험 하다는 것입니다.
  • 그래서 플랫폼 타입을 사용할 때는 항상 주의를 기울여야 합니다.
  • 설계자가 명시적으로 어노테이션으로 표시하거나, 주석으로 달아주지 않으면, 언제든지 동작이 변경될 가능성이 있습니다.
interface UserRepo {
    fun getUserName() = JavaClass().value
}

class RepoImpl : UserRepo {
    override fun getUserName() : String? {
        return null
    }
}

fun main() {
    val repo : UserRepo = RepoImpl()
    val text : String = repo.getUserName() // 런타임 때 NPE
    print("User name length is ${text.length}")
}

inferred 타입으로 리턴하지 말라

  • 타입 추론을 사용할 때는 몇 가지 위험한 부분들이 있습니다.
  • 할당 때 inferred 타입은 정확하게 오른쪽에 있는 피연산자에 맞게 설정된다는 것을 기억해야 합니다.
  • 절대로 슈퍼클래스 또는 인터페이스로는 설정되지 않습니다.
open class Animal
class Zebra : Animal()

fun main() {
    var animal = Zebra()
    animal = Animal() // 오류 Type mismatch
}
interface CarFactory {
    fun produce() = DEFAULT_CAR
}

val DEFAULT_CAR = Fiat126P()

CarFacotory에서는 이제 Fiat126P 이외의 자동차를 생산하지 못합니다.

예외를 활용해 코드에 제한을 걸어라

  • require 블록 : 아규먼트를 제한할 수 있습니다.
  • check 블록 : 상태와 관련된 동작을 제한할 수 있습니다.
  • assert 블록 : 어떤 것이 true인지 확인할 수 있습니다. assert 블록은 테스트 모드에서만 작동합니다.
  • return 또는 throw와 함께 활용하는 Elvis 연산자
fun pop(num : Int = 1) : List<T> {
    require(num <= size) {
        "Cannot remove more elements than current size"
    }

    check(isOpen){"Cannot pop from closed stack" }
    val ret = collections.take(num)
    collection = collection.drop(num)
    assert(ret.size == num)
    return ret
}
  • 제한을 걸면 문서를 읽지 않은 개발자도 문제를 확인할 수 있습니다.
  • 문제가 있을 경우에 함수가 예상하지 못한 동작을 하지 않고 예외를 throw 합니다.
  • 코드가 어느 정도 자체적으로 검사됩니다.
  • 스마트 캐스트 기능을 활용할 수 있게 되므로, 캐스트(타입 변환)를 적게 할 수 있습니다.

아규먼트

  • require 함수는 조건을 만족하지 못할 때 무조건적으로 IllegalArgumentException을 발생시키므로 제한을 무시할 수 없습니다.
fun factorial(n : Int) : Long {
    require(n >= 0) { "Cannot calculate factorial of $n becaute it is smaller than 0" }
    return if (n <= 1) 1 else factorial(n - 1) * n
}

상태

  • 어떤 객체가 미리 초기화되어 있어야만 처리를 하게 하고 싶은 함수
  • 사용자가 로그인했을 때만 처리를 하게 하고 싶은 함수
  • 객체를 사용할 수 있는 시점에 사용하고 싶은 함수
  • check 함수는 require 함수와 비슷하지만, 지정된 예측을 만족하지 못할 때, IllegalStateException을 throw 합니다.

Assert

  • Assert 계열의 함수는 코드를 자체 점검하며, 더 효율적으로 테스트할 수 있게 해 줍니다.
  • 특정 상황이 아닌 모든 상황에 대한 테스트를 할 수 있습니다.
  • 실행 시점에 정확하게 어떻게 되는지 확인할 수 있습니다.
  • 실제 코드가 더 빠른 시점에 실패하게 만듭니다. 따라서 예상하지 못한 동작이 언제 어디서 실행되었는지 쉽게 찾을 수 있습니다.

nullability와 스마트 캐스팅

  • check 블록으로 어떤 조건을 확인해서 true가 나왔다면, 해당 조건은 이후로도 true일 거라고 가정합니다.
  • 이를 활용해서 타입 비교를 했다면, 스마트 캐스트가 작동합니다.
  • class Person(val email : String?)
fun sendEmail(person : Person, message : String) {  
require(person.email != null)  
val email : String = person.email  
// ...  
}

이러한 경우 requireNotNull, checkNotNull이라는 특수한 함수를 사용해도 괜찮습니다.

fun sendEmail(person : Person, text : String) {
    val email : String = person.email ?: return 
    //...
}

오른쪽에 return을 넣으면, 오류를 발생시키지 않고 단순하게 함수를 중지할 수도 있습니다.

fun sendEmail(person : Person, text : String) {
    val email : String = person.email ?: run {
        log("Email not sent, no email address")
        return
    }
    //...
}

프로퍼티에 문자가 있어서 null일 때 여러 처리를 해야 할 때도, return/throw와 run 함수를 조합해서 활용하면 됩니다.

사용자 정의 오류보다는 표준 오류를 사용하라

  • IllegalArgumentException, IllegalStateException : require, check를 사용해 throw 할 수 있는 예외입니다.
  • IndexOutOfBoundsException : 인덱스 파라미터의 값이 범위를 벗어났다는 것을 나타냅니다.
  • ConcurrentModificationException : 동시 수정을 금지했는데, 발생해 버렸다는 것을 나타냅니다.
  • UnsupportedOperationException : 사용자가 사용하려고 했던 메서드가 현재 객체에서는 사용할 수 없다는 것을 나타냅니다. 기본적으로는 사용할 수 없는 메서드는 클래스에 없는 것이 좋습니다.
  • NoSuchElementException : 사용자가 사용하려고 했던 요소가 존재하지 않음을 나타냅니다. 예를 들어 내부에 요소가 없는 Iterable에 대해 next를 호출할 때 발생합니다.결과 부족이 발생할 경우 null과 Failure를 사용하라
  • 일단 예외는 정보를 전달하는 방법으로 사용해서는 안 됩니다.
  • 예외가 전파되는 과정을 제대로 추적하지 못합니다.
  • 코틀린의 모든 예외는 unchecked 예외입니다.
  • 예외는 예외적인 상황을 처리하기 위해서 만들어졌으므로 명시적인 테스트만큼 빠르게 동작하지 않습니다.
  • try-catch 블록 내부에 코드를 배치하면, 컴파일러가 할 수 있는 최적화가 제한됩니다.
inline fun <reified T> String.readObjectOrNull() : T? {
    //...
    if(incorrecSign) {
        return null
    }

    //...
    return result
}

inline fun <reified T> String.readObject() : Result<T> {
    //...
    if(incorrectSign) {
        return Failure(JsonParsingException())
    }
    //...
    return Success(result)
}

sealed class Result<out T>
class Success<out T>(val result: T) : Result<T>()
class Failure(val throwable : Throwable) : Result<Nothing>()

class JsonParsingException: Exception()

null과 Failure는 예상되는 오류를 표현할 때 굉장히 좋습니다. 이는 명시적이고, 효율적이며, 간단한 방법으로 처리할 수 있습니다.

적절하게 null을 처리하라

  • 스마트 캐스팅, 엘비스 연산자 등을 활용해서 안전하게 처리한다.
  • 오류를 throw 한다.
  • 함수 또는 프로퍼티를 리팩터링해서 nullable 타입이 나오지 않게 바꾼다.

null을 안전하게 처리하기

safe call과 스마트 캐스팅이 있습니다.

printer?.print() // 안전 호출
if(printer != null) printer.print() // 스마트 캐스팅

엘비스 연산자 오른쪽에 return 또는 throw를 포함한 모든 표현식이 허용됩니다.

val printerName1 = printer?.name ?: "Unnamed"
val printerName2 = printer?.name ?: return
val printerName3 = printer?.name ?: throw Error

not null assertion (!!)과 관련된 문제

  • nullable을 처리하는 가장 간단한 방법은 no null assertion을 사용하는 것입니다.
  • 그런데 !!를 사용하면 자바에서 nullable을 처리할 때 발생할 수 있는 문제가 똑같이 발생합니다.
  • !!로 코드를 작성하면, 이후에 프로퍼티를 계속해서 언팩해야 하므로 사용하기 귀찮아 집니다.
  • 해당 프로퍼티가 실제로 이후에 의미 있는 null 값을 가질 가능성 자체를 차단해 버립니다.
  • lateinit 또는 Delegates.notNull을 사용하는 것이 좋습니다.
  • 일반적으로 !! 연산자 사용을 피해야 합니다.

의미 없는 nullability 피하기

  • nullability는 어떻게든 적절하게 처리해야 하므로, 추가 비용이 발생합니다.
  • 따라서 필요한 경우가 아니라면, nullability 자체를 피하는 것이 좋습니다.
  • lateinit 프로퍼티와 notNull 델리게이트를 사용하는게 좋다.
  • 빈 컬렉션 대신 null을 리턴하지 마세요.
  • nullable enum과 None enum은 완전히 다른 의미이다. null enum은 별도로 처리해야 하지만, None enum 정의에 없으므로 필요한 경우에 사용하는 쪽에서 추가해서 활용할 수 있다는 의미입니다.

lateinit 프로퍼티와 notNull 델리게이트

  • lateinit 한정자는 프로퍼티가 이후에 설정될 것임을 명시하는 한정자입니다.
  • 만약 초기화 전에 값을 사용하려고 하면 예외가 발생합니다.
  • !! 연산자로 언팩하지 않아도 됩니다.
  • 이후에 어떤 의미를 나타내기 위해서 null을 사용하고 싶을 때, nullable로 만들 수도 있습니다.
  • 프로퍼티가 초기화된 이후에는 초기화되지 않은 상태로 돌아갈 수 없습니다.
  • 반대로 lateinit 사용할 수 없는 경우도 있습니다.
  • JVM에서 Int, Long, Double, Boolean과 같은 기본 타입과 연결된 타입으로 프로퍼티를 초기화해야 하는 경우입니다.
  • 이런 경우에는 lateinit보다는 약간 느리지만 Delegates.notNull을 사용합니다.use를 사용하여 리소스를 닫아라더 이상 필요하지 않을 때, close 메서드를 사용해서 명시적으로 닫아야 하는 리소스가 있습니다.
  • InputStream, OutputStream
  • java.sql.Connection
  • java.io.Reader(FileReader, BufferedReader, CSSParser)
  • java.new.Socket과 java.util.Scanner

이러한 리소스들은 AutoCloseable을 상속받는 Closeable 인터페이스를 구현하고 있습니다.
이러한 모든 리소스는 최종적으로 리소스에 대한 레퍼런스가 없어질 때, 가비지 컬렉터가 처리합니다.
하지만 굉장히 느리며 그동안 리소스를 유지하는 비용이 많이 들어갑니다.

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

리소스를 닫을 때 예외가 발생할 수도 있는데, 이러한 예외를 따로 처리하지 않기 때문입니다. 또한 try 블록과 finally 블록 내부에서 오류가 발생하면, 둘 중 하나만 전파됩니다.

use 함수를 사용해서 앞의 코드를 적절하게 변경하면, 다음과 같습니다. 이러한 코드는 모든 Closeable 객체에 사용할 수 있습니다.

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

파일을 리소스로 사용하는 경우가 많고, 파일을 한 줄씩 읽어 들이는 경우도 많으므로, 코틀린 표준 라이브러리는 파일을 한 줄씩 처리할 때 활용할 수 있는 useLines 함수도 제공합니다.

단위 테스트를 만들어라

단위 테스트는 일반적으로 다음과 같은 내용을 확인합니다.

  • 일반적으로 유스 케이스
  • 일반적인 오류 케이스와 잠재적인 문제
  • 에지케이스와 잘못된 아규먼트

테스트는 계속해소 축적되므로, 회귀 테스트도 쉽습니다.

단위 테스트의 장점은 아래와 같습니다.

  • 테스트가 잘 된 요소는 신뢰할 수 있습니다.
  • 테스트가 잘 만들어져 있다면, 리팩터링하는 것이 두렵지 않습니다.
  • 수동으로 테스트하는 것보다 단위 테스트로 확인하는 것이 빠릅니다.

단위 테스트의 단점은 아래와 같습니다.

  • 단위 테스트를 만드는 데 시간이 걸립니다.
  • 테스트를 활용할 수 있게 코드를 조정해야 합니다.
  • 좋은 단위 테스트를 만드는 작업이 꽤 어렵습니다.