확실하게 어떤 형태로 동작해야 하는 코드가 있다면,
예외를 활용해 제한을 걸어주는 것이 좋습니다.
코틀린에서는 코드의 동작에 제한을 걸 때 다음과 같은 방식을 선택하여 구현할 수 있다.
- require 블럭: 블럭 argument를 제한할 수 있다.
- check 블럭: state와 관련된 동작을 제한할 수 있다.
- assert 블럭: 어떤 것이 true인지 확인할 수 있습니다. assert 블럭은 테스트 모드에서만 동작한다.
- return 또는 throw와 함게 활용하는 Elvis 연산자
이러한 메커니즘을 사용하는 간단한 예는 다음과 같다.
//Stack<T> 의 일부
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
}
이런 제한을 걸어두면 다음과 같은 장점이 발생한다.
- 제한을 걸면 문서를 읽지 않은 개발자도 문제를 확인할 수 있다.
- 문제가 있을 경우에 함수가 예상하지 못한 동작을 하지 않고 예외를 throw한다. 예상하지 못한 동작을 하는 것은 예외를 throw하는 것보다 굉장히 위험하며, 상태를 관리하는 것이 힘들다. 제한을 걸어두면 문제를 놓치지 않을 수 있고, 코드가 더 안정적으로 동작한다.
- 코드가 어느 정도 자체적으로 검사된다. 이에 따른 단위 테스트를 줄일 수 있다.
- 스마트 캐스트 기능을 활용할 수 있게 되므로, 캐스트(cast)를 적게 할 수 있다.
책에 정리되어 있는 내용을 자세히 알아보자!
아규먼트
함수를 정의할 때 타입 시스템을 활용해서 아규먼트(argument)에 제한을 거는 코드를 많이 사용한다.
예를 들어, 팩토리얼(factorial)을 계산하기 위해 인자로 받는 정수는 양수여야 된다거나, 이메일 주소를 입력 받을 때 형식에 맞는지 등과 같은 경우에서 제한을 걸 때는 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.emial)
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
}
위에서 확인했듯이 require 함수는 아규먼트와 관련된 제한을 걸 때 사용할 수 있다.
상태
어떤 구체적인 조건을 만족할때에만 함수를 사용할 수 있게 해야 할 때가 있을 때, 예를 들어 다음과 같은 경우에 사용한다.
- 어떤 객체가 미리 초기화되어 있어야만 처리를 하게 하고 싶은 함수
- 사용자가 로그인했을 때에만 처리를 하게 하고 싶은 함수
- 객체를 사용할 수 있는 시점에 사용하고 싶은 함수
상태와 관련된 제한을 걸 때는 일반적으로 check 함수를 사용한다.
fun speak(text: String) {
check(isInitialized)
//...
}
fun getUserInfo(): UserInfo {
checkNotNull(token)
//...
}
fun next() : T {
check(isOpen)
//...
}
check 함수는 require와 비슷하지만, 지정된 예측 값을 만족하지 못할 때, IllegalStateException 을 throw한다. 상태가 올바른지 확인할 때 주로 사용한다고 보면 좋다. 예외 메세지는 require와 마찬가지로 지연 메세지를 사용해서 변경할 수 있다. 함수 전체에 대한 어떤 예측이 있을 때는 일반적으로 require 블록 뒤에 배치한다. (check를 나중에 하는 진행)
이러한 확인은 사용자가 규약을 어기고 사용하면 안되는 곳에서 함수를 호출하고 있다고 의심될 때 진행한다. 사용자가 코드를 제대로 사용할 것이라고 믿고 있는 것보다는 항상 문제 상황을 예측하고 예외 처리하는 것이 좋다.
Assert 계열의 함수 사용
함수 또는 로직 코드가 올바르게 구성되었는지 즉, 의도한 값을 반환해내는지 확인하기 위해서 테스트 코드를 작성하고 확인한다.
테스트 케이스를 작성하는 경우에 사용하는 함수가 Assert 계열이다.
유닛 테스트에서 의도한 결과를 도출했는지 확인하는데는 확실한 함수이긴 하나, 어플리케이션 런타임에서는 어떠한 exception throw를 하지 않기 때문에 사용 범위가 제한된다는 점이 존재는 하나, 개발 단계에서 TDD로 활용하기에는 유용한 함수이다.
다만 프로덕션 환경에서는 오류가 발생하지 않습니다. 만약 이 코드가 정말 심각한 오류고, 심각한 결과를 초래할 수 있는 경우에는 check를 사용하는 것이 좋습니다.
단위 테스트 대신 함수에서 assert를 사용하면, 다음과 같은 장점이 있다.
- Assert 계열의 함수는 코드를 자체 점검하며 더 효율적으로 테스트 할 수 있다.
- 특정 상황이 아닌 모든 상황에 대한 테스트를 할 수 있다.
- 실행 시점에 정확하게 어떻게 되는지 확인할 수 있다.
- 실제 코드가 더 빠른 시점에 실패하게 만들어 예상하지 못한 동작이 언제 어디서 실행되었는지 쉽게 찾을 수 있다.
Nullability와 스마트 캐스팅
코틀린에서 require와 check 블록으로 어느 조건을 확인해서 true가 나왔다면, 해당 조건은 이후로도 true일 것이라고 가정한다.
예를 들어, 다음의 코드와 같이
public incline 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
//...
}
require() 를 통해 특정 프로퍼티가 특정 조건에 true값을 가졌다면 require() 이하의 코드에서는 특정 조건을 만족한다고 가정하고 코드를 진행한다.
위 같은 경우에는 스마트 캐스팅이 지원되며, 다음의 코드와 같이 nullability check에서도 사용 가능하다.
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)
//...
}
위와 같이 require 함수를 사용해서 nullability check도 가능하지만 Elvis 연산자 (?.)를 사용해서도 가능하다.
정리
예외를 활용한 다양한 방식을 통해서 다음과 같은 이점을 가질 수 있다.
- 제한을 훨씬 더 쉽게 확인할 수 있다.
- 어플리케이션을 더 안정적으로 지킬 수 있다.
- 코드를 잘못 쓰는 상황을 막을 수 있다.
- 스마트 캐스팅을 활용할 수 있다.
require, check, assert, elvis, throw 메커니즘을 통해 적절한 예외 처리를 진행하는 것이 중요하다.
해당 포스팅은 이펙티브 코틀린 을 참고하여 작성하였습니다.
'Kotlin > 이펙티브 코틀린(Effective Kotlin)' 카테고리의 다른 글
[Item 04] inferred 타입으로 리턴하지 말라 (0) | 2023.04.30 |
---|---|
[Item 02] 변수의 스코프를 최소화하라 (0) | 2023.04.25 |
[Item 01] 가변성을 제한하라 (0) | 2023.04.22 |