4.Node.js/실무익히기

[REST API] 4편. Fastify + Prisma 구조 설계 하기: env, config, plugin

쿼드큐브 2026. 3. 18. 12:27
반응형
반응형

 

4편. Fastify + Prisma 구조 설계 하기 : env, config, plugin

 

📚 목차
1. env / config / plugin을 분리하는 이유
2. prisma.config.ts - DB 클라이언트 계층의 책임
3. prisma.plugin.ts - 프레임워크 통합 계층
4. 전체 라이프사이클 흐름 정리 (Bootstrap → Runtime → Shutdown)

 

📂 [GitHub 코드 보러가기] : https://github.com/cericube/nodejs-practice-lab/tree/main/fastify-api-rest

1. env / config / plugin을 분리하는 이유

1. env.ts: 실행 환경(환경 변수) 관리의 단일 진입점

Node.js 애플리케이션에서 환경 변수는 운영체제가 프로세스 시작 시 주입하며, 모든 설정은 process.env를 통해 접근합니다.
이 값들은 항상 문자열(string)로 제공되므로, 애플리케이션에서 안전하게 사용하기 위해 다음과 같은 전처리 과정이 필요합니다.


▸ 값 정규화 (Normalization)
    문자열 값을 숫자, 불리언 등 실제 타입으로 변환
▸ 기본값 정책 (Defaults)
    개발·로컬 환경에서 누락된 설정에 대해 합리적인 기본값 제공
▸ 유효성 검증 (Validation, 선택)
    운영 환경에서 필수 설정이 누락된 경우 서버 기동 자체를 차단
▸ 중앙 집중화 (Single Source of Truth)
    모든 코드가 동일한 규칙과 타입으로 설정을 사용하도록 단일 진입점 제공

 

.env 파일을 읽고 process.env로 로딩하는 방법으로서 import 'dotenv/config' 패턴을 사용하면 별도 초기화 코드 없이 애플리케이션 시작 시 자동으로 로드됩니다.

// src/config/env.ts

import 'dotenv/config';   // 지동 로드
import process from 'process';

// 환경 변수를 기본값과 함께 묶어 앱 설정으로 제공한다.
export const env = {
  NODE_ENV: process.env.NODE_ENV || 'development',
};

 

2. prisma.config.ts: DB 클라이언트 구성을 프레임워크와 분리

PrismaClient 인스턴스를 여러 개 생성할 경우, 내부적으로 커넥션 풀 또한 중복 생성될 수 있으므로, 프로세스 단위에서 싱글톤으로 관리하는 것이 여전히 중요합니다.

 

주요 이유는 다음과 같습니다.

▸ 프레임워크 비의존 재사용성
   배치 스크립트, 마이그레이션, 테스트 코드 등 Fastify와 무관한 환경에서도 동일한 Prisma 설정을 그대로 재사용할 수 있습니다.

로깅 및 관측(Observability) 설정의 일관성
   Prisma는 query, info, warn, error 등 다양한 로그 레벨과 stdout 또는 이벤트 기반 로깅 구성을 지원합니다.
   이를 한 곳에서 통제함으로써 전체 시스템의 로그 정책을 일관되게 유지할 수 있습니다.

싱글톤 및 커넥션 풀 통제
   PrismaClient가 여러 번 생성되면 커넥션 풀도 중복 생성될 수 있으므로, 프로세스당 하나의 인스턴스로 관리하고 풀 관련 옵션 역시 중앙에서 통제하는 구조가 바람직합니다.

즉, prisma.config.ts는 PrismaClient 생성과 전역 설정을 책임지는 순수 인프라 레이어로 역할을 제한합니다.

 

3. prisma.plugin.ts: Fastify 생명주기에 Prisma를 연결하는 통합 계층

Fastify는 기능 확장을 플러그인 단위로 수행하며, decorate() API를 통해 서버 인스턴스에 공용 객체를 주입하는 방식을 공식적으로 지원합니다.
따라서 prisma.plugin.ts의 책임은 다음 범위로 한정하는 것이 이상적입니다.

 

따라서 prisma.plugin.ts의 책임은 다음으로 한정하는 것이 이상적입니다.
이미 구성된 PrismaClient 인스턴스를 Fastify 인스턴스에 decorate로 주입
▸ Fastify 로거와 Prisma 로깅 시스템을 연결하여 로그 흐름을 통합
▸ 서버 종료 시 Prisma 연결을 graceful shutdown 방식으로 안전하게 종료

즉, 이 레이어는 Prisma 자체를 설정하는 곳이 아니라, 이미 준비된 DB 클라이언트를 Fastify의 생명주기와 연결하는 통합 어댑터 계층 역할을 담당합니다.

 

2. prisma.config.ts - DB 클라이언트 계층의 책임

핵심 목적은 데이터베이스 연결과 ORM 설정을 프레임워크 코드(Fastify)와 분리하여, 재사용 가능하고 예측 가능한 DB 접근 계층을 만드는 것입니다.

 

1. PrismaClient를 싱글톤으로 두는 이유

개발 서버(Next.js, Vite, tsx watch 등)에서는 코드 변경 시 서버 프로세스는 유지된 채 모듈만 다시 로드되는 HMR(Hot Module Replacement)이 발생합니다.

이 경우 이전 PrismaClient가 완전히 해제되지 않은 상태에서 새 인스턴스가 계속 생성되어, 결과적으로 PostgreSQ의 max_connections 제한에 도달하게 됩니다.

 

이를 방지하기 위해 이 구성에서는 개발 환경에서만 global 객체를 이용한 싱글톤 패턴을 사용합니다.

// src/config/prisma.config.ts
/**
 * 개발 환경에서 Hot Module Replacement(HMR) 발생 시
 * 기존 PrismaClient 인스턴스를 재사용하기 위한 전역 변수 선언
 *
 * 주의사항:
 * - declare global 블록 내부에서는 var만 사용 가능
 */
declare global {
  // PrismaClient 인스턴스를 저장할 전역 변수
  var __prisma_client__: PrismaClient | undefined;

  // PostgreSQL Connection Pool을 저장할 전역 변수
  var __pg_pool__: Pool | undefined;
}

 

그리고 인스턴스 생성 시 다음 전략을 적용합니다.
▸ 운영 환경: 프로세스 수명 동안 하나의 인스턴스만 생성되므로 글로벌 캐싱 불필요
▸ 개발 환경: HMR 발생 시에도 기존 인스턴스를 재사용하여 연결 누적 방지

// src/config/prisma.config.ts
const getInstances = () => {
  if (env.NODE_ENV === 'production') {
    return _createPrismaInstance();  // 운영 환경: 단순히 새 인스턴스 생성
  }

// 동작 원리:
// 1. 첫 실행 시: 글로벌 변수가 undefined이므로 새 인스턴스 생성 및 저장
// 2. HMR 발생 시: 글로벌 변수에 저장된 인스턴스 재사용
// 3. 서버 재시작 시: 글로벌 변수가 초기화되어 새 인스턴스 생성
   
  if (!global.__prisma_client__ || !global.__pg_pool__) {
    const { client, pool } = _createPrismaInstance();
    global.__prisma_client__ = client;
    global.__pg_pool__ = pool;
  }

  return {
    client: global.__prisma_client__,
    pool: global.__pg_pool__,
  };
};

 

2. Prisma 로깅: stdout 방식과 event 기반 방식

Prisma는 PrismaClient 생성 시 log 옵션을 통해 로그 레벨과 출력 방식을 설정할 수 있습니다.
이때 로그는 두 가지 방식으로 처리할 수 있습니다.
▸ emit: 'stdout' → Prisma가 콘솔에 직접 출력
▸ emit: 'event' → 이벤트로 발생시켜 애플리케이션에서 직접 처리

 

실무에서는 대부분 event 기반 로깅을 선택합니다. 환경별로 로그 레벨을 다음과 같이 분리합니다.

개발: 쿼리까지 포함하여 성능 및 SQL 확인 가능
영: error 중심으로 로그 용량과 성능 부담 최소화

// src/config/prisma.config.ts
const logLevels: Prisma.LogDefinition[] =
  env.NODE_ENV === 'production'
    ? [{ emit: 'event', level: 'error' }]
    : [
        { emit: 'event', level: 'error' },
        { emit: 'event', level: 'warn' },
        { emit: 'event', level: 'query' },
      ];

 

그리고 Fastify 플러그인에서 등록된 Pino Logger를 Prisma 설정 모듈로 전달받아, Prisma 이벤트를 애플리케이션 로거로 라우팅합니다.

// src/config/prisma.config.ts
export function registerPrismaLogger(logger: Logger) {
  _registeredLogger = logger;
  logger.info('Logger registered to Prisma config');
  _setupPrismaEventListeners();
}

// 쿼리 로그 이벤트 등록 예시
client.$on('query' as never, (e: Prisma.QueryEvent) => {
  log.debug(
    {
      query: e.query,
      params: e.params,
      duration: `${e.duration}ms`,
      target: e.target,
    },
    'Prisma Query',
  );
});

client.$on('error' as never, (e: Prisma.QueryEvent) => {...});
client.$on('warn' as never, (e: Prisma.QueryEvent) => {...});

 

3. Prisma 7 Driver Adapter + Connection Pool 구조

Prisma 7부터는 모든 데이터베이스 연결에 Driver Adapter 패턴이 필수로 적용됩니다.
즉, Prisma가 직접 커넥션을 관리하지 않고, 실제 DB 드라이버(Pool)를 어댑터를 통해 사용합니다.

 

Step 1. PostgreSQL Connection Pool 생성

const pool = new Pool({
  connectionString,
  max: env.DATABASE_POOL_MAX,
  idleTimeoutMillis: env.DATABASE_POOL_IDLE_TIMEOUT,
});

 

또한 개발 환경에서는 Pool 상태를 로깅하여 연결 흐름을 관찰할 수 있도록 합니다.

이는 트래픽 증가나 커넥션 병목 문제를 조기에 발견하는 데 매우 유용합니다.

pool.on('connect', () => { ... });
pool.on('remove', () => { ... });

 

Step 2. Prisma PostgreSQL Adapter 생성

▸ Prisma 쿼리를 실제 DB 드라이버 호출로 변환
▸ 커넥션 풀 관리를 Prisma가 아닌 node-pg에 위임
▸ 멀티 DB / 멀티 드라이버 구조 대응 가능

특히 schema 옵션을 통해 스키마 분리 기반 멀티 테넌트 구조도 자연스럽게 확장할 수 있습니다.

const adapter = new PrismaPg(pool, {
  schema: env.DATABASE_SCHEMA,
});

 

Step 3. PrismaClient 생성

이제 PrismaClient는:
node-pg Pool을 통한 실제 연결 관리
event 기반 로깅
트랜잭션 및 ORM 기능 제공
이라는 역할에만 집중하게 됩니다.

const client = new PrismaClient({
  adapter,
  log: logLevels,
});

 

4. 연결 해제와 Graceful Shutdown의 기준점

실무에서 “graceful shutdown”은 단순히 프로세스를 종료하는 것이 아닙니다.

 

다음 순서를 보장하는 것이 핵심입니다.
▸ 더 이상 새 쿼리를 받지 않음
▸ 진행 중인 트랜잭션 및 쿼리 완료 대기
▸ 커넥션 풀 반환 및 엔진 프로세스 종료
▸ 메모리와 네트워크 자원 정리
이를 위해 이 설정 모듈은 명시적인 종료 함수를 제공합니다.

export const shutdownPrisma = async (timeMillis = 5000) => { ... }

 

내부 흐름은 다음과 같습니다.

# Step 1. PrismaClient 종료
await client.$disconnect();

# Step 2. PostgreSQL Pool 종료
await pool.end();

# Step 3. 개발 환경 글로벌 변수 초기화
global.__prisma_client__ = undefined;
global.__pg_pool__ = undefined;

 

모든 단계는 타임아웃 래퍼로 감싸 무한 대기를 방지합니다.

Promise.race([promise, timeoutPromise])
반응형

 

3. prisma.plugin.ts - 프레임워크 통합 계층

앞선 섹션에서 env.ts는 환경 설정의 단일 진입점, prisma.config.ts는 프레임워크와 분리된 DB 접근 계층이라는 역할을 담당한다고 설명했습니다.

이제 prisma.plugin.ts는 이 두 계층에서 준비된 리소스를 Fastify의 생명주기 안으로 안전하게 통합하는 역할을 수행합니다.

 

즉, 이 파일의 책임은 다음 세 가지로 요약할 수 있습니다.
▸ PrismaClient를 Fastify 인스턴스에 의존성으로 주입(DI)
▸ Fastify 로거(Pino)와 Prisma 로그 시스템을 통합
▸ 서버 종료 시점에 데이터베이스 리소스를 안전하게 정리(Graceful Shutdown)

 

1. Fastify 플러그인 구조의 핵심: Encapsulation

Fastify에서 register()는 기본적으로 새로운 스코프(scope)를 생성합니다.

해당 스코프 안에서 decorate()로 추가한 속성이나 훅은, 원칙적으로는 상위 스코프나 다른 플러그인에서 보이지 않습니다.
이 동작 방식이 바로 Fastify의 Encapsulation(캡슐화) 모델입니다.

 

하지만 PrismaClient처럼 애플리케이션 전역에서 공통으로 사용해야 하는 인프라 의존성은, 의도적으로 이 캡슐화를 해제하고 상위 스코프로 노출할 필요가 있습니다.
이를 위해 Fastify에서 공식적으로 권장하는 방식이 fastify-plugin입니다.

 

fastify-plugin으로 감싼 플러그인은:
▸ 새로운 캡슐화 스코프를 만들지 않고
▸ 상위 스코프(루트 Fastify 인스턴스)에 데코레이터와 훅을 등록하게 되므로,

이후에 등록되는 모든 라우트와 플러그인에서 동일한 Prisma 인스턴스를 사용할 수 있습니다.

 

2. prisma.plugin.ts - DI + 로깅 통합 + 종료 훅

플러그인 코드의 흐름을 단계별로 살펴보겠습니다.

 

Step 1. TypeScript Declaration Merging - fastify.prisma 타입 안전성 확보

Fastify에서 decorate()로 서버 인스턴스에 속성을 추가하더라도, TypeScript는 기본적으로 그 사실을 알지 못합니다.
따라서 타입 시스템에 해당 속성이 존재함을 명시적으로 알려주어야 합니다.

이 방식은 TypeScript의 Declaration Merging(선언 병합) 기능을 이용한 표준 패턴이며,
Fastify 공식 문서에서도 플러그인 확장 시 동일한 방식의 타입 확장을 권장합니다.

declare module 'fastify' {
  interface FastifyInstance {
    prisma: typeof prisma;
  }
}

 

이 선언 덕분에 이후 코드에서:

fastify.prisma.user.findMany()

와 같은 접근이 자동 완성과 타입 검증의 보호를 받게 됩니다.

 

Step 2. 로거 통합 - Prisma 로그를 Fastify 로그 스트림으로

/* 
 * as Logger — 직접 캐스팅 (구조적 호환성 필요)
 * - 두 타입의 프로퍼티/메서드 구조가 어느 정도 맞아야 합니다. 많이 다르면 오류발생
 * as unknown as Logger — 완전한 강제 캐스팅 (타입 체크 우회)
 * 1. fastify.log as unknown
 *   → 모든 타입은 unknown으로 캐스팅 가능 (top type)
 * 2. unknown as Logger
 *   → unknown은 어떤 타입으로도 캐스팅 가능
 */  
registerPrismaLogger(fastify.log as unknown as Logger);

prisma.config.ts에서는 Prisma 이벤트 로그를 애플리케이션 로거로 전달할 수 있도록 registerLogger() 함수를 제공하고 있습니다.

해당 함수는 내부적으로 다음 작업을 수행합니다.
▸ 전달받은 Pino Logger를 모듈 전역에 저장
▸ Prisma $on('query' | 'warn' | 'error') 이벤트 리스너 등록
▸ 모든 Prisma 로그를 애플리케이션 로그 스트림으로 전달

 

Step 3. 의존성 주입(DI) - decorate('prisma', prisma)

fastify.decorate('prisma', prisma);

이 한 줄은 단순해 보이지만, 구조적으로 매우 중요한 의미를 가집니다.

이제부터 모든 라우트와 훅은 직접 prisma.config.ts를 import하지 않고, Fastify 인스턴스를 통해 prisma에 접근하게 됩니다.

즉, PrismaClient는 더 이상 “전역 싱글톤 객체”가 아니라, Fastify 애플리케이션의 구성 요소 중 하나로 취급됩니다.

 

Step 4. Graceful Shutdown - onClose 훅에서 안전하게 종료

fastify.addHook('onClose', async (_instance) => {
  await shutdownPrisma(5000);
});

prisma.config.ts에서는 다음 두 가지 리소스를 직접 관리합니다.
▸ PrismaClient (client.$disconnect())
▸ PostgreSQL Connection Pool (pool.end())

 

Fastify의 onClose 훅은 서버가 종료될 때 반드시 실행되므로,
이 시점에서 DB 리소스를 정리하는 것은 컨테이너 환경(Kubernetes, Docker)이나 무중단 배포(Rolling Update) 환경에서 매우 중요한 안정성 요소가 됩니다.

 

Step 5. 캡슐화 해제 - fp(prismaPlugin)의 의미

export default fp(prismaPlugin, {
  name: 'prisma-plugin',
});

앞서 설명한 Fastify의 Encapsulation 모델 때문에, 단순히 app.register(prismaPlugin)만 사용하면 decorate('prisma')가 다른 플러그인이나 라우트에서 보이지 않을 수 있습니다.

fastify-plugin(fp)로 감싸면 해당 플러그인은 새로운 스코프를 만들지 않고 상위 스코프(루트 Fastify 인스턴스)에 직접 등록됩니다.
그 결과, 이후 등록되는 모든 플러그인과 라우트에서 fastify.prisma를 일관되게 사용할 수 있게 됩니다.

 

3. 등록 및 사용 흐름

애플리케이션 초기화 단계에서 플러그인은 다음과 같이 등록됩니다.

const app = Fastify({..});
...
await app.register(prismaPlugin);

 

이후 모든 라우트에서 다음과 같이 사용 가능합니다.

const users = await fastify.prisma.user.findMany();

 

4. 전체 라이프사이클 흐름 정리 (Bootstrap → Runtime → Shutdown)

env, config, plugin으로 분리된 구조가 애플리케이션의 생명주기(Lifecycle) 동안 어떻게 상호작용하며 운영 안정성을 확보하는지 단계별로 살펴봅니다.

서버구동 및 종료 로그 예시
서버구동 및 종료 로그 예시

 

1. Bootstrap: 서버 시작 및 인프라 준비

Step 1. 환경 설정 정규화 (env.ts)
dotenv를 통해 로드된 원시 데이터(process.env)를 애플리케이션 내부에서 사용할 타입 안전한 객체로 변환합니다.

Step 2. Prisma 및 DB 커넥션 풀 초기화 (prisma.config.ts)
Fastify가 시작되기 전, 독립적인 DB 접근 계층을 먼저 구성합니다.

Step 3. Fastify 통합 및 플러그인 등록 (prisma.plugin.ts)
준비된 Prisma 인스턴스를 프레임워크에 주입(Injection)하여 사용 준비를 마칩니다.

 

2. Runtime: 요청 처리 및 관측

서버가 요청을 받는 상태로 진입하면, 설정된 인프라 위에서 비즈니스 로직이 안전하게 실행됩니다.

Step 1. 타입 안전한 데이터 접근
라우트와 서비스 레이어에서는 프레임워크에 주입된 fastify.prisma를 통해 쿼리를 수행합니다.
개발자는 커넥션 관리나 풀링 정책을 의식하지 않고 비즈니스 로직에만 집중합니다. 하부 계층에서 설정된 정책에 따라 자동으로 최적의 쿼리가 수행됩니다.

Step 2. 통합 로깅 파이프라인
Prisma에서 발생하는 모든 쿼리와 에러는 플러그인을 통해 Fastify 로거로 전달됩니다.

 

3. Shutdown: 리소스의 안전한 반납 (Graceful Shutdown)

서버가 재배포되거나 종료될 때, 데이터 유실과 커넥션 누수를 방지하기 위해 정해진 순서대로 리소스를 정리합니다.

Step 1. Fastify onClose 훅 트리거
서버가 더 이상 새로운 HTTP 요청을 받지 않는 상태가 되면, 등록된 종료 로직이 실행됩니다. 이때 진행 중인 요청이 모두 완료될 때까지 대기(Graceful)합니다.

Step 2. PrismaClient 연결 해제 ($disconnect)

진행 중인 쿼리가 안전하게 마무리되도록 보장합니다.

Step 3. PostgreSQL Pool 명시적 종료 (pool.end())

DB 서버 측의 커넥션을 명시적으로 닫아줍니다. 이 과정이 없으면 DB 서버에 유휴(Idle) 커넥션이 남아 다른 인스턴스의 연결을 방해할 수 있습니다.

Step 4. 개발 환경 캐시 초기화
개발 모드(HMR)에서는 global 객체에 저장된 참조를 명시적으로 제거하여, 코드 변경 시 이전 인스턴스가 남아서 발생하는 오염을 방지합니다.

 


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

반응형

 

반응형