4.Node.js/Vitest&TypeBox

[TypeBox] 1편. Fastify에서 TypeBox를 사용하는 이유와 적용 방법

쿼드큐브 2026. 2. 19. 12:33
반응형
반응형

 

[TypeBox] 1편. Fastify에서 TypeBox를 사용하는 이유와 적용 방법

 

📚 목차
1. Fastify에서 Schema가 필요한 이유
2. TypeBox의 역할과 동작 원리
3. Fastify에서 TypeBox 사용을 위한 기본 설정
4. 코드 연결하기: TypeBox 적용 예시

 

TypeBox 삽화 이미지
TypeBox 삽화 이미지

1. Fastify에서 Schema가 필요한 이유

🔷 Fastify에서 Schema의 역할

Fastify는 Schema 기반 프레임워크입니다.
Route를 정의할 때 Request와 Response 구조를 Schema로 명시할 수 있으며, 이 Schema는 다음과 같은 역할을 수행합니다.

 

스키마를 통해 얻을 수 있는 주요 이점은 다음과 같습니다:

▸ 데이터 검증 (Validation):

서버로 들어오는 요청 데이터가 올바른 형식인지 확인합니다. 잘못된 데이터가 비즈니스 로직(핸들러)까지 도달하지 못하도록 입구에서 자동으로 차단합니다.

 

▸ 성능 최적화 (Serialization):

응답 데이터를 JSON으로 변환할 때, 미리 정의된 구조를 바탕으로 불필요한 연산을 생략하여 일반적인 방식보다 훨씬 빠르게 결과를 내보냅니다.


▸ 문서화 자동화: 작성한 스키마를 기반으로 Swagger(OpenAPI)와 같은 API 명세서를 별도의 작업 없이 자동으로 생성할 수 있습니다.


▸ 개발 생산성 (Type Safety): TypeScript와 함께 사용하면 코드 작성 단계에서 데이터의 형태를 미리 알 수 있어, 오타나 잘못된 데이터 접근으로 인한 실수를 방지합니다.

 

예를 들어, 다음과 같이 스키마를 정의하면 Fastify가 자동으로 검증을 수행합니다.

fastify.post('/users', {
  schema: {
    body: {
      type: 'object',
      required: ['email'],
      properties: {
        email: { type: 'string', format: 'email' }
      }
    }
  }
}, async (request, reply) => {
  // email이 없거나 형식이 틀리면 핸들러가 실행되기 전 400 Bad Request를 반환합니다.
  return { ok: true }
})

 

🔷 Request Validation과 Response Serialization

Fastify의 성능은 검증과 직렬화를 전담하는 내부 라이브러리에서 나옵니다.

Fastify는 경로(Route)가 등록될 때 제공된 스키마를 분석하여 컴파일합니다.

 

🔸 Ajv를 이용한 Request Validation 구조

Fastify는 기본 검증기로 Ajv (Another JSON Schema Validator)를 사용합니다.

서버 시작 시 스키마를 Ajv 검증 함수로 컴파일합니다.
요청이 들어오면 컴파일된 함수가 즉시 실행됩니다.
이 과정은 정규표현식이나 단순 조건문보다 훨씬 빠릅니다.

 

🔸 fast-json-stringify를 이용한 Response 처리 구조

응답 시에는 fast-json-stringify 라이브러리가 동작합니다.
▸ 스키마를 분석하여 특정 구조에 최적화된 직렬화 코드를 생성합니다.
▸ 필요 없는 필드를 제거하고, 데이터 타입을 확정하여 직렬화 속도를 일반적인 방식 대비 최대 2~3배까지 끌어올립니다

 

처리 흐름은 다음과 같습니다.

Client Request 전송
   ↓
Schema Validation (Ajv):스키마와 대조하여 적합성 판별
   ↓
Handler 실행:비즈니스 로직 수행 
   ↓
Response Serialization (fast-json-stringify)
(스키마 기반 고속 JSON 변환)
   ↓
Client Response 반환

이 구조 덕분에 Fastify는 런타임 오버헤드를 최소화하면서도 높은 안정성을 유지할 수 있습니다.

 

🔷 TypeScript 타입만으로는 부족한 이유

TypeScript 타입은 컴파일 타임에만 존재하며, 컴파일 후 JavaScript 환경(런타임)에서는 모든 타입 정보가 사라집니다.

 

실제 환경에서의 문제는 다음과 같습니다:

▸ 런타임 검증 불가능: 외부에서 들어오는 실제 데이터가 string인지 number인지 알 방법이 없습니다.
▸ 중복 정의 문제: 런타임 검증을 위해 JSON Schema를 쓰고, 개발 환경을 위해 TypeScript 인터페이스를 따로 작성해야 합니다.

// 1. 개발용 타입
type CreateUserDto = { email: string }

// 2. 런타임 검증용 스키마 (따로 관리해야 함!)
const CreateUserSchema = { type: 'object', properties: { email: { type: 'string' } } }

이러한 중복 정의와 타입 불일치 문제를 근본적으로 해결하기 위해 우리는 TypeBox를 사용합니다.

 

2. TypeBox의 역할과 동작 원리

🔷 TypeScript 타입과 JSON Schema 중복 정의 문제

기존의 개발 방식에서는 동일한 데이터 구조를 두 번 작성해야 했습니다.
▸ TypeScript 타입: 코드 작성 시 자동 완성과 타입 체크를 위해 작성 (type UserDto = { email: string })
▸ JSON Schema: 실행 시 데이터 검증을 위해 작성 ({ type: 'object', ... })


구조가 변경될 때마다 두 곳을 모두 수정해야 하며, 한 곳이라도 누락되면 런타임 에러나 타입 불일치 버그로 이어지는 등 유지보수 비용이 크게 증가합니다.

 

🔷 TypeBox가 해결하는 구조

TypeBox를 사용하면 단 한 번의 정의로 모든 문제를 해결할 수 있습니다.

생성된 타입은 개발 중(컴파일 타임)에 사용되고, 원본 스키마 객체는 서버 실행 중(런타임) 데이터 검증에 사용됩니다.

import { Type, Static } from '@sinclair/typebox'

// 1. 스키마 정의 (JSON Schema 생성)
const UserSchema = Type.Object({
  email: Type.String()
})

// 2. 타입 추출 (TypeScript 타입 생성)
type UserDto = Static<typeof UserSchema>

 

🔷TypeBox가 Fastify와 잘 맞는 이유

Fastify와 TypeBox가 "최상의 궁합"이라 불리는 이유는 두 도구가 지향하는 목표가 완벽히 일치하기 때문입니다.

 

1. 표준의 일치:

▸ Fastify는 고속 처리를 위해 표준 JSON Schema를 요구합니다.

▸ TypeBox의 모든 출력물은 이 표준을 따르므로, 별도의 변환 과정 없이 Fastify의 핵심 엔진(Ajv)에 즉시 전달됩니다.

 

2. 타입 추론의 자동화

▸ Fastify는 TypeProvider라는 시스템을 통해 외부 라이브러리의 타입을 수용합니다.

▸ TypeBox는 이 시스템에 가장 최적화되어 있어, 개발자가 별도의 타입을 선언하지 않아도 request.body나 request.query의 타입을 실시간으로 추론해 줍니다.

 

3. 성능 저하 없는 안전성:

▸ TypeBox는 런타임에 추가적인 변환 오버헤드를 발생시키지 않습니다.

▸ 정의된 스키마는 곧바로 Fastify의 최적화 엔진으로 들어가며, 동시에 TypeScript의 엄격한 타입 체크까지 보장합니다.

반응형

 

3. Fastify에서 TypeBox 사용을 위한 기본 설정

🔷 패키지 설치

Fastify에서 TypeBox를 사용하려면 다음 패키지들을 설치해야 합니다. 여기서 주의할 점은 패키지 명칭과 버전의 차이입니다

npm install @sinclair/typebox 
npm install @fastify/type-provider-typebox

 

💡 참고: @sinclair/typebox vs typebox

NPM 라이브러리 목록에는 typebox와 @sinclair/typebox 두 가지가 존재하여 혼동을 줄 수 있습니다.

구분@sinclair/typeboxtypebox

패키지명 @sinclair/typebox typebox
버전 범위 v0.x (Legacy 라인) v1.x 이상 (Current 라인)
유지보수 상태 유지보수는 계속되지만 레거시 라인 성격 현재 공식 권장 패키지
특징 초기 TypeBox 패키지 명칭
Fastify, Ajv, TypeScript ecosystem에서 널리 사용된 기존 네임스페이스
패키지명이 단순화됨
v1.0부터 구조 및 API 정리, 장기 사용 기준의 공식 패키지
향후 방향 기존 프로젝트 호환성 유지 목적 중심 신규 프로젝트 기준 권장 선택

 

💡 어떤 것을 선택해야 할까요?

▸ 안정성 중심: 현재 많은 Fastify 플러그인과 생태계가 @sinclair/typebox 범위를 의존성으로 참조하고 있습니다.

지속적으로 관리되므로 안정적인 프로젝트에 적합합니다.
▸ 최신 트렌드 중심: GitHub 공식 저장소에서는 최신 명칭인 typebox 사용을 권장하고 있습니다. 최신 기능과 간결한 패키지 경로를 원한다면 이를 선택합니다.

 

4. 코드 연결하기: TypeBox 적용 예시

단순히 라이브러리를 설치한다고 해서 타입이 자동으로 연결되지는 않습니다.

Fastify 인스턴스에 "TypeBox를 사용하여 타입을 추론하겠다"라고 명시적으로 알려주어야 합니다.

 

🔷 서버 인스턴스 생성 (app.ts)

withTypeProvider를 사용하여 Fastify 엔진에 TypeBox 시스템을 장착합니다.

// src/app.ts
import Fastify from 'fastify';
import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';

export const createApp = async () => {
  const app = Fastify({ logger: true })
    // --------------------------------
    // 핵심 설정: Type Provider 연결
    // --------------------------------
    // 이 설정을 통해 이후 정의되는 모든 라우트에서 
    // 스키마 기반의 자동 타입 추론이 가능해집니다.
    .withTypeProvider<TypeBoxTypeProvider>();

  // 플러그인 및 라우터 등록...
  return app;
};

 

🔷 라우터에서의 활용 (user.route.ts)

이제 라우터에서는 별도의 타입 선언 없이도 안전하게 데이터를 다룰 수 있습니다.

import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox';
import { 
  CreateUserBodySchema, 
  UserParamsSchema, 
  UserQuerySchema, 
  UserHeaderSchema 
} from './user.dto';

// FastifyPluginAsyncTypebox 타입을 사용하여 플러그인을 정의하면
// 내부 핸들러에서 별도의 타입 지정 없이도 스키마 기반 타입 추론이 가능해집니다.
export const userRoutes: FastifyPluginAsyncTypebox = async (fastify) => {
  
  fastify.post('/:id', {
    schema: {
      body: CreateUserBodySchema,      // 요청 본문 검증 등록
      params: UserParamsSchema,        // 경로 파라미터 검증 등록
      querystring: UserQuerySchema,    // 쿼리 스트링 검증 등록
      headers: UserHeaderSchema,       // 헤더 검증 등록
      response: {
        // 성공 시(201) 반환될 데이터의 구조를 정의하여 직렬화 성능을 높입니다.
        201: Type.Object({ ok: Type.Boolean(), userId: Type.String() })
      }
    }
  }, async (request, reply) => {
    // [중요] 타입 캐스팅(as UserDto) 없이 모든 필드에 자동 완성이 지원됩니다.
    // 스키마에 정의된 형식에 맞춰 TypeScript 타입이 실시간으로 매핑됩니다.
    const { id } = request.params;       // string (uuid format)
    const { email, name } = request.body; // string, string
    const { mode } = request.query;      // 'async' | 'sync' | undefined
    const apiVersion = request.headers['x-api-version'];

    // 비즈니스 로직 수행...
    // reply.send 시에도 201 응답 스키마에 맞지 않는 데이터를 보내면 필터링됩니다.
    return reply.status(201).send({ ok: true, userId: id });
  });
};

 

🔷 설계시 핵심 포인트 : additionalProperties: false (user.dto.ts)

작성된 코드에서 가장 중요한 보안 및 데이터 정합성 설정은 additionalProperties: false입니다.

이 옵션은 스키마에 정의되지 않은 "추가 속성"을 어떻게 처리할지 결정합니다.

 

1. 요청(Request) 시: 불필요한 필드 차단

클라이언트가 스키마에 정의되지 않은 데이터를 보낼 경우 이를 무시하거나 에러를 발생시켜 서버의 데이터 정합성을 보호합니다.

▸ 스키마 예시

import { Type } from '@sinclair/typebox';

/**
 * 회원 가입 Body 스키마
 */
export const CreateUserBodySchema = Type.Object({
  email: Type.String({ format: 'email' }), // 이메일 형식 검증
  password: Type.String({ minLength: 8 }),  // 최소 8자 이상
  name: Type.String()                      // 필수 문자열
});

/**
 * 경로 파라미터(Path Params) 스키마
 * URL의 /users/:id 부분에서 'id'를 검증합니다.
 */
export const UserParamsSchema = Type.Object({
  id: Type.String({ format: 'uuid' })      // UUID 형식 검증
});

/**
 * 쿼리 스트링(Query String) 스키마
 * URL 뒤의 ?mode=async 등을 검증합니다.
 */
export const UserQuerySchema = Type.Object({
  mode: Type.Optional(Type.Enum({ async: 'async', sync: 'sync' })) // 선택적 열거형 값
});

/**
 * 요청 헤더(Headers) 스키마
 * 커스텀 헤더 등의 포함 여부와 형식을 검증합니다.
 */
export const UserHeaderSchema = Type.Object({
  'x-api-version': Type.String()           // 필수 헤더 필드
});

 

▸ 요청문 예시

Fastify는 isAdmin 필드를 감지하고 요청을 차단(400 Bad Request)하거나 해당 필드를 무시합니다.
이를 통해 공격자가 의도치 않은 필드(권한 등)를 조작하는 것을 방지합니다.

{
  "email": "test@test.com",
  "phoneNumber": "+821012345678",
  "isAdmin": true  // 정의되지 않은 필드!
}

 

2. 응답(Response) 시: 민감 정보 유출 방지 (필터링)

서버에서 클라이언트로 데이터를 보낼 때, 스키마에 명시된 필드만 골라내어 응답을 생성합니다.

 

▸ 스키마 예시

export const UserResponseSchema = Type.Object(
  {
    id: Type.Integer(),
    email: Type.String({ format: 'email' }),
    displayName: Type.Union([Type.String(), Type.Null()]),
  },
  { additionalProperties: false } // 응답 시에도 엄격하게 제한합니다.
);

 

▸ DB에서 조회된 원본 데이터 예시

{
  "id": 1,
  "email": "test@test.com",
  "passwordHash": "$2b$10$...", // 절대 노출되면 안 되는 필드
  "internalMemo": "관리용 메모",  // 클라이언트는 몰라도 되는 필드
  "displayName": "홍길동"
}

 

▸ 응답문 예시(클라이언트가 받는 JSON)

Fastify의 직렬화 엔진은 스키마를 기준으로 데이터를 필터링합니다.

클라이언트에게 전달되는 최종 JSON에는 passwordHash나 internalMemo 같은 필드가 자동으로 제거되어 안전하게 전송됩니다.

{
  "id": 1,
  "email": "test@test.com",
  "displayName": "홍길동"
}

 

 


※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형