4편. Fastify에서 null,undefined,optional의 계층별 처리 예시
📚 목차
1. null · optional · undefined의 계층별 의미
2. 컴파일 타임에서 의도 강제하기
3. 입력 설계 (DTO): TypeBox로 의도 표현하기
4. 입력 데이터 정제 예시 (Input Mapping)
5. Repository 계층의 정밀한 쿼리 설계 예시
6. 응답 변환 및 데이터 매핑 예시 (Output Mapping & DTO)

1. null · optional · undefined의 계층별 의미
실무에서 가장 자주 발생하는 데이터 오류 중 하나는 ‘값이 없는 상태’를 하나의 개념으로 단순화해 버리는 것에서 시작됩니다.
그러나 안정적인 시스템을 설계하려면 null, optional, undefined가 각 계층에서 어떤 의미를 가지는지 명확히 구분해야 합니다.
그러나 각 기술 스택은 “값의 부재”를 서로 다른 방식으로 해석합니다.
| 구분 | 계층 | 상태 | 의도 |
| null | DB / Prisma | 명시적 빈 값 | 기존 데이터를 지우고 빈 값으로 변경 |
| optional | TypeBox / TypeScript | 속성 자체가 없음 | 기존 데이터를 유지 |
| undefined | JavaScript Runtime | 할당되지 않은 상태 | 판단 대상 아님(skip) |
🔷 수정(PATCH) 요청의 3가지 케이스
사용자 프로필의 bio(자기소개) 필드를 수정한다고 가정할 때, 클라이언트의 요청에 따른 서버의 동작은 다음과 같이 세 갈래로 나뉩니다.
1. 값 업데이트 (Change)
{ "bio": "반갑습니다" }
await prisma.user.update({
where: { id: 1 },
data: { bio: "반갑습니다" }
});
→ DB 컬럼이 "반갑습니다"로 변경됩니다.
2. 명시적 삭제(Delete/Null)
# json
{ "bio": null }
# ts
await prisma.user.update({
where: { id: 1 },
data: { bio: null }
});
# sql
UPDATE User SET bio = NULL WHERE id = 1;
3. 변경 사항 없음 (Keep/Skip):
{}
{ bio: undefined }
→ Prisma에 undefined로 전달되어야 하며, Prisma는 이 필드의 업데이트를 건너뜁니다(Skip).
✔️ 왜 undefined를 직접 보내면 안 되나요?
JSON 표준에는 undefined라는 값이 없습니다.
클라이언트가 { "bio": undefined }라고 보내더라도 실제 네트워크를 타고 오는 JSON은 { }입니다.
따라서 서버 로직에서 undefined는 '클라이언트가 이 필드에 대해 아무 말도 하지 않았음'을 뜻하는 도구로만 사용해야 합니다.
📌 클라이언트가 { "bio": null }을 보내면, 서버는 bio라는 키(Key)와 null이라는 값(Value)을 정확하게 수신합니다.
2. 컴파일 타임에서 의도 강제 하기
상용 환경에서는 tsconfig.json에서 strict: true와 exactOptionalPropertyTypes: true를 반드시 활성화해야 합니다.
{
"compilerOptions": {
"strict": true, // strictNullChecks 포함
"exactOptionalPropertyTypes": true
}
}
🔷 strictNullChecks: "실수로 null을 던지는 것을 방지"
기본적으로 TypeScript는 string 타입 변수에 null이나 undefined를 넣어도 에러를 내지 않을 수 있습니다.
하지만 이 옵션을 켜면, 명시적으로 | null을 선언하지 않는 한 null 할당이 엄격히 금지됩니다.
// strictNullChecks: true 일 때
let name: string = "Gemini";
name = null; // ❌ 에러: 'null' 형식은 'string' 형식에 할당할 수 없습니다.
name = undefined; // ❌ 에러: 'undefined' 형식은 'string' 형식에 할당할 수 없습니다.
// 빈 값을 허용하려면 명시적으로 선언해야 함
let nullableName: string | null = "Gemini";
nullableName = null; // ✅ 정상
▸ "이 값은 절대 비어있을 수 없어!"라고 선언한 곳에 실수로 빈 값을 넣는 버그를 컴파일 타임에 잡아냅니다.
▸ 특히 DB에서 NOT NULL인 컬럼을 다룰 때 매우 강력한 보호막이 됩니다.
🔷 exactOptionalPropertyTypes: "속성 부재와 undefined를 엄격히 구분"
TypeScript의 기본 동작은 age?: number라는 선택적 속성에 { age: undefined }를 명시적으로 넣는 것을 허용합니다.
하지만 이 옵션을 켜면 "속성이 없는 것"과 "속성은 있는데 값이 undefined인 것"을 다르게 보고 후자를 금지합니다.
interface UserUpdateInput {
email?: string;
}
// exactOptionalPropertyTypes: true 일 때
const input: UserUpdateInput = {
email: undefined // ❌ 에러: 'undefined' 형식을 'string' 형식에 할당할 수 없습니다.
};
// 올바른 방법: 속성 자체를 정의하지 않음
const correctInput: UserUpdateInput = {}; // ✅ 정상
▸ Prisma Client는 id?: boolean처럼 모든 필드를 선택적 속성으로 정의합니다.
만약 우리가 실수로 { email: undefined }라는 객체를 Prisma에 전달하면, Prisma는 "이 필드를 무시(Skip)할지" 아니면 "에러를 낼지" 헷갈려할 수 있습니다.
▸ "조회나 수정을 안 할 거면 아예 속성을 빼버려!"라고 강제함으로써, Prisma가 우리의 의도를 100% 정확하게 파악하도록 돕습니다.
3. 입력 설계 (DTO): TypeBox로 의도 표현하기
Input DTO는 단순한 "형식 검증 도구"가 아닙니다.
이 계층의 역할은 클라이언트의 의도를 정확하게 표현하는 것입니다.
PATCH 요청에서는 특히 다음 질문이 중요합니다.
✔ 삭제를 허용할 필드인가? → Nullable 추가
✔ 유지가 가능해야 하는가? → Optional 추가
✔ NOT NULL 컬럼인가? → Nullable 금지
✔ exactOptionalPropertyTypes 활성화 여부 확인
이 차이를 명확히 인식하고 설계하는 것이 안정적인 API 계약을 만드는 핵심입니다.
🔷1. Optional과 Nullable의 역할 차이
🔸Optional → "이 필드는 보내지 않아도 된다"
속성이 아예 없으면 "기존 값을 유지"한다는 의미입니다.
🔸Nullable → "이 필드는 null을 보낼 수 있다"
null을 명시적으로 보내면 "기존 값을 삭제"하겠다는 의미입니다.
🔸Nullable 헬퍼 정의
import { Type, TSchema } from '@sinclair/typebox';
export const Nullable = <T extends TSchema>(type: T) =>
Type.Union([type, Type.Null()]);
🔷2. PATCH DTO 단계별 설계 예시
1) 단순 수정만 허용 (삭제 불가)
→ nickname은 삭제 불가 필드입니다.
const UserUpdateDto = Type.Object({
nickname: Type.Optional(Type.String())
});
# 허용되는 요청:
{ "nickname": "newName" }
{}
# 허용되지 않는 요청
{ "nickname": null }
2) 수정 + 삭제 허용
const UserUpdateDto = Type.Object({
displayName: Type.Optional(Nullable(Type.String()))
});
# 허용되는 요청
{ "displayName": "홍길동" } // 수정
{ "displayName": null } // 삭제
{} // 유지
🔷3. 왜 Optional(Nullable(T)) 구조가 중요한가?
만약 아래처럼 작성하면 문제가 생깁니다.
// ❌ 잘못된 설계
const BadDto = Type.Object({
displayName: Nullable(Type.String())
});
이 경우 displayName은 필수 필드가 됩니다.
즉, 클라이언트는 항상 값을 보내야 합니다.
{ "displayName": null }
또는
{ "displayName": "홍길동" }
PATCH 요청에서 "유지" 상태를 표현할 방법이 사라집니다.
따라서 PATCH에서는 대부분 다음 형태가 필요합니다.
Type.Optional(Nullable(Type.String()))
4. 입력 데이터 정제 예시 (Input Mapping)
DTO → Service → Repository로 이동하면서 가장 중요한 원칙은:
undefined는 제거하고, null은 보존한다.
🔷구조 분해 패턴
검색 조건과 더불어 페이징, 정렬, 관계 데이터 포함 여부 등 다양한 '제어 필드'가 섞여 들어옵니다.
interface UserUpdateInput {
nickname?: string | null;
displayName?: string | null;
phoneNumber?: string | null;
includeProfile?: boolean;
page?: number;
pageSize?: number;
}
1단계: 제어 필드 명시 추출
명시적으로 꺼낸 값은 “조건 처리용”, rest(...)로 묶인 값은 “도메인 데이터 영역”
const {
includeProfile,
page,
pageSize,
...coreFields
} = dto;
▸ includeProfile, page, pageSize → 제어 영역
▸ coreFields → 실제 업데이트 대상 필드
2단계: undefined 제거 (의도 정제)
const {
nickname,
displayName,
phoneNumber
} = coreFields;
const data = {
...(nickname !== undefined && { nickname }),
...(displayName !== undefined && { displayName }),
...(phoneNumber !== undefined && { phoneNumber })
};
→ undefined 필드는 완전히 제거됩니다.
async update(id: number, dto: UserUpdateInput) {
// 1️⃣ 제어 필드 분리
const {
includeProfile,
page,
pageSize,
...coreFields
} = dto;
// (제어 필드는 여기서 별도 로직 처리 가능)
// 2️⃣ undefined 제거 + null 보존
const {
nickname,
displayName,
phoneNumber
} = coreFields;
const data = {
...(nickname !== undefined && { nickname }),
...(displayName !== undefined && { displayName }),
...(phoneNumber !== undefined && { phoneNumber })
};
// 3️⃣ 정제된 도메인 데이터만 Repository로 전달
return this.repository.update(id, data);
}
🔷검색 조건 동적 조립 예시
Prisma where 객체를 구성할 때, 값이 존재할 때만 조건을 추가하는 것이 매우 중요합니다.
✔️ 잘못된 방식
▸ input.keyword가 없으면 contains: undefined가 전달될 수 있습니다.
▸ 불필요한 OR 조건이 생성될 수 있습니다.
▸ 그 결과, 쿼리 성능이 저하될 수 있습니다.
const where = {
deletedAt: null,
OR: [
{ email: { contains: input.keyword } },
{ displayName: { contains: input.keyword } }
]
};
✔️ 권장 패턴 (Spread + &&)
▸ input.keyword가 존재할 때만 OR 조건이 추가됩니다.
▸ 값이 없으면 해당 블록은 전개되지 않습니다.
▸ 불필요한 조건 생성 및 contains: undefined 전달을 방지합니다.
▸ 결과적으로 더 안전하고 최적화된 쿼리가 생성됩니다.
// [실무 패턴] 검색어가 있을 때만 LIKE 쿼리 적용
const where = {
deletedAt: null, // 공통 조건
// input.keyword가 존재할 경우에만 OR 검색 조건을 동적으로 추가
// (값이 없으면 false가 되어 spread 되지 않음)
...(input.keyword && {
OR: [
// email 필드에 keyword가 포함된 경우
{ email: { contains: input.keyword } },
// displayName 필드에 keyword가 포함된 경우
{ displayName: { contains: input.keyword } }
]
})
};
▸ 조건이 늘어나도 패턴이 일정합니다.
const where = {
deletedAt: null,
...(input.keyword && { ... }),
...(input.isActive !== undefined && { isActive: input.isActive }),
...(input.role && { role: input.role })
};
5. Repository 계층의 정밀한 쿼리 설계 예시
Service 계층에서 1차 정제가 끝났다면, Repository 계층의 역할은 Prisma에 전달할 쿼리를 안전하고 정밀하게 구성하는 것입니다.
🔷1. 상호 배타적 Unique 조회 패턴
✔️ 문제 상황
Prisma의 findUnique는 정확히 하나의 Unique 조건을 기대합니다. 만약 API 입력값으로 여러 고유 식별자가 섞여 들어올 경우, 어떤 값을 기준으로 조회할지 모호해지며 시스템의 예측 가능성이 떨어지게 됩니다.
✔️ 실무 패턴: 단일 식별자 추출 전략
아래 코드는 입력된 조건에서 제어 필드를 분리하고, 순수한 식별자만을 활용해 데이터를 조회하는 최적화된 Repository 패턴 예시입니다.
async findByUniqueKey(where: UserBaseWhere) {
// 1. 비즈니스 로직용 제어 필드(includeProfile)와 순수 식별자(uniqueKey)를 분리합니다.
const { includeProfile, ...uniqueKey } = where;
return this.prisma.user.findUniqueOrThrow({
where: {
...uniqueKey,
deletedAt: null, // Soft Delete 정책 반영
},
select: {
id: true,
email: true,
// 2. 필요에 따라 관계 데이터를 선택적으로 포함(Data Shaping)합니다.
...(includeProfile && {
profile: { select: profileSelect }
})
}
});
}
▸ includeProfile과 같은 옵션은 Prisma의 where 절에 직접 포함되어서는 안 되는 필드입니다. 구조 분해 할당을 통해 이를 미리 분리함으로써, uniqueKey 객체에는 Prisma가 이해할 수 있는 순수 Unique 필드만 남게 됩니다.
const { includeProfile, ...uniqueKey } = where;
▸ 효율적인 데이터 셰이핑 (Data Shaping)
필요한 필드만 조회하므로 네트워크 트래픽이 줄어들고, 불필요한 연관 데이터 로딩을 방지할 수 있습니다.
select: {
id: true,
email: true,
...(includeProfile && { profile: { select: profileSelect } })
}
✔️ 타입 레벨에서 상호 배타적 설계
이렇게 하면 컴파일 타임에 “id와 email을 동시에 전달하는 실수”를 차단할 수 있습니다.
type UserUniqueWhere =
| { id: string; email?: never }
| { email: string; id?: never };
🔷2. 안전한 동적 정렬(OrderBy) 조립 패턴
✔️ 문제 상황
클라이언트는 주로 { "field": "createdAt", "direction": "desc" }와 같은 구조로 요청합니다.
이를 검증 없이 { [field]: direction }으로 직접 매핑하면 다음과 같은 위험이 있습니다.
▸ 존재하지 않는 필드 요청 시 런타임 에러 발생
▸ 데이터베이스 스키마 정보 노출 및 보안 취약점
✔️ 실무 패턴: 화이트리스트 기반 매핑
허용된 필드 목록을 정의하고, 이를 바탕으로 정렬 구문을 조립합니다.
▸ 스키마에 없는 필드나 외부에 노출하고 싶지 않은 필드(예: password)에 대한 접근을 원천 차단합니다.
▸ 정렬 값이 누락되거나 잘못된 경우에 대한 기본 동작(createdAt, desc)을 한 곳에서 정의하여 API 일관성을 확보합니다.
▸ SortField를 유니온 리터럴로 고정하여 Prisma의 타입 추론 시스템과 완벽하게 호환됩니다.
// 1. 허용된 정렬 필드를 화이트리스트로 정의
// - 클라이언트가 요청할 수 있는 정렬 필드를 명시적으로 제한합니다.
// - 존재하지 않는 컬럼 접근을 방지하고,
// Prisma 스키마 외 필드로 인한 런타임 에러를 차단합니다.
// - `as const`를 사용하여 literal type으로 고정합니다.
const ALLOWED_SORT_FIELDS = [
'createdAt',
'email',
'displayName'
] as const;
// ALLOWED_SORT_FIELDS 배열의 각 요소를 union 타입으로 추출
// → 'createdAt' | 'email' | 'displayName'
type SortField = typeof ALLOWED_SORT_FIELDS[number];
// 2. 동적 정렬 조립 로직
// - 클라이언트가 전달한 orderBy.field가
// 화이트리스트에 포함되어 있는지 검증합니다.
// - 포함되어 있으면 해당 값을 사용하고,
// 그렇지 않으면 기본 정렬 필드('createdAt')를 사용합니다.
// - 타입 단언(as SortField)은 includes 검증 이후 안전하게 사용됩니다.
const sortField: SortField =
ALLOWED_SORT_FIELDS.includes(orderBy?.field as SortField)
? (orderBy!.field as SortField)
: 'createdAt'; // 기본 정렬 필드
// - 정렬 방향은 'asc' 또는 'desc'만 허용합니다.
// - 클라이언트가 'asc'를 명시적으로 요청한 경우만 asc,
// 그 외에는 모두 desc로 처리합니다.
// - 이를 통해 예상치 못한 문자열 입력을 방지합니다.
const sortOrder: 'asc' | 'desc' =
orderBy?.direction === 'asc' ? 'asc' : 'desc'; // 기본 정렬 방향
// 3. Prisma에서 요구하는 객체 기반 정렬 구조로 변환
// - Prisma는 { fieldName: 'asc' | 'desc' } 형태를 기대합니다.
// - 계산된 sortField를 key로 사용하여 동적 객체를 생성합니다.
const orderByClause = {
[sortField]: sortOrder
};
6. 응답 변환 및 데이터 매핑 예시 (Output Mapping & DTO)
DB 엔티티(Raw Data)를 클라이언트가 요구하는 규격(DTO)에 맞춰 가공하는 과정입니다
🔷1. 데이터 정제 및 도메인 보호 (Data Sanitization)
DB에서 조회한 엔티티에는 비밀번호 해시, 내부 메모 등 클라이언트에게 노출되어서는 안 될 민감한 정보가 포함되어 있을 수 있습니다. 매퍼(Mapper) 함수를 통해 이를 안전하게 걸러냅니다.
// [실무 패턴] Mapper 함수를 통한 안전한 데이터 변환
function toUserResponse(user: UserWithProfile): UserResponseDto {
// 1. 구조 분해를 사용하여 민감하거나 불필요한 필드를 배제합니다.
const { passwordHash, internalMemo, deletedAt, ...publicData } = user;
return {
// 2. 전개 연산자로 안전한 데이터만 응답 객체에 담습니다.
...publicData,
// 3. JavaScript Date 객체를 클라이언트 표준 포맷(ISO 8601)으로 직렬화합니다.
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
// 4. 관계 데이터가 존재할 경우, 클라이언트가 접근하기 편한 구조로 재구성(Flattening)합니다.
bio: user.profile?.bio ?? null,
// 5. 비즈니스 로직에 따른 조건부 필드 주입 (예: 이미지 풀 경로 생성)
...(user.profile?.avatarKey && {
avatarUrl: `https://cdn.example.com/${user.profile.avatarKey}`
})
};
}
▸ 민감정보 제거
const { passwordHash, internalMemo, deletedAt, ...publicData } = user;
▸ 날짜 직렬화 (Serialization)
createdAt: user.createdAt.toISOString()
🔷2. 일관성 있는 목록 응답 및 메타데이터 결합
목록 조회 시 실제 데이터와 더불어 전체 개수, 현재 페이지 정보 등 '메타데이터'를 구조화하여 제공함으로써 클라이언트의 UI 구현을 돕습니다.
// [실무 패턴] 일관성 있는 목록 응답 구조 생성
return {
// 사용자 정보를 매퍼를 통해 변환하여 배열로 담습니다.
data: users.map(toUserResponse),
// 페이징 및 추가 정보를 meta 객체에 통합합니다.
meta: {
total, // 전체 레코드 수
...(skip !== undefined && { skip }), // 요청된 건너뛰기 수
...(take !== undefined && { take }), // 요청된 가져오기 수
// 클라이언트가 다음 페이지 존재 여부를 즉시 판단할 수 있도록 돕습니다.
hasNextPage: total > (skip ?? 0) + (take ?? 0)
}
};
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.
'4.Node.js > Vitest&TypeBox' 카테고리의 다른 글
| [TypeBox] 3편. Fastify TypeBox(legacy) 스키마 패턴 모음 (0) | 2026.02.24 |
|---|---|
| [TypeBox] 2편. Fastify를 위한 TypeBox(legacy) 기본 문법 이해하기 (0) | 2026.02.23 |
| [TypeBox] 1편. Fastify에서 TypeBox를 사용하는 이유와 적용 방법 (0) | 2026.02.19 |
| [Vitest] 8편. Vitest + V8 기반 테스트 커버리지 : @vitest/coverage-v8 (0) | 2026.02.11 |
| [Vitest] 7편. Vitest Mocking 이해하기 : vi.mock()과 vi.fn() (0) | 2026.02.10 |