| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
- 김영한
- 스프링
- Spring
- 이차전지관련주
- 카카오
- Effective Java 3
- 클린아키텍처
- k8s
- effectivejava
- kubernetes
- 알고리즘
- JavaScript
- 엘라스틱서치
- 알고리즘정렬
- 스프링 핵심원리
- Effective Java
- 자바
- java
- Kotlin
- 이펙티브자바
- ElasticSearch
- 이펙티브 자바
- 예제로 배우는 스프링 입문
- 자바스크립트
- Sort
- 티스토리챌린지
- 스프링부트
- MongoDB
- 스프링핵심원리
- 오블완
- Today
- Total
Kim-Baek 개발자 이야기
Kotlin Null Safety 완벽 가이드 | ?, ?., ?:, !! 마스터하기 본문

이 글을 읽으면: Java 개발자의 악몽인 NullPointerException을 컴파일 타임에 완전히 차단하는 방법을 배울 수 있습니다. Nullable 타입, Safe Call, Elvis Operator, let 함수까지 실전 예제로 완벽하게 마스터하세요.
📌 목차
- 들어가며 - NPE의 공포
- Nullable Types - ? 연산자
- Safe Call - ?. 연산자
- Elvis Operator - ?: 연산자
- Not-null Assertion - !! 연산자
- let 함수와 Null Safety
- 안전한 타입 캐스팅 - as?
- 실전 패턴 모음
- 마무리 - 다음 편 예고
들어가며 - 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 허용. 용도가 다릅니다.
관련 글
- Kotlin 클래스와 객체 완벽 가이드 (이전 편)
- Kotlin 컬렉션 완벽 가이드 (다음 편)
- Kotlin 예외 처리와 Result 타입
💬 댓글로 알려주세요!
- NPE로 고생한 경험이 있나요?
- 어떤 Null Safety 패턴이 유용했나요?
- 이 글이 도움이 되셨나요?
태그: #Kotlin #NullSafety #NPE #SafeCall #Elvis #let #nullable
'개발 > java basic' 카테고리의 다른 글
| Kotlin 변수와 타입 완벽 가이드 | val vs var, 타입 추론, Nullable 타입 마스터하기 (0) | 2025.12.22 |
|---|---|
| Kotlin 클래스와 객체 완벽 가이드 | OOP의 Kotlin 스타일 (0) | 2025.12.22 |
| Kotlin 제어 구조 완벽 가이드 | if, when, for, while 마스터하기 (0) | 2025.12.21 |
| Kotlin 함수 완벽 가이드 | fun으로 시작하는 간결한 코드 (0) | 2025.12.21 |
| Kotlin 시작하기 | 왜 Kotlin인가? 개발환경 설정부터 Hello World까지 (0) | 2025.12.21 |
