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

woga·2023년 2월 25일
0

코틀린 공부

목록 보기
8/54
post-thumbnail

코틀린에서는 코드의 동작에 제한을 걸 수 있는데, 다음과 같이 쓸 수 있다

  • require 블록: 아큐먼트를 제한할 수 있다.

  • check 블록: 상태와 관련된 동작을 제한할 수 있다.

  • assert 블록: 어떤 것이 true인지 확인할 수 있다. assert 블록은 테스트 모드에서만 작동한다.

  • return 또는 throw와 함께 엘비스 연산자를 사용한다.

예를 들면, 아래 코드에서처럼 쓰일 수 있는데

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 = collection.take(num)
    collection = collection.drop(num)
    assert(ret.size == num)
    return ret
}

이럴 경우 제한으로 하여금 다양한 장점이 발생한다.

  • 제한을 걸면 문서를 읽지 않은 개발자도 문제를 확인할 수 있다.
  • 문제가 있을 경우 예상치 못한 동작을 하지 않고 예외를 던진다. (예상치 못한 동작을 하는게 더 위험하며 상태 관리하기 힘들다. 그래서 제한을 두는게 코드가 안전해진다)
  • 코드가 어느 정도 자체 검사가 된다. 그래서 단위 테스트가 줄어든다.
  • 스마트 캐스트 기능을 활용할 수 있게 되므로, 타입 변환을 적게 할 수 있다.

이렇게 제한을 두는게 장점이 많다는 걸 알게 됐는데, 그럼 어떤 제한들이 있는지 더 자세하게 알아보자!

아규먼트

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

ex 1) 숫자를 아규먼트로 받아서 팩토리얼 계산한다면 숫자는 양의 정수여야 한다.

ex 2) 좌표들을 아규먼트로 받아서 클러스터를 찾을 땐 비어 있지 않은 좌표 목록이 필요하다.

ex 3) 사용자로부터 이메일 주소를 입력받을 때는 값이 입력되어 있는지, 그리고 이메일 형식이 올바른지 확인해야 한다.

일반적으로 이러한 제한을 걸 때 require 함수를 사용한다. require 함수는 제한을 확인하고 만족하지 못하면 예외를 던진다.

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을 발생시키기 때문에 제한을 무시할 수 없다.

지금까지 본 거처럼 아규먼트와 관련된 제한을 걸 때 사용할 수 있는데 또 대표적으로 상태를 대상으로 제한을 걸 수도 있다.

상태

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

  • 어떤 객체가 미리 초기화되어 있어야만 처리를 하게 하고 싶은 함수

  • 사용자가 로그인했을 때만 처리를 하게 하고 싶은 함수

  • 객체를 사용할 수 있는 시점에 사용하고 싶은 함수

이 때, 상태과 관련된 제한은 check 함수를 사용하면 된다.

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

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

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

얼핏보면 require 함수와 비슷할 수 있지만, check 함수는 지정된 예측을 만족하지 못할 때, IllegalStateException을 던진다.

예외 메시지는 require와 마찬가지로 지연 메시지를 사용해서 변경할 수 있다. 그래서 함수 전체에 대한 어떤 예측이 있을 때는 일반적으로 require 블록 뒤에 배치해서 check를 나중에 한다.

그래서 이를 통해 사용자가 규약을 어기고 사용하면 안되는 곳에서 함수를 호출하고 있다고 의심될 때 사용해보자. 이는 사용자뿐만 아니라 구현하는 사람에게도 도움이 된다.

그럼 스스로 구현한 내용을 확인할 때는 어떤 함수를 쓸까?

Assert 계열 함수 사용

우리는 함수를 제대로 구현할 수도 있지만 올바르게 구현되지 않을 수도 있다. 이는 처음부터 잘못된 구현일 수도 있고 누군가가 리팩토링하면서 작동하지 않게 된 것일수도 있다.

그래서 이를 방지하기 위해 단위테스트를 아래와 같이 사용을 하는데, 이때 assert 함수를 사용한다.

class StackTest {
    @Test
    fun `Stack pops correct number of elements`() {
        val stack = Stack(20) { it }
        val let = stack.pop(10)
        assertEquals(10, ret.size)
    }
}

또한 현재 테스트 코드 말고도 pop 자체의 함수가 제대로 동작하는지 확인해보고 싶다면 다음처럼 pop 내부 함수에서도 쓸 수 있다.

fun pop(num: Int = 1): List<T> {
    // ...
    assert(ret.size == num)
    return ret
}

이런 코드도 예상대로 동작하는지 확인하므로 테스트라고 할 수 있다. 다만, 프로덕션 환경에서는 오류가 발생하지 않을 수도 있다. 테스트를 할 때만 활성화되므로 오류가 발생해도 눈치채기 어려울 수 있기 때문이다.

만약 이 코드가 심각한 오류고 심각한 결과를 초래할 수 있는 경우에는 check를 사용하자.

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

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

그래도 여전히 단위 테스트는 따로 작성해야 한다. 표준 app 실행에서는 assert가 예외를 던지지 않는 것도 기억하자.

nullability와 스마트 캐스팅

코틀린에서는 requirecheck 블록으로 어떤 조건을 확인해서 true가 나왔다면, 해당 조건은 이후로도 true일거라고 가정한다.

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

그래서 요걸 활용해서 타입 비교했다면, 스마트 캐스트가 작동한다.

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

이 코드처럼 사람의 복장이 드레스여야 정상적으로 진행된다. 이때 outfit 프로퍼티가 final이라면, outfit 프로퍼티가 Dress로 스마트 캐스트된다.

이 특징은 어떤 대상이 null인지 확인할 때 굉장히 유용하다.

class Person(val email: String?)

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

그리고 requireNotNull, checkNotNull 이라는 특수한 함수를 사용해도 둘 다 스마트 캐스트를 지원하므로 변수를 'unpack'하는 용도로도 쓸 수 있다.

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

fun sendEmail(person: Person, text: String) {
    val email = requireNotNull(person.email)
    validateEmail(email)
}

fun sendEmail(person: Person, text: String) {
    requireNotNull(person.email)
    validateEmail(person.email)
    // ...
}

그리고 다음과 같이 엘비스 연산자를 사용하면 가독성이 좋아지며 유연하게 사용할 수 있다.

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

혹은 return/throw run 함수를 조합해서 활용도 가능하다.

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

마무리

사실 코틀린 개발하면 썼던 거만 계속 썼는데 이 파트를 통해서 제한하는 함수가 더 많다는 것을 알게 되었다. 이를 통해 적극적으로 개발하며 유지보수에 힘 쓸 수 있도록 한 번 응용해봐도 좋을 듯 하다!

profile
와니와니와니와니 당근당근

0개의 댓글