한 줄 정의
인수와 상태에 대한 기대치는 문서가 아니라
require·check·error· 엘비스 연산자로 함수 도입부에 코드로 선언해야, 위반을 상태가 바뀌기 전에 명확한 예외로 끊고 이후 코드에서는 스마트 캐스팅까지 얻을 수 있습니다.
쉽게 말하면
함수의 기대치를 코드로 선언하는 일은 놀이기구 입구에 키 측정 막대 를 세우는 일입니다. “키 120cm 이상”이라고 안내판에만 적어 두면 안 읽은 사람은 그냥 올라타고, 문제는 기구가 출발한 뒤 한복판에서 터집니다. 입구에서 재면 세 가지가 달라집니다. 안내판을 안 읽어도 기준을 알게 되고, 출발 전에 걸러지니 도중에 멈춰 서는 일이 없고, 안에 들어온 사람은 전원 기준을 통과했으니 좌석마다 다시 재지 않아도 됩니다.
!! 는 재지 않고 “이 손님은 당연히 크겠지” 하고 태우는 것입니다. 오늘 온 손님은 전부 컸을지 몰라도 내일 손님은 아닐 수 있고, 사고가 나면 “누군가 기준 미달이었다”는 사실만 남을 뿐 어느 기준이었는지조차 알 수 없습니다.
왜 중요한가?
타입 시스템만으로는 표현할 수 없는 기대치가 있습니다. 팩토리얼의 인수는 음수가 아니어야 하고, 클러스터를 찾을 점 리스트는 비어 있지 않아야 하며, 이메일을 보내려면 주소가 유효해야 합니다. 상태도 마찬가지여서 초기화된 객체, 로그인한 사용자, 열려 있는 스택에서만 동작해야 하는 함수가 있습니다.
이런 기대치를 문서에만 적어 두면 읽지 않은 개발자는 알 길이 없고, 위반된 채 실행된 함수는 예외 대신 예상치 못한 동작 으로 이어집니다. 특히 위험한 것은 수정 사항이 일부만 적용된 채 남는 경우입니다. 그래서 검사는 상태가 바뀌기 전, 함수 도입부에 두어야 합니다. 모든 작업이 다 수행되거나 아무것도 수행되지 않게 만드는 아토믹 트랜잭션과 같은 발상입니다.
코드로 선언한 검사(declarative checks)는 문서를 대체하지는 않지만 네 가지 이점을 줍니다.
| 이점 | 의미 |
|---|---|
| 가시성 | 문서를 읽지 않아도 함수 도입부에서 기대치가 보입니다 |
| 안정성 | 위반 시 상태가 바뀌기 전에 예외로 끊어 부분 적용을 막습니다 |
| 정확성 | 조건이 코드에서 검증되므로 단위 테스트 부담이 줄어듭니다 |
| 스마트 캐스팅 | 검사를 통과한 값은 이후 코드에서 캐스팅 없이 씁니다 |
핵심 내용
네 가지 메커니즘
| 메커니즘 | 대상 | 실패 시 | 놓는 위치 |
|---|---|---|---|
require | 인수 | IllegalArgumentException | 함수 도입부 |
check | 상태 | IllegalStateException | require 다음, 상태 요구가 지역적이면 중간도 가능 |
error | 도달하면 안 되는 분기 | IllegalStateException | when 의 else 등 |
?: return / ?: throw | 널 가능 값 | 함수 종료 또는 지정한 예외 | 함수 도입부 |
// 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)
return ret
}인수 검사: require
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
}
fun findClusters(points: List<Point>): List<Cluster> {
require(points.isNotEmpty())
// ...
}
fun sendEmail(user: User, message: String) {
requireNotNull(user.email)
require(isValidEmail(user.email))
// ...
}람다로 넘기는 메시지는 실패했을 때만 평가되는 지연 메시지(lazy message)입니다. 데이터 클래스의 init 블록에 두면 요구사항을 만족하지 못하는 인스턴스는 아예 생성되지 않습니다.
data class User(
val name: String,
val email: String
) {
init {
require(name.isNotEmpty())
require(isValidEmail(email))
}
}상태 검사: check
check 는 require 와 같은 방식으로 동작하되 상태를 확인하고 IllegalStateException 을 던집니다. 함수 전체에 대한 기대치라면 도입부의 require 다음에 두고, 특정 구간에만 해당하는 상태 요구라면 그 지점에 둘 수 있습니다.
fun speak(text: String) {
check(isInitialized)
// ...
}
fun getUserInfo(): UserInfo {
checkNotNull(token)
// ...
}
fun next(): T {
check(isOpen)
// ...
}check 와 assert 의 역할 구분
check는 호출자가 규약을 어기고 함수를 부를 가능성이 있을 때 씁니다. 호출자가 그러지 않으리라 믿는 대신 확인하고 예외를 던지는 편이 낫습니다. 반면 스스로 구현한 코드가 상태를 제대로 다루는지 확인하는 용도라면assert를 씁니다.
스마트 캐스팅과 널 가능성
require 와 check 가 정상 반환되면 컴파일러는 검사한 조건이 그 이후로도 참이라고 가정합니다. 표준 라이브러리의 contract 가 이를 선언합니다.
public inline fun require(value: Boolean): Unit {
contract {
returns() implies value
}
require(value) { "Failed requirement." }
}그래서 검사 뒤에는 캐스팅이나 널 검사 없이 값을 바로 쓸 수 있습니다. 프로퍼티가 final 이면 타입 검사와 널 검사 모두 스마트 캐스팅됩니다.
fun changeDress(person: Person) {
require(person.outfit is Dress)
val dress: Dress = person.outfit
// ...
}
class Person(val email: String?)
fun sendEmail(person: Person, message: String) {
require(person.email != null)
val email: String = person.email
// ...
}널 검사에는 requireNotNull 과 checkNotNull 이 따로 있습니다. 두 함수 모두 스마트 캐스팅을 지원하며, 값을 반환하므로 변수를 언팩(unpack)하는 표현식으로도 쓸 수 있습니다.
fun sendEmail(person: Person, text: String) {
val email = requireNotNull(person.email)
validateEmail(email)
// ...
}널 아님 단언 !! 의 문제
requireNotNull 대신 !! 를 쓸 수도 있지만, 이는 자바의 널 문제를 그대로 가져오는 안이한 선택입니다. null 이 들어오면 NullPointerException 만 던질 뿐 무엇이 왜 잘못됐는지 알려 주지 않습니다. 짧고 간단해서 “타입은 널 가능이지만 지금은 null 이 안 올 것 같은” 자리에 남용되는데, 문제는 지금 안 오더라도 나중에는 올 수 있다는 점입니다. !! 는 널 가능성을 조용히 숨길 뿐입니다.
fun largestOf(a: Int, b: Int, c: Int, d: Int): Int =
listOf(a, b, c, d).maxOrNull()!!
// 인수 개수 제한 없이 쓰도록 리팩터링하면
fun largestOf(vararg nums: Int): Int =
nums.maxOrNull()!!
largestOf() // NPE네 개의 인수를 받을 때는 리스트가 절대 비지 않으니 !! 가 안전해 보입니다. 하지만 vararg 로 바꾸는 순간 빈 컬렉션이 가능해지고, !! 는 그 사실을 아무에게도 알리지 않은 채 NPE로 터집니다.
변수도 마찬가지입니다. 나중에 설정되지만 첫 사용 전에는 반드시 설정되는 프로퍼티를 null 로 초기화하고 !! 로 꺼내 쓰면, 매번 언팩해야 해서 번거로울 뿐 아니라 그 프로퍼티가 나중에 의미 있는 null 값을 가질 가능성까지 막아 버립니다.
class UserControllerTest {
private var dao: UserDao? = null
private var controller: UserController? = null
@BeforeEach
fun init() {
dao = mockk()
controller = UserController(dao!!)
}
@Test
fun test() {
controller!!.doSomething()
}
}
!!는 코드 스멜입니다코틀린 커뮤니티에서는
!!를 피하는 것이 널리 통용되는 규약이고, 많은 팀이 아예 금지하거나 정적 분석 도구 디텍트(Detekt)로 사용 시 오류를 내도록 설정합니다. 코드에!!가 보이면 “여기 문제가 있습니다!”라고 외치는 것과 같습니다. 정당한 경우는 널 가능성을 올바르게 표기하지 않은 라이브러리를 쓸 때 정도이며, 코틀린에 맞게 설계된 API에서는 쓰지 않습니다.
명시적인 오류가 NPE보다 훨씬 많은 정보를 담으므로, 예외를 던져야 한다면 !! 대신 구체적인 오류를 던지는 편이 대부분의 경우 낫습니다.
lateinit 으로 무의미한 널 가능성 없애기
!! 를 쓰지 않으려면 애초에 무의미한 널 가능성을 만들지 않아야 합니다. 첫 사용 전에 반드시 초기화될 것을 보장할 수 있는 프로퍼티는 lateinit 이나 Delegates.notNull 로 선언합니다. 클래스에 라이프사이클이 있고 처음 호출되는 메서드에서 프로퍼티를 설정하는 경우가 전형적입니다. 안드로이드 Activity 의 onCreate, iOS UIViewController 의 viewDidAppear, 리액트 componentDidMount 가 그 예입니다.
class UserControllerTest {
private lateinit var dao: UserDao
private lateinit var controller: UserController
@BeforeEach
fun init() {
dao = mockk()
controller = UserController(dao)
}
@Test
fun test() {
controller.doSomething()
}
}lateinit 프로퍼티가 초기화됐는지는 ::dao.isInitialized 처럼 프로퍼티 참조로 확인할 수 있습니다.
엘비스 연산자로 흐름 끊기
널 가능 값에 대해 오류를 던지는 대신 함수를 그냥 끝내고 싶다면 엘비스 연산자 오른쪽에 return 을 둡니다. 둘 이상의 동작이 필요하면 run 으로 감싸 로그를 남기고 종료할 수 있습니다.
fun sendEmail(person: Person, text: String) {
val email: String = person.email ?: run {
log("Email not sent, no email address")
return
}
// ...
}return 이나 throw 와 함께 쓰는 엘비스 연산자는 널리 쓰이는 관용구이므로 주저할 이유가 없습니다. 다만 이 코드도 함수 시작 부분에 두어 잘 보이게 해야 합니다.
error 함수
error 는 IllegalStateException 을 던지는 표준 라이브러리 함수로, 반환 타입이 Nothing 이라 어떤 식의 자리에도 들어갈 수 있습니다. 기대하지 않은 타입이 인수로 들어오는 것처럼 절대 발생하지 않아야 하는 상황을 처리할 때 씁니다.
// 코틀린 표준 라이브러리의 error 구현
public inline fun error(message: Any): Nothing =
throw IllegalStateException(message.toString())
// 사용 예
fun handleMessage(message: Message) = when(message) {
is TextMessage -> showText(message.text)
is ImageMessage -> showImage(message.image)
else -> error("Unknown message type")
}비교 / 트레이드오프
널 가능 값을 다루는 방법은 실패했을 때 남기는 정보량과 적합한 상황이 다릅니다.
| 방법 | 실패 시 | 남는 정보 | 적합한 경우 |
|---|---|---|---|
!! | NPE | 없음 | 널 가능성 표기가 없는 외부 라이브러리 정도 |
requireNotNull / checkNotNull | IAE / ISE | 인수·상태 구분과 지연 메시지 | 호출자가 규약을 어겼음을 알려야 할 때 |
?: return / ?: throw | 조용한 종료 또는 지정한 예외 | run 블록의 로그 | null 이 정상 흐름의 하나일 때 |
lateinit | UninitializedPropertyAccessException | 어느 프로퍼티인지 | 라이프사이클상 첫 사용 전 초기화가 보장될 때 |
내 생각
- Spring 서비스 계층의
require는 Bean Validation 다음의 두 번째 방어선입니다.@Valid는 DTO의 형식을 잡아 주지만 “출금액은 잔액 이하” 같은 도메인 규칙은 서비스·도메인 메서드 도입부의require가 맡고, 전역 핸들러에서IllegalArgumentException을 400으로 매핑하면 짝이 맞습니다. check는 상태 머신 전이 검사에 제격입니다. 결제 완료 상태에서만 취소가 가능하다는 규칙을 엔티티 메서드 첫 줄의check로 두면, 잘못된 전이가 DB에 절반만 반영되는 사고가 구조적으로 사라집니다.@Autowired lateinit var는 이미 익숙한 패턴입니다. 생성자 주입이 가능하면 그쪽이 낫지만, 테스트 클래스처럼 프레임워크가 생명주기를 쥐고 있는 곳에서는lateinit이!!를 없애는 표준 답입니다.when의else -> error(...)는 sealed class 와 만나면 사라집니다. 분기가 모든 하위 타입을 다루면else자체가 필요 없어지고, 새 타입 추가가 런타임 예외가 아니라 컴파일 에러로 잡히므로error보다 한 단계 안전합니다.
관련 개념
- 아이템 03 가능한 한 빨리 플랫폼 타입을 제거하라 —
!!가 정당화되는 거의 유일한 상황인 “널 가능성 표기가 없는 라이브러리”를 타입 표기 쪽에서 다룹니다 - 아이템 04 변수의 스코프를 최소화하라 — 선언과 동시에 초기화하면 널 가능
var와lateinit이 필요한 자리 자체가 줄어듭니다