Java로 개발할 때 가장 중요하게 여겼던 것은 Null에 대한 처리이다.
특히 NullPointerException(이하 NPE)이 발생하면 우리 서비스가 갑자기 종료될 수 있기에 사용자 경험에 매우 좋지 않다.
타입스크립트/자바스크립트에도 Java의 NPE와 비슷한 Cannot read properties of undefined 오류가 있다.
이 오류는 NPE만큼이나 치명적이다. 프론트엔드에서 이 오류가 발생하면 사용자 화면이 하얗게 변하거나, 특정 기능이 작동하지 않는다.
백엔드에서 발생하면 API 응답이 실패하고 전체 요청이 무산된다.(NPE와 동일하다.)
더 큰 문제는 JavaScript의 특성상 undefined와 null이라는 두 가지 '없음'이 공존한다는 점이다. Java 개발자 입장에서는 혼란스러울 수 있지만, TypeScript를 제대로 활용하면 이 문제를 컴파일 타임에 잡아낼 수 있다.
JavaScript는 Java와 다르게 undefined와 null이라는 두 가지 '없음'이 공존한다.
이런 특성이 우리를 어지럽게 만든다.
undefined와 null은 도대체 뭐가 다를까?
자바스크립트 undefined와 null의 차이
| 값 | 의도 | 타입(typeof) | 예시 |
| undefined | 값이 할당되지 않음(변수는 선언되었지만 초기화되지 않음) | 'udefined' | - 변수를 선언만 했을 경우 - 객체에 없는 속성에 접근시 |
| null | 값이 의도적으로 비어 있음(개발자가 명시적으로 빈 값을 할당) | 'object' | - 명시적으로 객체를 비울 때 - 함수가 유효하지 않은 값을 반환할 때 |
비슷한듯 다르다.
// 1. undefined: 값이 할당되지 않음
let name;
console.log(name); // 출력: undefined
console.log(typeof name); // 출력: "undefined"
const user = { username: "Alice" };
console.log(user.age); // 출력: undefined (속성이 없음)
// 2. null: 명시적으로 비어 있는 값
let data = null;
console.log(data); // 출력: null
console.log(typeof data); // 출력: "object"
이는 초기 언어 제작 시 발생한 문제로, 현재까지 고쳐지지 않는다.
비교연산을 할 경우에도 해당 문제가 발생한다.
js 계열에선 두 가지 비교 연산자를 사용한다.
== 과 ===이다.
== 비교 연산자는 타입을 강제로 변환해서 비교한다.
null과 undefined를 동등하다고 간주한다.
console.log(null == undefined); // 출력: true (타입 변환 후 동등하다고 간주)
console.log(null == 0); // 출력: false
console.log(undefined == 0); // 출력: false
=== 는 엄격하게 비교한다.
무슨 말이냐면, 타입과 값을 모두 비교한다.
따라서 이 두 값은 서로 다르게 취급된다.
console.log(null === undefined); // 출력: false (타입이 다르므로)
그러다 보니 js로 개발을 하면 대부분 === 를 사용하여 비교한다.
이런 자바스크립트의 null / undefined 문제와 비교해 , 코틀린과 자바는 어떻게 Null 안정성을 확보할까?
자바와 코틀린의 Null 처리
자바
자바의 경우, if-else 또는 Optional<T> 클래스를 사용해 Null을 처리한다.
Optional <T>의 의미는 "값이 있을 수도 있음"을 의미한다.
if-else를 사용한 null 처리
String result;
if (name != null) {
result = name.toUpperCase();
} else {
result = "DEFAULT_USER";
}
System.out.println(result);
null이 될 수 있는 값을 Optional로 감싸서 null 처리
Optional<String> nameOptional = getUsernameOptional();
String result = nameOptional // 값이 존재할 때만 함수 적용
.map(String::toUpperCase) // 값이 없으면 기본값을 반환
.orElse("DEFAULT_USER");
System.out.println(result);
Java의 경우, Optional <T>를 사용한 옵셔널 체이닝을 통해 null 체크 없는 코드를 작성할 수 있다.
코틀린
Kotlin의 경우엔 언어에서 null 안정성을 보장한다.
이게 무슨 말이냐면!
별도로 표기하지 않으면, 모든 타입은 기본적으로 null 값을 가질 수 없다.
fun main() {
var name = ""
name = null
println(name)
}
위 코드를 실행하면
Null cannot be a value of a non-null type 'String'
Null을 할당할 수 없다는 에러가 나온다.
컴파일부터 실패한다.
별도로 표기하지 않았을 경우고, 만약 null을 허용하고 싶다면 뒤에? 를 붙여주면 된다.
fun main() {
var name: String? = ""
name = null
println(name)
}
이러면 null 이 출력된다.
위 언어적 특성을 사용해 kotlin에선 다음과 같은 방식으로 코드를 작성한다.
fun main() {
var name: String? = "홍길동"
var length: Int? = name?.length // name이 null이 아니므로 3 할당
println(length) // 출력: 3
name = null
length = name?.length // name이 null이므로 전체 결과는 null 할당
println(length) // 출력: null
}
자바스크립트에선 이런 문제를 null 체크를 통해 해결한다.
이제 자바스크립트의 슈퍼셋인 타입스크립트에서 null을 어떻게 처리하는지 알아보자.
타입스크립트의 널 병합 연산자와 옵셔널 체이닝
타입스크립트는 ??(널 병합 연산자) 와 ?.(옵셔널 체이닝) 두 가지 문법을 활용해 널 안정성을 보장한다.
(좀 더 정확하게 들어가면 '??'과 '?.'는 ECMAScript 2020 표준 문법으로 자바스크립트에서도 사용할 수 있다. 하지만 타입스크립트는 여기에 타입 안정성을 더해준다.)
널 병합 연산자(??)
널 병합 연산자(??)란 왼쪽 피연산자가 null 또는 undefined일 때 오른쪽 피연산자를 반환하고, 그렇지 않으면 왼쪽 피연산자를 반환하는 논리 연산자이다.
const foo = null ?? "default string";
console.log(foo);
// 출력: "default string"
const baz = 0 ?? 42;
console.log(baz);
// 출력: 0
위 코드를 보면 || 연산자와 다른 점이 있다.
falsy 값이 와도 해당 값을 출력한다.
옵셔널 체이닝(?.)
?. 은 옵셔널 체이닝 연산자로, kotlin에서 null 처리하는 방식 그대로 사용하면 된다.
const human = {
name: "홍길동",
home: {
city: "서울",
address: "강남구",
},
};
const zipcode = human.home.zipcode;
console.log(zipcode);
console.log(human.notExistsMethod?.());
위 코드를 실행하면 undefined가 출력된다.
타입에러를 ?. 을 사용해서 막을 수 있다.
그래서 타입스크립트에선.? 와?? 를 섞어서 null 처리를 한다.
interface User {
id: number;
home?: {
address?: {
zipcode: string;
} | null;
};
}
const userWithoutHome: User = { id: 1};
const userWithHome: User = {id: 2, home: { address: {zipcode: "A123"}}};
const defaultZipcode = "00000"; // 기본값 정의
// 1. 집주소가 없는 사용자 처리
const zipcodeA = userWithoutHome.home?.address?.zipcode ?? defaultZipcode;
console.log(`집주소가 없는 사람의 우편번호 : ${zipcodeA}`); // 출력 00000
// 2. 집주소가 있는 사용자 처리
const zipcodeB = userWithHome.home?.address?.zipcode ?? defaultZipcode;
console.log(`집주소가 있는 사람의 우편번호 : ${zipcodeB}`);
위 코드를 실행하면
집주소가 없는 사람의 우편번호 : 00000
집주소가 있는 사람의 우편번호 : A123
위와 같이 타입에러 또는 undefined, null 처리 없이 깔끔하게 처리할 수 있다.
더 복잡한 상황이 온다면..?
앞에서 타입스크립트의 ?. 과 ?? 를 사용해 null을 안전하게 다루는 방법을 배웠다.
하지만 만약 다음과 같은 상황이면 어떨까?
function processUserData(userId: string) {
const user = findUser(userId);
if (!user) return null;
const validated = validateUser(user);
if (!validated) return null;
const enriched = enrichUserData(validated);
if (!enriched) return null;
const formatted = formatForDisplay(enriched);
if (!formatted) return null;
return formatted;
}
위 코드는 각 단계에서 실패할 수 있다.
추가로
[함수 호출 -> null 체크 -> 실패하면 null 반환 -> 성공하면 다음 단계]
이 과정이 반복된다.
코드가 길어진다 또는 각 단계마다 로깅을 한다거나 기본 값에 대한 처리가 달라진다면 더더 복잡해진다.
Kotlin 이었다면
val result = findUser(userId)
?.let { validateUser(it) }
?.let { enrichUserData(it) }
?.let { formatForDisplay(it) }
let 체이닝을 사용해 처리했을 것이다.
Java 라면
Optional<DisplayUser> result = findUser(userId)
.flatMap(this::validateUser)
.flatMap(this::enrichUserData)
.map(this::formatForDisplay);
Optional 체이닝을 사용했을 것이다.
TypeScript에선 어떻게 할 수 있을까?
다음 글에선 Java의 Optional과 같은 타입을 직접 구현해 보자.