findById
가 Optional.empty()
인 이유findById(id: Long)
는 Optional<T>
를 반환한다.Optional.empty()
상태가 되며,// 삭제 전: save() → findById() → Optional.of(entity)
val saved = contiRepository.save(conti)
val before: Optional<Conti> = contiRepository.findById(saved.id!!)
assertThat(before).isPresent
// 삭제 후: delete() → findById() → Optional.empty()
contiRepository.delete(saved)
val after: Optional<Conti> = contiRepository.findById(saved.id!!)
assertThat(after).isEmpty
Kotlin의 !!
연산자는 런타임에 NPE를 유발한다.
안정성이 중요한 프로덕션 코드에선 명시적인 null 처리가 필수다.
// ❌ 위험: null이면 곧바로 NPE 발생
fun process(x: String?) {
println(x!!.length)
}
requireNotNull(value) { "메시지" }
val safeX = requireNotNull(x) { "x는 반드시 필요합니다." }
println(safeX.length)
왜 안전한가?
컴파일러 지원: 호출 시점에 null인지 명시적으로 체크하도록 강제한다.
명확한 예외 메시지: requireNotNull
에 전달한 람다 메시지가 포함된 IllegalArgumentException
이 발생해, 문제 지점을 빠르게 파악할 수 있다.
런타임 안정성: NPE 대신 IllegalArgumentException
이 발생하므로, 로그와 스택트레이스를 통해 원인을 정확히 알 수 있다.
?: throw
val safeX = x ?: throw IllegalArgumentException("x가 없습니다")
println(safeX.length)
왜 안전한가?
간결함: 한 줄로 null 체크와 예외 발생을 모두 표현한다.
일관된 예외 처리: 예외 타입과 메시지를 컨트롤할 수 있어, 서비스 계층 표준 예외 전략에 맞출 수 있다.
명시적 의도: null일 때 절대 다음 로직에 진입하지 않겠다는 의도가 분명진다.
?.let { … } ?: run { … }
x?.let {
println(it.length)
} ?: run {
// 예외 처리 로직
}
왜 안전한가?
null-안전 블록 분리: null이 아닌 경우와 null인 경우를 완전히 분리된 블록으로 처리해, 로직의 가독성이 높아진다.
부수 효과 최소화: null인 경우에만 실행될 로직을 명확히 선언해 두어, 사이드 이펙트를 관리하기 쉽다.
무분별한 !!
제거: !!
대신 안전 호출(?.
)을 기본으로 사용함으로써, NPE 위험을 원천 차단한다.
테스트 코드에서는 “여기까지 와서 null이면 곧바로 실패하라”는 의도를 명확히 드러내기 위해
!!
를 쓰는 것이 오히려 가독성과 의도를 살리는 패턴이다.
// 테스트 내에서는 반드시 값이 있어야 하므로
val saved = contiRepository.save(conti)
val id = saved.id!! // ID가 null이면 테스트 즉시 실패
val detail = contiService.getContiDetail(id)
이렇게 하면 “실제론 결코 null일 리 없다”라는 전제가 명확히 드러난다.
만약 null이라면 바로 NPE로 깨져서 “여기서 문제가 있구나”를 곧바로 알려준다.