Kim-Baek 개발자 이야기

Kotlin Null Safety 완벽 가이드 | ?, ?., ?:, !! 마스터하기 본문

개발/java basic

Kotlin Null Safety 완벽 가이드 | ?, ?., ?:, !! 마스터하기

김백개발자 2025. 12. 23. 22:44
반응형

이 글을 읽으면: Java 개발자의 악몽인 NullPointerException을 컴파일 타임에 완전히 차단하는 방법을 배울 수 있습니다. Nullable 타입, Safe Call, Elvis Operator, let 함수까지 실전 예제로 완벽하게 마스터하세요.


📌 목차

  1. 들어가며 - NPE의 공포
  2. Nullable Types - ? 연산자
  3. Safe Call - ?. 연산자
  4. Elvis Operator - ?: 연산자
  5. Not-null Assertion - !! 연산자
  6. let 함수와 Null Safety
  7. 안전한 타입 캐스팅 - as?
  8. 실전 패턴 모음
  9. 마무리 - 다음 편 예고

들어가며 - NPE의 공포

Java로 개발하면서 이런 경험 있으신가요?

// Java - 언제 터질지 모르는 시한폭탄
public String getUserEmail(Long userId) {
    User user = userRepository.findById(userId);  // null일 수도...
    String email = user.getEmail();  // 💥 NPE 발생!
    return email.toLowerCase();      // 💥 또 NPE!
}

// 방어 코드 - 지저분해짐
public String getUserEmail(Long userId) {
    User user = userRepository.findById(userId);
    if (user != null) {
        String email = user.getEmail();
        if (email != null) {
            return email.toLowerCase();
        }
    }
    return "이메일 없음";
}

실제 프로젝트에서 겪은 NPE 사례:

2024-12-21 10:30:15 ERROR - NullPointerException at UserService.java:127
원인: user.getProfile().getAddress().getCity()
→ getProfile()이 null을 반환했는데 체크 안 함
→ 서비스 30분 다운
→ 긴급 핫픽스 배포

Kotlin의 해결책:

// Kotlin - 컴파일 타임에 NPE 차단
fun getUserEmail(userId: Long): String {
    val user = userRepository.findById(userId)  // User? (nullable)
    // val email = user.email  // ❌ 컴파일 에러!
    
    // Safe Call로 안전하게
    return user?.email?.lowercase() ?: "이메일 없음"
}

오늘은 Kotlin의 Null Safety 시스템을 완벽하게 마스터해보겠습니다!


Nullable Types - ? 연산자

Non-null이 기본

Kotlin의 핵심 철학: 기본적으로 null을 허용하지 않습니다!

fun main() {
    var name: String = "규철"
    // name = null  // ❌ 컴파일 에러!
    // Null can not be a value of a non-null type String
    
    println(name.length)  // ✅ 안전! NPE 발생 불가능
}

Nullable 타입 선언 - ?

fun main() {
    var name: String? = "규철"  // ?를 붙이면 null 허용
    name = null                 // ✅ OK
    
    // 하지만 메서드 직접 호출은 불가
    // println(name.length)  // ❌ 컴파일 에러!
    // Only safe (?.) or non-null asserted (!!.) calls are allowed
}

함수 파라미터와 반환 타입

// Non-null 파라미터
fun greet(name: String) {
    println("안녕하세요, $name님!")
}

// Nullable 파라미터
fun greetNullable(name: String?) {
    if (name != null) {
        println("안녕하세요, $name님!")
    } else {
        println("안녕하세요, 손님!")
    }
}

fun main() {
    greet("규철")
    // greet(null)  // ❌ 컴파일 에러
    
    greetNullable("규철")
    greetNullable(null)  // ✅ OK
}

Nullable 반환 타입

fun findUser(id: Long): User? {  // User? 반환
    return if (id > 0) {
        User(id, "규철")
    } else {
        null  // 못 찾으면 null 반환
    }
}

fun main() {
    val user = findUser(1)  // user의 타입은 User?
    // println(user.name)  // ❌ 컴파일 에러
}

Java와 비교

// Java - null 체크는 개발자의 몫
public String getEmail(User user) {
    // user가 null일 수도 있음 (컴파일러가 모름)
    return user.getEmail();  // 💥 NPE 위험
}

// @Nullable 어노테이션으로 힌트만 줄 뿐
public String getEmail(@Nullable User user) {
    return user.getEmail();  // 여전히 컴파일 가능
}
// Kotlin - 타입 시스템이 강제
fun getEmail(user: User?): String {
    // return user.email  // ❌ 컴파일 에러!
    return user?.email ?: ""  // ✅ 안전한 방법 강제
}

Safe Call - ?. 연산자

기본 사용법

null이면 null을 반환하고, 아니면 메서드/프로퍼티 접근

fun main() {
    val name: String? = null
    
    // Safe Call 사용
    val length = name?.length  // null 반환 (NPE 발생 안 함!)
    println(length)  // null
    
    val name2: String? = "규철"
    val length2 = name2?.length
    println(length2)  // 2
}

체이닝 가능

data class Address(val city: String?, val zipCode: String?)
data class Profile(val address: Address?)
data class User(val profile: Profile?)

fun main() {
    val user: User? = User(
        profile = Profile(
            address = Address(
                city = "서울",
                zipCode = null
            )
        )
    )
    
    // 안전하게 체이닝
    val city = user?.profile?.address?.city
    println(city)  // 서울
    
    val zipCode = user?.profile?.address?.zipCode
    println(zipCode)  // null
    
    // 중간에 null이면 전체가 null
    val nullUser: User? = null
    val nullCity = nullUser?.profile?.address?.city
    println(nullCity)  // null
}

메서드 호출에도 사용

fun main() {
    val text: String? = "  Kotlin  "
    
    // Safe Call로 메서드 호출
    val trimmed = text?.trim()
    println(trimmed)  // "Kotlin"
    
    val nullText: String? = null
    val result = nullText?.trim()?.uppercase()
    println(result)  // null
}

실전 예제 - 사용자 정보 출력

data class User(
    val id: Long,
    val name: String,
    val email: String?,
    val phoneNumber: String?
)

fun printUserContact(user: User?) {
    println("=== 연락처 정보 ===")
    println("이름: ${user?.name ?: "알 수 없음"}")
    println("이메일: ${user?.email ?: "없음"}")
    println("전화번호: ${user?.phoneNumber ?: "없음"}")
}

fun main() {
    val user = User(1L, "규철", null, "010-1234-5678")
    printUserContact(user)
    
    printUserContact(null)
}

Elvis Operator - ?: 연산자

기본 사용법

null이면 기본값을 반환합니다.

fun main() {
    val name: String? = null
    
    // Elvis Operator로 기본값 제공
    val displayName = name ?: "손님"
    println(displayName)  // 손님
    
    val name2: String? = "규철"
    val displayName2 = name2 ?: "손님"
    println(displayName2)  // 규철
}

Safe Call과 함께 사용

fun main() {
    val text: String? = null
    
    // Safe Call + Elvis
    val length = text?.length ?: 0
    println(length)  // 0
    
    val text2: String? = "Kotlin"
    val length2 = text2?.length ?: 0
    println(length2)  // 6
}

조기 반환 (Early Return)

fun validateUser(user: User?): String {
    // user가 null이면 조기 반환
    val validUser = user ?: return "사용자 정보가 없습니다"
    
    val email = validUser.email ?: return "이메일이 없습니다"
    
    // 여기까지 왔으면 user와 email 모두 non-null
    return "검증 완료: $email"
}

fun main() {
    println(validateUser(null))  // 사용자 정보가 없습니다
    
    val user = User(1L, "규철", null, null)
    println(validateUser(user))  // 이메일이 없습니다
    
    val validUser = User(2L, "영희", "test@example.com", null)
    println(validateUser(validUser))  // 검증 완료: test@example.com
}

예외 던지기

fun getUser(id: Long): User {
    val user = findUser(id)  // User? 반환
    
    // null이면 예외 던지기
    return user ?: throw IllegalArgumentException("사용자를 찾을 수 없습니다: $id")
}

fun findUser(id: Long): User? {
    return if (id == 1L) {
        User(1L, "규철", "user@example.com", null)
    } else {
        null
    }
}

fun main() {
    try {
        val user1 = getUser(1)
        println(user1)
        
        val user2 = getUser(999)  // 예외 발생
    } catch (e: IllegalArgumentException) {
        println(e.message)  // 사용자를 찾을 수 없습니다: 999
    }
}

실전 예제 - 설정값 가져오기

object Config {
    private val settings = mapOf(
        "max_retry" to "3",
        "timeout" to "5000"
    )
    
    fun getInt(key: String, default: Int = 0): Int {
        return settings[key]?.toIntOrNull() ?: default
    }
    
    fun getString(key: String, default: String = ""): String {
        return settings[key] ?: default
    }
}

fun main() {
    println(Config.getInt("max_retry"))        // 3
    println(Config.getInt("unknown", 10))      // 10 (기본값)
    println(Config.getString("timeout"))       // 5000
    println(Config.getString("unknown", "N/A")) // N/A
}

Not-null Assertion - !! 연산자

기본 사용법

"절대 null이 아니야!"라고 컴파일러에게 보증합니다. (위험!)

fun main() {
    val name: String? = "규철"
    
    // !! 로 강제 언래핑
    val length = name!!.length
    println(length)  // 2
    
    // 하지만 null이면 NPE 발생!
    val nullName: String? = null
    // val badLength = nullName!!.length  // 💥 NullPointerException!
}

언제 사용하나?

거의 사용하지 마세요! 정말 확실할 때만:

fun main() {
    // 1. lateinit과 함께
    lateinit var name: String
    
    // ... 어딘가에서 초기화
    name = "규철"
    
    // 확실히 초기화됐음
    println(name.length)
    
    // 2. 플랫폼 타입에서
    val javaString = getJavaString()  // Java에서 온 String?
    if (javaString != null) {
        val length = javaString!!.length  // 여기선 확실함
    }
}

fun getJavaString(): String? = "Java String"

!! 대신 이렇게 하세요

// ❌ 나쁜 예
fun bad(text: String?) {
    println(text!!.length)  // NPE 위험!
}

// ✅ 좋은 예 1 - Safe Call + Elvis
fun good1(text: String?) {
    println(text?.length ?: 0)
}

// ✅ 좋은 예 2 - if 체크
fun good2(text: String?) {
    if (text != null) {
        println(text.length)  // Smart Cast
    }
}

// ✅ 좋은 예 3 - let 사용
fun good3(text: String?) {
    text?.let {
        println(it.length)
    }
}

연속된 !! 사용 금지!

// 💀 최악의 코드 - 어디서 NPE 났는지 알 수 없음
val city = user!!.profile!!.address!!.city!!.uppercase()

// ✅ 좋은 코드
val city = user?.profile?.address?.city?.uppercase() ?: "도시 정보 없음"

let 함수와 Null Safety

기본 사용법

null이 아닐 때만 블록 실행

fun main() {
    val name: String? = "규철"
    
    name?.let {
        // 여기서 it은 String (non-null)
        println("이름: $it")
        println("길이: ${it.length}")
    }
    
    val nullName: String? = null
    nullName?.let {
        println("실행 안 됨")  // null이므로 실행 안 됨
    }
}

실전 예제 - 사용자 정보 처리

data class User(
    val id: Long,
    val name: String,
    val email: String?,
    val phoneNumber: String?
)

fun processUser(user: User?) {
    user?.let {
        println("=== 사용자 처리 시작 ===")
        println("ID: ${it.id}")
        println("이름: ${it.name}")
        
        // 이메일이 있으면 전송
        it.email?.let { email ->
            sendEmail(email)
        }
        
        // 전화번호가 있으면 SMS 전송
        it.phoneNumber?.let { phone ->
            sendSms(phone)
        }
    } ?: run {
        println("사용자 정보가 없습니다")
    }
}

fun sendEmail(email: String) {
    println("이메일 전송: $email")
}

fun sendSms(phone: String) {
    println("SMS 전송: $phone")
}

fun main() {
    val user = User(1L, "규철", "user@example.com", null)
    processUser(user)
    
    processUser(null)
}

let으로 변수 스코프 제한

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5)
    
    // 특정 조건의 결과만 let 블록 안에서 처리
    numbers.firstOrNull { it > 3 }?.let { number ->
        println("3보다 큰 첫 숫자: $number")
        println("제곱: ${number * number}")
    }
}

also, apply, run과 함께

data class User(
    val id: Long,
    var name: String,
    var email: String?
)

fun main() {
    val user: User? = User(1L, "규철", null)
    
    // let - 결과 반환
    val emailLength = user?.email?.let { it.length } ?: 0
    
    // also - 객체 자체 반환 (로깅, 디버깅)
    user?.also {
        println("사용자 처리: ${it.name}")
    }
    
    // apply - 객체 자체 반환 (설정)
    user?.apply {
        name = "김규철"
        email = "updated@example.com"
    }
    
    // run - 결과 반환
    val result = user?.run {
        "사용자: $name, 이메일: ${email ?: "없음"}"
    }
    
    println(result)
}

안전한 캐스팅 - as?

기본 사용법

fun main() {
    val obj: Any = "문자열"
    
    // 안전한 캐스팅 - 실패하면 null
    val str: String? = obj as? String
    println(str)  // "문자열"
    
    val number: Int? = obj as? Int
    println(number)  // null (실패)
}

실전 예제 - 타입별 처리

fun process(obj: Any) {
    when (val value = obj as? String) {
        null -> println("문자열이 아닙니다")
        else -> println("문자열: $value, 길이: ${value.length}")
    }
}

fun main() {
    process("Kotlin")  // 문자열: Kotlin, 길이: 6
    process(42)        // 문자열이 아닙니다
}

Elvis와 함께 사용

fun getStringLength(obj: Any): Int {
    return (obj as? String)?.length ?: 0
}

fun main() {
    println(getStringLength("Kotlin"))  // 6
    println(getStringLength(42))        // 0
}

실전 패턴 모음

패턴 1: Repository 패턴

interface UserRepository {
    fun findById(id: Long): User?
}

class UserService(private val repository: UserRepository) {
    
    fun getUserEmail(id: Long): String {
        return repository.findById(id)?.email ?: "이메일 없음"
    }
    
    fun updateUser(id: Long, newName: String): User {
        val user = repository.findById(id)
            ?: throw IllegalArgumentException("사용자를 찾을 수 없습니다")
        
        return user.copy(name = newName)
    }
}

패턴 2: 빌더 패턴

class UserBuilder {
    private var id: Long? = null
    private var name: String? = null
    private var email: String? = null
    
    fun id(id: Long) = apply { this.id = id }
    fun name(name: String) = apply { this.name = name }
    fun email(email: String) = apply { this.email = email }
    
    fun build(): User {
        return User(
            id = id ?: throw IllegalStateException("id is required"),
            name = name ?: throw IllegalStateException("name is required"),
            email = email  // nullable
        )
    }
}

fun main() {
    val user = UserBuilder()
        .id(1L)
        .name("규철")
        .email("user@example.com")
        .build()
    
    println(user)
}

패턴 3: 옵셔널 체이닝

data class Order(
    val id: Long,
    val user: User?,
    val items: List<Item>?
)

data class Item(val name: String, val price: Int)

fun getTotalPrice(order: Order?): Int {
    return order?.items?.sumOf { it.price } ?: 0
}

fun main() {
    val order = Order(
        id = 1L,
        user = User(1L, "규철", "user@example.com", null),
        items = listOf(
            Item("상품1", 10000),
            Item("상품2", 20000)
        )
    )
    
    println(getTotalPrice(order))  // 30000
    println(getTotalPrice(null))   // 0
}

패턴 4: 검증 체인

data class SignupRequest(
    val username: String?,
    val password: String?,
    val email: String?
)

fun validateSignup(request: SignupRequest): String? {
    request.username?.takeIf { it.length >= 3 }
        ?: return "사용자명은 3자 이상이어야 합니다"
    
    request.password?.takeIf { it.length >= 8 }
        ?: return "비밀번호는 8자 이상이어야 합니다"
    
    request.email?.takeIf { it.contains("@") }
        ?: return "올바른 이메일 형식이 아닙니다"
    
    return null  // 검증 성공
}

fun main() {
    val request1 = SignupRequest("ab", "password123", "user@example.com")
    println(validateSignup(request1))  // 사용자명은 3자 이상이어야 합니다
    
    val request2 = SignupRequest("규철", "pass", "user@example.com")
    println(validateSignup(request2))  // 비밀번호는 8자 이상이어야 합니다
    
    val request3 = SignupRequest("규철", "password123", "user@example.com")
    println(validateSignup(request3))  // null (성공)
}

마무리 - 다음 편 예고

오늘 배운 것 ✅

  • Nullable Types (?) - null 가능 타입 선언
  • Safe Call (?.) - 안전한 메서드/프로퍼티 접근
  • Elvis Operator (?:) - 기본값 제공
  • Not-null Assertion (!!) - 주의해서 사용
  • let - null이 아닐 때만 실행
  • as? - 안전한 타입 캐스팅

다음 편에서 배울 것 📚

7편: 컬렉션 완벽 가이드 | List, Set, Map 함수형 프로그래밍

  • List, Set, Map 생성과 조작
  • 불변/가변 컬렉션 차이
  • map, filter, reduce 활용
  • groupBy, partition 고급 활용
  • Sequence로 성능 최적화

실습 과제 💪

// 1. Safe Call 연습
// - 사용자 정보에서 도시명 추출 (중첩 null 처리)

// 2. Elvis Operator 활용
// - 설정값 가져오기 (기본값 제공)

// 3. let 함수 활용
// - 여러 필드 검증 후 처리

// 4. 실전 패턴
// - Repository 패턴 구현
// - 검증 체인 만들기

자주 묻는 질문 (FAQ)

Q: ?와 !!의 차이는?
A: ?는 안전(null이면 null 반환), !!는 위험(null이면 NPE). 거의 항상 ?를 쓰세요!

Q: Safe Call과 let 중 뭘 써야 하나요?
A: 단순 접근은 ?., 여러 줄 로직은 let을 쓰세요.

Q: Java 코드와 호환될까요?
A: 네! Java 타입은 Platform Type(Type!)으로 처리됩니다. 주의해서 사용하세요.

Q: lateinit과 nullable의 차이는?
A: lateinit은 나중에 반드시 초기화, nullable은 null 허용. 용도가 다릅니다.


관련 글


💬 댓글로 알려주세요!

  • NPE로 고생한 경험이 있나요?
  • 어떤 Null Safety 패턴이 유용했나요?
  • 이 글이 도움이 되셨나요?

태그: #Kotlin #NullSafety #NPE #SafeCall #Elvis #let #nullable

반응형
Comments