
1편에서는 코틀린 클린코드의 기준을 먼저 잡았습니다. 2편에서는 이름 짓기를 다뤘고, 3편에서는 함수 설계를 살펴봤습니다. 이번 4편에서는 실무에서 가장 자주 마주치는 주제인 null safety를 정리해보겠습니다.
시리즈 전체 흐름이 궁금하시다면 코틀린 클린코드 허브 페이지를 먼저 읽어보세요. 앞선 글이 필요하시다면 1편, 2편, 3편도 함께 보시면 좋습니다.
코틀린은 null을 타입 시스템으로 다룹니다. 그래서 자바보다 훨씬 안전합니다. 하지만 실무에서는 여전히 null 때문에 코드가 더러워집니다.
이유는 단순합니다. !!를 쉽게 쓰고, nullable 값을 오래 끌고 다니고, Java 코드와 만나는 경계에서 null을 느슨하게 처리하기 때문입니다.
좋은 null 처리는 null을 완전히 없애는 것이 아닙니다. null이 필요한 지점과 없어야 하는 지점을 분명하게 나누는 것입니다.
이번 글에서는 추상적인 설명보다 실무 기준을 먼저 잡겠습니다. 언제 ?를 붙여야 하는지, 언제 빨리 걷어내야 하는지, 왜 !!가 위험한지, Java interop에서는 무엇을 조심해야 하는지 차근차근 보겠습니다.
- 왜 null safety가 중요한가
- nullable 타입과 non-null 타입을 구분하는 기준
!!는 마지막 수단이어야 합니다- safe call과 Elvis 연산자를 읽기 좋게 쓰는 법
- nullable은 경계에서 처리하고 핵심 로직에는 오래 두지 않기
- smart cast와 early return으로 흐름 단순하게 만들기
- 컬렉션 null 처리에서 많이 헷갈리는 부분
- Java interop에서 platform type 조심하기
- nullable 프로퍼티를 너무 많이 두면 왜 읽기 어려운가
- 실무에서 자주 하는 실수
- 체크리스트
- 마무리
왜 null safety가 중요할까요?
실무 코드를 읽기 어렵게 만드는 대표적인 원인 중 하나가 null 처리입니다. null 자체가 문제라기보다, null이 흐름을 숨긴다는 점이 더 큰 문제입니다.
값이 없을 수 있다는 사실은 중요합니다. 하지만 그 사실이 함수 곳곳에 흩어져 있으면, 읽는 사람은 매번 “여기서도 null일 수 있나?”를 다시 생각해야 합니다.
그 순간 코드 읽기는 느려집니다. 리뷰도 어려워집니다. 테스트 케이스도 늘어납니다.
아래 코드는 짧지만 읽기 편하지 않습니다.
fun sendWelcomeMessage(user: User?) {
val email = user?.email
if (email != null) {
mailSender.send(email, "welcome")
}
}
문법은 안전합니다. 하지만 함수 시그니처만 보면 질문이 생깁니다. 이 함수는 사용자가 없을 수도 있는 상황을 정상 흐름으로 받는 걸까요? 아니면 호출 전에 걸러졌어야 할까요?
이 기준이 불분명하면 null 체크는 늘어납니다. 코드도 흐려집니다.
만약 이 함수가 정말로 “사용자가 있는 경우에만 환영 메일을 보낸다”는 책임이라면, 아래처럼 의도를 더 분명하게 드러내는 쪽이 낫습니다.
fun sendWelcomeMessage(user: User) {
mailSender.send(user.email, "welcome")
}
그리고 호출하는 쪽에서 null을 먼저 정리하면 됩니다.
val user = userRepository.findById(userId) ?: return
sendWelcomeMessage(user)
이 방식은 단순합니다. nullable 값이 퍼지는 범위를 줄여줍니다. 그리고 핵심 동작은 non-null 값 기준으로 읽히게 만듭니다.
nullable 타입과 non-null 타입을 구분하는 기준
코틀린에서는 타입 뒤에 ?를 붙이면 nullable이 됩니다. 이 문법은 간단합니다. 하지만 아무 값에나 ?를 붙이기 시작하면 코드가 금방 흐려집니다.
그래서 먼저 기준을 잡는 것이 좋습니다.
정말 값이 없을 수 있는가? 이 질문에 자신 있게 “그렇다”라고 답할 수 있을 때만 nullable을 쓰는 편이 좋습니다.
예를 들어 아래 두 경우는 의미가 다릅니다.
data class User(
val id: Long,
val nickname: String?,
)
닉네임은 선택 입력일 수 있습니다. 이 경우 nickname: String?는 자연스럽습니다.
data class User(
val id: Long?,
val name: String?,
val email: String?,
)
반면 이 모델은 읽는 순간 불안해집니다. 사용자라면 적어도 어떤 값은 반드시 있어야 할 것 같은데, 모두 nullable입니다. 이런 설계는 보통 도메인 규칙이 모델에 반영되지 않았다는 신호입니다.
도메인에서 필수인 값은 non-null로 두는 것이 좋습니다. 그래야 컴파일러가 우리 편이 됩니다.
data class User(
val id: Long,
val name: String,
val email: String,
val nickname: String?,
)
이렇게 두면 “사용자에게 이름과 이메일은 항상 있다”는 규칙이 타입에 드러납니다. 이 한 줄의 차이가 코드 전체의 안정성을 바꿉니다.
nullable을 쓰는 대표적인 상황
- 조회 결과가 없을 수 있는 경우
- 선택 입력 필드처럼 값이 비어 있어도 정상인 경우
- 외부 시스템 응답이 불완전할 수 있는 경우
- 초기 상태가 아직 완성되지 않은 경우
반대로 아래 경우에는 nullable을 다시 의심해보셔야 합니다.
- “일단 편해서” 붙인 경우
- 초기화를 미루고 싶어서 붙인 경우
- null인지 아닌지 규칙이 문서에만 있고 타입에는 없는 경우
!!는 마지막 수단이어야 합니다
코틀린에서 !!는 “여기는 무조건 null이 아니다”라고 단언하는 문법입니다. 짧고 편합니다. 그래서 실무에서 쉽게 늘어납니다.
문제는 이 문법이 컴파일러의 도움을 끊어버린다는 점입니다. 틀리면 바로 런타임 예외로 이어집니다.
fun printUserName(user: User?) {
println(user!!.name)
}
이 코드는 null이 들어오면 바로 깨집니다. 더 아쉬운 점은 함수 시그니처가 이미 User?라고 말하고 있다는 것입니다. 즉, 위험을 알고도 무시한 셈입니다.
대부분의 경우 !! 대신 더 좋은 선택지가 있습니다.
1. 함수가 null을 받을 이유가 없다면 시그니처를 바꾸세요
fun printUserName(user: User) {
println(user.name)
}
가장 좋은 해결책은 함수 입구를 바로잡는 것입니다. null이 오면 안 되는 함수라면 처음부터 nullable로 받지 않는 편이 좋습니다.
2. null이 정상 흐름이라면 early return으로 정리하세요
fun printUserName(user: User?) {
val actualUser = user ?: return
println(actualUser.name)
}
이 패턴은 단순합니다. 이후 로직은 non-null 기준으로 읽힙니다. 중간에 safe call이 계속 이어지지도 않습니다.
3. 꼭 값이 있어야 한다면 requireNotNull로 의도를 밝히세요
fun processPayment(paymentId: String?) {
val actualPaymentId = requireNotNull(paymentId) { "결제 ID는 반드시 있어야 합니다." }
paymentService.process(actualPaymentId)
}
requireNotNull은 단순히 예외를 던지는 것보다 읽기 좋습니다. 왜 null이면 안 되는지 메시지로 함께 남길 수 있기 때문입니다.
정리하면 이렇습니다. !!는 “컴파일러보다 내가 더 잘 안다”는 선언입니다. 이런 선언은 가능한 한 늦게, 가능한 한 좁은 곳에서만 써야 합니다.
그리고 대부분은 아예 쓰지 않아도 됩니다.
safe call과 Elvis 연산자를 읽기 좋게 쓰는 법
?.와 ?:는 코틀린 null 처리의 핵심입니다. 다만 이 연산자도 많이 쓴다고 항상 좋은 코드는 아닙니다.
중요한 것은 null을 어떻게 해석하느냐입니다. 대체값이 정말 맞는지 먼저 생각하셔야 합니다.
나쁜 예: 일단 빈 문자열로 덮어두기
fun displayUserName(user: User?): String {
return user?.name ?: ""
}
문법상 문제는 없습니다. 하지만 이 코드가 좋은지는 상황에 따라 다릅니다. 사용자 이름이 없을 때 정말 빈 문자열이 맞을까요? 아니면 “알 수 없음”이 더 맞을까요? 혹은 호출 자체가 잘못된 걸까요?
Elvis 연산자는 편합니다. 그래서 문제를 덮는 데도 자주 쓰입니다. 이 습관은 조심하셔야 합니다.
좋은 예: 대체값에 의미를 부여하기
fun displayUserName(user: User?): String {
return user?.name ?: "이름 미등록"
}
이 코드는 적어도 의도가 보입니다. null을 빈 값으로 숨기지 않습니다.
좋은 예: 대체값보다 흐름 종료가 맞는 경우
fun createSession(user: User?) {
val actualUser = user ?: return
sessionStore.create(actualUser.id)
}
가끔은 대체값을 만드는 것보다, 그 로직을 여기서 끝내는 편이 더 자연스럽습니다. 이럴 때 Elvis와 return 조합이 아주 유용합니다.
체이닝이 길어지면 중간 변수를 고려하세요
val city = order?.customer?.address?.city ?: "주소 미입력"
짧을 때는 괜찮습니다. 하지만 이런 체이닝이 반복되면 읽기 피로도가 크게 올라갑니다.
val customer = order?.customer ?: return "고객 정보 없음"
val address = customer.address ?: return "주소 미입력"
return address.city
조금 길어졌지만 더 읽기 쉬운 경우가 많습니다. 특히 중간 단계마다 의미 있는 실패 이유를 보여줄 수 있습니다.
nullable은 경계에서 처리하고 핵심 로직에는 오래 두지 마세요
실무에서 가장 효과가 큰 원칙입니다. nullable 값을 서비스 내부 깊숙이 끌고 가면, 그 뒤의 모든 코드가 방어적으로 바뀝니다.
그래서 null은 가능하면 입구에서 빨리 정리하는 편이 좋습니다.
아래 코드는 흔히 볼 수 있는 형태입니다.
fun placeOrder(userId: Long, productId: Long) {
val user = userRepository.findById(userId)
val product = productRepository.findById(productId)
if (user != null && product != null) {
orderRepository.save(Order(user.id, product.id))
mailSender.send(user.email, "주문이 완료되었습니다.")
}
}
이 코드는 동작은 합니다. 하지만 조건문 안에 핵심 로직이 갇혀 있습니다. 주문이 안 된 이유도 드러나지 않습니다.
아래처럼 입구에서 정리하면 훨씬 낫습니다.
fun placeOrder(userId: Long, productId: Long) {
val user = userRepository.findById(userId) ?: return
val product = productRepository.findById(productId) ?: return
orderRepository.save(Order(user.id, product.id))
mailSender.send(user.email, "주문이 완료되었습니다.")
}
핵심 흐름이 곧바로 보입니다. 이후 코드는 non-null 값만 다룹니다.
실패 이유를 명확히 보여줘야 하는 서비스라면 예외나 결과 타입으로 바꿀 수도 있습니다.
fun placeOrder(userId: Long, productId: Long): OrderResult {
val user = userRepository.findById(userId)
?: return OrderResult.UserNotFound
val product = productRepository.findById(productId)
?: return OrderResult.ProductNotFound
orderRepository.save(Order(user.id, product.id))
mailSender.send(user.email, "주문이 완료되었습니다.")
return OrderResult.Success
}
null을 끝까지 끌고 가는 대신, 경계에서 의미 있는 결과로 바꾸는 방식입니다. 이 패턴은 코드 리뷰에서도 평가가 좋습니다.
smart cast와 early return으로 흐름을 단순하게 만드세요
코틀린은 null 체크 이후 타입을 자동으로 좁혀주는 smart cast를 지원합니다. 이 기능을 잘 활용하면 코드가 짧아집니다. 더 중요한 점은, 읽는 흐름이 자연스러워진다는 것입니다.
fun sendCoupon(user: User?) {
if (user == null) return
couponService.issue(user.id)
mailSender.send(user.email, "쿠폰이 발급되었습니다.")
}
이 코드는 아주 읽기 좋습니다. 먼저 실패 조건을 제거합니다. 그다음 본론만 남깁니다.
반대로 아래처럼 중첩을 만들면 생각할 것이 늘어납니다.
fun sendCoupon(user: User?) {
if (user != null) {
couponService.issue(user.id)
mailSender.send(user.email, "쿠폰이 발급되었습니다.")
}
}
차이는 작아 보여도, 이런 패턴이 파일 전체에 반복되면 가독성 차이가 커집니다.
복잡한 조건도 먼저 탈락시키세요
fun approveReview(review: Review?) {
if (review == null) return
if (review.status != ReviewStatus.PENDING) return
if (review.contents.isBlank()) return
reviewRepository.save(review.approve())
}
이 구조는 빠르게 읽힙니다. 반대로 모든 조건을 하나의 거대한 if문에 넣으면 눈이 더 오래 머뭅니다.
컬렉션 null 처리에서 많이 헷갈리는 부분
null 안전성을 이야기할 때 자주 놓치는 부분이 컬렉션입니다. 특히 아래 두 타입은 의미가 다릅니다.
val names: List<String?> = listOf("A", null, "C")
이 타입은 “리스트는 항상 있지만, 원소는 null일 수 있다”는 뜻입니다.
val names: List<String>? = null
이 타입은 “리스트 자체가 없을 수 있다”는 뜻입니다.
겉보기에 비슷하지만, 다루는 방식은 다릅니다. 그래서 코드 리뷰에서 자주 오해가 생깁니다.
null 원소를 걷어내고 싶다면 filterNotNull()
val rawNames: List<String?> = listOf("A", null, "C")
val names: List<String> = rawNames.filterNotNull()
이 한 줄로 이후 로직을 훨씬 편하게 만들 수 있습니다.
변환 과정에서 null이 생긴다면 mapNotNull()
val ages = inputs.mapNotNull { input -> input.toIntOrNull() }
이 코드는 변환과 정리를 한 번에 처리합니다. 의도도 분명합니다.
리스트가 nullable이라면 초기에 정리하세요
fun printNames(names: List<String>?) {
val actualNames = names ?: emptyList()
actualNames.forEach(::println)
}
이 방식은 괜찮습니다. 다만 여기서도 질문은 같습니다. 정말 빈 리스트가 맞는 대체값인가요? 없다면 early return이 더 나을 수 있습니다.
Java interop에서 platform type을 조심하세요
코틀린 내부 코드만 볼 때는 null safety가 강력합니다. 하지만 Java 코드와 만나는 순간 이야기가 달라집니다.
Java 쪽 값은 null 가능성이 타입에 완전히 드러나지 않을 수 있습니다. 그래서 코틀린은 이런 값을 platform type으로 다룹니다.
이 지점이 실무에서 꽤 중요합니다. “코틀린인데 왜 여기서 NPE가 나지?”라는 질문이 자주 나오는 이유이기도 합니다.
val name = legacyUserClient.findName(userId)
println(name.length)
겉으로는 문제가 없어 보일 수 있습니다. 하지만 Java 메서드가 실제로 null을 반환하면 런타임에서 깨질 수 있습니다.
그래서 Java 경계에서는 더 보수적으로 처리하는 편이 좋습니다.
val name: String? = legacyUserClient.findName(userId)
val actualName = name ?: return
println(actualName.length)
또는 아예 경계에서 안전한 모델로 변환할 수도 있습니다.
fun loadUserName(userId: Long): String {
val name: String? = legacyUserClient.findName(userId)
return name ?: "이름 미등록"
}
핵심은 단순합니다. Java에서 들어온 값은 처음부터 완전히 믿지 않는 편이 좋습니다. 가능하다면 Java 코드에 nullability annotation을 추가하는 것도 큰 도움이 됩니다.
nullable 프로퍼티를 너무 많이 두면 왜 읽기 어려울까요?
클래스 설계 단계에서도 null safety는 큰 차이를 만듭니다. 특히 하나의 데이터 클래스에 nullable 프로퍼티가 너무 많으면 상태가 흐려집니다.
data class PaymentState(
val paymentId: String?,
val approvedAt: LocalDateTime?,
val failedReason: String?,
val receiptUrl: String?,
)
이 모델은 가능한 조합이 너무 많습니다. 결제가 성공했는데 실패 사유가 함께 들어 있을 수도 있습니다. 승인 시간 없이 영수증 URL만 있을 수도 있습니다.
즉, 타입이 상태 규칙을 설명하지 못합니다.
이럴 때는 nullable 필드를 늘리는 대신 상태를 나누는 쪽이 더 좋습니다.
sealed interface PaymentState {
data object Pending : PaymentState
data class Approved(
val paymentId: String,
val approvedAt: LocalDateTime,
val receiptUrl: String,
) : PaymentState
data class Failed(
val paymentId: String,
val failedReason: String,
) : PaymentState
}
이 구조는 훨씬 명확합니다. 어떤 값이 언제 있어야 하는지가 상태에 따라 정해집니다.
즉, null 체크를 줄이는 가장 좋은 방법은 때로는 null 처리 문법이 아니라 모델링 개선입니다.
실무에서 자주 하는 실수
1. !!로 일단 통과시키기
리뷰에서 자주 보이는 패턴입니다. 지금은 편하지만, 나중에 예외가 터지면 원인 추적이 더 어려워집니다.
2. nullable을 너무 오래 끌고 가기
함수 시작에서 한 번 정리할 수 있는 값을 끝까지 ?.로 이어 붙이면 코드가 흐려집니다.
3. Elvis로 문제를 덮어버리기
?: "", ?: 0, ?: emptyList()는 편합니다. 하지만 그 대체값이 정말 비즈니스 의미와 맞는지 꼭 확인하셔야 합니다.
4. 도메인 필수값까지 모두 nullable로 두기
필수값을 nullable로 두면 컴파일러가 못 도와줍니다. 결국 사람이 규칙을 기억해야 합니다.
5. Java 경계를 너무 낙관적으로 보기
코틀린 내부 규칙만 믿고 Java 반환값을 바로 쓰면, null safety의 보호막이 약해질 수 있습니다.
체크리스트
- 이 값은 정말 없을 수 있는가
- null이 정상 흐름이라면 어디서 가장 빨리 정리할 수 있는가
!!대신 시그니처 수정, early return,requireNotNull로 바꿀 수 없는가- Elvis의 대체값이 비즈니스 의미와 맞는가
- nullable 값이 핵심 로직까지 깊게 들어가고 있지 않은가
- 컬렉션 자체가 nullable인지, 원소가 nullable인지 구분이 분명한가
- Java 경계에서 들어온 값을 너무 쉽게 믿고 있지 않은가
- null 체크를 줄이기 위해 모델링을 개선할 수는 없는가
마무리
코틀린의 null safety는 강력합니다. 하지만 문법이 좋은 것과 코드가 좋은 것은 다릅니다.
클린코드 관점에서 더 중요한 것은 흐름입니다. null을 허용할 곳과 허용하지 않을 곳을 나누고, nullable 값을 가능한 한 빨리 정리하고, 핵심 로직은 non-null 값 기준으로 읽히게 만드는 것이 핵심입니다.
실무에서는 이 원칙만 잘 지켜도 코드가 훨씬 단단해집니다. 리뷰 속도도 빨라집니다. 예상치 못한 예외도 줄어듭니다.
다음 5편에서는 data class와 sealed class를 어떻게 써야 모델이 더 명확해지는지를 이어서 다뤄보겠습니다. null 체크를 줄이는 데도 큰 도움이 되는 주제입니다.
이전 글도 함께 보시면 흐름이 더 잘 이어집니다.