[2026] Fastify 완전 정리 — 스키마 최적화·Find-my-way·훅 생명주기·플러그인·프로덕션
이 글의 핵심
Fastify를 “빠른 Express”로만 보면 내부 이점의 절반을 놓칩니다. 스키마가 런타임에 어떻게 컴파일되는지, find-my-way가 요청을 어떻게 매칭하는지, 훅·플러그인이 스코프를 어떻게 나누는지, 그리고 프로덕션에서 무엇을 고정해야 하는지까지 한 흐름으로 정리합니다.
들어가며
Fastify는 Node.js용 웹 프레임워크로, 공식적으로 낮은 오버헤드와 스키마 기반 계약을 전면에 둡니다. 문법만 익히면 빠르게 API를 만들 수 있지만, 왜 스키마를 권장하는지, 라우터가 내부적으로 어떤 자료구조인지, 훅이 몇 단계로 나뉘는지, 플러그인이 왜 “보이지 않는 벽”을 만드는지를 이해하면 설계·디버깅·운영 모두에서 실수가 줄어듭니다.
이 글은 튜토리얼의 문법 나열에 머무르지 않고, 스키마 컴파일과 직렬화 경로, find-my-way 라우터, 훅 파이프라인과 애플리케이션 생명주기, 플러그인 캡슐화 모델, 프로덕션에서의 고정 패턴을 중심으로 설명합니다. Express·Koa와의 문법 비교는 Express 가이드와 함께 보시면 맥락이 잡힙니다.
스키마 기반 최적화
Fastify는 JSON Schema를 “문서용 메타데이터”가 아니라 실행 경로의 입력으로 취급합니다. 라우트를 fastify.route({ schema: { … } }) 또는 fastify.get(url, { schema }, handler) 형태로 등록하면, 프레임워크는 가능한 한 등록 시점(부팅 단계) 에 다음을 확정합니다.
- 요청 검증(validation): 본문·쿼리스트링·params·헤더에 대해 AJV 등으로 검증 함수를 만들어 둠.
- 응답 직렬화(serialization):
response스키마가 있으면 출력 JSON을 스키마에 맞게 직렬화하는 경로를 준비함(설정에 따라 스키마가 없을 때와 다른 동작).
왜 “매 요청마다 검증”보다 유리한가
전통적으로 개발자가 핸들러 안에서 if (!body.id) throw … 식으로 검증하면, 매 요청마다 분기·객체 탐색이 반복됩니다. Fastify는 스키마가 주어지면 검증 로직을 사전 컴파일해, 런타임 비용을 “범용 분기”보다 좁혀진 검증 코드에 가깝게 만듭니다. 또한 응답 스키마를 쓰면 핸들러가 큰 객체를 반환하더라도 직렬화 단계에서 필드가 거르거나(coerce) 일정한 형태로 나가게 할 수 있어, 계약이 코드 리뷰가 아니라 런타임 경계로 남습니다.
스키마가 없을 때의 함정
스키마를 생략하면 Fastify는 여전히 잘 동작하지만, 검증·응답 형태가 핸들러 구현에 묶입니다. 팀 규모가 커질수록 “필드 하나 추가”가 여러 레이어를 깨뜨리기 쉬우므로, 공개 API에서는 최소한 요청 스키마와 에러 응답 형태를 고정하는 편이 안전합니다.
컴파일러 커스터마이징: validatorCompiler / serializerCompiler
기본 구현을 바꾸거나(TypeBox·Zod-to-JSON-Schema 등), AJV 옵션을 조직 표준에 맞출 때 setValidatorCompiler, setSerializerCompiler 를 사용합니다. 여기서 중요한 점은 “라우트마다 다른 컴파일러”가 아니라 인스턴스 수준에서 전략을 통일하는 경우가 많다는 것입니다. 그렇지 않으면 스키마는 같아도 실제 검증 규칙이 라우트별로 미묘하게 달라지는 운영 사고가 납니다.
대부분의 서비스는 기본 컴파일러를 유지하고, 필요할 때만 AJV에 removeAdditional, coerceTypes, allErrors 같은 옵션을 한곳에서 주입합니다. 커스텀 컴파일러를 도입하면 스키마 해시 → 검증 함수 캐시 전략을 직접 관리해야 하므로, 팀에 AJV·JSON Schema에 익숙한 사람이 있을 때만 권장됩니다.
const Fastify = require('fastify');
const app = Fastify({ logger: true });
// 스키마가 있으면 부팅 시 검증·응답 직렬화 경로가 준비됨(기본 컴파일러).
app.post(
'/users',
{
schema: {
body: {
type: 'object',
required: ['name'],
additionalProperties: false,
properties: { name: { type: 'string', minLength: 1 } },
},
response: {
201: {
type: 'object',
additionalProperties: false,
properties: { id: { type: 'string' }, name: { type: 'string' } },
},
},
},
},
async (req) => ({ id: 'u1', name: req.body.name })
);
app.listen({ port: 3000 });
additionalProperties: false는 스키마에 없는 필드가 조용히 통과하는 것을 막아, 계약이 문서가 아니라 검증기에 고정되게 합니다. 운영 환경에서는 민감 필드 노출 방지와도 직결됩니다.
$ref와 모듈화
스키마가 커지면 $ref로 조각을 나누는 것이 일반적입니다. 이때 주의할 점은 동일 의미의 스키마가 파일마다 복제되면 AJV 캐시 효율이 떨어지고, 역직렬화/검증 오류 메시지가 팀마다 달라질 수 있다는 것입니다. 공용 스키마 레지스트리(OpenAPI의 components.schemas와 병행하는 팀도 많음)를 두면 API 문서·클라이언트 생성·서버 검증이 한 원천을 바라보게 됩니다.
find-my-way 라우터 구조
Fastify의 HTTP 라우팅은 find-my-way 라이브러리에 기대고 있습니다. 이 라우터의 핵심은 HTTP 메서드별로 분리된 라디스 트(radix tree, 압축 프리픽스 트리) 에 경로를 저장하고, 요청이 들어오면 트리를 따라가며 가장 구체적인 핸들러를 찾는 방식입니다.
Express와 직관적 차이
많은 개발자에게 익숙한 모델은 “등록 순서대로 패턴을 시도한다”에 가깝습니다. 반면 find-my-way는 경로 문자열을 공유 프리픽스로 묶어 트리에 걸어 두기 때문에, 단순 선형 스캔보다 깊은 경로·정적 세그먼트가 많은 API에서 유리한 경우가 많습니다. 다만 이것이 “항상 더 빠르다”는 뜻은 아니고, 라우트 수·동적 파라미터 비율·제약 조건에 따라 달라집니다.
정적 경로와 동적 파라미터
/user/lookup처럼 고정 세그먼트가 많으면 트리의 가지가 명확해집니다. /user/:id 같은 동적 노드는 파라미터 이름과 함께 별도 슬롯으로 취급되어, 이후 세그먼트가 트리에서 어떻게 이어지는지가 중요해집니다. 그래서 Fastify에서는 동적 세그먼트와 와일드카드, 정적 경로와의 충돌을 등록 시점에 엄격히 다루려 하며, 모순된 등록은 부팅 단계에서 에러로 끝나는 편이 좋습니다(프로덕션에서 런타임에 “갑자기 다른 핸들러”가 뜨는 상황을 줄임).
버전·호스트·커스텀 제약(Constraints)
find-my-way는 동일 경로에 여러 핸들러를 두되, 제약 조건으로 어느 것을 고를지 결정할 수 있습니다. 대표적으로 HTTP 버전 헤더나 Accept 기반의 API 버전, Host 기반 멀티 테넌시가 있습니다. Fastify에서는 이런 제약을 라우트 옵션으로 걸 수 있어, URL은 /api/items로 동일하지만 v1과 v2 핸들러를 분리하는 패턴이 가능합니다. 운영 관점에서는 게이트웨이에서 버전을 떼어 다른 서비스로 보내는지, 한 프로세스 안에서 제약으로 나누는지를 아키텍처에 맞게 선택해야 합니다.
라우트 등록 비용 vs 요청 경로 비용
라우터는 부팅 시 트리 구축 비용과 요청당 탐색 비용의 트레이드오프가 있습니다. 라우트를 수천 개 동적으로 붙이고 떼는 패턴(멀티테넌트에서 테넌트마다 전체 재등록)은 부팅·핫 리로드 비용이 커질 수 있어, 이런 경우에는 별도 인스턴스 풀이나 경로 프리픽스 단위 마운트 같은 운영 전략을 검토합니다.
const Fastify = require('fastify');
const app = Fastify();
// 동적 파라미터와 정적 세그먼트가 함께 있는 예
app.get('/files/:name/metadata', async (req) => {
return { file: req.params.name, meta: true };
});
app.listen({ port: 3000 });
훅 시스템과 생명주기
Fastify의 요청 처리는 미들웨어 체인이라는 말로만 이해하기 어렵고, 단계가 정해진 파이프라인으로 보는 편이 정확합니다. 각 단계는 비동기 훅으로 확장되며, 훅 이름이 곧 처리 순서의 계약입니다.
요청 파이프라인(개략 순서)
문서와 버전에 따라 세부 이름이 늘어나거나 조정될 수 있으나, 개념적으로는 다음 흐름을 기억하면 디버깅이 쉽습니다.
onRequest: 가장 이른 단계. 연결·요청 라인이 들어온 직후에 가깝습니다.preParsing: 원시 요청을 파싱하기 전·후 보정(드물게 사용).preValidation: 검증 직전에 페이로드 가공(권한에 따른 필드 제거 등).preHandler: 라우트 핸들러 직전. 인증·인가 미들웨어가 자주 여기에 놓입니다.- 라우트 핸들러
preSerialization: 응답 페이로드가 직렬화되기 전.onSend: 응답이 전송되기 전(페이로드·상태 코드 조정 가능).onResponse: 응답이 클라이언트로 나간 뒤(로깅·메트릭).onError: 파이프라인 어디서든 에러가 나면 대체 경로.
타임아웃이 설정되어 있으면 onTimeout 이 별도로 개입합니다. 순서를 외우기보다 “검증 전·핸들러 전·직렬화 전·전송 전·응답 완료 후” 라는 다섯 축으로 기억하면, 팀 내 코드 리뷰에서 훅 위치 논쟁이 줄어듭니다.
onRequest vs preHandler
인증을 어디에 둘지 고민될 때가 많습니다. 대개 연결 단위·아주 이른 공통 작업(요청 ID 주입, 클라이언트 IP 정규화)은 onRequest에 두고, 라우트별로 필요한 사용자 컨텍스트는 preHandler에 둡니다. 반대로 섞이면 아직 라우트가 결정되지 않았는데 인가를 시도하거나, 불필요한 비용이 모든 경로에 깔리는 문제가 생깁니다.
onSend와 응답 스키마
응답 스키마를 쓰는 팀은 onSend에서 페이로드를 만지려다 이중 직렬화를 겪기도 합니다. 훅의 의미를 정확히 이해하고, 필드 마스킹은 preSerialization 쪽이 더 자연스러운 경우가 많습니다. 팀 내에서 “어느 훅까지는 객체”, “어느 지점부터는 문자열/버퍼”인지 합의가 필요합니다.
애플리케이션 생명주기 훅
요청과 별개로, 부팅·종료에도 훅이 있습니다. 예를 들어 onReady는 모든 플러그인이 등록되고 초기화된 뒤 실행되어, DB 커넥션 풀을 열고 마이그레이션 확인 같은 작업에 쓰입니다. onClose는 역순 정리에 맞춰 리소스를 닫습니다. 프로덕션에서는 이 둘과 프로세스 시그널(SIGTERM 등) 을 연결해 우아한 종료를 완성합니다.
const Fastify = require('fastify');
const app = Fastify({ logger: true });
app.addHook('onRequest', async (req) => {
req.startedAt = Date.now();
});
app.addHook('onResponse', async (req, reply) => {
const ms = Date.now() - req.startedAt;
req.log.info({ responseTime: ms }, 'request completed');
});
app.get('/ping', async () => ({ ok: true }));
app.listen({ port: 3000 });
플러그인 캡슐화
Fastify의 플러그인 시스템은 avvio 위에 있습니다. 핵심은 각 register 블록이 자식 컨텍스트(종종 “encapsulated instance”)를 받으며, 그 안에서 추가한 장식자·훅·라우트는 기본적으로 부모와 격리된다는 점입니다.
왜 격리가 기본인가
대규모 코드베이스에서 플러그인 A가 reply에 메서드를 덮어쓰면 플러그인 B가 깨지는 문제를 막기 위해, Fastify는 스코프를 나누는 것을 기본값으로 둡니다. 즉 의도하지 않은 전역 오염을 줄이려는 설계입니다.
fastify-plugin으로 부모와 합류하기
fastify-plugin으로 래핑하면 부모 컨텍스트에 장식자·훅을 노출할 수 있습니다. 인증 모듈처럼 애플리케이션 전역에서 authenticate 데코레이터를 쓰려면 사실상 필수에 가깝습니다. 반대로 테넌트별 설정처럼 격리가 필요하면 래핑하지 않고 register만으로 자식 트리를 유지합니다.
prefix와 중첩 등록
app.register(plugin, { prefix: '/v1' }) 패턴은 URL 네임스페이스를 물리적으로 나눕니다. 이때 자식 플러그인의 라우트는 접두사를 물려받으며, OpenAPI 상의 base path와도 맞추기 쉽습니다. 다만 접두사 중복·슬래시 규칙을 팀 규칙으로 고정하지 않으면 /v1//users 같은 사고가 생깁니다.
decorate의 위험과 규율
decorate, decorateRequest, decorateReply는 강력하지만, 이름 충돌과 요청 객체의 무거운 페이로드를 만듭니다. 특히 decorateRequest에 큰 객체를 기본값으로 심으면 요청마다 비용이 생길 수 있어, getter 또는 필요 시에만 채우는 패턴이 권장됩니다.
const Fastify = require('fastify');
const fp = require('fastify-plugin');
async function authPlugin(fastify) {
fastify.decorate('verifySession', async (req) => {
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '');
if (!token) throw fastify.httpErrors.unauthorized();
return { sub: 'user-1' };
});
fastify.decorate('authenticate', async (req, reply) => {
req.user = await fastify.verifySession(req);
});
}
module.exports = fp(authPlugin, { name: 'auth-plugin' });
프로덕션 Fastify 패턴
구조화 로깅과 요청 상관관계
Fastify는 기본적으로 pino 계열 로깅과 잘 맞습니다. 요청 ID(genReqId) 를 로드밸런서·프록시의 X-Request-ID와 일치시키면, 분산 추적 전 단계에서도 장애 분석이 쉬워집니다. 헬스 체크처럼 로그가 시끄러운 경로는 disableRequestLogging 등으로 조정합니다.
trustProxy와 클라이언트 IP
리버스 프록시 뒤에서 req.ip 를 쓰려면 trustProxy 설정을 반드시 이해하고 켜야 합니다. 잘못하면 클라이언트가 만든 X-Forwarded-For를 그대로 신뢰해 레이트 리밋·감사 로그가 무력화됩니다. 프록시 한 홉을 신뢰할지, 헤더를 어디까지 읽을지는 인프라 계약입니다.
바디 한도·타임아웃·연결 유지
bodyLimit, connectionTimeout, keepAliveTimeout은 운영 사고를 막는 안전장치입니다. JSON 본문 폭주·느린 클라이언트·커넥션 고갈은 애플리케이션 로직 전에 막는 편이 낫습니다.
에러 핸들러와 일관된 오류 계약
setErrorHandler에서 공개 API의 오류 형태(코드, 메시지, 추적 ID, 필드 에러)를 통일합니다. 내부 스택은 개발·스테이징에서만 노출하고, 프로덕션에서는 민감 정보 유출을 방지합니다.
과부하 보호
@fastify/under-pressure 같은 플러그인으로 이벤트 루프 지연·메모리 사용량에 따라 503과 Retry-After를 반환하는 패턴이 널리 쓰입니다. 이는 서비스가 죽기 전에 스스로 요청을 거절해 상위 계층의 재시도·서킷 브레이커와 맞물리게 합니다.
우아한 종료
Kubernetes·Cloud Run 등에서는 SIGTERM 후 짧은 유예 시간 안에 포트를 닫아야 합니다. close()가 진행 중 요청을 마칠 시간을 주는지, DB 풀·메시지 컨슈머가 먼저 멈춰야 하는지 순서를 코드로 고정합니다.
const Fastify = require('fastify');
const app = Fastify({
logger: true,
trustProxy: true,
bodyLimit: 1_048_576,
requestIdHeader: 'x-request-id',
genReqId: (req) => req.headers['x-request-id'] || require('crypto').randomUUID(),
});
app.setErrorHandler((err, req, reply) => {
req.log.error({ err }, 'unhandled');
const status = err.statusCode ?? 500;
reply.status(status).send({
error: {
code: err.code ?? 'INTERNAL',
message: status === 500 ? 'Internal Server Error' : err.message,
requestId: req.id,
},
});
});
const close = async () => {
await app.close();
};
process.on('SIGTERM', async () => {
await close();
process.exit(0);
});
app.listen({ port: 3000, host: '0.0.0.0' });
내부 동작과 핵심 메커니즘
이 글의 주제는 「[2026] Fastify 완전 정리 — 스키마 최적화·Find-my-way·훅 생명주기·플러그인·프로덕션」입니다. 앞선 튜토리얼을 구현·런타임 관점에서 다시 압축합니다. 구성 요소 간 책임 분리와 관측 가능한 지점을 기준으로 “입력이 어디서 검증되고, 핵심 연산이 어디서 일어나며, 부작용(I/O·네트워크·디스크)·동시성이 어디서 터지는가”를 한 장면으로 그리면 장애 분석이 빨라집니다.
처리 파이프라인(개념도)
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
경계에서의 지연·실패(시퀀스 관점)
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(프로세스·런타임·게이트웨이) participant D as 의존성(외부 API·DB·큐) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
알고리즘·프로토콜·리소스 관점 체크포인트
- 불변 조건(Invariant): 각 단계가 만족해야 하는 조건(버퍼 경계, 프로토콜 상태, 트랜잭션 격리, 파일 디스크립터 상한)을 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 동일 입력에 동일 출력이 보장되는 순수 층과, 시간·네트워크·스레드 스케줄에 의해 달라질 수 있는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화/역직렬화, 문자 인코딩, syscall 횟수, 락 경합, GC·할당, 캐시 미스처럼 누적 비용을 의심 목록에 넣습니다.
- 백프레셔: 생산자가 소비자보다 빠를 때(소켓 버퍼, 큐 깊이, 스트림) 어디서 어떤 신호로 속도를 줄일지 정의합니다.
프로덕션 운영 패턴
실서비스에서는 기능과 함께 관측·배포·보안·비용·규제가 동시에 요구됩니다.
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율/지연 분위수(p95/p99), 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시 계층·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션 호환성·플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·파일 디스크립터·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 가능한 한 프로덕션에 가깝게 맞추는 것이 재현율을 높입니다.
확장 예시: 엔드투엔드 미니 시나리오
「[2026] Fastify 완전 정리 — 스키마 최적화·Find-my-way·훅 생명주기·플러그인·프로덕션」을 실제 배포·운영 흐름으로 옮긴 체크리스트형 시나리오입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드 표를 API 또는 이벤트 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 한 화면(로그+메트릭+트레이스)에서 추적한다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지(또는 피처 플래그) 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값이 기대 범위인지 본다.
의사코드 스케치(프레임워크 무관)
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request) // 경계에서 거절
authorize(validated, ctx) // 권한·테넌트
result = domainCore(validated) // 순수에 가까운 규칙
persistOrEmit(result, idempotentKey) // I/O: 멱등·재시도 정책
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성 불안정, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정이 로컬과 다름 | 프로필·시크릿·기본값, 지역 리전 | 단일 소스(예: 스키마 검증된 설정)와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
정리
Fastify의 성능 이야기는 단순히 “Node보다 빠르다”가 아니라, 스키마를 통해 검증·직렬화 경로를 부팅 시점에 고정하고, find-my-way로 라우팅을 트리 기반에 맡기며, 훅 파이프라인으로 관심사를 단계별로 분해하고, 플러그인 캡슐화로 전역 오염을 줄이는 쪽으로 이어집니다. 마지막으로 프로덕션에서는 로깅·프록시 신뢰·한도·에러 계약·과부하·종료를 코드와 인프라가 함께 지키게 만드는 것이 장기 운영의 핵심입니다.
다음 단계로는 OpenAPI와 스키마 단일 원천, 관측 가능성(메트릭·트레이싱)과 훅의 결합, 테스트에서 앱 인스턴스를 어떻게 부트스트랩할지를 팀 표준으로 묶어 가면 Fastify의 이점을 최대로 살릴 수 있습니다.