Kim-Baek 개발자 이야기

Kotlin Null Safety 완벽 가이드 | NPE와 영원히 작별하는 법 ?, ?., ?:, !! 마스터하기 💪 본문

개발/java basic

Kotlin Null Safety 완벽 가이드 | NPE와 영원히 작별하는 법 ?, ?., ?:, !! 마스터하기 💪

김백개발자 2025. 12. 1. 23:32
반응형

이 글을 읽으면: Java 개발자의 악몽인 NullPointerException(NPE)를 컴파일 타임에 방지하는 Kotlin의 Null Safety 시스템을 완벽하게 이해할 수 있습니다. 실무에서 바로 적용 가능한 4가지 연산자를 실전 예제와 함께 배워보세요.


📌 목차

  1. NPE의 악몽 - 왜 Null Safety가 필요한가
  2. Nullable Types (?) - Null 가능 타입
  3. Safe Call Operator (?.) - 안전한 호출
  4. Elvis Operator (?:) - 기본값 제공
  5. Not-null Assertion (!!) - 개발자의 보증
  6. 실전 활용 패턴
  7. 성능 최적화 팁

1. NPE의 악몽 - 왜 Null Safety가 필요한가

😱 Java 개발자의 일상

// Java - 언제 터질지 모르는 시한폭탄
public String getUserEmail(Long userId) {
    User user = userRepository.findById(userId);
    return user.getEmail().toUpperCase();  // 💣 NPE 발생!
}

실행 결과:

Exception in thread "main" java.lang.NullPointerException
    at UserService.getUserEmail(UserService.java:42)

🤔 문제가 뭘까요?

  1. user가 null일 수 있음
  2. getEmail()이 null을 반환할 수 있음
  3. 컴파일러가 경고조차 해주지 않음
  4. 운영 환경에서 터짐 → 장애 발생

✨ Kotlin의 해결책

// Kotlin - 컴파일 타임에 null 체크!
fun getUserEmail(userId: Long): String? {
    val user = userRepository.findById(userId)  // User?
    return user?.email?.uppercase()             // String?
}
// ✅ 컴파일 성공!
// ✅ NPE 발생 불가능!

2. Nullable Types (?) - Null 가능 타입

2.1 기본 개념

Kotlin은 모든 타입을 기본적으로 Non-null로 취급합니다.

// Non-null 타입 (기본)
var name: String = "김철수"
name = null  // ❌ 컴파일 에러!
// Null can not be a value of a non-null type String

// Nullable 타입 (? 추가)
var name: String? = "김철수"
name = null  // ✅ OK!

2.2 실전 예제 - DTO 설계

// 서비스 생성 요청 DTO
data class ItgServiceCreateRequest(
    val name: String,                    // 필수: Non-null
    val catalogPath: CatalogPath,        // 필수: Non-null
    val description: String,             // 필수: Non-null
    val planner: List<String>? = null,   // 선택: Nullable
    val admin: String,                   // 필수: Non-null
    val member: List<String>,            // 필수: Non-null
    val recoveryPlanUrl: String? = null  // 선택: Nullable
)

💡 설계 원칙

필드 성격 타입 선택 기본값

필수 입력 String 없음
선택 입력 String? null
나중에 초기화 lateinit var -
지연 초기화 lazy { } -

2.3 Java vs Kotlin 비교

Java - Runtime 에러:

public class User {
    private String email;  // null일 수 있다는 정보 없음
    
    public String getEmail() {
        return email;  // null 반환 가능
    }
}

// 사용처
String email = user.getEmail();
String upper = email.toUpperCase();  // 💣 NPE!

Kotlin - Compile 에러:

data class User(
    val email: String?  // null 가능성을 명시
)

// 사용처
val email = user.email
val upper = email.toUpperCase()  // ❌ 컴파일 에러!
// Only safe (?.) or non-null asserted (!!.) calls are allowed

3. Safe Call Operator (?.) - 안전한 호출

3.1 기본 사용법

// null이 아닐 때만 메서드 호출
val length = name?.length

// 동일한 Java 코드
String length = name != null ? name.length() : null;

3.2 체이닝 (Chaining)

// 여러 단계의 null 체크를 우아하게!
val cityName = user?.address?.city?.name

// Java로 작성하면...
String cityName = null;
if (user != null) {
    if (user.getAddress() != null) {
        if (user.getAddress().getCity() != null) {
            cityName = user.getAddress().getCity().getName();
        }
    }
}

3.3 실전 예제 - let과 함께 사용

// 실무 코드: 내가 소유한 서비스 ID 조회
val myOwnItgServiceIds = myOwn?.let { 
    getMyOwnItgServiceIds() 
}

동작 원리:

  1. myOwn이 null이면 → 전체 결과 null
  2. myOwn이 null이 아니면 → let 블록 실행

더 복잡한 예제

// 사용자 정보 업데이트
fun updateUserEmail(userId: Long, newEmail: String?) {
    userRepository.findById(userId)?.let { user ->
        newEmail?.let { email ->
            user.email = email
            userRepository.save(user)
            println("이메일 업데이트 완료: $email")
        }
    }
}

실행 결과:

updateUserEmail(1L, "new@example.com")  // "이메일 업데이트 완료: new@example.com"
updateUserEmail(1L, null)               // 아무것도 출력 안 됨
updateUserEmail(999L, "test@test.com")  // 아무것도 출력 안 됨 (사용자 없음)

4. Elvis Operator (?:) - 기본값 제공

4.1 기본 개념

// null이면 기본값 사용
val name = nullableName ?: "기본 이름"

// Java 동일 코드
String name = nullableName != null ? nullableName : "기본 이름";

4.2 실전 예제 - Entity 업데이트

data class ItgServiceCatalog(
    val itgCatalogId: String,
    val name: String,
    val description: String,
    // ...
) {
    // 업데이트 메서드
    fun updated(dto: ItgServiceUpdateDto) = this.copy(
        itgCatalogId = dto.itgCatalogId ?: this.itgCatalogId,
        name = dto.name ?: this.name,
        description = dto.description ?: this.description,
        updatedAt = LocalDateTime.now()
    )
}

사용 예시:

val original = ItgServiceCatalog(
    itgCatalogId = "SERVICE-001",
    name = "원본 서비스",
    description = "원본 설명"
)

val updateDto = ItgServiceUpdateDto(
    name = "수정된 서비스",
    description = null  // 설명은 수정하지 않음
)

val updated = original.updated(updateDto)
// 결과:
// itgCatalogId = "SERVICE-001"  (유지)
// name = "수정된 서비스"         (변경)
// description = "원본 설명"      (유지 - null이라서)

4.3 Elvis with throw - 조기 종료 패턴

// null이면 예외 발생
fun getUser(userId: Long): User {
    return userRepository.findById(userId)
        ?: throw UserNotFoundException("사용자를 찾을 수 없습니다: $userId")
}

// null이면 return
fun processUser(userId: Long?) {
    val id = userId ?: return
    // id는 여기서 Non-null로 Smart Cast됨
    println("처리 중: $id")
}

4.4 응용 - 빈 문자열 처리

// 빈 문자열도 함께 처리
val displayName = name?.takeIf { it.isNotBlank() } ?: "이름 없음"

// 실전 예제
data class User(
    val name: String?,
    val nickname: String?
) {
    fun getDisplayName() = 
        nickname?.takeIf { it.isNotBlank() } 
            ?: name?.takeIf { it.isNotBlank() }
            ?: "익명"
}

실행 결과:

User("김철수", "철수짱").getDisplayName()  // "철수짱"
User("김철수", null).getDisplayName()      // "김철수"
User(null, null).getDisplayName()          // "익명"
User("", "").getDisplayName()              // "익명"

5. Not-null Assertion (!!) - 개발자의 보증

5.1 기본 개념

// "내가 보장하는데, 이건 절대 null이 아니야!"
val length = name!!.length

주의: NPE가 발생할 수 있습니다!

5.2 언제 사용해야 할까?

✅ 사용해도 되는 경우

1. 직전에 null 체크를 했을 때

if (name != null) {
    println(name!!.length)  // OK - 이미 체크함
}

2. 프레임워크가 보장하는 경우

@Autowired
private lateinit var userService: UserService  // Spring이 주입 보장

fun doSomething() {
    userService.findAll()  // OK - Spring이 보장
}

3. 테스트 코드에서

@Test
fun `사용자 조회 테스트`() {
    val user = userRepository.findById(1L)!!  // 테스트니까 OK
    assertEquals("김철수", user.name)
}

❌ 사용하면 안 되는 경우

1. 외부 입력 데이터

// 나쁜 예
fun processRequest(request: Request) {
    val userId = request.userId!!  // ❌ 위험!
    // userId가 null이면 NPE 발생
}

// 좋은 예
fun processRequest(request: Request) {
    val userId = request.userId 
        ?: throw BadRequestException("userId는 필수입니다")
}

2. 데이터베이스 조회 결과

// 나쁜 예
val user = userRepository.findById(userId)!!  // ❌ 위험!

// 좋은 예
val user = userRepository.findById(userId)
    ?: throw UserNotFoundException("사용자를 찾을 수 없습니다")

5.3 실전 예제

// 실무 코드: Slack 멘션 문자열 생성
data class ItgServiceCreateRequest(
    val planner: List<String>?,
    val member: List<String>
) {
    fun getPlannerMentions(): String {
        // planner가 null이면 빈 문자열 반환
        if (planner == null) return ""
        
        // 여기서는 planner가 null이 아님을 확신
        return planner!!.joinToString(" ") { "@$it" }
    }
}

더 나은 방법:

// !! 대신 ?. 사용
fun getPlannerMentions(): String {
    return planner?.joinToString(" ") { "@$it" } ?: ""
}

// 또는 Elvis 연산자
fun getPlannerMentions(): String {
    return planner?.joinToString(" ") { "@$it" }.orEmpty()
}

6. 실전 활용 패턴

6.1 패턴 1: Null 체크 후 처리

// 나쁜 예 - 중첩된 if
fun getAddressString(user: User?): String {
    if (user != null) {
        if (user.address != null) {
            if (user.address.city != null) {
                return user.address.city.name
            }
        }
    }
    return "주소 없음"
}

// 좋은 예 - Safe Call + Elvis
fun getAddressString(user: User?): String {
    return user?.address?.city?.name ?: "주소 없음"
}

6.2 패턴 2: 조건부 실행

// let을 사용한 null 체크
fun sendEmail(email: String?) {
    email?.let { address ->
        println("이메일 전송: $address")
        emailService.send(address)
    }
}

// 사용 예시
sendEmail("user@example.com")  // 전송됨
sendEmail(null)                // 아무것도 안 함

6.3 패턴 3: Collection의 Null 처리

// Nullable List 처리
fun getActiveUserIds(users: List<User>?): List<Long> {
    return users
        ?.filter { it.isActive }
        ?.map { it.id }
        ?: emptyList()
}

// List의 Nullable 요소 처리
fun getValidEmails(emails: List<String?>): List<String> {
    return emails
        .filterNotNull()  // null 제거
        .filter { it.contains("@") }  // 유효한 이메일만
}

6.4 패턴 4: Early Return

fun processOrder(orderId: Long?) {
    // null이면 바로 return
    val id = orderId ?: return
    
    // 여기서부터 id는 Non-null
    val order = orderRepository.findById(id) ?: return
    
    // 여기서부터 order도 Non-null
    order.process()
    orderRepository.save(order)
}

6.5 패턴 5: Map의 Null 처리

// Map에서 값 가져오기
val userMap: Map<Long, User> = getUserMap()

// 나쁜 예
val user = userMap[userId]
if (user != null) {
    println(user.name)
}

// 좋은 예
userMap[userId]?.let { user ->
    println(user.name)
}

// 더 간단하게
userMap[userId]?.name?.let { println(it) }

7. 성능 최적화 팁

7.1 불필요한 Null 체크 피하기

// 비효율적
fun processUser(user: User?) {
    val name = user?.name ?: "Unknown"
    val age = user?.age ?: 0
    val email = user?.email ?: ""
    // user가 3번 체크됨
}

// 효율적
fun processUser(user: User?) {
    user?.let { u ->
        val name = u.name
        val age = u.age
        val email = u.email
        // user가 1번만 체크됨
    } ?: run {
        // null일 때 처리
    }
}

7.2 lateinit vs Nullable

// Nullable - 매번 null 체크 필요
private var service: UserService? = null

fun doSomething() {
    service?.findAll()  // null 체크 오버헤드
}

// lateinit - null 체크 없음
private lateinit var service: UserService

fun doSomething() {
    service.findAll()  // 직접 호출
}

사용 기준:

  • lateinit: 반드시 초기화되는 경우 (DI, onCreate 등)
  • Nullable: null일 수 있는 경우

7.3 Smart Cast 활용

fun processUser(user: User?) {
    // null 체크 후 Smart Cast
    if (user != null) {
        // 여기서는 user가 Non-null로 자동 캐스팅
        println(user.name)  // user?가 아닌 user
        println(user.age)
        println(user.email)
    }
}

// when 활용
fun getUserStatus(user: User?): String {
    return when {
        user == null -> "사용자 없음"
        user.isActive -> "활성"  // user는 Non-null
        else -> "비활성"
    }
}

8. 자주 하는 실수와 해결법

❌ 실수 1: 불필요한 !! 남발

// 나쁜 예 - !! 남발
fun getFullName(user: User?): String {
    return user!!.firstName + " " + user!!.lastName
}

// 좋은 예
fun getFullName(user: User?): String {
    return user?.let { "${it.firstName} ${it.lastName}" }
        ?: "이름 없음"
}

❌ 실수 2: Platform Type 무시

// Java 코드에서 온 값은 Platform Type
val name: String = javaService.getName()  // String!
// null일 수 있는데 체크 안 함!

// 올바른 방법
val name: String? = javaService.getName()
val safeName = name ?: "기본값"

❌ 실수 3: 빈 Collection을 null로 표현

// 나쁜 예
fun getUsers(): List<User>? {
    return if (users.isEmpty()) null else users
}

// 좋은 예 - 빈 리스트 반환
fun getUsers(): List<User> {
    return users  // 빈 리스트도 OK
}

이유:

  • null 체크보다 isEmpty() 체크가 더 명확
  • Collection은 빈 상태가 유효한 값

9. 테스트 코드에서의 Null Safety

9.1 Null 케이스 테스트

class UserServiceTest {
    
    @Test
    fun `null 사용자 처리 테스트`() {
        // given
        val service = UserService()
        
        // when
        val result = service.getUserName(null)
        
        // then
        assertEquals("익명", result)
    }
    
    @Test
    fun `null 이메일 처리 테스트`() {
        // given
        val user = User(name = "김철수", email = null)
        
        // when
        val result = service.sendWelcomeEmail(user)
        
        // then
        assertFalse(result)
    }
}

9.2 MockK에서의 Nullable

@Test
fun `외부 API null 응답 처리`() {
    // given
    every { externalApi.getUser(any()) } returns null
    
    // when
    val result = service.processUser(123L)
    
    // then
    assertEquals("처리 실패", result.status)
}

10. 마이그레이션 가이드 (Java → Kotlin)

10.1 단계별 변환

Step 1: Java 코드

public String getUserEmail(User user) {
    if (user != null) {
        String email = user.getEmail();
        if (email != null) {
            return email.toUpperCase();
        }
    }
    return "NO_EMAIL";
}

Step 2: Kotlin 기본 변환

fun getUserEmail(user: User?): String {
    if (user != null) {
        val email = user.email
        if (email != null) {
            return email.uppercase()
        }
    }
    return "NO_EMAIL"
}

Step 3: Kotlin 스타일 적용

fun getUserEmail(user: User?): String {
    return user?.email?.uppercase() ?: "NO_EMAIL"
}

10.2 @Nullable / @NotNull 변환

Java + Annotation:

public String processData(@Nullable String input) {
    return input != null ? input.trim() : "";
}

@NotNull
public String getDefaultName() {
    return "Default";
}

Kotlin:

fun processData(input: String?): String {
    return input?.trim() ?: ""
}

fun getDefaultName(): String {
    return "Default"
}

💡 핵심 요약 (10초 복습)

// 1. Nullable Type (?)
var name: String? = null

// 2. Safe Call (?.)
val length = name?.length

// 3. Elvis Operator (?:)
val result = name ?: "기본값"

// 4. Not-null Assertion (!!)
val length = name!!.length  // 주의: NPE 가능

// 5. 조합
val upper = name?.uppercase() ?: "NO_NAME"

📊 Operator 선택 가이드

상황 사용할 Operator 예제

null 가능성 표시 ? var name: String?
null이면 건너뛰기 ?. user?.email
null이면 기본값 ?: name ?: "기본"
null이면 종료 ?: + return val id = userId ?: return
null 아님 확신 !! name!!.length (비추천)

❓ FAQ (자주 묻는 질문)

Q1. ?와 !!의 차이는 뭔가요?

?는 null을 허용하는 타입 선언, !!는 "null 아님"을 강제하는 연산자입니다. !!는 NPE를 발생시킬 수 있어 가급적 사용하지 마세요.

Q2. ?.과 ?:를 같이 쓰는 이유는?

?.는 null이면 null 반환, ?:는 null일 때 대체값 제공입니다. 조합하면 null 안전한 기본값 로직을 간결하게 작성할 수 있습니다.

Q3. lateinit과 Nullable의 차이는?

lateinit은 나중에 반드시 초기화되는 Non-null 변수, Nullable은 null 가능 변수입니다. Spring DI처럼 초기화가 보장되면 lateinit을 사용하세요.

Q4. Java에서 Kotlin 코드 호출 시 Null Safety가 유지되나요?

아니요. Java는 Kotlin의 Null Safety를 무시할 수 있습니다. Platform Type(!)으로 처리되므로 주의가 필요합니다.

Q5. 성능 차이가 있나요?

컴파일 시 동일한 null 체크 코드로 변환되므로 성능 차이는 없습니다. 오히려 버그가 줄어 전체 성능이 향상됩니다.


🎯 실전 과제

과제 1: Safe Call 연습

data class Company(val name: String, val ceo: Person?)
data class Person(val name: String, val age: Int?)

// TODO: CEO의 이름을 안전하게 가져오기
// null이면 "CEO 정보 없음" 반환
fun getCeoName(company: Company?): String {
    // 여기에 코드 작성
}

과제 2: Elvis Operator 활용

data class User(
    val name: String?,
    val nickname: String?,
    val email: String?
)

// TODO: 표시할 이름 결정
// 우선순위: nickname > name > email > "익명"
fun getDisplayName(user: User): String {
    // 여기에 코드 작성
}

과제 3: 실전 상황 - 주문 처리

data class Order(
    val id: Long,
    val user: User?,
    val shippingAddress: Address?,
    val paymentMethod: PaymentMethod?
)

// TODO: 주문 유효성 검증
// user, shippingAddress, paymentMethod가 모두 있어야 true
fun isValidOrder(order: Order?): Boolean {
    // 여기에 코드 작성
}

힌트는 댓글로! 😊


📢 마치며

Kotlin의 Null Safety를 완벽하게 마스터하셨습니다!

이제 여러분은:

  • ✅ NPE 걱정 없이 코딩할 수 있습니다
  • ✅ 4가지 연산자를 상황에 맞게 사용할 수 있습니다
  • ✅ Java보다 안전한 코드를 작성할 수 있습니다

다음 글 예고: 다음에는 Kotlin의 강력한 함수형 프로그래밍 기능인 Lambda, Higher-order Functions, Collection Operations를 다룹니다. map, filter, groupBy 등을 마스터하여 코드를 더욱 간결하게 만들어보세요!

궁금한 점은 댓글로 남겨주세요! 💬

반응형
Comments