[REST API] 3편. Fastify API 서버 기본 골격 구현하기 : app, server
3편. Fastify API 서버 기본 골격 구현하기: app, server
📚 목차
1. 환경 변수 관리 : env.ts
2. Fastify 인스턴스 생성 및 구성 : app.ts
3. 서버 엔트리 포인트 및 Graceful Shutdown : server.ts
4. 전역 에러 핸들러 및 로깅
5. Fastify API 서버 실행
📂 [GitHub 코드 보러가기] : https://github.com/cericube/nodejs-practice-lab/tree/main/fastify-api-rest
1. 환경 변수 관리 : env.ts
.env 파일은 단순히 문자열 데이터를 담고 있는 파일입니다. 이를 process.env로 직접 접근할 경우 다음과 같은 문제와 장점이 있습니다.
.env가 있는데 env.ts를 따로 만드는 이유
▸ 타입 안정성 (Type Safety):
process.env.PORT를 Number로 변환하고 타입을 정의하여 런타임 에러를 방지합니다.
▸ 중앙 집중식 유효성 검사:
서버 시작 시 필수 환경 변수(DB URL, API Key 등) 누락을 즉시 확인하고 프로세스를 중단시킵니다.
▸ 기본값 제공 및 단일 진입점:
환경별로 다른 기본값을 설정하고, 애플리케이션 전반에서 일관된 설정 값을 참조할 수 있습니다.
▸ 자동 완성 및 가독성: IDE의 자동 완성 기능을 통해 개발 생산성이 향상되고 설정 구조를 한눈에 파악할 수 있습니다.
✔️ src/config/env.ts 코드
▸ process.env 직접 사용 ❌
▸ env.X 형태로만 접근 ⭕
▸ 테스트 환경(.env.test)에서도 동일한 인터페이스 유지
📌주의 사항
▸ .env 파일이 있어도 Node.js는 자동으로 로드하지 않습니다.
▸ dotenv/config 은 import 되는 순간 .env를 로드합니다.
// src/config/env.ts
// 중요:
// dotenv/config 은 import 되는 순간 .env를 로드합니다.
import 'dotenv/config';
import process from 'process';
// 환경 변수를 기본값과 함께 묶어 앱 설정으로 제공한다.
export const env = {
// 실행 환경 (기본값: development)
NODE_ENV: process.env.NODE_ENV || 'development',
// 서버 바인딩 주소/포트 및 로깅 레벨
HOST: process.env.HOST || '0.0.0.0',
PORT: process.env.PORT ? Number(process.env.PORT) : 3000,
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
// DB 연결 문자열 (미설정 시 빈 문자열)
DATABASE_URL: process.env.DATABASE_URL || '',
};
2. Fastify 인스턴스 생성 및 구성 : app.ts
app.ts는 애플리케이션의 "청사진" 입니다. 실무에서는 테스트 용이성을 위해 서버를 바로 실행하지 않고, 인스턴스를 반환하는 buildApp() 함수 형태로 작성합니다.
특히 app.ts에는 애플리케이션 전체에 영향을 주는 핵심 요소들만 배치하여 구조적 일관성을 유지합니다.
1. app.ts의 주요 역할 및 구성 요소
app.ts
├─ Fastify 인스턴스 생성
├─ 모든 플러그인 등록
├─ 모든 라우트 등록
├─ 전역 에러 핸들러
└─ 전역 Hook
▸ Fastify 인스턴스 생성:
logger, trustProxy, bodyLimit 등 서버의 전역 작동 방식을 결정하는 옵션을 설정하여 인스턴스를 초기화합니다.
▸ 공통 플러그인 등록:
CORS, Helmet, DB 커넥터, 인증 플러그인 등 모든 라우트에서 공통으로 사용하는 미들웨어 성격의 플러그인을 등록합니다.
▸ 모든 라우트 등록:
분리된 라우터 파일들을 app.register를 통해 하나로 결합합니다.
▸ 전역 에러 핸들러:
예기치 못한 에러를 가공하여 일관된 JSON 응답 포맷을 클라이언트에 전달합니다.
▸ 전역 Hook:
onRequest, preHandler, onSend 등 요청 생명주기 전반에 걸쳐 실행될 공통 로직(예: 로그 추적용 ID 부여)을 정의합니다.
2. Fastify 생성 시 사용할 수 있는 주요 옵션
| 옵션 | 기본값 | 설명 |
| logger | false | Pino 로거 활성화 여부 또는 설정 객체. true 또는 { level, transport, serializers } 형태로 세부 제어 가능 |
| bodyLimit | 1048576 (1MB) | 요청 본문 최대 크기(바이트). 업로드/대용량 요청 API에서 반드시 조정 |
| connectionTimeout | Node.js 기본값 | TCP 연결 수립 제한 시간(ms). 느린 클라이언트 연결 차단 |
| keepAliveTimeout | Node.js 기본값 | keep-alive 연결 유지 시간(ms). HTTP/1.1 성능과 직결 |
| requestTimeout | 0 (무제한) | 전체 요청 처리 제한 시간(ms). 장시간 처리 요청 차단용 느린 외부 API, DB 병목 시 워커 보호 |
| trustProxy | false | 프록시 뒤에서 X-Forwarded-* 헤더(IP/프로토콜)를 신뢰할지 여부. 로드밸런서/Ingress 뒤에서는 필수 |
| ignoreTrailingSlash | false | /path 와 /path/ 를 동일한 라우트로 처리 |
| maxParamLength | 100 | URL path 파라미터 최대 길이. 과도한 문자열로 인한 공격 방지 |
| caseSensitive | true | 라우트 매칭 시 대소문자 구분 여부 |
| ajv | Fastify 기본 AJV 옵션 | JSON Schema 검증기 설정. 타입 강제, 기본값 적용, 추가 필드 제거 등 제어 가능 |
| pluginTimeout | 10000 (10초) | 플러그인 등록 제한 시간(ms). Prisma, Redis 등 초기 연결 실패 방지 |
| disableRequestLogging | false | 요청마다 자동으로 남기는 request log 비활성화 |
| https | undefined | HTTPS 서버 구동 시 TLS 옵션 (key, cert 등) |
| http2 | false | HTTP/2 활성화 여부. HTTPS와 함께 사용 |
3. 실무에서 많이 사용하는 Fastify 옵션 예시
// src/app.ts
const app = Fastify({
// 로깅 레벨은 환경 변수로 제어한다.
logger: {
level: env.LOG_LEVEL,
},
// 요청 본문 최대 크기(바이트). 과도한 페이로드 방지.
bodyLimit: 1024 * 1024,
// 연결/요청 타임아웃 설정.
connectionTimeout: 10_000,
keepAliveTimeout: 5_000,
requestTimeout: 30_000,
// 리버스 프록시 환경에서 클라이언트 IP/프로토콜 신뢰.
trustProxy: true,
pluginTimeout: 10_000,
routerOptions: {
// /path 와 /path/를 동일하게 취급.
ignoreTrailingSlash: true,
// 라우트 대소문자 구분을 끄고 파라미터 길이 제한을 둔다.
caseSensitive: false,
maxParamLength: 200,
},
});
3. 서버 엔트리 포인트 및 Graceful Shutdown : server.ts
server.ts는 app.ts에서 생성된 애플리케이션 인스턴스를 실제로 구동하고, 프로세스 수준의 이벤트를 관리하는 역할을 합니다.
특히 실무 환경(Kubernetes, PM2 등)에서는 Graceful Shutdown(우아한 종료) 처리가 필수적입니다.
1. 기본 구조
// src/server.ts
async function startServer() {
// createApp()에서 등록된 플러그인/라우트가 포함된 앱을 생성한다.
const app = await createApp();
try {
// HTTP 서버를 시작하고 실제 바인딩 주소를 로그로 남긴다.
const address = await app.listen({
host: env.HOST,
port: env.PORT,
});
app.log.info(`Server listening at ${address}`);
} catch (err) {
// 시작 실패를 로그로 남기고 비정상 종료한다(프로세스 매니저 감지용).
app.log.error({ err }, 'Failed to start server');
process.exit(1);
}
}
startServer();
2. Graceful Shutdown - 운영 환경을 위한 서버 종료 설계
목표 : “새 요청은 받지 않고, 이미 시작한 작업은 안전하게 마무리한 뒤 종료한다.”
종료처리단계
1 .신호 감지: 운영체제나 오케스트레이터로부터 종료 신호를 수신합니다.
2. 신규 연결 차단: 서버가 더 이상 새로운 요청을 받지 않도록 설정합니다.
3. 진행 중인 작업 완료: 현재 처리 중인 모든 요청이 응답을 마칠 때까지 대기합니다.
4. 자원 해제: DB 커넥션 풀을 닫고, 연결된 외부 리소스를 안전하게 정리합니다.
5. 프로세스 종료: 모든 준비가 끝나면 process.exit(0)으로 안전하게 종료합니다.
✔️ graceful shutdown 로직
Fastify 애플리케이션 종료는 반드시 app.close()를 통해야 합니다.
Fastify는 이 시점부터 새로운 요청 수신을 중단하고, 기존 keep-alive 연결을 정리한 뒤 모든 플러그인의 onClose 훅을 순차적으로 실행합니다.
그 결과 DB 커넥션(Prisma), 캐시·메시지 큐, 백그라운드 워커 등 애플리케이션이 사용하던 모든 외부 리소스가 안전하게 정리된 후 프로세스가 종료됩니다.
// 프로세스 종료 시 공통적으로 호출되는 graceful shutdown 로직
const shutdown = async (signal: string) => {
if (isShuttingDown) return;
isShuttingDown = true;
try {
app.log.info({ signal }, 'Shutting down');
// - keep-alive 연결 정리
// - plugin onClose 훅 실행 (DB, Redis, Queue 정리 지점)
await app.close();
process.exit(0);
} catch (err) {
app.log.error({ err }, 'Shutdown failed');
process.exit(1);
}
};
✔️ 처리해야 할 시그널 및 런타임 에러
// SIGINT: Ctrl + C (개발 환경)
process.on('SIGINT', () => void shutdown('SIGINT'));
// SIGTERM: Docker/K8s/PM2 등 운영 환경 종료 신호 (가장 중요)
process.on('SIGTERM', () => void shutdown('SIGTERM'));
// 처리되지 않은 Promise 에러 → 상태 오염 가능 → 즉시 종료
process.on('unhandledRejection', (reason) => {
app.log.error({ err: reason }, 'Unhandled rejection');
void shutdown('unhandledRejection');
});
// try/catch 밖에서 터진 동기 예외 → undefined state → 즉시 종료
process.on('uncaughtException', (err) => {
app.log.error({ err }, 'Uncaught exception');
void shutdown('uncaughtException');
});
4. 전역 에러 핸들러 및 로깅
에러는 개별 라우트에서 처리하기보다 app.setErrorHandler를 통해 전역에서 관리합니다.
일관된 응답 포맷을 유지하고, 예기치 못한 에러를 로깅하여 추적할 수 있게 합니다.
1. 표준 에러 클래스(HttpError 클래스) 정의
HttpError 클래스를 별도로 정의하여 사용하는 이유는 크게 비즈니스 로직의 명확성, 에러 처리의 자동화, 그리고 유지보수 효율성 때문입니다
// src/common/errors/http.error.ts
export class BusinessError<T = unknown> extends Error {
constructor(
public errorCode: ErrorCode,
public message: string,
public statusCode: number = 400,
public details?: T, // any 대신 제네릭 T 사용
) {
super(message);
// 이 클래스의 인스턴스임을 명확히 함 (TS에서 instanceof 체크를 위해 필요)
Object.setPrototypeOf(this, BusinessError.prototype);
this.name = this.constructor.name;
}
}
2. Fastify 전역 에러 핸들러
Fastify 전역 에러 핸들러(Global Error Handler)는 애플리케이션에서 발생하는 모든 예외를 한 곳에서 받아서, HTTP 응답과 로그로 일관되게 처리하는 최종 관문입니다.
컨트롤러, 서비스, 플러그인, 훅(hook) 어디에서 에러가 발생하든 최종적으로 이 핸들러를 거쳐 응답이 결정됩니다.
Fastify에서 전역 에러 핸들러는 다음 API로 등록합니다.
// src/app.ts
import { errorHandler } from './common/errors/error.handler';
...
app.setErrorHandler(errorHandler);
...
▸ 전역 에러 핸들러 예시
// src/common/errors/error.handler.ts
/** 공통 에러 응답 포맷 */
interface ErrorResponse {
success: false;
code: string;
message: string;
}
/** Fastify 전역 에러 핸들러 */
export function errorHandler(
error: unknown, // 어떤 타입의 에러도 올 수 있으므로 unknown
request: FastifyRequest, // 요청 컨텍스트 (로깅, trace id 등 포함)
reply: FastifyReply, // HTTP 응답 객체
) {
// 기본값 (Fail-safe)
// - 어떤 분기에도 걸리지 않으면 무조건 500으로 처리
let statusCode = 500;
let errorCode: string = ErrorCode.INTERNAL_SERVER_ERROR;
let message = '서버에서 알 수 없는 오류가 발생했습니다.';
let details: unknown = null;
// BusinessError
// 도메인/비즈니스 레이어에서 "의도적으로" 던진 예외
if (error instanceof BusinessError) {
statusCode = error.statusCode;
errorCode = error.errorCode;
message = error.message;
details = error.details ?? null;
} else if (error !== null && typeof error === 'object' && 'validation' in error) {
// Fastify Validation Error
// 요청 바디 / 쿼리 / 파라미터가 JSON Schema 검증에 실패한 경우
// Fastify는 validation 속성을 가진 에러 객체를 던진다
statusCode = 400;
errorCode = ErrorCode.VALIDATION_ERROR;
message = '입력 형식이 올바르지 않습니다.';
details = (error as { validation: unknown }).validation;
} else if (error instanceof Prisma.PrismaClientKnownRequestError) {
// Prisma Known Request Error
// 위에서 작성한 Prisma 매핑 로직이 이 자리에 들어갑니다.
const mapping = mapPrismaError(error);
statusCode = mapping.status;
errorCode = mapping.code;
message = mapping.message;
details = mapping.details;
}
/**
* 표준 로그 페이로드
*/
const logPayload = {
level: statusCode >= 500 ? 'error' : 'info', // 서버 장애 여부 기준
timestamp: new Date().toISOString(),
service: env.SERVICE_NAME,
traceId: request.id,
context: {
path: request.url,
method: request.method,
},
error: {
code: errorCode,
message: message,
details: details, // 내부 분석용 (클라이언트 미노출)
},
request: {
body: request.body, // 개인정보 포함 가능 → 마스킹 고려
query: request.query,
},
};
// 로그 기록
if (statusCode >= 500) {
request.log.error(logPayload);
} else {
request.log.info(logPayload);
}
return reply.status(statusCode).send({
success: false,
code: errorCode,
message: message,
} satisfies ErrorResponse);
}
3. 로깅 시스템 구성 (Pino Transport)
▸ Fastify의 기본 로거인 Pino는 속도가 매우 빠르고 오버헤드가 적은 JSON 로거입니다.
transport 설정을 통해 로그 데이터를 다양한 목적지로 스트리밍할 수 있습니다.
▸ 하나의 로그 이벤트를 콘솔(표준 출력)과 물리 파일에 동시에 기록합니다.
이를 통해 개발 환경에서는 실시간 모니터링을, 운영 환경에서는 데이터 영속성을 확보합니다.
▸ 운영 환경에서는 모든 로그를 JSON 포맷으로 저장합니다.
이는 데이터 구조가 일정하여 ELK Stack(Elasticsearch, Logstash, Kibana)이나 AWS CloudWatch 로그 수집기에서 파싱하고 분석하기에 매우 유리합니다.
// 로깅 레벨은 환경 변수로 제어한다.
logger: {
level: env.LOG_LEVEL,
transport: {
targets: [
env.NODE_ENV === 'development'
? // 개발 환경: 콘솔 (stdout, JSON)
{
target: 'pino/file',
level: env.LOG_LEVEL,
options: { destination: 1 }, // 1 = stdout
}
: // 운영용 파일 출력
{
target: 'pino/file',
options: { destination: './logs/app.log', mkdir: true },
level: 'info',
},
],
},
},
5. Fastify API 서버 실행
tsx를 사용하여 TypeScript 소스를 빌드 없이 직접 실행합니다.
개발 중에는 파일 변경 시 자동 재시작되는 watch 모드를 사용합니다.
watch 모드는 파일 변경을 감지하여 프로세스를 자동으로 재시작하는 기능입니다.
1. 수동 실행 방법(watch 모드)
npx tsx watch ./src/server.ts
2. package.json 스크립트로 실행
매번 긴 명령어를 입력하지 않도록, package.json에 스크립트를 등록합니다.
{
"scripts": {
"dev": "tsx watch ./src/server.ts",
"test": "echo \"Error: no test specified\" && exit 1"
}
}
npm run dev

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