| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- Sort
- 이펙티브자바
- 스프링 핵심원리
- 자바
- 자바스크립트
- 엘라스틱서치
- ElasticSearch
- JavaScript
- 카카오 면접
- 김영한
- Kotlin
- Effective Java 3
- 이차전지관련주
- 티스토리챌린지
- 스프링
- Spring
- Effective Java
- kubernetes
- effectivejava
- 오블완
- 이펙티브 자바
- 스프링핵심원리
- 알고리즘정렬
- java
- 클린아키텍처
- 카카오
- k8s
- 예제로 배우는 스프링 입문
- 스프링부트
- 알고리즘
- Today
- Total
Kim-Baek 개발자 이야기
Kotlin Null Safety 완벽 가이드 | NPE와 영원히 작별하는 법 ?, ?., ?:, !! 마스터하기 💪 본문
Kotlin Null Safety 완벽 가이드 | NPE와 영원히 작별하는 법 ?, ?., ?:, !! 마스터하기 💪
김백개발자 2025. 12. 1. 23:32이 글을 읽으면: Java 개발자의 악몽인 NullPointerException(NPE)를 컴파일 타임에 방지하는 Kotlin의 Null Safety 시스템을 완벽하게 이해할 수 있습니다. 실무에서 바로 적용 가능한 4가지 연산자를 실전 예제와 함께 배워보세요.
📌 목차
- NPE의 악몽 - 왜 Null Safety가 필요한가
- Nullable Types (?) - Null 가능 타입
- Safe Call Operator (?.) - 안전한 호출
- Elvis Operator (?:) - 기본값 제공
- Not-null Assertion (!!) - 개발자의 보증
- 실전 활용 패턴
- 성능 최적화 팁
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)
🤔 문제가 뭘까요?
- user가 null일 수 있음
- getEmail()이 null을 반환할 수 있음
- 컴파일러가 경고조차 해주지 않음
- 운영 환경에서 터짐 → 장애 발생
✨ 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()
}
동작 원리:
- myOwn이 null이면 → 전체 결과 null
- 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 등을 마스터하여 코드를 더욱 간결하게 만들어보세요!
궁금한 점은 댓글로 남겨주세요! 💬
'개발 > java basic' 카테고리의 다른 글
| Kotlin 기초 완벽 가이드 | Data Class, Named Parameters로 코드 가독성 3배 높이는 법 🚀 (0) | 2025.11.30 |
|---|---|
| 함수형 인터페이스(Functional Interface) 완벽 가이드 (0) | 2025.02.16 |
| CompletableFuture 란 무엇일까 (0) | 2024.11.26 |
| 자바 리플렉션이란 (0) | 2024.11.24 |
| 리액티브 프로그래밍이란 (0) | 2024.11.17 |
