Fastify 깊이 보기: 스키마 기반 직렬화, find-my-way 라우터, 훅 생명주기, 플러그인
이 글의 핵심
Fastify를 '빠른 Express'로만 쓰면 스키마 컴파일과 플러그인 스코프가 주는 이점의 절반을 놓칩니다. 응답 스키마에 없는 필드가 출력에서 빠지는 동작, 스키마 없이 라우트를 둘 때의 함정, decorate를 남용할 때 생기는 문제처럼 실제로 부딪히는 지점을 중심으로 봅니다. 구조화 로깅, 바디 한도, 일관된 오류 계약 등 프로덕션에서 고정해 둘 설정도 함께 정리했습니다.
들어가며
Fastify는 낮은 오버헤드와 스키마 기반 계약을 전면에 내세우는 Node.js 웹 프레임워크입니다. 문법만 익혀도 API를 빠르게 만들 수 있지만, 왜 스키마를 권장하는지, 라우터가 어떤 자료구조로 동작하는지, 훅이 어떤 순서로 실행되는지, 플러그인이 왜 서로 보이지 않는지를 알고 나면 설계와 디버깅, 운영에서 하는 실수가 크게 줄어듭니다.
이 글은 스키마 컴파일과 직렬화, find-my-way 라우터, 훅 파이프라인, 플러그인 캡슐화, 프로덕션 설정 순서로 설명합니다. Express·Koa와의 문법 비교는 Express 가이드와 함께 보시면 맥락이 잡힙니다.
스키마 기반 최적화
Fastify는 JSON Schema를 문서용 메타데이터가 아니라 실행 경로의 입력으로 취급합니다. fastify.route({ schema: { … } })나 fastify.get(url, { schema }, handler) 형태로 라우트를 등록하면, 프레임워크는 부팅 단계에서 두 가지를 준비합니다. 하나는 body, querystring, params, headers에 대한 검증 함수로, 기본적으로 Ajv가 스키마를 JavaScript 함수로 컴파일합니다. 다른 하나는 response 스키마가 있을 때 fast-json-stringify로 만드는 직렬화 함수입니다.
왜 핸들러 안에서 검증하는 것보다 유리한가
핸들러 안에서 if (!body.id) throw … 식으로 검증하면 요청마다 범용 분기와 객체 탐색이 반복되고, 검증 규칙도 코드 곳곳에 흩어집니다. Fastify는 스키마를 미리 컴파일해 해당 형태만 검사하는 전용 함수를 만들어 둡니다. 응답도 마찬가지로, JSON.stringify가 매번 객체의 키와 타입을 살피는 대신 fast-json-stringify가 스키마에 정의된 필드만 정해진 순서로 문자열에 이어 붙입니다.
이 직렬화 방식에는 중요한 부수 효과가 있습니다. 응답 스키마에 정의하지 않은 필드는 출력 JSON에서 빠집니다. DB 객체를 그대로 반환해도 내부 필드가 노출되지 않는 안전장치가 되지만, 새 필드를 추가하고 스키마를 갱신하지 않으면 클라이언트에 값이 전달되지 않는 버그로 이어집니다. 필드를 추가할 때는 핸들러와 응답 스키마를 같은 변경에서 함께 수정해야 합니다.
스키마가 없을 때의 함정
스키마를 생략해도 Fastify는 정상 동작하지만, 검증과 응답 형태가 핸들러 구현에 묶입니다. 팀이 커질수록 필드 하나를 추가하는 변경이 여러 레이어를 깨뜨리기 쉬우므로, 공개 API에서는 최소한 요청 스키마와 에러 응답 형태를 고정해 두는 편이 안전합니다.
Ajv 옵션과 컴파일러 교체
Fastify의 기본 Ajv 설정은 removeAdditional: true, useDefaults: true, coerceTypes: 'array', allErrors: false입니다. 이 기본값 때문에 흔히 생기는 오해가 있습니다. body 스키마에 additionalProperties: false를 두면 스키마에 없는 필드가 들어왔을 때 400으로 거부될 것 같지만, 기본 설정에서는 그 필드가 조용히 제거된 뒤 검증이 통과합니다. 정의되지 않은 필드를 에러로 처리하고 싶다면 인스턴스 생성 시 ajv: { customOptions: { removeAdditional: false } }로 바꿔야 합니다.
const Fastify = require('fastify');
const app = Fastify({
logger: true,
ajv: { customOptions: { removeAdditional: false } }, // 미정의 필드를 제거하지 않고 거부
});
app.post(
'/users',
{
schema: {
body: {
type: 'object',
required: ['name'],
additionalProperties: false,
properties: { name: { type: 'string', minLength: 1 } },
},
response: {
201: {
type: 'object',
properties: { id: { type: 'string' }, name: { type: 'string' } },
},
},
},
},
async (req, reply) => {
reply.code(201);
return { id: 'u1', name: req.body.name };
}
);
app.listen({ port: 3000 });
응답 스키마는 상태 코드별로 지정하므로, 핸들러가 reply.code(201)을 설정하지 않으면 기본 200 응답에는 이 스키마가 적용되지 않습니다.
TypeBox나 Zod 기반 스키마를 쓰거나 Ajv 대신 다른 검증기를 쓰려면 setValidatorCompiler, setSerializerCompiler로 컴파일러를 교체합니다. 라우트마다 다른 컴파일러를 쓰기보다 인스턴스 수준에서 전략을 통일하는 것이 일반적입니다. 그렇지 않으면 스키마는 같아도 실제 검증 규칙이 라우트별로 조금씩 달라집니다.
$ref와 스키마 재사용
스키마가 커지면 fastify.addSchema({ $id: 'user', … })로 공용 스키마를 등록하고 $ref: 'user#'로 참조합니다. 같은 의미의 스키마를 파일마다 복사해 두면 한쪽만 수정되는 일이 반드시 생기고, 오류 메시지도 라우트마다 달라집니다. OpenAPI의 components.schemas와 같은 원천에서 스키마를 관리하면 API 문서, 클라이언트 코드 생성, 서버 검증이 모두 한 정의를 바라보게 됩니다.
find-my-way 라우터 구조
Fastify의 HTTP 라우팅은 find-my-way 라이브러리가 담당합니다. 경로를 HTTP 메서드별로 분리된 라딕스 트리(radix tree, 공통 접두사를 압축한 트리)에 저장하고, 요청이 들어오면 트리를 따라 내려가며 핸들러를 찾습니다.
Express와의 차이
Express는 등록된 라우트와 미들웨어를 순서대로 검사하므로 라우트 수가 늘어날수록 매칭에 드는 검사 횟수도 늘어납니다. find-my-way는 경로 문자열을 공통 접두사로 묶어 두기 때문에, 탐색 비용이 전체 라우트 수보다는 URL 길이와 트리 깊이에 좌우됩니다. 다만 라우팅이 전체 요청 처리 시간에서 차지하는 비중은 보통 작으므로, 라우터 차이만으로 체감 성능이 크게 달라진다고 보기는 어렵습니다.
정적 경로와 동적 파라미터
/user/lookup 같은 정적 세그먼트와 /user/:id 같은 파라미터 세그먼트가 함께 있으면, find-my-way는 정적 경로를 먼저 매칭하고 그다음 파라미터, 마지막으로 와일드카드(*) 순으로 우선순위를 둡니다. 그래서 등록 순서와 관계없이 /user/lookup은 :id로 잡히지 않습니다. 같은 메서드와 경로를 두 번 등록하면 부팅 시 FST_ERR_DUPLICATED_ROUTE 에러가 나므로, 충돌이 런타임에 조용히 덮어써지지 않습니다.
버전·호스트·커스텀 제약 조건
find-my-way는 같은 경로에 여러 핸들러를 두고 제약 조건(constraints)으로 어느 것을 고를지 결정할 수 있습니다. 기본 제공되는 제약은 두 가지입니다. version은 Accept-Version 요청 헤더를 semver 범위로 매칭하고, host는 Host 헤더로 매칭해 호스트 기반 멀티 테넌시에 씁니다. 그 밖의 조건은 constraintStrategies로 직접 정의합니다. 이렇게 하면 URL은 /api/items로 같아도 v1과 v2 핸들러를 분리할 수 있습니다. 버전을 게이트웨이에서 나눠 다른 서비스로 보낼지, 한 프로세스 안에서 제약 조건으로 나눌지는 아키텍처에 맞게 정합니다.
app.get('/api/items', { constraints: { version: '1.0.0' } }, async () => ({ v: 1 }));
app.get('/api/items', { constraints: { version: '2.0.0' } }, async () => ({ v: 2 }));
// Accept-Version: 2.x → 두 번째 핸들러
Accept-Version 헤더에 따라 응답이 달라지므로, 캐시가 앞단에 있다면 Vary: Accept-Version을 함께 보내야 합니다.
라우트 등록 비용
라우터에는 부팅 시 트리를 구축하는 비용과 요청마다 탐색하는 비용이 있습니다. Fastify는 서버가 시작된 뒤에는 라우트를 추가할 수 없으므로, 테넌트마다 라우트를 동적으로 붙였다 떼는 구조는 맞지 않습니다. 그런 요구가 있다면 파라미터 하나로 라우팅하고 핸들러 안에서 테넌트를 분기하거나, 인스턴스를 나누는 방식을 검토합니다.
훅 시스템과 생명주기
Fastify의 요청 처리는 순서가 고정된 파이프라인입니다. 각 단계는 훅으로 확장할 수 있고, 훅 이름이 곧 실행 순서를 나타냅니다.
요청 파이프라인 순서
- 라우팅: 요청이 들어오면 먼저 라우트를 찾습니다. 따라서 이후 모든 훅에서
request.routeOptions를 볼 수 있습니다. onRequest: 가장 이른 훅입니다. 아직 body를 파싱하지 않았으므로request.body는undefined입니다.preParsing: 요청 스트림을 받아 압축 해제 등 변환된 스트림을 돌려줄 수 있습니다.- 콘텐츠 타입 파서가 body를 파싱합니다.
preValidation: 스키마 검증 직전입니다.- 스키마 검증을 수행합니다.
preHandler: 핸들러 직전입니다. body가 파싱·검증된 상태입니다.- 라우트 핸들러가 실행됩니다.
preSerialization: 반환값이 객체일 때만 호출되며, 직렬화 전에 페이로드를 바꿀 수 있습니다. 문자열·Buffer·스트림을 반환하면 호출되지 않습니다.onSend: 직렬화된 페이로드(문자열·Buffer·스트림)를 받아 전송 직전에 바꿀 수 있습니다.onResponse: 응답 전송이 끝난 뒤 호출되어 로깅과 메트릭에 씁니다.
이와 별도로 에러가 나면 onError가, connectionTimeout으로 소켓 타임아웃이 나면 onTimeout이, 클라이언트가 요청 도중 연결을 끊으면 onRequestAbort가 호출됩니다. onError는 응답을 바꾸는 훅이 아니라 에러를 기록하기 위한 훅이고, 응답 형태는 setErrorHandler에서 정합니다.
인증은 onRequest와 preHandler 중 어디에 둘까
라우팅이 먼저 끝나므로 onRequest에서도 라우트별 설정을 읽어 인가를 결정할 수 있습니다. 토큰 헤더만 보면 되는 인증은 onRequest에 두는 편이 유리한데, 인증에 실패한 요청의 body를 파싱하고 검증하는 비용을 아예 쓰지 않기 때문입니다. 반대로 body의 내용을 봐야 판단할 수 있는 인가(예: 요청한 리소스 소유자 확인)는 body가 준비된 preHandler에 둡니다.
onSend와 preSerialization
onSend 시점의 페이로드는 이미 문자열이나 Buffer입니다. 여기서 필드를 지우려고 JSON.parse 후 다시 JSON.stringify하면 직렬화를 두 번 하게 되고, 응답 스키마로 얻은 이점도 사라집니다. 객체 단계에서 필드를 마스킹하거나 감싸는 작업은 preSerialization에서 하고, onSend는 헤더 추가나 압축처럼 바이트 단위 처리에 씁니다.
애플리케이션 생명주기 훅
요청과 별개로 부팅과 종료에도 훅이 있습니다. onReady는 모든 플러그인이 로드된 뒤, 서버가 요청을 받기 시작하기 전에 실행되므로 캐시 예열이나 의존 서비스 연결 확인 같은 작업에 씁니다. onClose는 app.close()가 호출된 뒤 실행되어 DB 풀 같은 리소스를 정리합니다. 프로덕션에서는 이 훅들을 프로세스 시그널(SIGTERM 등) 처리와 연결해 우아한 종료를 완성합니다.
const Fastify = require('fastify');
const app = Fastify({ logger: true });
app.addHook('onRequest', async (req) => {
req.startedAt = process.hrtime.bigint();
});
app.addHook('onResponse', async (req, reply) => {
const ms = Number(process.hrtime.bigint() - req.startedAt) / 1e6;
req.log.info({ responseTime: ms, statusCode: reply.statusCode }, 'request completed');
});
app.get('/ping', async () => ({ ok: true }));
app.listen({ port: 3000 });
Fastify 자체도 reply.elapsedTime으로 요청 처리 시간을 제공하므로, 단순 측정이라면 직접 시간을 기록하지 않아도 됩니다.
플러그인 캡슐화
Fastify의 플러그인 시스템은 avvio 위에 만들어져 있습니다. register를 호출할 때마다 자식 컨텍스트(encapsulated context)가 만들어지고, 그 안에서 추가한 장식자, 훅, 라우트는 기본적으로 부모와 형제 컨텍스트에 보이지 않습니다. 자식은 부모의 것을 물려받지만, 부모는 자식의 것을 볼 수 없습니다.
왜 격리가 기본인가
규모가 큰 코드베이스에서 플러그인 A가 추가한 훅이나 장식자가 플러그인 B의 동작을 바꾸는 일을 막기 위해서입니다. 예를 들어 관리자 라우트를 묶은 플러그인에만 인증 훅을 걸면, 그 훅은 같은 플러그인 안의 라우트에만 적용되고 공개 라우트에는 영향을 주지 않습니다.
fastify-plugin으로 부모 컨텍스트에 노출하기
fastify-plugin으로 래핑하면 캡슐화가 해제되어, 플러그인이 추가한 장식자와 훅이 등록한 쪽 컨텍스트에 그대로 남습니다. 애플리케이션 전체에서 authenticate 같은 데코레이터를 쓰려면 사실상 필수입니다. 반대로 테넌트별 설정처럼 격리가 필요한 경우에는 래핑하지 않고 register만으로 자식 컨텍스트를 유지합니다.
const fp = require('fastify-plugin');
async function authPlugin(fastify) {
fastify.decorateRequest('user', null);
fastify.decorate('authenticate', async (req, reply) => {
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '');
if (!token) {
const err = new Error('Unauthorized');
err.statusCode = 401;
throw err;
}
req.user = { sub: 'user-1' }; // 실제로는 토큰 검증 결과
});
}
module.exports = fp(authPlugin, { name: 'auth-plugin' });
// 사용: app.get('/me', { onRequest: [app.authenticate] }, async (req) => req.user);
fastify.httpErrors.unauthorized() 같은 헬퍼는 @fastify/sensible을 등록해야 생기므로, 플러그인 없이 쓰면 undefined 호출 에러가 납니다. 위 코드는 statusCode를 붙인 에러를 던져 기본 에러 핸들러가 401로 응답하게 했습니다.
prefix와 중첩 등록
app.register(plugin, { prefix: '/v1' })로 등록하면 플러그인 안의 라우트가 접두사를 물려받아 URL 네임스페이스가 나뉩니다. 중첩 등록 시 접두사가 이어 붙으므로, 접두사와 라우트 경로 양쪽에 슬래시를 붙이는 규칙을 팀에서 정해 두는 것이 좋습니다.
decorate의 규율
decorate, decorateRequest, decorateReply는 편리하지만 이름 충돌의 원인이 됩니다. 이미 있는 이름으로 다시 장식하면 FST_ERR_DEC_ALREADY_PRESENT 에러가 납니다. 또 decorateRequest에 객체나 배열 같은 참조 타입을 기본값으로 넘기면 모든 요청이 같은 객체를 공유하게 되므로, Fastify v4부터는 이를 에러(FST_ERR_DEC_REFERENCE_TYPE)로 막습니다. 위 예제처럼 null로 선언해 두고 훅에서 요청마다 새 값을 할당하거나, getter를 사용합니다. 요청 객체의 형태를 미리 선언해 두면 V8이 모든 요청 객체를 같은 hidden class로 다룰 수 있다는 점도 이 패턴을 쓰는 이유입니다.
프로덕션 Fastify 패턴
구조화 로깅과 요청 상관관계
Fastify는 pino를 내장 로거로 씁니다. requestIdHeader를 로드밸런서나 프록시의 x-request-id에 맞추면, 분산 추적을 도입하기 전에도 프록시 로그와 애플리케이션 로그를 요청 ID로 연결할 수 있습니다. 헬스 체크처럼 로그가 지나치게 많이 쌓이는 경로는 라우트 옵션의 logLevel을 낮춰 조정합니다. 전역 옵션 disableRequestLogging은 모든 경로의 요청 로그를 끄므로 용도가 다릅니다.
trustProxy와 클라이언트 IP
리버스 프록시 뒤에서 req.ip가 실제 클라이언트 IP를 가리키게 하려면 trustProxy를 설정해야 합니다. 다만 trustProxy: true는 X-Forwarded-For의 모든 홉을 신뢰하므로, 클라이언트가 직접 넣은 헤더 값까지 IP로 쓰게 되어 레이트 리밋과 감사 로그가 무력화될 수 있습니다. 실제 프록시 구성에 맞춰 신뢰할 홉 수(숫자)나 프록시의 IP 대역을 지정하는 편이 안전합니다.
바디 한도·타임아웃·연결 유지
bodyLimit(기본 1MiB), connectionTimeout, keepAliveTimeout은 운영 사고를 막는 안전장치입니다. 거대한 JSON 본문, 느린 클라이언트, 커넥션 고갈은 애플리케이션 로직에 도달하기 전에 막는 것이 낫습니다. 로드밸런서 뒤에 있다면 keepAliveTimeout을 로드밸런서의 idle timeout보다 길게 두어야, 로드밸런서가 이미 닫힌 연결로 요청을 보내 502가 나는 경우를 피할 수 있습니다.
에러 핸들러와 일관된 오류 계약
setErrorHandler에서 공개 API의 오류 형태(코드, 메시지, 요청 ID, 필드 오류)를 통일합니다. 내부 스택은 개발·스테이징에서만 노출하고, 프로덕션 500 응답에는 일반 메시지만 담아 민감 정보 유출을 막습니다.
과부하 보호
@fastify/under-pressure는 이벤트 루프 지연, 힙 사용량, RSS 등이 임계값을 넘으면 503을 반환합니다. 서비스가 완전히 멈추기 전에 스스로 요청을 거절하므로, 상위 계층의 재시도와 서킷 브레이커가 다른 인스턴스로 트래픽을 돌릴 여유가 생깁니다. retryAfter 옵션으로 Retry-After 헤더 값을 지정할 수 있습니다.
우아한 종료
Kubernetes나 Cloud Run은 SIGTERM을 보낸 뒤 유예 시간이 지나면 프로세스를 강제로 종료합니다. app.close()는 새 연결을 받지 않고 진행 중인 요청이 끝나기를 기다린 뒤 onClose 훅을 실행합니다. 메시지 컨슈머처럼 HTTP 바깥에서 들어오는 작업이 있다면, 그것을 먼저 멈춘 뒤 DB 풀을 닫는 순서를 onClose 훅에 코드로 고정해 둡니다.
const Fastify = require('fastify');
const { randomUUID } = require('node:crypto');
const app = Fastify({
logger: true,
trustProxy: 1, // 앞단 프록시 한 홉만 신뢰
bodyLimit: 1_048_576,
requestIdHeader: 'x-request-id',
genReqId: () => randomUUID(), // 헤더가 없을 때만 호출됨
});
app.setErrorHandler((err, req, reply) => {
const status = err.statusCode ?? 500;
if (status >= 500) req.log.error({ err }, 'unhandled');
reply.status(status).send({
error: {
code: err.code ?? 'INTERNAL',
message: status >= 500 ? 'Internal Server Error' : err.message,
requestId: req.id,
},
});
});
process.on('SIGTERM', async () => {
try {
await app.close();
process.exit(0);
} catch (err) {
app.log.error({ err }, 'shutdown failed');
process.exit(1);
}
});
app.listen({ port: 3000, host: '0.0.0.0' });