프로그래밍을 처음 배울 때 null과 undefined는 보통 “값이 없다”는 의미라고 배운다.
let selectedCoupon;
const expiresAt = null;
selectedCoupon에는 아직 값이 할당되지 않았기 때문에 undefined가 들어 있고, expiresAt에는 개발자가 의도적으로 null을 넣었다.
입문 단계에서는 다음과 같이 이해해도 충분하다.
undefined: 값이 아직 정의되지 않았다.null: 값이 없다고 명시했다.하지만 실제 서비스를 개발하기 시작하면 단순히 “값이 없다”는 설명만으로는 부족하다.
쿠폰 서비스에서 만료일이 없다는 것은 무엇을 의미할까?
이 상태들을 모두 null이나 undefined 하나로 표현하면 코드만 보고는 현재 어떤 상황인지 알기 어렵다.
실제 서비스에서 값이 없다는 것은 하나의 값이 아니라, 데이터가 만들어지고 조회되고 변경되는 과정에서 발생하는 여러 상태다.
따라서 null과 undefined를 다룬다는 것은 빈 값을 처리하는 문법을 배우는 일이 아니다.
값이 없는 이유를 구분하고, 그 의미를 서비스 전체에서 일관되게 전달하는 일이다.
쿠폰 객체에 만료일이 없다고 가정해 보자.
interface Coupon {
id: string;
title: string;
expiresAt: Date | null;
}
const coupon: Coupon = {
id: "coupon-123",
title: "커피 한 잔 사주기",
expiresAt: null,
};
여기서 expiresAt: null은 단순히 날짜가 비어 있다는 뜻이 아니다.
서비스에서 다음과 같은 의미로 정의할 수 있다.
이 쿠폰은 만료일 없이 사용할 수 있다.
null의 의미를 이렇게 정의했다면 정상적인 서비스 데이터다. 오류도 아니고 아직 처리되지 않은 상태도 아니다.
반면 다음 객체는 의미가 다르다.
interface CouponFormValues {
title?: string;
expiresAt?: string;
}
const formValues: CouponFormValues = {};
여기서 expiresAt이 undefined라는 것은 사용자가 만료일 없는 쿠폰을 선택했다는 의미가 아닐 수 있다.
아직 해당 입력을 건드리지 않았거나, 폼의 초기값이 설정되지 않았거나, 요청 객체에 필드가 포함되지 않았다는 뜻일 수 있다.
문법적으로는 둘 다 값이 없지만 서비스에서는 서로 다른 상태다.
| 표현 | 서비스에서 사용할 수 있는 의미 |
|---|---|
expiresAt: null | 만료일이 없다고 명시했다 |
expiresAt: undefined | 값이 제공되지 않았거나 아직 정해지지 않았다 |
expiresAt 필드 생략 | 요청에서 이 필드를 변경하지 않는다 |
조회 결과 null | 조회했지만 대상이 존재하지 않는다 |
| 로딩 상태 | 아직 조회 결과가 도착하지 않았다 |
| 오류 상태 | 조회를 시도했지만 결과를 얻지 못했다 |
중요한 것은 null과 undefined에 보편적으로 적용되는 절대적인 의미를 찾는 것이 아니다.
서비스 안에서 각각이 무엇을 의미하는지 정의하고 그 약속을 일관되게 지키는 것이다.
쿠폰 상세 화면에서 쿠폰을 조회한다고 가정해 보자.
let coupon: Coupon | null = null;
이 값만으로 화면 상태를 표현하면 문제가 생긴다.
coupon === null일 때 다음 중 어떤 상황인지 알 수 없다.
결국 화면에서는 모든 상태를 같은 방식으로 처리하게 된다.
if (coupon === null) {
return "쿠폰이 없습니다.";
}
페이지에 처음 들어와 데이터를 불러오는 순간에도 “쿠폰이 없습니다”라는 문구가 잠깐 보일 수 있다. 서버 오류가 발생해도 존재하지 않는 쿠폰처럼 표시된다.
값 하나에 여러 상태를 겹쳐 넣었기 때문이다.
각 상태를 분리할 수 있다.
type CouponQueryState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; coupon: Coupon }
| { status: "not-found" }
| { status: "forbidden" }
| { status: "error"; message: string };
const state: CouponQueryState = {
status: "loading",
};
이제 coupon이 없다는 사실만 저장하지 않는다. 쿠폰이 없는 이유까지 표현한다.
function getCouponPageMessage(
state: CouponQueryState
): string {
switch (state.status) {
case "idle":
return "쿠폰 조회를 준비하고 있습니다.";
case "loading":
return "쿠폰을 불러오고 있습니다.";
case "success":
return state.coupon.title;
case "not-found":
return "존재하지 않는 쿠폰입니다.";
case "forbidden":
return "이 쿠폰을 볼 권한이 없습니다.";
case "error":
return state.message;
}
}
이 코드는 단순히 빈 값을 확인하지 않는다.
서비스가 현재 어떤 상태인지 구분하고, 사용자에게 적절한 결과를 전달한다.
의미 있는 상태가 필요할 때
null하나로 모든 상황을 표현하면 정보가 사라진다.
쿠폰 목록에서 선택한 쿠폰을 찾는 코드를 생각해 보자.
const selectedCoupon = coupons.find(
(coupon) => coupon.id === selectedCouponId
);
JavaScript의 find는 조건에 맞는 항목이 없으면 undefined를 반환한다.
console.log(selectedCoupon);
// undefined
여기서 undefined는 “쿠폰이라는 데이터가 원래 존재하지 않는다”는 뜻이 아니다.
현재 가지고 있는 coupons 배열에서 조건에 맞는 항목을 찾지 못했다는 뜻이다.
그 이유는 여러 가지일 수 있다.
따라서 다음 코드는 상황에 따라 잘못된 판단을 할 수 있다.
if (!selectedCoupon) {
throw new Error("존재하지 않는 쿠폰이다.");
}
목록을 아직 불러오지 않았는데도 존재하지 않는 쿠폰으로 판단할 수 있기 때문이다.
조회 상태와 조회 결과를 구분해야 한다.
interface CouponListState {
status: "loading" | "success" | "error";
coupons: Coupon[];
}
function findSelectedCoupon(
state: CouponListState,
selectedCouponId: string
): Coupon | undefined {
if (state.status !== "success") {
return undefined;
}
return state.coupons.find(
(coupon) => coupon.id === selectedCouponId
);
}
하지만 이 함수도 undefined 하나로 “아직 조회할 수 없음”과 “조회했지만 없음”을 함께 반환한다.
구분이 중요하다면 반환 타입에서 상태를 명시할 수 있다.
type FindCouponResult =
| { status: "not-ready" }
| { status: "found"; coupon: Coupon }
| { status: "not-found" };
function findSelectedCoupon(
state: CouponListState,
selectedCouponId: string
): FindCouponResult {
if (state.status !== "success") {
return { status: "not-ready" };
}
const coupon = state.coupons.find(
(item) => item.id === selectedCouponId
);
if (coupon === undefined) {
return { status: "not-found" };
}
return {
status: "found",
coupon,
};
}
코드는 조금 길어졌지만 호출자가 추측하지 않아도 된다.
데이터를 아직 확인하지 못한 상태와 확인했지만 존재하지 않는 상태가 분리되었다.
TypeScript에서는 ?를 사용해 선택적 필드를 만들 수 있다.
interface Coupon {
id: string;
title: string;
expiresAt?: Date;
}
이 타입은 다음 객체를 모두 허용한다.
const couponWithoutField: Coupon = {
id: "coupon-1",
title: "커피 한 잔",
};
const couponWithUndefined: Coupon = {
id: "coupon-2",
title: "저녁 식사",
expiresAt: undefined,
};
일반적인 설정에서는 두 형태가 비슷하게 취급된다.
하지만 서비스 계약에서는 다음 질문이 생긴다.
undefined인 것인가?만료일 없음이 쿠폰의 정상적인 상태라면 필드를 항상 제공하고 null로 표현하는 편이 명확할 수 있다.
interface Coupon {
id: string;
title: string;
expiresAt: Date | null;
}
const coupon: Coupon = {
id: "coupon-1",
title: "커피 한 잔",
expiresAt: null,
};
이 구조에서는 모든 쿠폰이 expiresAt 필드를 가진다.
날짜가 들어 있으면 만료일이 있고, null이면 만료일이 없다.
반대로 일부 API 응답에서 의도적으로 필드를 제공하지 않는다면 선택적 필드가 적절할 수 있다.
interface CouponSummaryResponse {
id: string;
title: string;
expiresAt?: string | null;
}
예를 들어 목록 조회 성능을 위해 만료일을 요청한 경우에만 응답한다면 undefined 또는 필드 생략은 “이 응답에 포함되지 않았다”는 의미가 된다.
const couponSummary = {
id: "coupon-1",
title: "커피 한 잔",
};
이때 expiresAt이 없다는 사실을 “만료일이 없다”고 해석해서는 안 된다.
선택적 필드는 값이 없을 수 있다는 사실뿐 아니라, 해당 정보가 제공되지 않을 수 있다는 계약을 만든다.
프론트엔드에서 다음 객체를 서버로 전송한다고 가정해 보자.
const payload = {
title: "커피 한 잔 사주기",
expiresAt: undefined,
};
이를 JSON으로 변환하면 expiresAt 필드가 사라진다.
JSON.stringify(payload);
// '{"title":"커피 한 잔 사주기"}'
반면 null은 그대로 유지된다.
const payload = {
title: "커피 한 잔 사주기",
expiresAt: null,
};
JSON.stringify(payload);
// '{"title":"커피 한 잔 사주기","expiresAt":null}'
프론트엔드 코드에서는 undefined와 null 모두 값이 없는 것처럼 보이지만 HTTP 요청에서는 서로 다른 메시지가 된다.
{
"title": "커피 한 잔 사주기"
}
첫 번째 요청에는 expiresAt 필드가 없다.
{
"title": "커피 한 잔 사주기",
"expiresAt": null
}
두 번째 요청에는 expiresAt 필드가 있고 값이 null이다.
이 차이는 수정 API에서 특히 중요하다.
기존 쿠폰에 만료일이 설정되어 있다고 가정해 보자.
const coupon = {
id: "coupon-123",
title: "커피 한 잔 사주기",
expiresAt: new Date("2026-12-31T23:59:59Z"),
};
사용자가 제목만 변경한다면 요청은 다음과 같을 수 있다.
{
"title": "디저트 사주기"
}
expiresAt이 생략되었다.
이 요청의 의미를 다음과 같이 정의할 수 있다.
제목은 변경하지만 기존 만료일은 그대로 유지한다.
반면 사용자가 만료일을 제거했다면 다음과 같이 보낼 수 있다.
{
"expiresAt": null
}
이 요청의 의미는 다르다.
기존 만료일을 삭제하고 만료일 없는 쿠폰으로 변경한다.
TypeScript로 표현하면 다음과 같다.
interface UpdateCouponInput {
title?: string;
expiresAt?: Date | null;
}
각 값은 서로 다른 의도를 나타낸다.
| 입력 | 의미 |
|---|---|
title: undefined 또는 생략 | 제목을 변경하지 않는다 |
title: "디저트 사주기" | 제목을 새로운 값으로 변경한다 |
expiresAt 생략 | 기존 만료일을 유지한다 |
expiresAt: Date | 만료일을 설정하거나 변경한다 |
expiresAt: null | 기존 만료일을 제거한다 |
이를 코드로 처리할 수 있다.
function updateCoupon(
coupon: Coupon,
input: UpdateCouponInput
): Coupon {
const updatedCoupon = { ...coupon };
if (input.title !== undefined) {
const title = input.title.trim();
if (title.length === 0) {
throw new Error(
"쿠폰 제목은 비어 있을 수 없다."
);
}
updatedCoupon.title = title;
}
if (input.expiresAt !== undefined) {
updatedCoupon.expiresAt = input.expiresAt;
}
return updatedCoupon;
}
이 함수에서 undefined는 변경하지 않는다는 의미이고, null은 기존 값을 제거한다는 의미다.
단순히 둘 다 빈 값으로 처리하면 이런 구분이 사라진다.
if (input.expiresAt == null) {
updatedCoupon.expiresAt = null;
}
== null은 null과 undefined를 모두 같은 조건으로 처리한다.
그 결과 사용자가 expiresAt을 보내지 않았는데도 기존 만료일이 삭제될 수 있다.
수정 요청에서는 “값이 없음”보다 “필드를 보내지 않음”과 “값을 제거하도록 요청함”을 구분해야 한다.
HTML 입력창의 값은 일반적으로 문자열로 관리된다.
사용자가 만료일을 입력하지 않았다면 다음과 같은 값이 생길 수 있다.
const expiresAtInput = "";
이 값을 그대로 서비스 객체에 저장하면 여러 종류의 빈 값이 섞인다.
interface CouponFormValues {
expiresAt: string;
}
interface Coupon {
expiresAt: Date | null;
}
폼에서는 빈 문자열이 자연스러울 수 있지만 서비스 내부에서는 Date | null이 더 명확하다.
경계에서 값을 변환해야 한다.
function parseExpiresAt(
value: unknown
): Date | null {
if (value === "" || value === null) {
return null;
}
if (typeof value !== "string") {
throw new Error(
"만료일은 문자열 또는 null이어야 한다."
);
}
const expiresAt = new Date(value);
if (Number.isNaN(expiresAt.getTime())) {
throw new Error(
"올바르지 않은 만료일이다."
);
}
return expiresAt;
}
이 함수는 외부에서 들어온 여러 표현을 서비스가 사용하는 두 가지 상태로 정규화한다.
parseExpiresAt("");
// null
parseExpiresAt(null);
// null
parseExpiresAt("2026-12-31T23:59:59Z");
// Date
서비스 내부에서 "", null, undefined, 유효하지 않은 날짜 문자열을 모두 처리하게 만들면 모든 비즈니스 로직이 복잡해진다.
입력 경계에서 값을 정리하면 내부 코드는 명확한 타입만 다룰 수 있다.
flowchart LR
A[폼의 빈 문자열]
--> D[입력 검증 및 정규화]
B[JSON의 null]
--> D
C[날짜 문자열]
--> D
D --> E[Date 또는 null]
E --> F[서비스 규칙]
F --> G[데이터베이스]
외부의 다양한 빈 값 표현을 내부까지 그대로 가져갈 필요는 없다.
JavaScript에서는 여러 값이 조건문에서 false처럼 처리된다.
if (!value) {
// ...
}
이 조건에는 다음 값들이 모두 들어온다.
false;
0;
"";
null;
undefined;
NaN;
쿠폰의 남은 사용 횟수를 확인한다고 가정해 보자.
if (!coupon.remainingUses) {
throw new Error(
"남은 사용 횟수가 없습니다."
);
}
remainingUses가 0이라면 의도대로 동작할 수 있다.
하지만 undefined, null, NaN도 모두 같은 오류로 처리된다.
이 값들은 서로 다른 문제일 수 있다.
0: 쿠폰을 모두 사용했다.undefined: 필드가 누락되었다.null: 데이터 모델이 잘못되었다.NaN: 숫자 변환에 실패했다.의도를 정확하게 검사해야 한다.
if (coupon.remainingUses === 0) {
throw new Error(
"쿠폰을 모두 사용했습니다."
);
}
if (
!Number.isInteger(coupon.remainingUses) ||
coupon.remainingUses < 0
) {
throw new Error(
"올바르지 않은 사용 횟수입니다."
);
}
기본값을 설정할 때도 같은 문제가 발생한다.
const remainingUses =
input.remainingUses || 1;
사용자가 0을 전달하면 1로 바뀐다.
const remainingUses =
input.remainingUses ?? 1;
??는 값이 null 또는 undefined일 때만 기본값을 사용한다. 따라서 0은 그대로 유지된다.
0 || 1;
// 1
0 ?? 1;
// 0
하지만 ??가 항상 정답이라는 뜻은 아니다.
null을 “기본값을 사용하라”는 의미로 정의했다면 적절하지만, “기존 값을 삭제하라”는 의미라면 기본값으로 바꾸면 안 된다.
문법을 선택하기 전에 빈 값의 서비스 의미를 먼저 정의해야 한다.
쿠폰 테이블에 expires_at 컬럼이 있다고 가정해 보자.
CREATE TABLE coupons (
id UUID PRIMARY KEY,
title TEXT NOT NULL,
expires_at TIMESTAMPTZ NULL
);
expires_at의 NULL을 다음과 같이 정의할 수 있다.
만료일이 없는 쿠폰이다.
이 경우 NULL은 정상적인 데이터다.
SELECT *
FROM coupons
WHERE expires_at IS NULL;
하지만 모든 컬럼에 별다른 이유 없이 NULL을 허용하면 의미가 불분명해진다.
예를 들어 쿠폰 제목까지 NULL을 허용한다고 가정해 보자.
title TEXT NULL
제목이 없는 쿠폰이 실제로 허용되는 서비스 상태가 아니라면 NULL을 허용할 이유가 없다.
title TEXT NOT NULL
데이터베이스 제약조건을 통해 잘못된 상태가 저장되는 것을 막는 편이 안전하다.
또한 SQL의 NULL은 일반적인 값처럼 비교하지 않는다.
SELECT *
FROM coupons
WHERE expires_at = NULL;
이 조건은 의도대로 동작하지 않는다.
NULL 여부는 IS NULL로 확인해야 한다.
SELECT *
FROM coupons
WHERE expires_at IS NULL;
애플리케이션과 데이터베이스에서 모두 null이라는 표현을 사용하더라도 동작 방식과 책임은 다르다.
null은 서비스 상태를 표현한다.NULL은 컬럼에 값이 존재하지 않음을 표현한다.null은 클라이언트와 서버가 합의한 메시지다.각 경계를 통과할 때 의미를 확인하고 필요한 형태로 변환해야 한다.
쿠폰을 조회하는 함수가 있다고 가정해 보자.
async function findCouponById(
couponId: string
): Promise<Coupon | null> {
// ...
}
null을 “정상적으로 조회했지만 해당 쿠폰이 없음”으로 정의할 수 있다.
const coupon =
await findCouponById(couponId);
if (coupon === null) {
throw new Error(
"존재하지 않는 쿠폰입니다."
);
}
이것은 비교적 명확한 계약이다.
하지만 데이터베이스 연결 실패까지 null로 반환하면 문제가 된다.
async function findCouponById(
couponId: string
): Promise<Coupon | null> {
try {
return await couponRepository.findById(
couponId
);
} catch {
return null;
}
}
호출자는 쿠폰이 정말 존재하지 않는지, 데이터베이스 오류로 조회하지 못했는지 알 수 없다.
const coupon =
await findCouponById(couponId);
if (coupon === null) {
return {
status: 404,
message: "존재하지 않는 쿠폰입니다.",
};
}
데이터베이스 장애인데 사용자에게 404를 반환하게 된다.
“없음”과 “실패”는 분리해야 한다.
async function findCouponById(
couponId: string
): Promise<Coupon | null> {
return couponRepository.findById(couponId);
}
조회 과정에서 오류가 발생했다면 예외가 호출자에게 전달되도록 한다.
try {
const coupon =
await findCouponById(couponId);
if (coupon === null) {
return {
status: 404,
message: "존재하지 않는 쿠폰입니다.",
};
}
return {
status: 200,
data: coupon,
};
} catch {
return {
status: 500,
message:
"쿠폰을 조회하는 중 문제가 발생했습니다.",
};
}
이제 결과가 세 가지로 구분된다.
실패를 빈 값으로 숨기면 장애가 정상적인 데이터처럼 보인다.
사용자가 다른 사람의 쿠폰을 조회했다고 가정해 보자.
서버에서는 실제로 쿠폰이 존재하지만 현재 사용자에게 접근 권한이 없을 수 있다.
const coupon =
await couponRepository.findById(couponId);
이때 서비스 정책에 따라 두 가지 방식이 가능하다.
if (coupon === null) {
return { status: 404 };
}
if (coupon.receiverId !== currentUser.id) {
return { status: 403 };
}
이 방식은 “존재하지 않음”과 “권한 없음”을 구분한다.
하지만 쿠폰의 존재 자체를 외부에 노출하면 안 된다면 권한이 없는 경우에도 404를 반환할 수 있다.
if (
coupon === null ||
coupon.receiverId !== currentUser.id
) {
return { status: 404 };
}
외부 응답은 같지만 내부적으로는 이유를 구분해 기록할 수 있다.
type CouponAccessResult =
| { status: "allowed"; coupon: Coupon }
| { status: "not-found" }
| { status: "forbidden" };
빈 값 처리에는 보안 정책도 영향을 준다.
“값이 없다”고 응답하는 것이 실제로 데이터가 없다는 뜻인지, 공개하지 않기로 한 것인지 서비스가 결정해야 한다.
사용자 이름이 없을 때 기본값을 표시할 수 있다.
const senderName =
coupon.senderName ?? "알 수 없는 사용자";
UI에서는 적절한 처리일 수 있다.
하지만 모든 계층에서 기본값을 넣으면 데이터 문제를 숨길 수 있다.
const coupon = {
senderId: databaseRow.sender_id ?? "",
};
senderId가 반드시 존재해야 하는 값이라면 빈 문자열로 바꾸면 안 된다.
데이터베이스 관계가 깨졌거나 변환 로직에 문제가 있다는 사실을 숨기기 때문이다.
if (databaseRow.sender_id === null) {
throw new Error(
"쿠폰 발행자 정보가 누락되었습니다."
);
}
기본값을 사용할 수 있는 경우와 오류로 처리해야 하는 경우를 구분해야 한다.
| 상황 | 처리 예시 |
|---|---|
| UI에 표시할 별명이 없음 | "이름 없음" 같은 문구 표시 |
| 선택 가능한 만료일이 없음 | null로 유지 |
| 필수 사용자 ID가 누락됨 | 오류 처리 |
| 수정 요청에서 필드가 생략됨 | 기존 값 유지 |
| 조회 결과가 없음 | null 또는 명시적인 결과 타입 |
| 요청 처리에 실패함 | 오류로 전달 |
| 로딩 중이라 아직 값이 없음 | 로딩 상태로 표현 |
기본값은 사용성을 위한 선택일 수 있지만 데이터 무결성 문제를 덮는 도구가 되어서는 안 된다.
화면의 데이터는 시간에 따라 변한다.
쿠폰 상세 화면은 다음 순서로 진행될 수 있다.
초기 상태
→ 로딩
→ 성공 또는 존재하지 않음 또는 실패
이 흐름을 Coupon | null | undefined로 표현할 수도 있다.
let coupon:
| Coupon
| null
| undefined;
개발자가 다음과 같이 약속할 수 있다.
undefined: 아직 조회하지 않음null: 조회했지만 존재하지 않음Coupon: 조회 성공하지만 조회 실패 상태가 추가되면 별도의 값이 필요하다.
let error: Error | null = null;
로딩 상태도 추가할 수 있다.
let isLoading = false;
이제 서로 모순되는 상태가 만들어질 수 있다.
const coupon = null;
const isLoading = true;
const error = new Error("조회 실패");
로딩 중이면서 오류가 발생했고 동시에 조회 결과가 없다는 상태다. 어떤 상태를 우선해야 하는지 불분명하다.
가능한 상태를 하나의 타입으로 묶으면 모순을 줄일 수 있다.
type CouponDetailState =
| { status: "idle" }
| { status: "loading" }
| {
status: "success";
coupon: Coupon;
}
| { status: "not-found" }
| {
status: "error";
message: string;
};
이 타입에서는 성공 상태일 때만 coupon이 존재하고, 오류 상태일 때만 오류 메시지가 존재한다.
function renderCouponDetail(
state: CouponDetailState
): string {
switch (state.status) {
case "idle":
return "쿠폰을 선택해 주세요.";
case "loading":
return "불러오는 중입니다.";
case "success":
return state.coupon.title;
case "not-found":
return "쿠폰을 찾을 수 없습니다.";
case "error":
return state.message;
}
}
null과 undefined를 사용하지 말아야 한다는 뜻은 아니다.
단순히 값의 존재 여부만 필요하다면 충분히 적절하다. 하지만 빈 값에 여러 이유와 상태가 담기기 시작하면 명시적인 상태 모델이 필요하다.
값이 없는 상태를 다루는 방식은 서비스가 성장하면서 달라진다.
if (!coupon) {
return "쿠폰이 없습니다.";
}
코드는 짧지만 쿠폰이 없는 이유를 구분하지 못한다.
let coupon:
| Coupon
| null
| undefined;
undefined: 아직 조회하지 않았다.null: 조회했지만 존재하지 않는다.Coupon: 조회에 성공했다.단순한 흐름에서는 사용할 수 있다.
interface CouponPageState {
coupon: Coupon | null;
isLoading: boolean;
error: string | null;
}
로딩과 실패를 표현할 수 있지만 서로 모순되는 조합이 생길 수 있다.
type CouponPageState =
| { status: "loading" }
| { status: "success"; coupon: Coupon }
| { status: "not-found" }
| { status: "error"; message: string };
각 상태에서 사용할 수 있는 데이터가 명확해진다.
const input =
parseUpdateCouponRequest(request.body);
const coupon =
await findCouponById(couponId);
const updatedCoupon =
updateCoupon(coupon, input);
const response =
toCouponResponse(updatedCoupon);
각 단계에서 빈 값의 의미가 다르다.
null: 기존 값을 제거한다.null: 대상이 존재하지 않는다.null: 해당 값이 없는 정상적인 상태다.null: 클라이언트와 합의한 빈 값이다.같은 null과 undefined라도 데이터가 위치한 경계와 역할에 따라 의미가 달라진다.
null일 수 있는가?undefined와 필드 생략을 구분해야 하는가?null, undefined가 함께 사용되고 있지는 않은가?null을 보내면 기존 값을 삭제하는가?undefined가 JSON 변환 과정에서 사라지는 것을 고려했는가?null은 찾지 못했다는 뜻인가?null로 숨기고 있지는 않은가?|| 때문에 0, false, 빈 문자열이 사라지지는 않는가???에서 null과 undefined를 같은 의미로 처리해도 되는가?NULL이 허용되는 이유가 명확한가?NOT NULL 제약조건이 있는가?NULL을 API에서 그대로 노출해도 되는가?NULL을 혼용하고 있지는 않은가?이 질문에 답하면 빈 값을 임시로 피하는 것이 아니라 서비스의 상태로 설계할 수 있다.
if (!coupon.remainingUses) {
throw new Error(
"사용 횟수가 없습니다."
);
}
0, null, undefined, NaN을 구분하지 못한다.
if (coupon.remainingUses === 0) {
throw new Error(
"쿠폰을 모두 사용했습니다."
);
}
필요한 상태를 정확하게 비교해야 한다.
if (coupon === null) {
return "쿠폰이 없습니다.";
}
초기값이 null이면 요청이 완료되기 전에도 잘못된 메시지가 보일 수 있다.
if (state.status === "loading") {
return "불러오는 중입니다.";
}
if (state.status === "not-found") {
return "쿠폰이 없습니다.";
}
조회 과정과 조회 결과를 분리해야 한다.
if (input.expiresAt == null) {
coupon.expiresAt = null;
}
필드를 보내지 않은 요청도 기존 값을 삭제한다.
if (input.expiresAt !== undefined) {
coupon.expiresAt = input.expiresAt;
}
undefined는 변경하지 않음, null은 삭제라는 계약을 유지할 수 있다.
try {
return await findCoupon(couponId);
} catch {
return null;
}
존재하지 않는 데이터와 조회 실패를 구분할 수 없다.
const coupon =
await findCoupon(couponId);
return coupon;
정상적인 조회 결과 없음만 null로 반환하고, 처리 실패는 오류로 전달해야 한다.
interface Coupon {
expiresAt:
| Date
| ""
| null
| undefined;
}
모든 비즈니스 로직이 네 가지 경우를 반복해서 처리해야 한다.
interface Coupon {
expiresAt: Date | null;
}
서비스 경계에서 외부 입력을 정규화해 내부 표현을 단순하게 유지해야 한다.
const senderId =
databaseRow.senderId ?? "";
필수 관계가 깨졌는데도 빈 문자열을 가진 쿠폰이 만들어진다.
if (databaseRow.senderId === null) {
throw new Error(
"발행자 정보가 누락되었습니다."
);
}
필수값의 누락은 오류로 다뤄야 한다.
interface Coupon {
expiresAt?: Date;
}
expiresAt이 없는 이유가 불분명하다.
interface Coupon {
expiresAt: Date | null;
}
만료일 없음이 정상적인 도메인 상태라면 이를 명시적으로 표현하는 편이 낫다.
const couponTitle =
coupon.title ?? "쿠폰";
쿠폰 제목이 필수값이라면 기본값을 넣는 순간 데이터 오류가 숨겨진다.
if (coupon.title === null) {
throw new Error(
"쿠폰 제목이 누락되었습니다."
);
}
기본값을 넣기 전에 해당 값이 없어도 정상인지부터 판단해야 한다.
null과 undefined는 모두 값이 없는 상태를 표현할 수 있다.
하지만 실제 서비스에서 중요한 것은 값이 없다는 사실 자체가 아니다.
그 값이 왜 없는지를 구분하는 일이다.
이 상태들은 사용자에게 보여줄 화면도 다르고, 서버가 수행할 행동도 다르며, 로그에 남겨야 할 정보도 다르다.
type CouponQueryResult =
| { status: "loading" }
| { status: "success"; coupon: Coupon }
| { status: "not-found" }
| { status: "forbidden" }
| { status: "error"; message: string };
여러 의미가 필요하다면 null 하나에 모든 상황을 넣기보다 가능한 상태를 명시적으로 표현해야 한다.
API 수정 요청에서는 필드 생략과 null을 구분할 수 있다.
interface UpdateCouponInput {
title?: string;
expiresAt?: Date | null;
}
undefined 또는 필드 생략은 변경하지 않는다는 뜻이다.null은 기존 값을 제거한다는 뜻이다.외부에서 들어오는 빈 문자열이나 날짜 문자열은 서비스 경계에서 정규화할 수 있다.
const expiresAt =
parseExpiresAt(request.body.expiresAt);
서비스 내부에서는 Date | null처럼 명확하고 제한된 형태를 사용한다.
조회 결과가 없다는 사실과 조회에 실패했다는 사실도 구분해야 한다. 정상적인 데이터 부재는 null로 표현할 수 있지만, 시스템 오류까지 빈 값으로 바꾸면 실패 원인이 사라진다.
결국 null과 undefined를 설계한다는 것은 다음 질문에 답하는 일이다.
이 값은 왜 존재하지 않으며, 서비스는 그 이유에 따라 어떤 행동을 해야 하는가?
null과 undefined는 단순히 값이 없다는 뜻이 아니다.
미입력, 미조회, 데이터 없음, 값 삭제, 권한 제한, 처리 실패처럼 서로 다른 서비스 상태를 구분하고 전달하기 위한 표현이다.