반응형
Notice
Recent Posts
Recent Comments
Link
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

안드로이드 개발자 노트

[이펙티브 코틀린] Item5. 예외를 활용해 코드에 제한을 걸어라 본문

Kotlin/이펙티브 코틀린

[이펙티브 코틀린] Item5. 예외를 활용해 코드에 제한을 걸어라

어리둥절범고래 2023. 9. 3. 11:01
반응형

확실한 형태로 동작해야 하는 코드가 있다면, 예외를 활용해 제한을 걸어두는 것이 좋습니다.

 

  • require 블록: 아규먼트를 제한할 수 있다.
  • check 블록: 상태와 관련된 동작을 제한할 수 있다.
  • assert 블록: 어떤 것이 true인지 확인할 수 있다. assert 블록은 테스트 모드에서만 작동한다.
  • return 또는 throw와 함께 활용하는 Elvis 연산자

 

제한을 걸어 주면 다양한 장점이 발생합니다.

 

  • 제한을 걸면 문서를 읽지 않은 개발자도 문제를 확인할 수 있다.
  • 문제가 있을 경우에 함수가 예상하지 못한 동작을 하지 않고 예외를 throw한다. 예상하지 못한 동작을 하는 것은 throw하는 것보다 굉장히 위험하며, 상태를 관리하는 것이 굉장히 힘들다.
  • 코드가 어느 정도 자체적으로 검사된다. 따라서 이와 관련된 단위 테스트를 줄일 수 있다.
  • 스마트 캐스트 기능을 활용할 수 있게 되므로, 캐스트(타입 변환)를 적게 할 수 있다.

 

 

 


아규먼트

 

함수를 정의할 때 타입 시스템을 활용해서 아규먼트(argument)에 제한을 거는 코드를 많이 사용합니다.

예를 들면 다음과 같습니다.

 

  • 숫자를 아규먼트로 받아서 팩토리얼을 계산한다면 숫자는 양의 정수여야 한다.
  • 좌표들을 아규먼트로 받아서 클러스터를 찾을 때는 비어 있지 않은 좌표 목록이 필요하다.
  • 사용자로부터 이메일 주소를 입력받을 때는 값이 입력되어 있는지, 그리고 이멤일 형식이 올바른지 확인해야 한다.

 

일반적으로 이러한 제한을 걸 때는 require 함수를 사용합니다.

require 함수는 제한을 확인하고, 제한을 만족하지 못할 경우 예외를 throw합니다.

 

fun factorial(n: Int): Long {
    require(n >= 0)
    return if (n <= 1) 1 else factorial(n - 1) * n
}

fun findClusters(points: List<Point>): List<Cluster> {
    require(points.isNotEmpty())
    //...
}

fun sendEmail(user: User, message: String) {
    requireNotNull(user.email)
    require(isValidEmail(user.email))
    //...
}

 

require 함수는 조건을 만족하지 못할 때 무조건적으로 IllegalArgumentException을 발생시키므로 제한을 무시할 수 없습니다.

이러한 처리는 함수의 가장 앞부분에 하게 되며, 코드를 읽을 때 쉽게 확인할 수 있습니다.

 

또한 람다를 활용해서 지연 메시지를 정의할 수도 있습니다.

fun factorial(n: Int): Long {
    require(n >= 0) {
        "Cannot calculate factorial of $n because it is smaller than 0"
    }
    return if (n <= 1) 1 else factorial(n - 1) * n
}

 

 

 


상태

 

 

어떤 구체적인 조건을 만족할 때만 함수를 사용할 수 있게 해야 할 때가 있습니다.

 

  • 어떤 객체가 미리 초기화되어 있어야만 처리를 하게 하고 싶은 함수
  • 사용자가 로그인했을 때만 처리를 하게 하고 싶은 함수
  • 객체를 사용할 수 있는 시점에 사용하고 싶은 함수

상태와 관련된 제한을 걸 때는 일반적으로 check 함수를 사용합니다.

 

fun speak(text: String) {
    check(isInitialized)
    //...
}

fun getUserInfo(): UserInfo {
    checkNotNull(token)
    //...
}

fun next(): T {
    check(isOpen)
    //...
}

 

check 함수는 require과 비슷하지만, 지정된 예측을 만족하지 못할 때, IllegalStateException을 throw합니다.

check 함수와 require 함수의 차이는 throw하는 예외가 내부적으로 다릅니다.

check 함수는 IllegalStateException를, require 함수는IllegalArgumentException를 throw합니다.

또한, 일반적으로 require 블록 뒤에 check를 배치합니다.

 

// require 내부 구현
public inline fun require(value: Boolean, lazyMessage: () -> Any): Unit {
    contract {
        returns() implies value
    }
    if (!value) {
        val message = lazyMessage()
        throw IllegalArgumentException(message.toString())
    }
}
// check 내부 구현
public inline fun check(value: Boolean, lazyMessage: () -> Any): Unit {
    contract {
        returns() implies value
    }
    if (!value) {
        val message = lazyMessage()
        throw IllegalStateException(message.toString())
    }
}

 

 

 


Assert 계열 함수 사용

 

assert 계열의 함수는 코드가 예상대로 동작하는지 확인하므로 테스트라고 할 수 있습니다.

 

// assert 내부 구현
public inline fun assert(value: Boolean, lazyMessage: () -> Any) {
    if (_Assertions.ENABLED) {
        if (!value) {
            val message = lazyMessage()
            throw AssertionError(message)
        }
    }
}

 

프로덕션 환경에서는 오류가 발생하지 않으므로 오류가 발생해도 사용자가 알아차릴 수는 없습니다.

단위 테스트 대신 함수에서 assert를 사용하면 다음과 같은 장점이 있습니다.

 

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

 

 

 


nullability와 스마트 캐스팅

 

require와 check 블록으로 어떤 조건을 확인해서 true가 나왔다면, 해당 조건은 이후로도 true일 거라고 가정하고 이를 스마트 캐스팅이라고 합니다.

require와 check 둘 다 스마트 캐스트를 지원하므로, 변수를 언팩하는 용도로 활용할 수 있습니다.

 

이러한 특징은 어떤 대상이 null인지 확인할 때 유용합니다.

 

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

fun sendEmail(person: Person, message: String) {
    require(person.email != null)
    val email: String = person.email
}

 

 

nullability를 목적으로 Elvis 연산자를 활용하는 것도 좋은 방법입니다.

Elvis 연산자 오른쪽에 throw 또는 return을 두면, 오류를 발생시키지 않고 단순하게 함수를 중지할 수도 있습니다.

run 함수를 조합해서 return하기 전에 처리해야할 로직을 넣을 수도 있습니다.

 

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

 

 

 

 


정리

이와 같은 내용을 기반으로, 다음과 같은 이득을 얻을 수 있습니다.

 

  • 제한을 훨씬 더 쉽게 확인할 수 있다.
  • 애플리케이션을 더 안정적으로 지킬 수 있다.
  • 코드를 잘못 쓰는 상황을 막을 수 있다.
  • 스마트 캐스팅을 활용할 수 있다.

 

이를 위해 활용했던 메커니즘을 정리하면 다음과 같습니다.

 

  • require 블록: 아규먼트와 관련된 예측을 정의할 때 사용
  • check 블록: 상태와 관련된 예측을 정의할 때 사용
  • assert 블록: 테스트 모드에서 테스트를 할 때 사용
  • return과 throw와 함께 Elvis 연산자 사용
반응형