4.Node.js/Vitest&TypeBox

[TypeBox] 4편. Fastify에서 null,undefined,optional의 계층별 처리 예시

쿼드큐브 2026. 2. 26. 08:17
반응형
반응형

 

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 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형