들어가며
지난 글에서 prisma를 설치하고 마이그레이션까지 성공했다. 이제 실제로 애플리케이션에서 데이터베이스를 사용할 수 있도록 연동 작업을 진행해야 한다.
스프링 부트에서는 application.yml에 데이터베이스 설정만 해두면 자동으로 연결이 되고, 생성자 주입으로 Repository를 주입받아 사용하면 끝이었다. 하지만 fastify는 그런 자동화가 없다. 모든 걸 직접 설정해야 한다고 한다.
이번 글에서는 환경변수를 타입 안전하게 관리하고, prisma를 fastify 플러그인으로 등록하는 과정을 정리해보려고 한다.
환경변수 관리의 문제
기존에는 server.ts에서 이렇게 환경변수를 사용하고 있었다.
const PORT = Number(process.env.PORT) || 3000;
문제는 이렇게 사용하면
- 타입 안정성이 없다 (
process.env.PORT는string | undefined) - 필수 환경변수가 누락되어도 모른다
- 여러 파일에서 중복 코드가 발생한다
스프링의 @Value나 @ConfigurationProperties 같은 기능이 그리웠다. 타입스크립트의 타입 시스템을 활용해서 비슷하게 만들어보기로 했다.
환경변수 타입 정의 및 검증
환경변수 타입 정의 및 검증을 하기전에 먼저 .env.dev``.env.prod 이렇게 각각 2개의 파일을 생성해준다. 환경변수를 개발환경, 운영환경 별로 나누어 주입받기 위함이다.
# .env.dev
NODE_ENV=dev
DATABASE_URL="mysql://prisma:1234@localhost:3307/ack_existing?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true"
PORT=3000
# .env.prod
NODE_ENV=prod
DATABASE_URL="mysql://prisma:1234@localhost:3307/ack_existing?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true"
PORT=3001
개발환경인지 운영환경인지를 판별하는 NODE_ENV 라는 값을 명시해준다.
일단 변수값이 환경별로 적용이 잘되는지 확인해보기 위해 포트값만 서로 다르게 작성해주었다.
그리고 src/config 폴더를 만들고 env.ts 파일을 아래와 같이 작성했다.
import dotenv from 'dotenv';
type NodeEnv = 'dev' | 'prod' | 'test';
const nodeEnv = (process.env.NODE_ENV || 'dev') as EnvConfig['NODE_ENV'];
dotenv.config({ path: `.env.${nodeEnv}` });
interface EnvConfig {
PORT: number;
NODE_ENV: NodeEnv;
DATABASE_URL: string;
}
function validateEnv(): EnvConfig {
const databaseUrl = ['DATABASE_URL'] as const;
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
for (const val of databaseUrl) {
if (!process.env[val]) {
throw new Error(`환경변수 ${val}가 설정되지 않았습니다.`);
}
}
if (!['dev', 'prod', 'test'].includes(nodeEnv)) {
throw new Error(`NODE_ENV는 dev, prod, test 중 하나여야 합니다.`);
}
return {
PORT: port,
NODE_ENV: nodeEnv,
DATABASE_URL: process.env.DATABASE_URL!,
};
}
export const env = validateEnv();
핵심 포인트
1. 타입 정의
type NodeEnv = 'dev' | 'prod' | 'test'; // 유니온 타입으로 제한
interface EnvConfig {
PORT: number; // 문자열이 아닌 숫자 타입
NODE_ENV: NodeEnv;
DATABASE_URL: string;
}
이제 env.PORT는 무조건 정수타입이고, env.NODE_ENV는 3가지 값 중 하나다.
2. 필수 환경변수 체크
const databaseUrl = ['DATABASE_URL'] as const;
for (const val of databaseUrl) {
if (!process.env[val]) {
throw new Error(`환경변수 ${val}가 설정되지 않았습니다.`);
}
}
서버가 시작할 때 필수 DATABASE_URL에 대한 환경변수가 없으면 에러를 던지도록 처리했다.
3. 기본값 설정
const nodeEnv = (process.env.NODE_ENV || 'dev') as EnvConfig['NODE_ENV'];
선택적인 환경변수는 기본값을 설정해둔다. 개발 환경에서는 일일이 설정 하지않아도 된다.
그 다음, 아래와 같이 package.json 스크립트 블록에서 dev와 prod 환경을 구분해주기위해 dev에는 NODE_ENV 프로퍼티 값을 dev로 설정해주고 start(운영환경)에는 prod로 설정해준다.
"scripts": {
"dev": "NODE_ENV=dev tsx watch src/server.ts",
"build": "tsc",
"start": "NODE_ENV=prod node dist/server.js",
"type-check": "tsc --noEmit"
}
이렇게 하면 설정값들을 각 환경에 알맞게 로드하여 서버를 실행한다.
Fastify 플러그인이란?
스프링의 di container나 자동 설정 개념에 익숙한 나로서는 fastify의 플러그인 시스템이 처음에는 낯설었다. 하지만 간단하다.
Spring vs Fastify
Spring
private final PlanRepository planRepository;
public PlanService(PlanRepository planRepository) {
this.planRepository = planRepository;
}
Fastify
app.register(databasePlugin); // 명시적으로 등록
// 이후 app.prisma로 접근
fastify의 플러그인은 일종의 앱 확장 개념이다. 플러그인을 등록하면 fastify 인스턴스에 새로운 기능이나 속성이 추가된다.
하지만 fastify의 플러그인 등록register() 은 DI Container의 일부 기능과 유사한 역할을 하지만 스프링 DI Container처럼 완전한 IoC 구조는 아니다.
먼저 서로의 공통점을 살펴보자.
- 객체 관리 - 스프링의 Bean, Fastify의 플러그인은 둘 다 “재사용 가능한 구성요소”를 등록하고 관리한다.
- 의존성 제공 - 등록된 Bean 또는 플러그인은 애플리케이션 내 다른 부분에서 주입받거나 접근할 수 있다.
- 생명주기 관리 - 컨테이너(Fastify 인스턴스 / Spring ApplicationContext)가 객체의 초기화, 해제 등을 책임진다.
fastify.register() 가 의존성 또는 기능을 등록 -> 이후 모든 라우트에서 접근 가능 한 구조가
스프링의 DI 컨테이너의 빈 등록 -> 주입 가능 개념과 비슷하다는 것을 공통 요소로 볼 수 있다.
다음으로는 차이점을 보자.
- 개념적 기반
- 스프링 DI 컨테이너: IoC + DI 기반의 진짜 컨테이너
- Fastify 플러그인 시스템: “데코레이터 기반 확장 시스템”, 주로 스코프 공유 목적
- 주입 방식
- 스프링 DI 컨테이너: 클래스나 생성자 기반 자동 주입 (@Autowired, 생성자 주입 등)
- Fastify 플러그인 시스템: 명시적 등록 (fastify.register(plugin)) 후 fastify.decorate()로 공유
- 주입 대상
- 스프링 DI 컨테이너: Bean 간의 타입 기반 의존성 (e.g. PlanService → PlanRepository)
- Fastify 플러그인 시스템: Fastify 인스턴스 자체의 확장 (e.g. fastify.db, fastify.auth)
- 관리 범위
- 스프링 DI 컨테이너: Bean 생명주기, Scope(singleton, prototype 등)까지 포함
- Fastify 플러그인 시스템: 플러그인 로드 순서와 스코프만 관리 (child scope 가능)
결론적으로 fastify의 플러그인 시스템은 스프링 DI 컨테이너의 빈 등록 및 공유와는 비슷한 구조지만,
의존성 자동 주입, 타입 기반 해석, 생명주기 제어 같은 DI의 핵심기능은 없다.
데이터베이스 플러그인 작성
// database.ts
import { FastifyPluginAsync } from 'fastify';
import fp from 'fastify-plugin';
import { PrismaClient } from '@prisma/client';
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient;
}
}
const databasePlugin: FastifyPluginAsync = async (fastify, options) => {
const prisma = new PrismaClient({
log: fastify.log.level === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
});
// Prisma 연결 확인
try {
await prisma.$connect();
fastify.log.info('데이터베이스 연결 성공');
} catch (error) {
fastify.log.error('데이터베이스 연결 실패:', error);
throw error;
}
// Fastify 인스턴스에 Prisma 클라이언트 등록
fastify.decorate('prisma', prisma);
// 서버 종료 시 Prisma 연결 해제
fastify.addHook('onClose', async (instance) => {
await instance.prisma.$disconnect();
instance.log.info('데이터베이스 연결 해제');
});
};
export default fp(databasePlugin, {
name: 'database',
});
코드가 좀 길어 보이지만 하나씩 뜯어보면 생각보다 단순하다고 생각했다.
1. TypeScript 모듈 확장
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient;
}
}
이 부분이 처음에는 이해가 안 갔다. 왜 Fastify 모듈을 수정하는 걸까?
이건 타입스크립트의 “Declaration Merging"이라는 기능이다. fastify의 타입 정의에 prisma 속성을 추가해주는 거다. 이렇게 하면 아래와 같은 장점이 있다.
app.prisma.plan.findMany()같은 코드를 작성할 때 타입 에러가 안 난다- IDE에서
app.prisma입력하면 자동완성이 된다
2. Prisma Client 생성 및 연결
const prisma = new PrismaClient({
log: fastify.log.level === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
});
try {
await prisma.$connect();
fastify.log.info('데이터베이스 연결 성공');
} catch (error) {
fastify.log.error('데이터베이스 연결 실패:', error);
throw error;
}
- 개발 환경에서는 모든 SQL 쿼리를 로그로 출력
- 프로덕션에서는 warn과 error만 출력 (로그 절약)
$connect()로 실제 연결을 시도하고, 실패하면 에러를 던져서 서버가 시작 안 되게 함
DB 연결이 안 되면 서버가 시작조차 안 되는 게 안전하다고 생각했다.
3. Prisma 클라이언트 등록
fastify.decorate('prisma', prisma);
decorate()는 fastify 인스턴스에 속성을 추가하는 메서드다. 이제 모든 라우트 핸들러에서 app.prisma로 접근할 수 있다.
4. Graceful Shutdown
fastify.addHook('onClose', async (instance) => {
await instance.prisma.$disconnect();
instance.log.info('데이터베이스 연결 해제');
});
서버가 종료될 때 DB 연결을 깔끔하게 정리한다.
그렇지 않으면 다음과 같은 문제가 발생한다.
- 데이터베이스 커넥션 풀이 제대로 해제 안 됨
- 프로덕션에서 재시작 반복하면 커넥션이 쌓임
5. fastify-plugin 래핑
export default fp(databasePlugin, {
name: 'database',
});
fastify-plugin (fp)으로 감싸는 이유는 플러그인을 전역으로 만들기 위해서다.
감싸지 않으면 플러그인이 등록된 스코프 안에서만 사용 가능가능하고, 감싸면 앱 전체에서 사용 가능하다.
name: 'database'는 플러그인 식별자다.
앱에 플러그인 등록 및 DB 접근 테스트
// app.ts
import Fastify from 'fastify';
import { env } from './config/env';
import databasePlugin from './plugins/database';
const app = Fastify({
logger: {
level: env.NODE_ENV === 'dev' ? 'info' : 'warn',
},
});
// 플러그인 등록
app.register(databasePlugin);
// 헬스체크 엔드포인트
app.get('/health', async (request, reply) => {
return { status: 'ok' };
});
// DB 연결 테스트 엔드포인트
app.get('/db-test', async (request, reply) => {
try {
const count = await app.prisma.plan.count();
return {
status: 'connected',
database: 'MySQL',
planCount: count,
};
} catch (error) {
reply.code(500);
return {
status: 'error',
message: error instanceof Error ? error.message : 'Unknown error',
};
}
});
export default app;
앞서 작성한 database.ts 을 플로그인으로 등록한 후, /db-test 엔드포인트로 접근을 하니
으로 전환하기(3) - 환경변수와 데이터베이스 플러그인 설정 Feat. Spring vs Fastify-1762928876626.png)
위와 같이 정상적으로 응답이 오는 것을 확인할 수 있다. 실제 Plan테이블이 비어있으니 레코드 count는 당연히 0개이다.
마치며
환경변수 타입 정의와 데이터베이스 플러그인 작성을 완료했다. 스프링 부트의 자동 설정에 비하면 손이 많이 가지만 그 과정에서 많은 걸 배울 수 있었다.
다음 단계로는 실제 비즈니스 로직을 구현할 차례다. Plan 도메인의 DTO, Repository, Service, Routes를 하나씩 만들어볼 예정이다. 스프링의 레이어드 아키텍처를 Fastify에서는 어떻게 구현하는지 정리해보려고 한다.