널 안전성
널 안전성
널 참조는 많은 언어에서 골칫거리예요. 자바에서도 null인 참조의 멤버에 접근하려 하면 NullPointerException, 줄여서 NPE가 터지죠. 사람들은 이걸 "10억 달러의 실수"라고 부르기도 해요. Kotlin의 **널 안전성(null safety)**은 바로 이 위험을 크게 줄이기 위해 설계된 기능이에요.
Kotlin은 타입 시스템 안에서 널 허용 여부를 명시적으로 다뤄요. 어떤 변수나 프로퍼티가 null을 가질 수 있는지 타입만으로 드러나고, 널이 아닌 변수에는 컴파일러가 null이 들어가지 못하게 강제해요. 그래서 잠재적인 널 문제를 실행 시점이 아니라 컴파일 시점에 잡을 수 있어요.
이 페이지에서는 널 허용 타입과 널 불가 타입의 차이부터 시작해서, ?. 안전 호출, ?: 엘비스 연산자, !! 단언 연산자 같은 처리 도구들을 하나씩 살펴볼게요.
널 허용 타입과 널 불가 타입
Kotlin의 타입 시스템은 null을 담을 수 있는 타입(널 허용)과 그렇지 않은 타입(널 불가)을 구분해요. 예를 들어 String 타입 변수는 null을 담을 수 없어요.
fun main() {
// 널이 아닌 문자열을 변수에 할당
var a: String = "abc"
// 널 불가 타입에 null을 다시 할당하려 하면 오류
a = null
print(a)
}
a는 널 불가 변수라서 언제나 유효한 String 값을 가져요. 그래서 그 위에서 메서드를 호출하거나 프로퍼티에 접근해도 NPE가 날 걱정이 없어요.
fun main() {
// 널이 아닌 문자열을 val에 할당
val a: String = "abc"
// 널 불가 변수의 길이를 반환
val l = a.length
print(l) // 3
}
null을 허용하고 싶으면 타입 바로 뒤에 ?를 붙여요. 예를 들어 String?라고 선언하면 그 타입은 null을 받아들일 수 있어요.
fun main() {
// 널 허용 문자열을 var에 할당
var b: String? = "abc"
// 널 허용 변수에는 null을 다시 할당 가능
b = null
print(b) // null
}
널 허용 변수 b에서 직접 length에 접근하면 컴파일러가 오류를 뱉어요. 널 허용 변수는 null을 담을 수 있기 때문에, 직접 프로퍼티에 접근하는 건 NPE로 이어질 수 있기 때문이에요. 컴파일러는 널 허용 타입의 수신자에서는 안전 호출(?.)이나 널 단언 호출(!!.)만 허용해요.
if 조건으로 널 검사하기
널 허용 타입을 안전하게 다루는 가장 직접적인 방법은 if 조건식으로 null인지 먼저 확인하는 거예요.
fun main() {
// 널 허용 변수에 null 할당
val b: String? = null
// 널 여부를 먼저 확인한 뒤 length에 접근
val l = if (b != null) b.length else -1
print(l) // -1
}
이 예시에서 컴파일러는 **스마트 캐스트(smart cast)**로 b의 타입을 널 허용 String?에서 널 불가 String으로 바꿔요. 검사했다는 정보를 추적한 덕분에 if 안에서 length 호출을 허용해 주죠. 더 복잡한 조건도 지원해요.
fun main() {
// 널 허용 문자열을 val에 할당
val b: String? = "Kotlin"
// 널 여부를 먼저 확인한 뒤 length에 접근
if (b != null && b.length > 0) {
print("String of length ${b.length}") // String of length 6
} else {
print("Empty string")
}
}
주의할 점은 이런 스마트 캐스트가 성립하려면 컴파일러가 검사와 사용 사이에 b가 바뀌지 않는다는 걸 보장할 수 있어야 한다는 거예요.
안전 호출 연산자 ?.
?. 연산자는 널 처리를 더 짧게 줄여줘요. 객체가 null이면 NPE를 던지는 대신 그냥 null을 반환하죠.
fun main() {
// 널 허용 문자열 할당
val a: String? = "Kotlin"
// 널 허용 변수에 null 할당
val b: String? = null
// 널 여부를 확인하고 길이 또는 null 반환
println(a?.length) // 6
println(b?.length) // null
}
b?.length는 b가 널이 아니면 b.length를, 널이면 null을 반환해요. 이 표현식의 타입은 Int?예요.
안전 호출은 연쇄할 때 특히 유용해요. 예를 들어 직원 Bob이 부서에 배정됐을 수도 아닐 수도 있고, 그 부서에는 다시 부서장이 있을 수도 없을 수도 있다고 해볼게요. Bob 부서장의 이름을 얻으려면 이렇게 쓰면 돼요.
bob?.department?.head?.name
체인에서 어느 한 프로퍼티가 null이면 이 표현식은 null을 반환해요.
할당문의 왼쪽에도 안전 호출을 둘 수 있어요.
person?.department?.head = managersPool.getManager()
여기서 체인 안의 수신자가 null이면 할당은 건너뛰고 오른쪽 표현식은 아예 평가되지 않아요. person이나 person.department가 null이면 함수도 호출되지 않죠.
엘비스 연산자 ?:
널 허용 타입을 다룰 때 null이면 대체 값을 주고 싶은 경우가 많아요. if 식으로도 되겠지만, **엘비스 연산자(Elvis operator) ?:**를 쓰면 훨씬 간결해져요.
fun main() {
// 널 허용 변수에 null 할당
val b: String? = null
// null이 아니면 length, null이면 0을 반환
val l = b?.length ?: 0
println(l) // 0
}
?: 왼쪽 표현식이 null이 아니면 그 값을 그대로 반환하고, null이면 오른쪽 표현식을 반환해요. 오른쪽 표현식은 왼쪽이 null일 때만 평가돼요.
Kotlin에서 throw와 return도 표현식이므로 엘비스 연산자 오른쪽에 쓸 수 있어요. 함수 인자를 검사할 때 특히 편리하죠.
fun foo(node: Node): String? {
// getParent()가 null이 아니면 parent에 할당, null이면 null 반환
val parent = node.getParent() ?: return null
// getName()이 null이 아니면 name에 할당, null이면 예외 던짐
val name = node.getName() ?: throw IllegalArgumentException("name expected")
// ...
}
널 단언 연산자 !!
**널 단언 연산자(not-null assertion) !!**는 어떤 값이든 널 불가 타입으로 바꿔요. 값이 null이 아니면 그냥 널 불가 타입처럼 취급돼서 정상적으로 실행되고, null이면 강제로 널 불가로 다뤄지다가 NPE가 나요.
fun main() {
// 널 허용 문자열 할당
val b: String? = "Kotlin"
// b를 널 불가로 취급하고 length에 접근
val l = b!!.length
println(l) // 6
}
b가 null이면 !!는 널 불가로 다루라고 강제하므로 NPE가 발생해요.
!!는 값이 확실히 null이 아니라고 자신할 때 유용해요. 컴파일러가 어떤 규칙 때문에 널 불가임을 보장하지 못하지만, 내가 보기엔 분명 널이 아니라는 상황이죠. 그럴 때 !!로 "이 값은 널이 아니다"라고 명시적으로 알려주는 거예요.
널 허용 수신자와 let 함수
확장 함수는 널 허용 수신자 타입에도 정의할 수 있어요. 그러면 변수가 null일 수도 있는 자리에서 안전하게 그 함수를 호출할 수 있죠. 함수 내부에서 null을 처리하면 호출하는 곳마다 일일이 검사하지 않아도 돼요.
예를 들어 .toString() 확장 함수는 널 허용 수신자에서 호출돼요. null 값에서 호출하면 예외 없이 문자열 "null"을 안전하게 돌려줘요.
fun main() {
// person 변수에 null 할당
val person: Person? = null
// 널 허용 변수에 .toString 적용 후 출력
println(person.toString()) // null
}
// 간단한 Person 데이터 클래스
data class Person(val name: String)
.toString()이 널 허용 문자열을 돌려주도록 하고 싶다면(null일 수도 있는 문자열) 안전 호출 연산자 ?.를 쓰면 돼요. ?.는 객체가 널이 아닐 때만 .toString()을 호출하고, 아니면 null을 반환해요.
fun main() {
// 널 허용 Person 객체 할당
val person1: Person? = null
val person2: Person? = Person("Alice")
// person이 null이면 "null" 출력, 아니면 toString 결과 출력
println(person1?.toString()) // null
println(person2?.toString()) // Person(name=Alice)
}
// Person 데이터 클래스 정의
data class Person(val name: String)
널 허용 타입에서만 작업을 수행하고 싶을 땐 안전 호출 ?.와 let 함수를 함께 써요. 표현식을 평가해 결과가 null인지 확인하고, 널이 아닐 때만 코드를 실행해 주죠.
fun main() {
// 널 허용 문자열 리스트 선언
val listWithNulls: List<String?> = listOf("Kotlin", null)
// 각 항목을 순회하며 null이 아닌 값만 출력
for (item in listWithNulls) {
item?.let { println(it) } // Kotlin
}
}
안전 캐스트와 널 컬렉션
일반 캐스트 연산자 as는 객체가 대상 타입이 아니면 예외를 던질 수 있어요. **안전 캐스트 as?**는 값을 지정 타입으로 캐스팅하려 시도하고, 맞지 않으면 null을 반환해요.
fun main() {
// 어떤 타입의 값이든 담을 수 있는 Any 변수 선언
val a: Any = "Hello, Kotlin!"
// 'as?' 연산자로 Int로 안전 캐스트
val aInt: Int? = a as? Int
// 'as?' 연산자로 String으로 안전 캐스트
val aString: String? = a as? String
println(aInt) // null
println(aString) // "Hello, Kotlin!"
}
a는 Int가 아니므로 캐스트가 안전하게 실패해 null을 출력하고, String? 타입과는 일치하므로 안전 캐스트가 성공해요.
널 요소를 담는 컬렉션에서 널이 아닌 값만 남기고 싶다면 filterNotNull() 함수를 써요.
fun main() {
// null과 non-null 정수를 담는 리스트 선언
val nullableList: List<Int?> = listOf(1, 2, null, 4)
// null 값을 걸러내 널 불가 정수 리스트 생성
val intList: List<Int> = nullableList.filterNotNull()
println(intList) // [1, 2, 4]
}
더 알아보기
- Java와 Kotlin에서 널 처리의 차이는 Java-to-Kotlin 널 안전성 가이드에서 확인해요.
- 널이 확실히 아닌 제네릭 타입은 generics 문서를 참고하세요.