6장. Java와 Kotlin 사이의 Null
앞 장에서 우리는 코틀린의 Null Safety를 배웠습니다.
타입 뒤에 ?가 있느냐 없느냐로
null을 다루는 방법이었죠.
그런데 여기서 한 가지 의문이 생깁니다.
코틀린은 자바 라이브러리를 그대로 가져다 씁니다.
(1장에서 이야기했던 내용입니다.)
그런데 자바에는 ? 같은 표시가 없습니다.
자바는 모든 값이 null이 될 수도, 안 될 수도 있으니까요.
그렇다면 코틀린은
자바에서 넘어온 값을
null로 봐야 할까요, 아닐까요?
이 장은 바로 그 경계에 대한 이야기입니다.
코틀린과 자바가 만나는 지점에서
null이 어떻게 다뤄지는지 살펴봅니다.
그리고 백엔드 개발자가
실제 서버에서 만나는 null들을
어떻게 다뤄야 하는지도 함께 고민해 봅니다.
6.1 Platform Type
Java에서 넘어오는 String!
먼저 코틀린 코드 하나를 봅시다.
val name: String = "홍길동" // null이 될 수 없음
val nick: String? = null // null이 될 수 있음
5장에서 배운 그대로입니다.?가 없으면 null 금지,?가 있으면 null 허용입니다.
그런데 자바 코드에서 값을 가져오면
이 규칙이 애매해집니다.
예를 들어 자바로 만든 클래스가 있다고 합시다.
// Java 코드
public class User {
public String getName() {
return name;
}
}
이 getName()은 무엇을 돌려줄까요?
문자열을 돌려주긴 하는데,
그게 null일 수도 있습니다.
자바에는 “이 값은 절대 null이 아니다“라고
표시하는 문법이 없기 때문입니다.
그래서 코틀린은
이 값을 특별한 이름으로 부릅니다.
String!
느낌표(!)가 붙은 이 타입을
플랫폼 타입(platform type)이라고 합니다.
플랫폼 타입이란
“코틀린이 null 여부를 확신하지 못하는 타입“입니다.
느낌표는 이렇게 읽으면 됩니다.
“null일 수도 있고 아닐 수도 있어.
그건 네가 판단해.”
Kotlin이 null 여부를 판단할 수 없는 이유
왜 코틀린은 판단하지 못할까요?
이유는 간단합니다.
자바 코드 안에 정보가 없기 때문입니다.
코틀린은 ?로 null 여부를 구분합니다.
하지만 자바에는 그런 구분 자체가 없습니다.
즉, 코틀린 입장에서는
“이 자바 값이 null인지 아닌지“를
알아낼 방법이 없는 것입니다.
여기서 코틀린은 두 가지 선택을 할 수 있었습니다.
첫째, 모든 자바 값을 nullable(String?)로 취급하기.
그러면 안전하긴 하지만,
자바 값을 쓸 때마다 ? 처리를 해야 해서
코드가 너무 번거로워집니다.
둘째, 모든 자바 값을 non-null(String)로 취급하기.
그러면 편하지만,
실제로 null이 오면 그대로 터져 버립니다.
코틀린은 그 사이의 절충안을 택했습니다.
그것이 바로 플랫폼 타입입니다.
정리하면 이렇습니다.
| 상황 | 타입 표기 | 뜻 |
|---|---|---|
| 코틀린 non-null | String | null 절대 안 됨 |
| 코틀린 nullable | String? | null 될 수 있음 |
| 자바에서 넘어옴 | String! | 코틀린이 모름 (개발자가 책임) |
여기서 중요한 점이 하나 있습니다.
플랫폼 타입은
우리가 코드에 직접 적는 타입이 아닙니다.
val name: String! = user.getName() // 이렇게 직접 쓰지 않음
String!은 컴파일러가 속으로 쓰는 표현입니다.
우리는 값을 받을 때 타입을 직접 정해 주면 됩니다.
// 이 값은 null이 아니라고 내가 확신할 때
val name: String = user.getName()
// null일 수도 있다고 안전하게 받을 때
val name: String? = user.getName()
즉, 플랫폼 타입은
“코틀린이 판단을 개발자에게 넘기는 순간“입니다.
그래서 자바 값을 받을 때는
습관처럼 이렇게 생각하는 것이 좋습니다.
“이 자바 값,
정말 null이 아닐까?”
이 한 번의 의심이
서버에서 터질 오류를 미리 막아 줍니다.
6.2 Java API 호출하기
Nullable Annotation
앞에서 “자바에는 null 정보가 없다“고 했습니다.
그런데 사실 방법이 아예 없는 것은 아닙니다.
자바에서도 값에
“이건 null이 될 수 있어“라거나
“이건 null이 아니야“라고
표시를 붙일 수 있습니다.
이때 쓰는 것이 애너테이션(annotation)입니다.
애너테이션이란
코드에 붙이는 “설명 딱지“라고 생각하면 쉽습니다.@로 시작하는 표시입니다.
대표적인 두 가지는 이렇습니다.
@Nullable: 이 값은 null이 될 수 있다@NotNull: 이 값은 null이 될 수 없다
자바 코드에 붙이면 이런 모습입니다.
// Java 코드
public class User {
@NotNull
public String getName() { // null 아님을 약속
return name;
}
@Nullable
public String getNickname() { // null일 수 있음
return nickname;
}
}
코틀린은 이 딱지를 읽어 줍니다.
그래서 애너테이션이 붙어 있으면
플랫폼 타입(String!)이 아니라
정확한 타입으로 인식합니다.
| 자바 쪽 표시 | 코틀린이 보는 타입 |
|---|---|
@NotNull String | String |
@Nullable String | String? |
| 아무 표시 없음 | String! (플랫폼 타입) |
즉, 자바 라이브러리가
이 애너테이션을 잘 붙여 두었다면
코틀린에서 훨씬 안전하게 쓸 수 있습니다.
좋은 자바 라이브러리일수록
null 애너테이션을 꼼꼼히 붙여 둡니다.
스프링 같은 유명한 프레임워크도
이 애너테이션을 적극적으로 붙여 놓았습니다.
그래서 스프링을 코틀린으로 쓸 때
null 처리가 꽤 자연스럽게 이어집니다.
(스프링과 코틀린의 관계는 나중에 스프링 편에서 다룹니다.)
Java 코드에서 발생하는 NPE
이제 가장 조심해야 할 상황을 봅시다.
애너테이션도 없고,
그래서 플랫폼 타입으로 넘어온 자바 값.
그걸 우리가 non-null로 받았을 때입니다.
여기서 잠깐 NPE라는 말을 짚고 갑시다.
NPE는 NullPointerException,
우리말로 “널 포인터 예외“입니다.
NPE란
“값이 없는데(null) 그 값을 쓰려다”
나는 오류입니다.
자바 세계에서 가장 흔한 오류이고,
서버를 멈추게 하는 대표적인 원인입니다.
코드로 보겠습니다.
// user.getName()이 사실은 null을 돌려줬다면?
val name: String = user.getName() // 여기서 문제
println(name.length)
이 코드는
컴파일할 때는 오류가 나지 않습니다.
코틀린이 getName()을 플랫폼 타입으로 보고,
“개발자가 non-null이라고 했으니 믿어 줄게” 하기 때문입니다.
하지만 실제로 null이 넘어오면
실행 중에 NPE가 터집니다.
Exception in thread "main" java.lang.NullPointerException
여기서 코틀린의 특징이 드러납니다.
순수 코틀린 코드에서는
NPE가 거의 나지 않습니다.? 규칙이 미리 막아 주기 때문입니다.
하지만 자바와 만나는 경계에서는
여전히 NPE가 날 수 있습니다.
코틀린의 null 안전은
“코틀린 안에서” 강력합니다.
자바 경계에서는 개발자가 한 번 더 챙겨야 합니다.
그래서 자바 API를 부를 때는
안전한 습관이 필요합니다.
확신이 없다면
일단 nullable(?)로 받는 것입니다.
// 안전하게 nullable로 받기
val name: String? = user.getName()
// 그다음 5장에서 배운 방법으로 처리
println(name?.length) // 안전 호출
val safe = name ?: "이름 없음" // 엘비스 연산자
?.(안전 호출)과 ?:(엘비스 연산자)는
5장에서 배운 도구입니다.
자바 경계에서 이 도구들이 특히 빛을 발합니다.
정리하면 이렇습니다.
- 자바 값을 non-null로 받으면 → 편하지만 NPE 위험
- 자바 값을 nullable로 받으면 → 조금 번거롭지만 안전
처음에는 안전한 쪽을 기본으로 삼기를 권합니다.
6.3 백엔드에서 만나는 Null
지금까지는 자바와의 경계에서
null이 어떻게 다뤄지는지 봤습니다.
이제 시선을 조금 넓혀 봅시다.
백엔드 서버를 만들다 보면
null은 정말 자주 등장합니다.
경계에서만 나오는 것이 아닙니다.
이 절에서는 서버에서 만나는
대표적인 null 상황 네 가지를 살펴봅니다.
요청 값
서버는 클라이언트로부터 요청(request)을 받습니다.
여기서 클라이언트란
서버에 무언가를 요청하는 쪽입니다.
웹 브라우저나 앱이 대표적입니다.
문제는,
요청에 담긴 값이 항상 있는 것은 아니라는 점입니다.
예를 들어 회원가입 요청을 받는다고 합시다.
- 이름 : 반드시 있어야 함
- 전화번호 : 안 적어도 됨 (선택 사항)
이때 전화번호는
값이 올 수도 있고 안 올 수도 있습니다.
안 오면 그 값은 null이 됩니다.
그래서 요청을 담는 코드에서는
선택 값에 ?를 붙입니다.
// 회원가입 요청을 담는 그릇
class SignupRequest(
val name: String, // 필수 → non-null
val phone: String?, // 선택 → nullable
)
이렇게만 해 두어도
“이 값은 없을 수 있다“는 사실이
타입에 그대로 드러납니다.
코드를 읽는 사람이
문서를 따로 안 봐도 알 수 있는 것입니다.
DB 조회 결과
서버는 데이터베이스(database)에서
데이터를 꺼내 옵니다.
데이터베이스란
데이터를 저장해 두는 창고입니다.
줄여서 DB라고 부릅니다.
(DB는 나중에 데이터 접근 편에서 자세히 다룹니다.)
그런데 DB에서 무언가를 찾을 때
“찾는 게 없을” 수 있습니다.
예를 들어
“3번 회원을 찾아 줘“라고 했는데
3번 회원이 이미 탈퇴했다면?
이럴 때 조회 결과는 null이 됩니다.
// id로 회원을 찾는 함수 (없으면 null)
fun findUser(id: Long): User? {
// ... DB에서 조회 ...
}
반환 타입이 User?인 것에 주목합시다.
“찾으면 User를 주고,
없으면 null을 준다.”
이 의미가 타입 하나로 표현됩니다.
이 함수를 쓰는 쪽에서는
자연스럽게 null 처리를 하게 됩니다.
val user = findUser(3)
if (user == null) {
println("회원을 찾을 수 없습니다.")
} else {
println("${user.name}님 반갑습니다.")
}
DB 조회에서 “없음“은 아주 흔한 일입니다.
그래서 null 처리는
백엔드에서 매일 하는 일이라고 봐도 됩니다.
외부 API 응답
서버는 다른 서버를 부를 때도 있습니다.
예를 들어
날씨 정보를 얻으려고
날씨 서버에 물어보는 식입니다.
이렇게 외부의 서버를 부르는 통로를
외부 API(external API)라고 합니다.
API는 Application Programming Interface,
쉽게 말해
“프로그램끼리 대화하는 창구“입니다.
외부 API의 응답에는
null이 특히 자주 섞여 있습니다.
이유는 이렇습니다.
- 상대 서버가 값을 안 줄 수도 있다
- 어떤 항목은 원래 비어 있을 수 있다
- 응답 형식이 우리 예상과 다를 수 있다
즉, 외부 API는
“내가 통제할 수 없는 값“입니다.
그래서 외부 응답을 다룰 때는
더욱 조심해야 합니다.
// 외부 날씨 API 응답을 담는 그릇
class WeatherResponse(
val temperature: Double?, // 없을 수 있음
val description: String?, // 없을 수 있음
)
외부 값은 “일단 의심“이
기본자세입니다.
필수처럼 보여도 nullable로 두는 편이 안전합니다.
Optional과 Nullable
자바를 조금 아는 분이라면Optional이라는 것을 들어 봤을 수 있습니다.
Optional은 자바가
“값이 없을 수 있음“을 표현하려고 만든 도구입니다.
자바에는 코틀린의 ?가 없으니,
대신 값을 상자에 담아
“안이 비었을 수도 있다“고 알려 준 것입니다.
자바 코드로는 이렇게 생겼습니다.
// Java 코드
Optional<User> findUser(Long id);
이 함수는 User를 바로 주는 게 아니라
User가 담긴 “상자“를 줍니다.
상자를 열어 봐야 값이 있는지 알 수 있습니다.
그런데 코틀린에는 이미 ?가 있습니다.
그래서 굳이 상자가 필요 없습니다.
같은 의미를 이렇게 간단히 씁니다.
// Kotlin 코드
fun findUser(id: Long): User?
두 방식을 비교하면 이렇습니다.
| 구분 | 자바 Optional | 코틀린 Nullable |
|---|---|---|
| 표현 | Optional<User> | User? |
| 값 꺼내기 | .get(), .orElse() | ?., ?: |
| 코드 길이 | 길다 | 짧다 |
| 코틀린과 어울림 | 어색함 | 자연스러움 |
정리하면 이렇습니다.
자바에서 Optional이 하던 일을,
코틀린에서는?하나가 대신합니다.
그래서 코틀린 코드에서는Optional을 거의 쓰지 않습니다.
그냥 nullable 타입을 쓰는 것이 정석입니다.
다만 자바 라이브러리가Optional을 돌려주는 경우는 있습니다.
그럴 때는 코틀린에서
값을 꺼내 nullable로 바꿔 받으면 됩니다.
// 자바가 Optional<User>를 돌려줄 때
val user: User? = repository.findUser(3).orElse(null)
이렇게 하면
그다음부터는 코틀린 방식(?., ?:)으로
편하게 이어 갈 수 있습니다.
6.4 Null을 어떻게 모델링할 것인가
지금까지는 null을 “다루는 방법“을 봤습니다.
이제 한 단계 더 나아가 봅시다.
“애초에 null을 써야 하는가“라는 질문입니다.
값이 없는 상황을 표현하는 방법은
null만 있는 것이 아닙니다.
상황에 따라
더 좋은 방법이 있을 수 있습니다.
이렇게 “값이 없음을 어떻게 표현할지 정하는 일“을
모델링(modeling)이라고 합니다.
세 가지 갈림길을 살펴봅시다.
null과 기본값
첫 번째 갈림길입니다.
“값이 없을 때, 그냥 기본값을 쓰면 안 될까?”
기본값(default value)이란
“값이 없을 때 대신 쓰는 값“입니다.
예를 들어 봅시다.
사용자가 자기소개를 안 적었다고 합시다.
이때 두 가지 방법이 있습니다.
방법 1. null로 두기.
val introduction: String? = null
방법 2. 빈 문자열을 기본값으로 두기.
val introduction: String = ""
어느 쪽이 좋을까요?
정답은 “경우에 따라 다르다“입니다.
- 자기소개가 없다는 사실 자체가 중요하다면 → null
- 그냥 비어 있는 걸로 취급해도 된다면 → 기본값
기본값을 쓰면
그 뒤의 코드가 편해집니다.
null 처리를 안 해도 되니까요.
// 기본값이라면 그냥 바로 쓸 수 있음
println("소개 길이: ${introduction.length}")
그래서 이런 기준을 세울 수 있습니다.
“없음“과 “비어 있음“이 같은 의미라면
기본값이 더 깔끔하다.
null과 예외
두 번째 갈림길입니다.
“값이 없으면, 그냥 오류를 내면 안 될까?”
여기서 예외(exception)란
“프로그램이 정상적으로 진행할 수 없는 상황“을
알리는 신호입니다.
(예외는 나중에 예외 처리 편에서 자세히 다룹니다.)
예를 들어 봅시다.
“결제 정보를 찾아 줘“라고 했는데
결제 정보가 없다면?
이건 그냥 “없음“이 아닙니다.
있어야 할 것이 없는,
분명히 잘못된 상황입니다.
이럴 때는 null보다 예외가 낫습니다.
fun findPayment(orderId: Long): Payment {
return paymentRepository.find(orderId)
?: throw IllegalStateException("결제 정보가 없습니다: $orderId")
}
?:(엘비스 연산자) 뒤에서
바로 예외를 던지는 모습입니다.
이렇게 하면 반환 타입이Payment?가 아니라 Payment가 됩니다.
즉, 이 함수를 쓰는 쪽은
null 걱정 없이 값을 그대로 쓸 수 있습니다.
null과 예외를 나누는 기준은 이렇습니다.
| 상황 | 어울리는 방법 |
|---|---|
| 없을 수도 있는 게 자연스러움 | null |
| 없으면 명백히 잘못된 것 | 예외 |
“없어도 괜찮은가?“를 스스로 물어보세요.
괜찮으면 null,
안 괜찮으면 예외입니다.
null과 별도의 상태 객체
세 번째 갈림길입니다.
조금 더 발전된 방법입니다.
가끔 null 하나로는
상황을 다 설명하기 어려울 때가 있습니다.
예를 들어
외부 API를 불렀는데 결과가 없다고 합시다.
이 “없음“에는
사실 여러 이유가 숨어 있습니다.
- 성공했는데 진짜로 데이터가 없음
- 요청이 잘못돼서 실패함
- 네트워크가 끊겨서 실패함
이 셋을 전부 null로만 표현하면
“왜 없는지“를 알 수 없습니다.
이럴 때는
상태를 담는 별도의 객체를 만들면 좋습니다.
// 결과 상태를 표현하는 객체
sealed class Result {
data class Success(val data: User) : Result()
object NotFound : Result()
data class Error(val message: String) : Result()
}
여기 나온 sealed class와 data class는
지금 자세히 몰라도 괜찮습니다.
(클래스 편에서 다룹니다.)
지금은 이 그림만 기억하면 됩니다.
null은 “없다“까지만 말해 줍니다.
상태 객체는 “왜 없는지“까지 말해 줍니다.
이 방식은
성공과 실패를 구분해야 하는
복잡한 상황에서 특히 강력합니다.
물론 모든 곳에 쓸 필요는 없습니다.
단순한 “있음/없음“에는
그냥 nullable(?)이 훨씬 간단합니다.
세 가지 방법을 마지막으로 정리해 봅시다.
| 방법 | 언제 쓰나 | 예 |
|---|---|---|
| 기본값 | 없음 = 비어 있음일 때 | 빈 문자열, 0 |
| null | 없을 수 있는 게 자연스러움 | 선택 입력 값 |
| 예외 | 없으면 명백히 잘못됨 | 필수 데이터 누락 |
| 상태 객체 | 없는 이유가 여러 개일 때 | 성공/실패/오류 구분 |
이 선택에 정답은 없습니다.
상황에 맞게 고르는 감각을
경험으로 쌓아 가면 됩니다.
6장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 자바에서 넘어온 값은 플랫폼 타입(
String!)이 되고,
코틀린이 null 여부를 확신하지 못한다는 점 @Nullable,@NotNull애너테이션이 있으면
코틀린이 정확한 타입으로 인식한다는 점- 자바 경계에서는 여전히 NPE가 날 수 있어,
확신이 없으면 nullable로 받는 것이 안전하다는 점 - 백엔드에서 만나는 null(요청 값, DB 조회,
외부 API 응답)과 자바 Optional과의 관계 - 값이 없음을 표현하는 여러 방법(기본값, null,
예외, 상태 객체)과 그 선택 기준
5장에서 배운 null 도구들(?, ?., ?:)이
자바 경계에서 어떻게 쓰이는지 보았습니다.
문법을 아는 것과
“언제 무엇을 쓸지” 아는 것은 다릅니다.
이 장은 후자를 위한 장이었습니다.
다음 장부터는
코틀린으로 코드를 구조화하는 방법,
즉 함수와 클래스의 세계로 들어갑니다.