3편. Fastify TypeBox(legacy) 스키마 패턴 모음
📚 목차
1. 기본 CRUD & 유효성 검증 패턴 (Validation)
2. 고급 타입 & 재사용 패턴
3. 파일 업로드 & 다운로드 (Multipart & Stream)
4. 응답 표준화 & 전역 스키마 등록

1. 기본 CRUD & 유효성 검증 패턴 (Validation)
🔷회원가입 & 로그인 (보안 및 교차 검증)
회원가입 시 비밀번호 확인 필드나, 특정 조건에 따른 필드 필수화는 실무에서 자주 발생합니다.
import { Type, Static } from '@sinclair/typebox';
/**
* 회원가입 스키마: 사용자로부터 입력받는 값들의 형식을 정의합니다.
*/
export const UserRegistrationSchema = Type.Object({
// format: 'email'은 RFC 5322 표준에 맞는 이메일 형식인지 검증합니다.
// 이메일 정규표현식을 직접 작성하는 수고를 덜어주며 일관된 검증을 보장합니다.
email: Type.String({
format: 'email',
description: '사용자의 고유 식별 이메일 주소'
}),
// 보안을 위한 비밀번호 정책: 최소 8자, 대소문자/숫자/특수문자 조합 강제
password: Type.String({
minLength: 8,
maxLength: 20, // maxLength는 메모리 고갈 공격(Buffer Overflow/DoS)을 방지하는 보안의 필수 요소입니다.
// 정규표현식(Regex): 보안 가이드라인에 따른 복잡도 검증
// ReDoS 공격을 방지하기 위해 정규식은 가급적 단순하고 명확하게 작성하는 것이 좋습니다.
pattern: '^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$',
description: '영문 대소문자, 숫자, 특수문자가 조합된 비밀번호'
}),
// 비밀번호 확인: 서버 스키마 단계에서는 단순 문자열 여부만 체크합니다.
// 두 필드의 일치 여부는 DB 트랜잭션 전 로직(Controller)에서 비즈니스 규칙으로 처리합니다.
confirmPassword: Type.String({ description: '비밀번호 확인용 재입력 값' }),
// 닉네임: 너무 길거나 짧은 입력을 방지하여 DB 인덱스 성능과 UI 레이아웃을 보호합니다.
nickname: Type.String({
minLength: 2,
maxLength: 10,
description: '사용자 별명 (2-10자)'
}),
// 마케팅 동의: 불리언 값이며, 입력이 없을 시 기본값을 할당하여 비즈니스 로직 오류를 방지합니다.
marketingAgreed: Type.Boolean({
default: false,
description: '마케팅 정보 수신 동의 여부'
})
}, {
// additionalProperties: false - 정의되지 않은 필드 유입 시 에러 발생
// Mass Assignment 공격(불필요한 권한 필드 주입 등)을 방지하는 필수 보안 설정입니다.
additionalProperties: false
});
/**
* 로그인 스키마: 인증에 필요한 최소한의 정보만 정의하여 공격 표면을 줄입니다.
*/
export const LoginSchema = Type.Object({
email: Type.String({ format: 'email' }),
password: Type.String(),
// Type.Optional: 해당 필드는 전달되지 않아도 검증을 통과합니다. (로그인 유지 여부 등)
rememberMe: Type.Optional(Type.Boolean({ default: false }))
}, { additionalProperties: false });
// 추출된 타입을 통해 핸들러에서 request.body 등의 타입을 강제하여 런타임 에러를 방지합니다.
export type UserRegistration = Static<typeof UserRegistrationSchema>;
export type LoginRequest = Static<typeof LoginSchema>;
🔷 상태 변경 및 Enum 관리
리소스의 상태(Status)를 변경할 때는 허용된 값만 받도록 Union 혹은 Enum을 사용합니다.
▸ 사용자 관리: 휴면 계정 전환, 차단, 권한 변경(USER -> ADMIN) 등.
▸ 주문/결제: 결제 대기, 완료, 취소, 환불 진행 상태 추적.
▸ 게시글 관리: 임시 저장, 공개, 비공개, 삭제(Soft Delete) 상태 제어.
/**
* Discriminated Union 패턴: status 값에 따라 스키마 구조를 동적으로 결정합니다.
* 하나의 엔드포인트에서 여러 유형의 상태 업데이트를 안전하게 처리할 수 있습니다.
*/
export const UpdateStatusSchema = Type.Union([
// 정상 활성화 상태: 추가 정보 없이 상태값만 변경
Type.Object({
status: Type.Literal('ACTIVE'),
}),
// 정지 상태: 정지 사유(reason) 필드가 누락되면 검증 에러가 발생합니다.
Type.Object({
status: Type.Literal('SUSPENDED'),
reason: Type.String({ minLength: 5, description: '정지 사유 필수 입력' })
}),
// 탈퇴 상태: 피드백은 있으면 받고 없어도 무관한 Optional 처리
Type.Object({
status: Type.Literal('DELETED'),
feedback: Type.Optional(Type.String())
})
], { description: '상태값에 따른 조건부 필드 유효성 검사' });
🔷 페이지네이션 & 정밀 검색 (복합 필터링)
단순 검색 외에 날짜 범위(Date Range)나 다중 선택 필터링을 포함하는 패턴입니다.
/**
* 목록 조회 시 쿼리 스트링 검증 스키마
* 클라이언트가 보내는 모든 조회 옵션에 대해 상한선과 형식을 지정합니다.
*/
export const PaginationQuerySchema = Type.Object({
// Type.Integer: 쿼리 스트링으로 들어오는 "1" 같은 문자열을 숫자로 자동 변환 후 검증합니다.
page: Type.Optional(Type.Integer({ minimum: 1, default: 1 })),
// limit에 최대치를 두어 대량 데이터 조회 공격(Slow Query)을 방지합니다.
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 20 })),
// 검색 필드 범위를 Literal로 제한하여 SQL Injection 위험을 줄이고 쿼리 최적화를 돕습니다.
searchField: Type.Optional(Type.Union([
Type.Literal('name'),
Type.Literal('email'),
Type.Literal('content')
])),
searchQuery: Type.Optional(Type.String({ minLength: 1, maxLength: 50 })),
// format: 'date-time'은 ISO8601 형식을 검증하며, DB 날짜 쿼리에 즉시 사용 가능합니다.
startDate: Type.Optional(Type.String({ format: 'date-time' })),
endDate: Type.Optional(Type.String({ format: 'date-time' })),
// 다중 필터링: 배열 크기를 제한하여 불필요한 메모리 소모를 막습니다.
categories: Type.Optional(Type.Array(Type.String(), {
maxItems: 5,
description: '필터링할 카테고리 목록 (최대 5개)'
})),
// 정렬 옵션 명시: 존재하지 않는 컬럼으로의 정렬 시도를 차단합니다.
sortBy: Type.Optional(Type.Union([
Type.Literal('createdAt'),
Type.Literal('updatedAt'),
Type.Literal('price')
])),
sortOrder: Type.Optional(Type.Union([
Type.Literal('asc'),
Type.Literal('desc')
], { default: 'desc' }))
}, { additionalProperties: false });
2. 고급 타입 & 재사용 패턴
실무에서는 코드 중복을 줄이기 위해 기존 스키마를 기반으로 새로운 스키마를 파생시키는 기법을 적극적으로 사용합니다.
🔷 스키마 변형 및 Nullable 처리
DB 연동 시 필드가 아예 없는 것(undefined)과 존재하지만 값이 비어있는 것(null)은 엄격히 구분됩니다.
/**
* 서비스 전체에서 공유되는 기본 도메인 객체 정의
*/
const BaseUser = Type.Object({
id: Type.String(),
email: Type.String({ format: 'email' }),
role: Type.Union([Type.Literal('ADMIN'), Type.Literal('USER')]),
// Nullable 패턴: DB 컬럼이 Null 허용인 경우 반드시 Type.Null()을 Union으로 묶어야 합니다.
// 데이터베이스 무결성과 API 응답 스펙을 일치시키는 핵심 작업입니다.
profileImageUrl: Type.Union([Type.String({ format: 'uri' }), Type.Null()]),
createdAt: Type.String({ format: 'date-time' })
});
/**
* Partial & Omit 활용: 기존 스키마를 재사용하여 코드 중복을 제거합니다.
* 1. Omit: 수정되어서는 안 될 시스템 필드(PK, 생성일 등)를 제거합니다.
* 2. Partial: 모든 필드를 선택사항으로 바꾸어 필요한 필드만 보내는 PATCH 요청에 대응합니다.
*/
export const UpdateProfileSchema = Type.Partial(
Type.Omit(BaseUser, ['id', 'email', 'createdAt', 'role'])
);
/**
* Intersect 활용: 여러 스키마를 결합하여 새로운 스키마를 생성합니다. (상속과 유사한 효과)
* 실무에서는 일반 사용자 정보에 관리자 전용 필드를 추가할 때 자주 사용됩니다.
*/
export const AdminUserDetail = Type.Intersect([
BaseUser,
Type.Object({
internalMemo: Type.String({ description: '관리자 전용 비공개 메모' }),
lastLoginIp: Type.String({ format: 'ipv4', description: '최근 접속 IP' })
})
]);
/**
* KeyOf 활용: 객체의 키값들만 추출하여 문자열 유니온 타입을 생성합니다.
* 주로 정렬 필드(sortBy)나 필터 조건의 키를 제한할 때 유용하며,
* 원본 스키마 수정 시 자동으로 동기화됩니다.
*/
export const UserSortField = Type.KeyOf(BaseUser);
🔷 계층형 구조 (Recursive Types)
댓글의 대댓글, 조직도, 카테고리 트리 등 자기 참조가 필요한 구조에서 사용합니다.
/**
* 재귀적 구조 정의: 카테고리 트리나 댓글/대댓글 같은 무한 계층 데이터를 표현합니다.
* Type.Recursive 내에서 인자로 받는 Self는 현재 정의 중인 스키마 자체를 의미합니다.
*/
export const CategorySchema = Type.Recursive((Self) =>
Type.Object({
id: Type.String(),
name: Type.String(),
// children 필드 내에 다시 CategorySchema(Self)가 위치하여 재귀 구조를 완성합니다.
children: Type.Optional(Type.Array(Self))
})
);
3. 파일 업로드 & 다운로드 (Multipart & Stream)
파일 처리는 일반 JSON 요청과 분리하여 처리하는 것이 일반적입니다. (Content-Type: multipart/form-data)
🔷 파일 및 업로드 결과 스키마
보통 '파일 업로드 전용 API'를 따로 두어 파일을 먼저 서버(또는 S3)에 올리고, 반환받은 fileId나 url을 실제 비즈니스 데이터(JSON)와 함께 전송합니다.
/**
* 업로드된 파일의 메타데이터 검증 스키마
* fastify-multipart 등의 라이브러리를 통해 파싱된 파일 객체의 형식을 정의합니다.
*/
const FileSchema = Type.Object({
// fieldname: <input name="files"> 에서 지정한 이름
fieldname: Type.String(),
// filename: 클라이언트 환경의 원본 파일명 (로그 및 저장 시 참조)
filename: Type.String(),
// 보안을 위한 MIME 타입 화이트리스트 검증:
// 실행 파일(.exe, .sh) 업로드 방지의 첫 걸음이며, pattern을 통해 엄격하게 제한합니다.
mimetype: Type.String({
pattern: 'image/(jpeg|png|webp)',
description: '허용된 이미지 타입만 추출 (jpg, png, webp)'
}),
// encoding: 파일 인코딩 방식 (일반적으로 7bit 또는 binary)
encoding: Type.String()
});
/**
* 다중 파일 업로드 스키마
* 배열의 최소/최대 크기를 지정하여 인프라 자원 소모를 예측 가능한 범위로 제한합니다.
*/
export const FileUploadSchema = Type.Object({
files: Type.Array(FileSchema, {
minItems: 1,
maxItems: 5,
description: '최소 1장 이상 필수, 서버 부하 방지를 위해 최대 5장까지만 허용'
})
}, {
// 업로드 시 불필요한 필드 주입을 차단하여 파싱 로직의 안정성을 확보합니다.
additionalProperties: false
});
/**
* 업로드 완료 후 반환 스키마 (클라이언트가 다음 단계 비즈니스 로직에서 사용할 정보)
*/
export const UploadedFileResponseSchema = Type.Object({
// fileId: DB 인덱싱된 고유 식별자 (UUID 등)
fileId: Type.String({ description: '파일을 식별하기 위한 고유 ID' }),
// url: CDN 또는 스토리지 접근 주소 (Public 접근이 필요한 경우)
url: Type.String({ format: 'uri', description: '파일의 웹 접근 가능 URL' }),
// size: 업로드된 실제 용량 (클라이언트 측 표시용)
size: Type.Integer({ description: '파일의 실제 크기 (Byte 단위)' })
});
🔷 파일 다운로드 (보안 및 스트리밍)
서버의 실제 경로를 숨기고, 권한이 있는 사람만 스트림 방식으로 파일을 안전하게 받게 합니다.
// 1. 다운로드 요청 검증 스키마
export const DownloadParamsSchema = Type.Object({
// 보안 팁: 실제 파일 경로 대신 UUID/ID를 사용하여 Path Traversal(경로 조작) 공격을 원천 차단합니다.
fileId: Type.String({
pattern: '^[0-9a-fA-F-]{36}$', // UUID v4 형식 검증 예시
description: '다운로드할 파일의 고유 식별자'
})
});
// 2. 실무형 다운로드 핸들러 구현 가이드
/*
fastify.get<{ Params: Static<typeof DownloadParamsSchema> }>(
'/download/:fileId',
{ schema: { params: DownloadParamsSchema } },
async (request, reply) => {
const { fileId } = request.params;
// [보안] DB에서 파일 정보 조회 및 사용자의 파일 접근 권한 확인 필수
const file = await db.files.findUnique({ where: { id: fileId } });
if (!file) {
return reply.status(404).send({ message: '파일이 존재하지 않습니다.' });
}
// [성능] 대용량 파일 전송 시 메모리 부족 방지를 위해 스트림(fs.createReadStream) 사용
const stream = fs.createReadStream(file.absolutePath);
// [응답 제어]
// - Content-Type: 파일 종류 지정
// - Content-Disposition: 'attachment' 설정 시 브라우저에서 다운로드 창 활성화
// - encodeURIComponent: 한글 파일명이 깨지는 현상 방지
reply
.header('Content-Type', file.mimetype)
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(file.originalName)}"`)
.send(stream);
}
);
*/
4. 응답 표준화 & 전역 스키마 등록
모든 API가 동일한 응답 구조를 갖도록 표준화하고, 자주 쓰는 스키마는 전역(Shared)으로 등록하여 관리합니다
🔷 API 응답 표준화
import { TSchema, Type } from '@sinclair/typebox';
/**
* 페이지네이션 결과를 위한 공통 메타데이터 구조
* 목록 조회 결과가 비어있더라도 meta 정보는 항상 반환하여 클라이언트의 계산 오류를 방지합니다.
*/
export const MetaSchema = Type.Object({
total: Type.Number({ description: '필터링된 전체 데이터 개수' }),
page: Type.Number({ description: '현재 응답 데이터의 페이지 번호' }),
limit: Type.Number({ description: '한 페이지당 요청된 최대 항목 수' }),
totalPages: Type.Optional(Type.Number({ description: '전체 페이지 수' }))
});
/**
* 표준 응답 래퍼 함수 (Factory Pattern)
* @param data 각 API에서 반환할 구체적인 데이터 스키마
* 성공 시 항상 success: true와 함께 타임스탬프를 제공하여 데이터의 선후 관계 파악을 돕습니다.
*/
export const StandardResponse = <T extends TSchema>(data: T) =>
Type.Object({
success: Type.Boolean({ default: true, description: '요청 성공 여부' }),
data: data, // 호출 시 전달한 개별 스키마가 여기에 동적으로 주입됩니다.
meta: Type.Optional(MetaSchema), // 목록형 응답(Array)인 경우에만 선택적으로 포함됩니다.
timestamp: Type.String({
format: 'date-time',
default: new Date().toISOString(),
description: '서버 측 응답 생성 시각'
})
});
/**
* API 에러 발생 시의 공통 응답 구조
* HTTP Status 외에 구체적인 비즈니스 에러 코드를 제공하여 클라이언트 분기 처리를 돕습니다.
*/
export const ErrorResponseSchema = Type.Object({
success: Type.Literal(false),
error: Type.Object({
code: Type.String({ description: '프론트엔드 분기 처리를 위한 비즈니스 에러 코드' }),
message: Type.String({ description: '사용자에게 보여줄 에러 메시지' }),
details: Type.Optional(Type.Any({ description: '스키마 검증 에러 등 디버깅을 위한 상세 정보' }))
})
});
🔷 전역 공유 스키마 정의 및 등록 (Shared Schemas & $id)
대규모 프로젝트에서 파일 간 import 의존성(Circular Dependency)을 줄이고, 스키마의 일관성을 유지하는 가장 효과적인 방법입니다.
/**
* 1. 공유 스키마 정의
* 각 스키마 객체에 고유한 $id 문자열을 부여합니다.
*/
export const UserSchema = Type.Object({
id: Type.String({ format: 'uuid', description: '사용자 고유 ID' }),
name: Type.String({ minLength: 1, description: '사용자 성명' }),
email: Type.String({ format: 'email', description: '사용자 이메일' })
}, { $id: 'User' }); // 전역 고유 식별자 부여
/**
* 2. Fastify 인스턴스에 등록 (app.ts)
* fastify.addSchema(UserSchema); 를 통해 전역 레지스트리에 등록합니다.
*/
/**
* 3. 임포트 없이 참조하기 (Type.Ref)
* 등록된 스키마는 $id 값을 통해 참조할 수 있습니다. 실제 JSON Schema 상에서 $ref: "User"로 변환됩니다.
*/
export const PostSchema = Type.Object({
title: Type.String({ description: '게시글 제목' }),
// 방법 A: 정의된 스키마 객체를 참조 (컴파일 타임 안전성)
author: Type.Ref(UserSchema),
// 방법 B: $id 문자열을 직접 사용 (순환 참조 방지에 유리)
// author: Type.Ref('User')
});
/**
* 응답 스키마 적용 예시
* StandardResponse 같은 래퍼와 결합할 때도 Type.Ref를 사용하여 스키마 중복을 최소화합니다.
*/
export const GetUserResponse = StandardResponse(Type.Ref(UserSchema));
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.
'4.Node.js > Vitest&TypeBox' 카테고리의 다른 글
| [TypeBox] 4편. Fastify에서 null,undefined,optional의 계층별 처리 예시 (1) | 2026.02.26 |
|---|---|
| [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 |