널 안전성이 필요한 이유
프로그램에서 null은 값이 없다는 뜻입니다. 예를 들어 아직 이름을 입력하지 않았거나, 검색 결과가 없거나, 서버에서 값이 내려오지 않았을 때 null을 사용할 수 있습니다.
문제는 null을 실제 값처럼 사용할 때 생깁니다.
val name: String? = null
println(name.length) // 컴파일 오류name에는 문자열이 들어 있을 수도 있지만, null일 수도 있습니다. 그런데 length는 문자열이 있을 때만 사용할 수 있습니다. 코틀린은 이런 코드를 컴파일 단계에서 막습니다.
자바 같은 언어에서는 이런 실수가 실행 중에 NullPointerException으로 터지는 경우가 많았습니다. 코틀린의 널 안전성은 이 문제를 최대한 컴파일 시점에 발견하게 해 줍니다. 즉, 오류를 사용자가 만나는 시점이 아니라 개발자가 코드를 작성하는 시점으로 앞당깁니다.

nullable 타입과 non-null 타입
코틀린의 타입은 기본적으로 null을 허용하지 않습니다.
val name: String = "Kotlin"
val age: Int = 10위 코드에서 name은 항상 String이어야 합니다. null을 넣을 수 없습니다.
val name: String = null // 컴파일 오류null을 허용하려면 타입 뒤에 ?를 붙입니다.
val nickname: String? = null
val score: Int? = nullString과 String?는 서로 다른 타입입니다.
| 타입 | 의미 |
|---|---|
String | 반드시 문자열이 있음 |
String? | 문자열이 있거나 null일 수 있음 |
Int | 반드시 정수가 있음 |
Int? | 정수가 있거나 null일 수 있음 |
이 차이가 코틀린 널 안전성의 출발점입니다. 타입에 ?가 붙어 있으면 코틀린 컴파일러는 그 값을 사용할 때마다 "없을 수도 있다"고 판단합니다.
fun printLength(text: String?) {
println(text.length) // 컴파일 오류
}text가 null이면 길이를 구할 수 없습니다. 그래서 코틀린은 안전하게 처리하라고 요구합니다.
안전 호출 연산자 ?.
nullable 값을 가장 간단하게 다루는 방법은 안전 호출 연산자 ?.를 사용하는 것입니다.
val text: String? = null
val length: Int? = text?.length
println(length) // nulltext?.length는 이렇게 동작합니다.
text가null이 아니면text.length를 실행합니다.text가null이면 전체 결과가null이 됩니다.
즉, ?.는 값이 있을 때만 뒤의 작업을 실행합니다.
fun printUppercase(text: String?) {
println(text?.uppercase())
}
printUppercase("kotlin") // KOTLIN
printUppercase(null) // null안전 호출은 여러 번 이어서 사용할 수도 있습니다.
val result: String? = user?.profile?.email이 코드는 user, profile, email 중 하나라도 null이면 전체 결과가 null이 됩니다. 중간에 값이 없는데 억지로 접근하지 않으므로 안전합니다.
아직 클래스와 객체를 자세히 배우지 않았더라도 핵심은 간단합니다. ?.는 "있으면 실행하고, 없으면 멈춘다"는 뜻입니다.
엘비스 연산자 ?:
안전 호출의 결과가 null일 때 기본값을 주고 싶다면 엘비스 연산자 ?:를 사용합니다.
val text: String? = null
val length: Int = text?.length ?: 0
println(length) // 0text?.length ?: 0은 이렇게 읽으면 됩니다.
text?.length값이 있으면 그 값을 사용하고,null이면0을 사용한다.
문자열 기본값을 줄 때도 자주 사용합니다.
fun displayName(name: String?): String {
return name ?: "이름 없음"
}
println(displayName("지민")) // 지민
println(displayName(null)) // 이름 없음엘비스 연산자는 조기 반환에도 유용합니다.
fun sendMessage(message: String?) {
val text = message ?: return
println("메시지 전송: $text")
}message가 null이면 함수가 바로 끝납니다. null이 아니면 text에는 non-null 값이 들어갑니다. 따라서 아래에서는 안전하게 사용할 수 있습니다.
필수 값이 없을 때 오류를 내고 싶다면 return 대신 예외를 사용할 수도 있습니다.
fun requireName(name: String?): String {
return name ?: throw IllegalArgumentException("이름이 필요합니다")
}이처럼 ?:는 nullable 값을 실제 코드 흐름에 맞게 정리해 주는 연산자입니다.
널 검사와 스마트 캐스트
직접 null인지 검사하면, 코틀린은 그 이후의 타입을 더 정확하게 판단합니다. 이것을 스마트 캐스트라고 합니다.
fun printLength(text: String?) {
if (text != null) {
println(text.length)
}
}text의 타입은 원래 String?입니다. 하지만 if (text != null) 블록 안에서는 null이 아니라는 사실이 확인되었습니다. 그래서 코틀린은 그 안에서 text를 String처럼 사용할 수 있게 해 줍니다.
반대로 null일 때 먼저 함수를 끝내는 방식도 많이 씁니다.
fun printUppercase(text: String?) {
if (text == null) return
println(text.uppercase())
}if (text == null) return을 지나왔다면, 그 아래 코드에서 text는 null이 아닙니다. 그래서 uppercase()를 안전하게 호출할 수 있습니다.
이 방식은 함수 초반에 잘못된 입력을 걸러낼 때 특히 읽기 좋습니다.
fun divide(a: Int?, b: Int?): Int? {
if (a == null) return null
if (b == null) return null
if (b == 0) return null
return a / b
}위 코드에서 마지막 줄의 a와 b는 이미 null 검사를 통과했습니다. 그래서 Int처럼 사용할 수 있습니다.
다만 스마트 캐스트가 항상 가능한 것은 아닙니다. 값이 중간에 바뀔 수 있으면 컴파일러는 안전하다고 확신하지 못합니다. 지역 변수 중에서도 변경되지 않는 val이 스마트 캐스트와 가장 잘 어울립니다.
!!는 널 안전성을 끄는 스위치다
코틀린에는 not-null 단언 연산자 !!가 있습니다.
val text: String? = null
println(text!!.length) // 실행 중 NullPointerException 발생!!는 컴파일러에게 이렇게 말하는 것과 같습니다.
이 값은 절대
null이 아니라고 내가 보장한다.
문제는 그 보장이 틀리면 실행 중에 바로 오류가 난다는 점입니다. 결국 코틀린이 막아 주던 NullPointerException을 다시 코드 안으로 불러들이는 셈입니다.
그래서 !!는 습관적으로 쓰면 안 됩니다.
나쁜 예를 봅시다.
fun printLength(text: String?) {
println(text!!.length)
}이 코드는 컴파일은 됩니다. 하지만 text가 null이면 바로 실패합니다. 더 안전한 코드는 다음과 같습니다.
fun printLength(text: String?) {
val length = text?.length ?: 0
println(length)
}또는 null이면 함수가 할 일이 없다고 판단할 수도 있습니다.
fun printLength(text: String?) {
val value = text ?: return
println(value.length)
}!!를 써야 할 때가 완전히 없는 것은 아닙니다. 하지만 기준은 엄격해야 합니다.
정말로
null이 될 수 없는 값인가?그 사실을 컴파일러만 모르는 상황인가?
null이면 프로그램이 즉시 실패해도 괜찮은가?더 안전한 표현인
?.,?:,if검사로 바꿀 수는 없는가?
대부분의 경우 답은 마지막 질문에서 나옵니다. 바꿀 수 있다면 !!를 쓰지 않는 편이 좋습니다.
필수 값이 없을 때 명확한 오류를 내고 싶다면 !!보다 requireNotNull이나 checkNotNull이 더 읽기 좋습니다.
fun saveName(name: String?) {
val value = requireNotNull(name) { "name은 반드시 필요합니다" }
println("저장: $value")
}이 코드는 값이 반드시 필요하다는 의도를 분명히 드러냅니다. 단순히 name!!라고 쓰는 것보다 실패 이유를 이해하기 쉽습니다.
let으로 값이 있을 때만 처리하기
nullable 값을 다룰 때 let도 자주 사용합니다.
fun greet(name: String?) {
name?.let {
println("안녕하세요, $it")
}
}name?.let { ... }은 name이 null이 아닐 때만 블록을 실행합니다. 블록 안의 it은 non-null 값입니다.
greet("수아") // 안녕하세요, 수아
greet(null) // 아무 일도 하지 않음let은 nullable 값을 짧은 범위 안에서만 안전하게 쓰고 싶을 때 좋습니다.
fun printTrimmed(text: String?) {
text?.let { value ->
val trimmed = value.trim()
println(trimmed)
}
}여기서는 value라는 이름을 직접 붙였습니다. 블록이 길어질수록 it보다 의미 있는 이름을 쓰는 편이 읽기 쉽습니다.
let은 값을 변환할 때도 유용합니다.
fun normalizeName(name: String?): String {
return name
?.let { it.trim() }
?.let { it.uppercase() }
?: "GUEST"
}흐름은 다음과 같습니다.
name이null이면 결과는"GUEST"입니다.값이 있으면 공백을 제거합니다.
다시 대문자로 바꿉니다.
최종 결과를 반환합니다.
이런 코드는 nullable 값을 처리하는 범위를 좁혀 줍니다. 값이 필요한 곳에서만 열어 보고, 나머지 코드에는 영향을 적게 줍니다.
run으로 여러 작업을 한 번에 묶기
run도 nullable 값과 함께 자주 쓰입니다.
let은 값을 it 또는 이름 붙인 매개변수로 받습니다. 반면 run은 블록 안에서 그 값을 this처럼 사용합니다.
fun makeMessage(name: String?): String {
return name?.run {
val trimmed = trim()
"안녕하세요, $trimmed"
} ?: "이름이 없습니다"
}name이 null이 아니면 run 블록이 실행됩니다. 블록 안에서는 name의 함수인 trim()을 바로 호출할 수 있습니다.
단순한 값 하나를 출력할 때는 let이 읽기 쉽습니다.
name?.let { println(it) }반대로 같은 값으로 여러 작업을 이어서 할 때는 run이 자연스럽습니다.
fun createCode(text: String?): String {
return text?.run {
val noSpace = trim()
val upper = noSpace.uppercase()
"CODE-$upper"
} ?: "CODE-EMPTY"
}let과 run 중 무엇이 항상 더 좋은 것은 아닙니다. 기준은 읽기 쉬움입니다.
값에 이름을 붙여서 쓰고 싶다면
let같은 대상의 함수나 값을 여러 번 쓰고 싶다면
runnull일 때 기본값이 필요하면 마지막에?:를 함께 사용
nullable 값을 설계할 때 생각할 점
널 안전성은 단순히 연산자를 외우는 기능이 아닙니다. 함수의 입력과 출력에서 "값이 없을 수 있는가?"를 명확히 표현하는 설계 방식입니다.
fun findNickname(userId: Int): String? {
// 닉네임이 없을 수 있다면 nullable 반환
return null
}반환 타입이 String?이면 호출하는 쪽은 반드시 null 가능성을 처리해야 합니다.
val nickname = findNickname(1) ?: "익명"
println(nickname)반대로 값이 반드시 있어야 한다면 nullable로 만들지 않는 편이 좋습니다.
fun formatName(name: String): String {
return name.trim().uppercase()
}이 함수는 null을 받지 않습니다. 호출하는 쪽에서 이미 이름이 있다는 사실을 보장해야 합니다. 덕분에 함수 안에서는 불필요한 널 검사를 하지 않아도 됩니다.
좋은 기준은 간단합니다.
값이 없을 수 있으면
?를 붙입니다.값이 반드시 있어야 하면
?를 붙이지 않습니다.nullable 값을 받았다면 가능한 빨리 처리합니다.
처리한 뒤에는 non-null 값으로 코드를 이어 갑니다.
널 가능성을 코드 전체에 퍼뜨리지 않는 것이 중요합니다. nullable 값은 필요한 지점에서 짧게 다루고, 이후에는 안전한 값으로 바꾸는 편이 좋습니다.
자주 쓰는 널 처리 패턴
실제 코드에서는 다음 패턴을 자주 보게 됩니다.
기본값 사용
val count: Int = input?.toIntOrNull() ?: 0값이 없거나 숫자로 바꿀 수 없으면 0을 사용합니다.
값이 없으면 함수 종료
fun printEmail(email: String?) {
val value = email ?: return
println(value.lowercase())
}email이 없으면 아무 일도 하지 않고 끝냅니다.
값이 있을 때만 실행
fun log(message: String?) {
message?.let { println("LOG: $it") }
}message가 있을 때만 로그를 출력합니다.
직접 검사 후 사용
fun isLongText(text: String?): Boolean {
if (text == null) return false
return text.length >= 10
}널을 먼저 걸러낸 뒤 일반 값처럼 사용합니다.
필수 값 검증
fun register(id: String?) {
val userId = requireNotNull(id) { "id가 필요합니다" }
println("등록: $userId")
}값이 없으면 명확한 오류를 내고, 있으면 non-null 값으로 다룹니다.
정리
코틀린의 널 안전성은 null을 금지하는 기능이 아닙니다. null이 가능한 값을 타입으로 드러내고, 안전하게 처리하도록 돕는 기능입니다.
이번 장에서 기억할 핵심은 다음과 같습니다.
String과String?는 다른 타입입니다.?가 붙은 타입은null일 수 있으므로 바로 사용할 수 없습니다.?.는 값이 있을 때만 호출합니다.?:는null일 때 사용할 기본값이나 대체 흐름을 정합니다.if (value != null)검사 이후에는 스마트 캐스트로 안전하게 사용할 수 있습니다.!!는 가능한 한 피해야 합니다.let과run은 nullable 값을 좁은 범위 안에서 처리할 때 유용합니다.
널을 잘 다루면 실행 중에 갑자기 프로그램이 멈추는 일을 줄일 수 있습니다. 코틀린다운 코드는 null을 억지로 무시하지 않습니다. 값이 없을 가능성을 타입에 드러내고, 필요한 곳에서 분명하게 처리합니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.