널 안전성
널 안전성 (Null Safety)
초급 투어에서 코드에서 null 값을 다루는 방법을 배웠어요. 이 장에서는 널 안전성 기능의 흔한 사용 사례와, 그것을 최대한 활용하는 방법을 다룰게요.
출처: Kotlin 공식문서
본문
스마트 캐스트와 안전한 캐스트 (Smart casts and safe casts)
Kotlin은 가끔 명시적인 선언 없이 타입을 추론할 수 있어요. 변수나 객체를 특정 타입에 속하는 것처럼 취급하라고 Kotlin에 알려주는 과정을 **캐스트(casting)**라고 해요. 타입이 (추론될 때처럼) 자동으로 캐스트될 때는 **스마트 캐스트(smart casting)**라고 불러요.
is와 !is 연산자 (is and !is operators)
캐스트가 어떻게 동작하는지 살펴보기 전에, 객체가 특정 타입인지 확인하는 방법부터 볼게요. 이를 위해 when이나 if 조건식과 함께 is와 !is 연산자를 쓸 수 있어요:
is는 객체가 그 타입인지 확인해 불리언 값을 돌려줘요.!is는 객체가 그 타입이 아닌지 확인해 불리언 값을 돌려줘요.
예를 들어:
fun printObjectType(obj: Any) {
when (obj) {
is Int -> println("It's an Integer with value $obj")
!is Double -> println("It's NOT a Double")
else -> println("Unknown type")
}
}
fun main() {
val myInt = 42
val myDouble = 3.14
val myList = listOf(1, 2, 3)
// The type is Int
printObjectType(myInt)
// It's an Integer with value 42
// The type is List, so it's NOT a Double.
printObjectType(myList)
// It's NOT a Double
// The type is Double, so the else branch is triggered.
printObjectType(myDouble)
// Unknown type
}
TIP:
when조건식에서is와!is연산자를 쓰는 예시는 이미 'Open and other special classes' 장에서 본 적이 있어요.
as와 as? 연산자 (as and as? operators)
객체를 다른 어떤 타입으로든 명시적으로 캐스트하려면 as 연산자를 써요. 여기에는 널러블 타입에서 널이 아닌 타입으로 캐스트하는 것도 포함돼요. 캐스트가 불가능하면 프로그램이 런타임에 중단돼요. 그래서 이걸 **안전하지 않은 캐스트 연산자(unsafe cast operator)**라고 불러요.
fun main() {
val a: String? = null
val b = a as String
// Triggers an error at runtime
print(b)
}
객체를 널이 아닌 타입으로 명시적으로 캐스트하되, 실패하면 오류를 던지는 대신 null을 돌려주려면 as? 연산자를 써요. as? 연산자는 실패해도 오류를 일으키지 않으므로 **안전한 연산자(safe operator)**라고 불러요.
fun main() {
val a: String? = null
val b = a as? String
// Returns null value
print(b)
// null
}
as? 연산자는 엘비스 연산자 ?:와 결합해 여러 줄의 코드를 한 줄로 줄일 수 있어요. 예를 들어 아래 calculateTotalStringLength() 함수는 혼합 리스트에 제공된 모든 문자열의 총 길이를 계산해요:
fun calculateTotalStringLength(items: List<Any>): Int {
var totalLength = 0
for (item in items) {
totalLength += if (item is String) {
item.length
} else {
0 // Add 0 for non-String items
}
}
return totalLength
}
이 예시는:
totalLength변수를 카운터로 사용해요.for루프로 리스트의 모든 항목을 순회해요.- 항목이 문자열이면 문자열의 길이를 카운터에 더해요.
- 항목이 문자열이 아니면 카운터를 늘리지 않아요.
totalLength변수의 최종 값을 돌려줘요.
이 코드는 이렇게 줄일 수 있어요:
fun calculateTotalStringLength(items: List<Any>): Int {
return items.sumOf { (it as? String)?.length ?: 0 }
}
이 예시는 .sumOf() 확장 함수를 쓰며 람다 식을 제공하는데, 이 람다는:
- 리스트의 각 항목에 대해
as?를 사용해String으로 안전한 캐스트를 수행해요. - 호출이
null을 돌려주지 않으면?.안전 호출로length프로퍼티에 접근해요. - 안전 호출이
null을 돌려주면 엘비스 연산자?:로0을 돌려줘요.
널 값과 컬렉션 (Null values and collections)
Kotlin에서 컬렉션을 다룰 때는 null 값을 처리하고 불필요한 요소를 걸러 내는 일이 자주 필요해요. Kotlin에는 리스트, 세트, 맵, 기타 컬렉션 타입을 다룰 때 깔끔하고 효율적이며 널 안전한 코드를 작성하는 데 유용한 함수들이 있어요.
리스트에서 null 값을 걸러 내려면 filterNotNull() 함수를 써요:
fun main() {
val emails: List<String?> = listOf("[email protected]", null, "[email protected]", null, "[email protected]")
val validEmails = emails.filterNotNull()
println(validEmails)
// [[email protected], [email protected], [email protected]]
}
리스트를 만들 때 바로 null 값 필터링을 수행하고 싶다면 listOfNotNull() 함수를 써요:
fun main() {
val serverConfig = mapOf(
"appConfig.json" to "App Configuration",
"dbConfig.json" to "Database Configuration"
)
val requestedFile = "appConfig.json"
val configFiles = listOfNotNull(serverConfig[requestedFile])
println(configFiles)
// [App Configuration]
}
이 두 예시 모두에서, 모든 항목이 null 값이면 빈 리스트가 돌아와요.
Kotlin은 컬렉션에서 값을 찾는 데 쓸 수 있는 함수도 제공해요. 값을 찾지 못하면 오류를 일으키는 대신 null 값을 돌려줘요:
maxOrNull()은 가장 높은 값을 찾아요. 존재하지 않으면null값을 돌려줘요.minOrNull()은 가장 낮은 값을 찾아요. 존재하지 않으면null값을 돌려줘요.
예를 들어:
fun main() {
// Temperatures recorded over a week
val temperatures = listOf(15, 18, 21, 21, 19, 17, 16)
// Find the highest temperature of the week
val maxTemperature = temperatures.maxOrNull()
println("Highest temperature recorded: ${maxTemperature ?: "No data"}")
// Highest temperature recorded: 21
// Find the lowest temperature of the week
val minTemperature = temperatures.minOrNull()
println("Lowest temperature recorded: ${minTemperature ?: "No data"}")
// Lowest temperature recorded: 15
}
이 예시는 엘비스 연산자 ?:를 사용해 함수가 null 값을 돌려줄 때 출력문을 돌려줘요.
NOTE:
maxOrNull()과minOrNull()함수는null값을 포함하지 않는 컬렉션과 함께 쓰도록 설계됐어요. 그렇지 않으면 함수가 원하는 값을 찾지 못한 건지null값을 찾은 건지 구분할 수 없거든요.
람다 식과 함께 singleOrNull() 함수를 쓰면 조건에 맞는 단일 항목을 찾을 수 있어요. 조건에 맞는 항목이 없거나 여러 개면 함수는 null 값을 돌려줘요:
fun main() {
// Temperatures recorded over a week
val temperatures = listOf(15, 18, 21, 21, 19, 17, 16)
// Check if there was exactly one day with 30 degrees
val singleHotDay = temperatures.singleOrNull{ it == 30 }
println("Single hot day with 30 degrees: ${singleHotDay ?: "None"}")
// Single hot day with 30 degrees: None
}
NOTE:
singleOrNull()함수는null값을 포함하지 않는 컬렉션과 함께 쓰도록 설계됐어요.
일부 함수는 람다 식을 사용해 컬렉션을 변환하고, 목적을 달성하지 못하면 null 값을 돌려줘요.
람다 식으로 컬렉션을 변환하면서 null이 아닌 첫 번째 값을 돌려주려면 firstNotNullOfOrNull() 함수를 써요. 그런 값이 없으면 함수는 null 값을 돌려줘요:
fun main() {
data class User(val name: String?, val age: Int?)
val users = listOf(
User(null, 25),
User("Alice", null),
User("Bob", 30)
)
val firstNonNullName = users.firstNotNullOfOrNull { it.name }
println(firstNonNullName)
// Alice
}
람다 식으로 각 컬렉션 항목을 순차적으로 처리해 누적 값을 만들려면(컬렉션이 비어 있으면 null 값을 돌려주려면) reduceOrNull() 함수를 써요:
fun main() {
// Prices of items in a shopping cart
val itemPrices = listOf(20, 35, 15, 40, 10)
// Calculate the total price using the reduceOrNull() function
val totalPrice = itemPrices.reduceOrNull { runningTotal, price -> runningTotal + price }
println("Total price of items in the cart: ${totalPrice ?: "No items"}")
// Total price of items in the cart: 120
val emptyCart = listOf<Int>()
val emptyTotalPrice = emptyCart.reduceOrNull { runningTotal, price -> runningTotal + price }
println("Total price of items in the empty cart: ${emptyTotalPrice ?: "No items"}")
// Total price of items in the empty cart: No items
}
이 예시도 엘비스 연산자 ?:를 사용해 함수가 null 값을 돌려줄 때 출력문을 돌려줘요.
NOTE:
reduceOrNull()함수는null값을 포함하지 않는 컬렉션과 함께 쓰도록 설계됐어요.
Kotlin 표준 라이브러리를 탐색해 코드를 더 안전하게 만드는 데 쓸 수 있는 더 많은 함수를 찾아보세요.
조기 반환과 엘비스 연산자 (Early returns and the Elvis operator)
초급 투어에서 특정 지점 이후로 함수가 더 진행되는 것을 멈추는 **조기 반환(early return)**을 쓰는 법을 배웠어요. 함수에서 전제 조건을 확인할 때 엘비스 연산자 ?:를 조기 반환과 함께 쓸 수 있어요. 이 방식은 중첩 확인을 쓸 필요가 없어서 코드를 간결하게 유지하는 데 아주 좋아요. 코드 복잡도가 줄어들면 유지보수도 더 쉬워져요. 예를 들어:
data class User(
val id: Int,
val name: String,
// List of friend user IDs
val friends: List<Int>
)
// Function to get the number of friends for a user
fun getNumberOfFriends(users: Map<Int, User>, userId: Int): Int {
// Retrieves the user or return -1 if not found
val user = users[userId] ?: return -1
// Returns the number of friends
return user.friends.size
}
fun main() {
// Creates some sample users
val user1 = User(1, "Alice", listOf(2, 3))
val user2 = User(2, "Bob", listOf(1))
val user3 = User(3, "Charlie", listOf(1))
// Creates a map of users
val users = mapOf(1 to user1, 2 to user2, 3 to user3)
println(getNumberOfFriends(users, 1))
// 2
println(getNumberOfFriends(users, 2))
// 1
println(getNumberOfFriends(users, 4))
// -1
}
이 예시에서:
- 사용자의
id,name과 친구 리스트를 프로퍼티로 가진User데이터 클래스가 있어요. User인스턴스의 맵과 사용자 ID를 정수로 받아요.- 주어진 사용자 ID로
User인스턴스 맵의 값을 접근해요. - 맵 값이
null값이면 엘비스 연산자로-1값을 가지고 함수를 조기 반환해요. - 맵에서 찾은 값을
user변수에 할당해요. size프로퍼티로 사용자 친구 리스트의 친구 수를 돌려줘요.User인스턴스 세 개를 만들어요.- 이
User인스턴스들의 맵을 만들어users변수에 할당해요. users변수에서 값1과2로getNumberOfFriends()함수를 호출해"Alice"에게는 친구 두 명,"Bob"에게는 친구 한 명을 돌려줘요.users변수에서 값4로getNumberOfFriends()함수를 호출해-1값을 가지고 조기 반환을 트리거해요.
조기 반환 없이도 코드가 더 간결해질 수 있다는 걸 눈치챘을 거예요. 그런데 이 방식은 users[userId]가 null 값을 돌려줄 수 있으므로 안전 호출을 여러 번 사용해야 하고, 그 결과 코드를 읽기가 조금 더 어려워져요:
fun getNumberOfFriends(users: Map<Int, User>, userId: Int): Int {
// Retrieve the user or return -1 if not found
return users[userId]?.friends?.size ?: -1
}
이 예시는 엘비스 연산자로 조건 하나만 확인하지만, 중요한 오류 경로를 모두 다루도록 여러 확인을 추가할 수 있어요. 조기 반환과 엘비스 연산자는 null 값이나 잘못된 경우를 감지하는 즉시 멈춰서, 프로그램이 불필요한 작업을 하지 않게 막고 코드를 더 안전하게 만들어 줘요.
코드에서 return을 사용하는 방법에 대한 자세한 내용은 반환과 점프(Returns and jumps) 문서를 참고하세요.
연습 (Practice)
연습 1
사용자가 다양한 종류의 알림을 켜거나 끌 수 있는 앱용 알림 시스템을 개발하고 있어요. getNotificationPreferences() 함수를 완성해서 다음이 되도록 해 보세요:
validUser변수는as?연산자로user가User클래스의 인스턴스인지 확인해요. 아니면 빈 리스트를 돌려줘요.userName변수는 엘비스 연산자?:로 사용자의 이름이null이면"Guest"로 기본값을 정해요.- 마지막 return 문은
.takeIf()함수로 이메일과 SMS 알림 선호 설정이 활성화된 경우에만 포함해요. main()함수가 성공적으로 실행되고 예상 출력을 출력해요.
TIP:
takeIf()함수는 주어진 조건이 참이면 원래 값을 돌려주고, 그렇지 않으면null을 돌려줘요. 예를 들어:
fun main() {
// The user is logged in
val userIsLoggedIn = true
// The user has an active session
val hasSession = true
// Gives access to the dashboard if the user is logged in
// and has an active session
val canAccessDashboard = userIsLoggedIn.takeIf { hasSession }
println(canAccessDashboard ?: "Access denied")
// true
}
data class User(val name: String?)
fun getNotificationPreferences(user: Any, emailEnabled: Boolean, smsEnabled: Boolean): List<String> {
val validUser = // Write your code here
val userName = // Write your code here
return listOfNotNull( /* Write your code here */)
}
fun main() {
val user1 = User("Alice")
val user2 = User(null)
val invalidUser = "NotAUser"
println(getNotificationPreferences(user1, emailEnabled = true, smsEnabled = false))
// [Email Notifications enabled for Alice]
println(getNotificationPreferences(user2, emailEnabled = false, smsEnabled = true))
// [SMS Notifications enabled for Guest]
println(getNotificationPreferences(invalidUser, emailEnabled = true, smsEnabled = true))
// []
}
연습 2
사용자가 여러 구독을 가질 수 있지만 한 번에 하나만 활성화될 수 있는 구독 기반 스트리밍 서비스를 만들고 있어요. getActiveSubscription() 함수를 완성해서 singleOrNull() 함수를 조건자와 함께 사용해 활성 구독이 둘 이상이면 null 값을 돌려주도록 해 보세요:
data class Subscription(val name: String, val isActive: Boolean)
fun getActiveSubscription(subscriptions: List<Subscription>): Subscription? // Write your code here
fun main() {
val userWithPremiumPlan = listOf(
Subscription("Basic Plan", false),
Subscription("Premium Plan", true)
)
val userWithConflictingPlans = listOf(
Subscription("Basic Plan", true),
Subscription("Premium Plan", true)
)
println(getActiveSubscription(userWithPremiumPlan))
// Subscription(name=Premium Plan, isActive=true)
println(getActiveSubscription(userWithConflictingPlans))
// null
}
연습 3
사용자가 사용자 이름과 계정 상태를 가진 소셜 미디어 플랫폼을 만들고 있어요. 현재 활성 상태인 사용자 이름 목록을 보고 싶어요. getActiveUsernames() 함수를 완성해서 mapNotNull() 함수에 활성 상태면 사용자 이름을, 아니면 null 값을 돌려주는 조건자를 만들어 보세요:
data class User(val username: String, val isActive: Boolean)
fun getActiveUsernames(users: List<User>): List<String> {
return users.mapNotNull { /* Write your code here */ }
}
fun main() {
val allUsers = listOf(
User("alice123", true),
User("bob_the_builder", false),
User("charlie99", true)
)
println(getActiveUsernames(allUsers))
// [alice123, charlie99]
}
TIP: 연습 1에서처럼, 사용자가 활성 상태인지 확인할 때
takeIf()함수를 쓸 수 있어요.
연습 4
전자상거래 플랫폼용 재고 관리 시스템을 만들고 있어요. 판매를 처리하기 전에, 요청한 상품 수량이 가용 재고 기준으로 유효한지 확인해야 해요.
validateStock() 함수를 완성해서 조기 반환과 엘비스 연산자(적용 가능한 곳)를 사용해 다음을 확인하도록 해 보세요:
requested변수가null인지.available변수가null인지.requested변수가 음수인지.requested변수의 값이available변수의 값보다 높은지.
위의 모든 경우에 함수는 -1 값을 가지고 조기 반환해야 해요.
fun validateStock(requested: Int?, available: Int?): Int {
// Write your code here
}
fun main() {
println(validateStock(5,10))
// 5
println(validateStock(null,10))
// -1
println(validateStock(-2,10))
// -1
}
더 알아보기
- 원문: Null safety