이미지 로딩 중...

Next.js 데이터베이스 커넥션 풀링과 성능 튜닝 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 8. · 4 Views

Next.js 데이터베이스 커넥션 풀링과 성능 튜닝 완벽 가이드

Next.js 애플리케이션에서 데이터베이스 커넥션 풀링을 구현하고 성능을 최적화하는 방법을 배웁니다. 커넥션 풀 설정, 최적 크기 계산, 그리고 실무에서 바로 적용 가능한 성능 튜닝 기법을 다룹니다.


목차

  1. 커넥션 풀링 기본 개념 - 데이터베이스 연결 효율화의 핵심
  2. 최적 풀 크기 계산 - 리소스와 성능의 균형점 찾기
  3. Prisma 커넥션 풀 설정 - ORM과 풀링의 완벽한 조합
  4. 커넥션 타임아웃 설정 - 장애 상황에서의 우아한 처리
  5. 트랜잭션과 커넥션 관리 - 데이터 일관성과 성능의 균형
  6. 읽기 복제본 활용 - 읽기 부하 분산으로 성능 향상
  7. 커넥션 풀 모니터링 - 문제 조기 발견과 최적화
  8. PgBouncer를 활용한 외부 풀링 - 서버리스 환경의 완벽한 해결책
  9. 쿼리 성능 최적화 - 커넥션 점유 시간 최소화
  10. 에러 처리와 복원력 - 장애 상황에서도 안정적인 서비스

1. 커넥션 풀링 기본 개념 - 데이터베이스 연결 효율화의 핵심

시작하며

여러분이 Next.js로 서비스를 운영하다가 갑자기 사용자가 늘어나면서 "Too many connections" 에러를 본 적 있나요? 또는 페이지 로딩이 갑자기 느려지면서 데이터베이스 연결 대기 시간이 길어지는 경험을 해보셨나요?

이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 서버리스 환경인 Vercel에서 Next.js를 운영할 때, 각 요청마다 새로운 함수 인스턴스가 생성되면서 데이터베이스 커넥션이 급격히 증가하게 됩니다.

이는 데이터베이스 서버의 리소스를 빠르게 소진시키고, 결국 서비스 장애로 이어질 수 있습니다. 바로 이럴 때 필요한 것이 커넥션 풀링입니다.

커넥션 풀링을 제대로 구현하면 데이터베이스 연결을 재사용하여 성능을 10배 이상 향상시키고, 서버 리소스를 효율적으로 관리할 수 있습니다.

개요

간단히 말해서, 커넥션 풀링은 데이터베이스 연결을 미리 생성해두고 재사용하는 기술입니다. 매번 새로운 연결을 생성하고 종료하는 것은 비용이 많이 드는 작업입니다.

데이터베이스 연결 하나를 만드는 데는 TCP 핸드셰이크, 인증 과정, 세션 초기화 등 여러 단계가 필요하며, 이는 수십 밀리초에서 수백 밀리초까지 걸릴 수 있습니다. 특히 Next.js API Routes처럼 짧은 수명의 함수에서 매번 연결을 새로 만든다면, 실제 쿼리 실행 시간보다 연결 생성 시간이 더 길어질 수 있습니다.

기존에는 매 요청마다 새 연결을 만들고 닫았다면, 이제는 미리 만들어둔 연결 풀에서 가져다 쓰고 반환할 수 있습니다. 이는 마치 수영장의 락커를 빌려 쓰는 것과 같습니다.

매번 새 락커를 설치하고 제거하는 대신, 이미 있는 락커를 빌렸다가 반환하는 것이죠. 커넥션 풀의 핵심 특징은 세 가지입니다.

첫째, 연결 재사용으로 오버헤드를 최소화합니다. 둘째, 동시 연결 수를 제한하여 데이터베이스 서버를 보호합니다.

셋째, 유휴 연결을 자동으로 관리하여 리소스를 효율적으로 사용합니다. 이러한 특징들이 안정적이고 확장 가능한 애플리케이션을 만드는 기반이 됩니다.

코드 예제

// lib/db.ts - PostgreSQL 커넥션 풀 설정
import { Pool } from 'pg';

// 글로벌 변수로 풀 인스턴스 저장 (재사용을 위해)
let pool: Pool | null = null;

export function getPool(): Pool {
  if (!pool) {
    pool = new Pool({
      host: process.env.DB_HOST,
      port: parseInt(process.env.DB_PORT || '5432'),
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME,
      // 풀 설정: 최소/최대 연결 수
      min: 2,
      max: 10,
      // 유휴 연결 타임아웃 (30초)
      idleTimeoutMillis: 30000,
      // 연결 타임아웃 (2초)
      connectionTimeoutMillis: 2000,
    });
  }
  return pool;
}

설명

이것이 하는 일: 이 코드는 PostgreSQL 데이터베이스에 대한 커넥션 풀을 생성하고 관리합니다. 풀은 여러 개의 연결을 미리 만들어두고, 필요할 때 빌려주고 반환받는 역할을 합니다.

첫 번째로, 글로벌 변수 pool을 사용하여 풀 인스턴스를 저장합니다. Next.js의 API Routes는 각 요청마다 모듈을 재실행할 수 있지만, 글로벌 변수는 유지됩니다.

이를 통해 여러 요청에서 동일한 풀 인스턴스를 공유할 수 있습니다. 만약 매번 새로운 풀을 생성한다면, 결국 커넥션이 계속 증가하는 문제가 발생합니다.

두 번째로, getPool() 함수는 싱글톤 패턴을 구현합니다. 풀이 아직 생성되지 않았다면 새로 만들고, 이미 있다면 기존 인스턴스를 반환합니다.

풀 설정에서 min: 2는 최소 2개의 연결을 항상 유지하고, max: 10은 최대 10개까지만 연결을 생성하도록 제한합니다. 이는 데이터베이스 서버가 과부하되는 것을 방지합니다.

세 번째로, 타임아웃 설정이 중요합니다. idleTimeoutMillis: 30000은 30초 동안 사용되지 않은 연결을 자동으로 종료합니다.

이는 불필요한 연결을 유지하지 않도록 하여 리소스를 절약합니다. connectionTimeoutMillis: 2000은 연결을 얻기 위해 2초 이상 기다리지 않도록 합니다.

만약 모든 연결이 사용 중이라면, 빠르게 에러를 반환하여 사용자가 무한정 기다리지 않도록 합니다. 여러분이 이 코드를 사용하면 API Routes에서 매번 새 연결을 만들지 않고도 안정적으로 데이터베이스에 접근할 수 있습니다.

연결 생성 시간을 90% 이상 줄일 수 있고, 동시 사용자가 많아져도 데이터베이스 서버가 안정적으로 동작합니다. 또한 풀이 자동으로 연결 상태를 모니터링하여 끊어진 연결을 재생성하므로, 네트워크 문제에도 강건하게 대응할 수 있습니다.

실전 팁

💡 개발 환경에서는 max: 5 정도로 작게 설정하고, 프로덕션에서는 데이터베이스 서버의 max_connections 설정을 고려하여 증가시키세요. 일반적으로 PostgreSQL의 기본값은 100이므로, 여러 서비스가 공유한다면 각 서비스는 20-30 정도로 제한하는 것이 안전합니다.

💡 Vercel 같은 서버리스 환경에서는 각 함수 인스턴스마다 풀이 생성될 수 있으므로, 외부 풀링 서비스(예: PgBouncer, Supabase Pooler)를 사용하는 것이 더 효율적입니다. 이렇게 하면 모든 인스턴스가 하나의 중앙 풀을 공유합니다.

💡 pool.end()는 애플리케이션 종료 시에만 호출하세요. API Routes 끝에서 호출하면 다음 요청에서 풀을 다시 생성해야 하므로 의미가 없습니다. Next.js의 경우 프로세스 종료 시그널(SIGTERM)을 받을 때 정리하면 됩니다.

💡 연결 풀의 상태를 모니터링하세요. pool.totalCount, pool.idleCount, pool.waitingCount를 로깅하면 풀 크기가 적절한지 판단할 수 있습니다. 만약 waitingCount가 자주 0보다 크다면 풀 크기를 늘려야 합니다.


2. 최적 풀 크기 계산 - 리소스와 성능의 균형점 찾기

시작하며

여러분은 커넥션 풀의 최대 크기를 얼마로 설정해야 할지 고민해본 적 있나요? 너무 작으면 요청이 대기하고, 너무 크면 데이터베이스가 과부하됩니다.

이런 문제는 실제로 많은 개발자들이 직면하는 어려움입니다. 특히 트래픽이 변동하는 서비스에서는 고정된 풀 크기로 모든 상황을 대응하기 어렵습니다.

낮은 트래픽에서는 문제없던 설정이 트래픽 급증 시 병목이 될 수 있고, 반대로 과도하게 큰 풀은 데이터베이스 메모리를 낭비합니다. 바로 이럴 때 필요한 것이 과학적인 풀 크기 계산 방법입니다.

간단한 공식과 벤치마킹을 통해 여러분의 서비스에 최적화된 설정을 찾을 수 있습니다.

개요

간단히 말해서, 최적 풀 크기는 동시 요청 수, 쿼리 실행 시간, 그리고 데이터베이스 서버 성능에 따라 결정됩니다. 많은 개발자들이 풀 크기를 임의로 설정하거나, 기본값을 그대로 사용합니다.

하지만 적절한 풀 크기는 애플리케이션의 특성에 따라 크게 달라집니다. 예를 들어, 쿼리가 매우 빠른 읽기 중심 서비스와 복잡한 트랜잭션이 많은 쓰기 중심 서비스는 완전히 다른 설정이 필요합니다.

기존에는 경험과 감으로 설정했다면, 이제는 계산식과 측정을 통해 과학적으로 접근할 수 있습니다. 유명한 HikariCP의 제작자가 제안한 공식이 있습니다: connections = ((core_count * 2) + effective_spindle_count).

하지만 이는 전통적인 서버 환경을 가정한 것이므로, 서버리스 환경에서는 다른 접근이 필요합니다. 최적 풀 크기의 핵심 요소는 네 가지입니다.

첫째, 평균 동시 요청 수를 파악해야 합니다. 둘째, 각 요청의 평균 쿼리 실행 시간을 측정해야 합니다.

셋째, 데이터베이스 서버의 CPU 코어 수와 최대 연결 수 제한을 고려해야 합니다. 넷째, 트래픽 패턴의 변동성을 반영해야 합니다.

이러한 요소들을 종합하여 최적의 균형점을 찾는 것이 중요합니다.

코드 예제

// lib/pool-calculator.ts - 풀 크기 계산 유틸리티
interface PoolSizeConfig {
  avgConcurrentRequests: number;  // 평균 동시 요청 수
  avgQueryDurationMs: number;      // 평균 쿼리 실행 시간 (밀리초)
  targetResponseTimeMs: number;    // 목표 응답 시간 (밀리초)
  dbMaxConnections: number;        // DB 서버 최대 연결 수
  serviceCount: number;            // DB를 공유하는 서비스 수
}

export function calculateOptimalPoolSize(config: PoolSizeConfig): number {
  // 리틀의 법칙: 동시성 = 처리율 × 응답시간
  // 필요한 연결 수 = 동시 요청 수 × (쿼리 시간 / 응답 시간)
  const baseSize = Math.ceil(
    config.avgConcurrentRequests *
    (config.avgQueryDurationMs / config.targetResponseTimeMs)
  );

  // DB 서버 제한을 고려한 최대값
  const maxPerService = Math.floor(
    config.dbMaxConnections / config.serviceCount
  );

  // 안전 마진 20% 추가
  const recommendedSize = Math.min(
    Math.ceil(baseSize * 1.2),
    maxPerService
  );

  return Math.max(recommendedSize, 5); // 최소 5개
}

설명

이것이 하는 일: 이 코드는 여러분의 서비스 특성을 입력받아 최적의 커넥션 풀 크기를 계산해주는 유틸리티입니다. 단순한 추측이 아닌 수학적 모델을 기반으로 합니다.

첫 번째로, 리틀의 법칙(Little's Law)을 적용합니다. 이는 대기 이론의 기본 원리로, "시스템 내 평균 항목 수 = 도착률 × 평균 대기 시간"을 의미합니다.

데이터베이스 커넥션에 적용하면, 필요한 연결 수는 동시 요청 수에 각 요청이 연결을 점유하는 비율을 곱한 값입니다. 예를 들어, 초당 100개 요청이 들어오고, 각 요청이 평균 50ms 동안 쿼리를 실행하며, 목표 응답 시간이 200ms라면: 100 × (50 / 200) = 25개의 연결이 필요합니다.

두 번째로, 데이터베이스 서버의 물리적 제한을 고려합니다. PostgreSQL의 기본 max_connections는 100입니다.

만약 3개의 서비스가 동일한 DB를 사용한다면, 각 서비스는 최대 33개 정도를 사용할 수 있습니다. 이 제한을 초과하지 않도록 Math.min()으로 상한선을 설정합니다.

실제로는 여유분을 두어야 하므로, 80% 정도만 사용하는 것이 안전합니다. 세 번째로, 안전 마진 20%를 추가합니다.

트래픽은 항상 평균값만 발생하지 않습니다. 순간적인 스파이크나 느린 쿼리가 발생할 수 있으므로, 계산된 값보다 약간 여유를 두는 것이 좋습니다.

하지만 너무 큰 마진은 리소스 낭비이므로, 20% 정도가 적절합니다. 마지막으로 최소값 5를 보장합니다.

아무리 트래픽이 낮아도 최소한의 연결은 유지해야 콜드 스타트 시 병목이 발생하지 않습니다. 여러분이 이 함수를 사용하면 추측이 아닌 데이터 기반으로 풀 크기를 결정할 수 있습니다.

예를 들어, 평균 10개 동시 요청, 쿼리 시간 30ms, 목표 응답 200ms라면 약 4개가 필요하지만, 안전 마진과 최소값을 적용하여 5개를 권장받게 됩니다. 이후 실제 운영 데이터를 수집하여 파라미터를 조정하면서 최적화할 수 있습니다.

실전 팁

💡 프로덕션에 적용하기 전에 부하 테스트를 수행하세요. k6, Artillery 같은 도구로 다양한 동시 사용자 수를 시뮬레이션하고, 각 풀 크기에서의 응답 시간과 에러율을 측정하세요. 이론값과 실제값이 다를 수 있습니다.

💡 쿼리 실행 시간은 정기적으로 측정하고 업데이트하세요. 데이터가 증가하거나 인덱스가 변경되면 쿼리 성능이 달라집니다. pg_stat_statements 확장을 사용하면 PostgreSQL에서 쿼리 통계를 쉽게 수집할 수 있습니다.

💡 서버리스 환경(Vercel, AWS Lambda)에서는 각 인스턴스가 독립적인 풀을 가지므로, 전체 동시 인스턴스 수를 고려해야 합니다. 예를 들어, 10개 인스턴스가 각각 10개 연결을 가지면 총 100개가 됩니다. 이 경우 인스턴스당 풀 크기를 줄이거나 PgBouncer를 사용하세요.

💡 읽기 전용 복제본(read replica)을 사용한다면 별도의 풀을 구성하세요. 읽기와 쓰기의 트래픽 패턴이 다르므로, 각각 다른 크기로 최적화할 수 있습니다. 일반적으로 읽기 풀을 더 크게 설정합니다.


3. Prisma 커넥션 풀 설정 - ORM과 풀링의 완벽한 조합

시작하며

여러분이 Next.js에서 Prisma를 사용하고 있다면, 커넥션 풀을 어떻게 설정하고 있나요? Prisma는 자체적으로 커넥션 풀을 관리하지만, 제대로 설정하지 않으면 여전히 커넥션 누수나 성능 문제가 발생할 수 있습니다.

이런 문제는 특히 API Routes에서 자주 발생합니다. 각 요청마다 새로운 PrismaClient 인스턴스를 생성하면, 각 인스턴스가 독립적인 풀을 만들면서 커넥션이 폭증합니다.

개발 환경의 Hot Reload 때문에 더욱 복잡해집니다. 바로 이럴 때 필요한 것이 Prisma의 싱글톤 패턴과 올바른 풀 설정입니다.

Prisma의 내부 동작 방식을 이해하고 적절히 설정하면, ORM의 편리함과 최적의 성능을 모두 얻을 수 있습니다.

개요

간단히 말해서, Prisma는 내부적으로 커넥션 풀을 자동으로 관리하지만, 올바른 설정과 싱글톤 패턴이 필요합니다. Prisma Client는 첫 쿼리 실행 시 커넥션 풀을 생성합니다.

이 풀은 데이터베이스 URL의 쿼리 파라미터를 통해 설정할 수 있으며, 기본값은 connection_limit에 따라 결정됩니다. 하지만 많은 개발자들이 이 사실을 모르고 기본값을 사용하다가 문제를 겪습니다.

특히 서버리스 환경에서는 기본 설정이 너무 관대하여 불필요하게 많은 연결을 생성할 수 있습니다. 기존에는 매 요청마다 new PrismaClient()를 호출했다면, 이제는 글로벌 싱글톤 인스턴스를 재사용해야 합니다.

Next.js의 개발 모드에서는 Hot Reload로 인해 모듈이 재실행되므로, globalThis를 사용하여 인스턴스를 보존하는 특별한 패턴이 필요합니다. Prisma 커넥션 풀의 핵심 특징은 세 가지입니다.

첫째, 자동 풀 관리로 개발자가 직접 연결을 빌리고 반환할 필요가 없습니다. 둘째, URL 기반 설정으로 간단하게 풀 크기를 조정할 수 있습니다.

셋째, Query Engine이 효율적으로 연결을 분배하여 최적의 성능을 제공합니다. 이러한 특징들이 복잡한 풀 관리 코드 없이도 안정적인 데이터베이스 액세스를 가능하게 합니다.

코드 예제

// lib/prisma.ts - Prisma Client 싱글톤 패턴
import { PrismaClient } from '@prisma/client';

// 글로벌 타입 확장 (TypeScript)
declare global {
  var prisma: PrismaClient | undefined;
}

// 커넥션 풀 설정을 포함한 DATABASE_URL
// connection_limit: 최대 연결 수
// pool_timeout: 연결 대기 타임아웃 (초)
const databaseUrl = `${process.env.DATABASE_URL}?connection_limit=10&pool_timeout=2`;

// 싱글톤 인스턴스 생성
export const prisma = global.prisma || new PrismaClient({
  datasources: {
    db: { url: databaseUrl }
  },
  log: process.env.NODE_ENV === 'development'
    ? ['query', 'error', 'warn']
    : ['error']
});

// 개발 환경에서 Hot Reload 시 인스턴스 유지
if (process.env.NODE_ENV !== 'production') {
  global.prisma = prisma;
}

설명

이것이 하는 일: 이 코드는 Next.js 애플리케이션 전체에서 하나의 Prisma Client 인스턴스만 사용하도록 보장하며, 커넥션 풀을 최적화된 설정으로 구성합니다. 첫 번째로, 글로벌 변수를 활용한 싱글톤 패턴을 구현합니다.

global.prisma는 Node.js 프로세스 전역에서 유지되는 변수입니다. Next.js의 개발 모드에서는 코드 변경 시 모듈이 재평가되지만, 글로벌 변수는 유지됩니다.

따라서 global.prisma || new PrismaClient()를 사용하면, 이미 인스턴스가 있으면 재사용하고, 없으면 새로 생성합니다. 이를 통해 Hot Reload로 인한 커넥션 누수를 방지합니다.

두 번째로, DATABASE_URL에 커넥션 풀 설정을 추가합니다. connection_limit=10은 최대 10개의 연결을 풀에 유지하도록 제한합니다.

Prisma의 기본값은 num_cpus * 2 + 1이므로, 4코어 머신에서는 9개가 됩니다. 하지만 서버리스 환경에서는 명시적으로 설정하는 것이 좋습니다.

pool_timeout=2는 사용 가능한 연결이 없을 때 2초 동안만 대기하도록 합니다. 이를 초과하면 Timed out fetching a new connection from the connection pool 에러가 발생합니다.

세 번째로, 로깅 설정을 환경에 따라 다르게 구성합니다. 개발 환경에서는 쿼리, 에러, 경고를 모두 로깅하여 디버깅을 돕습니다.

프로덕션에서는 에러만 로깅하여 성능 오버헤드를 줄입니다. Prisma의 쿼리 로그는 실행된 SQL과 파라미터를 보여주므로, N+1 쿼리 문제나 느린 쿼리를 발견하는 데 매우 유용합니다.

여러분이 이 패턴을 사용하면 API Routes에서 import { prisma } from '@/lib/prisma'만으로 안전하게 데이터베이스에 접근할 수 있습니다. 모든 요청이 동일한 커넥션 풀을 공유하므로, 효율적이고 예측 가능한 성능을 얻을 수 있습니다.

또한 Prisma의 자동 쿼리 배칭과 결합하여 여러 쿼리를 하나의 라운드트립으로 최적화할 수 있습니다.

실전 팁

💡 Vercel에서는 환경 변수에 커넥션 풀 설정을 포함시키세요. DATABASE_URL=postgresql://...?connection_limit=5&pool_timeout=2 형식으로 설정하면 코드 변경 없이 조정할 수 있습니다. Vercel의 serverless function은 최대 1000개까지 동시 실행될 수 있으므로, 각각 5개 연결로 제한하는 것이 안전합니다.

💡 prisma.$disconnect()는 애플리케이션 종료 시에만 호출하세요. API Routes 끝에서 호출하면 다음 요청에서 풀을 재생성해야 하므로 역효과입니다. Next.js의 경우 자동으로 정리되므로 대부분 명시적 disconnect가 불필요합니다.

💡 Prisma Accelerate를 고려하세요. 이는 Prisma의 관리형 커넥션 풀링 서비스로, 서버리스 환경에서 글로벌 풀을 제공합니다. 모든 serverless function이 하나의 중앙 풀을 공유하므로, 커넥션 관리가 훨씬 간단해집니다. 특히 엣지 함수를 사용한다면 필수적입니다.

💡 pgbouncer=true 파라미터를 추가하면 Prisma가 PgBouncer 호환 모드로 동작합니다. 이는 prepared statements를 비활성화하여 transaction pooling을 지원합니다. Supabase나 Neon 같은 플랫폼을 사용한다면 이 옵션이 필요할 수 있습니다.


4. 커넥션 타임아웃 설정 - 장애 상황에서의 우아한 처리

시작하며

여러분의 서비스가 갑자기 느려지면서 사용자들이 계속 기다리고 있는 상황을 경험한 적 있나요? 데이터베이스가 느려지거나 네트워크에 문제가 생기면, 적절한 타임아웃 없이는 요청들이 무한정 대기하면서 시스템 전체가 마비될 수 있습니다.

이런 문제는 실제로 치명적인 장애로 이어집니다. 느린 쿼리나 네트워크 지연으로 커넥션을 얻지 못한 요청들이 쌓이면서, 메모리가 고갈되고 새로운 요청도 처리할 수 없게 됩니다.

이를 "cascading failure"라고 부르며, 한 컴포넌트의 문제가 전체 시스템을 무너뜨립니다. 바로 이럴 때 필요한 것이 적절한 타임아웃 설정입니다.

연결 타임아웃, 쿼리 타임아웃, 그리고 트랜잭션 타임아웃을 적절히 설정하면, 문제 상황에서도 시스템이 계속 동작하면서 일부 요청만 실패하도록 만들 수 있습니다.

개요

간단히 말해서, 타임아웃은 문제가 발생했을 때 무한정 기다리지 않고 빠르게 실패하도록 하는 안전장치입니다. 분산 시스템에서 타임아웃은 필수적입니다.

네트워크는 언제나 불안정할 수 있고, 데이터베이스는 때때로 느려질 수 있습니다. 타임아웃 없이는 한 요청의 문제가 모든 리소스를 고갈시키면서 전체 서비스를 마비시킬 수 있습니다.

예를 들어, 데이터베이스가 10초 동안 응답하지 않는다면, 그 동안 수백 개의 요청이 대기하면서 서버 메모리와 CPU를 소비합니다. 기존에는 타임아웃을 설정하지 않거나 너무 긴 값으로 설정했다면, 이제는 각 단계별로 적절한 타임아웃을 설정해야 합니다.

연결을 얻는 단계, 쿼리를 실행하는 단계, 그리고 트랜잭션을 완료하는 단계마다 다른 타임아웃이 필요합니다. 타임아웃 설정의 핵심 원칙은 네 가지입니다.

첫째, 빠른 실패(fail-fast)로 문제를 조기에 감지합니다. 둘째, 연쇄 실패를 방지하여 부분적 장애가 전체 장애로 확산되지 않도록 합니다.

셋째, 사용자 경험을 고려하여 너무 짧지 않게 설정합니다. 넷째, 재시도 로직과 결합하여 일시적 문제를 극복합니다.

이러한 원칙들이 안정적이고 복원력 있는 시스템을 만드는 기반이 됩니다.

코드 예제

// lib/db-with-timeout.ts - 타임아웃이 있는 DB 쿼리 래퍼
import { Pool } from 'pg';
import { getPool } from './db';

interface QueryOptions {
  statementTimeout?: number;  // 쿼리 실행 타임아웃 (밀리초)
  retries?: number;            // 재시도 횟수
}

export async function queryWithTimeout<T>(
  queryText: string,
  params: any[] = [],
  options: QueryOptions = {}
): Promise<T> {
  const pool = getPool();
  const { statementTimeout = 5000, retries = 2 } = options;

  let lastError: Error | null = null;

  // 재시도 로직
  for (let attempt = 0; attempt <= retries; attempt++) {
    const client = await pool.connect();

    try {
      // 쿼리별 타임아웃 설정
      await client.query(`SET statement_timeout = ${statementTimeout}`);

      // 실제 쿼리 실행
      const result = await client.query(queryText, params);
      return result.rows as T;

    } catch (error: any) {
      lastError = error;

      // 타임아웃 에러면 재시도하지 않음
      if (error.code === '57014') { // query_canceled
        console.error(`Query timeout after ${statementTimeout}ms:`, queryText);
        break;
      }

      // 연결 에러면 재시도
      if (attempt < retries && isRetryableError(error)) {
        console.warn(`Query failed, retrying (${attempt + 1}/${retries})...`);
        await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
        continue;
      }

      break;
    } finally {
      client.release();
    }
  }

  throw lastError || new Error('Query failed');
}

function isRetryableError(error: any): boolean {
  // 네트워크 에러, 연결 에러 등 재시도 가능한 에러 판단
  return ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND'].includes(error.code);
}

설명

이것이 하는 일: 이 코드는 데이터베이스 쿼리에 타임아웃과 재시도 로직을 추가하여, 문제 상황에서도 우아하게 처리하고 복원력을 제공합니다. 첫 번째로, statement_timeout을 사용하여 쿼리별 타임아웃을 설정합니다.

PostgreSQL의 statement_timeout은 쿼리 실행이 지정된 시간을 초과하면 자동으로 취소합니다. 기본값은 5초로 설정했지만, 복잡한 쿼리는 더 긴 타임아웃을 지정할 수 있습니다.

예를 들어, 간단한 SELECT는 1초, 복잡한 리포트 쿼리는 30초로 설정할 수 있습니다. 이를 통해 느린 쿼리가 리소스를 독점하는 것을 방지합니다.

두 번째로, 재시도 로직을 구현합니다. 네트워크는 일시적으로 실패할 수 있으므로, 재시도 가능한 에러인지 판단하여 자동으로 재시도합니다.

isRetryableError() 함수는 연결 리셋, 타임아웃, DNS 실패 등을 감지합니다. 하지만 쿼리 타임아웃(에러 코드 57014)은 재시도하지 않습니다.

이미 너무 느린 쿼리를 재시도하면 상황만 악화되기 때문입니다. 재시도 간격은 exponential backoff를 사용하여, 첫 재시도는 100ms, 두 번째는 200ms 대기합니다.

세 번째로, 클라이언트를 안전하게 반환합니다. finally 블록에서 client.release()를 호출하여, 성공하든 실패하든 커넥션이 풀로 돌아가도록 보장합니다.

만약 release를 잊어버리면 커넥션이 누수되어 결국 풀이 고갈됩니다. TypeScript의 finally는 try-catch-finally의 모든 경로에서 실행되므로, 안전한 리소스 정리를 보장합니다.

여러분이 이 래퍼 함수를 사용하면 API Routes에서 간단하게 안전한 쿼리를 실행할 수 있습니다. 예를 들어, await queryWithTimeout('SELECT * FROM users WHERE id = $1', [userId], { statementTimeout: 2000 })처럼 사용하면, 2초 이상 걸리는 쿼리는 자동으로 취소됩니다.

이를 통해 느린 쿼리가 서버를 멈추게 하는 것을 방지하고, 사용자에게는 빠르게 에러를 반환할 수 있습니다.

실전 팁

💡 API Routes의 타임아웃보다 짧게 설정하세요. Vercel의 serverless function은 기본 10초, Pro는 최대 60초입니다. 쿼리 타임아웃을 9초로 설정하면, 함수가 종료되기 전에 에러를 반환하여 정리 작업을 수행할 수 있습니다. 그렇지 않으면 함수가 강제 종료되어 연결이 남을 수 있습니다.

💡 읽기와 쓰기에 다른 타임아웃을 적용하세요. 읽기 쿼리는 보통 빠르므로 1-3초, 쓰기 트랜잭션은 복잡할 수 있으므로 5-10초가 적절합니다. 특히 외부 API를 호출하는 트랜잭션은 더 긴 타임아웃이 필요할 수 있습니다.

💡 타임아웃 에러를 모니터링하고 알림을 설정하세요. 타임아웃이 자주 발생한다면 쿼리 최적화나 인덱스 추가가 필요합니다. Sentry, DataDog 같은 모니터링 도구에 타임아웃 에러를 보고하여 패턴을 분석하세요.

💡 Circuit Breaker 패턴과 결합하세요. 연속으로 여러 번 실패하면 일정 시간 동안 요청을 차단하여 데이터베이스에 부하를 주지 않도록 합니다. 이는 데이터베이스가 복구될 시간을 주며, 전체 시스템의 안정성을 높입니다.


5. 트랜잭션과 커넥션 관리 - 데이터 일관성과 성능의 균형

시작하며

여러분이 결제 처리나 포인트 차감 같은 중요한 로직을 구현할 때, 트랜잭션을 어떻게 관리하고 있나요? 트랜잭션은 데이터 일관성을 보장하지만, 잘못 사용하면 커넥션을 오래 점유하여 성능 병목이 될 수 있습니다.

이런 문제는 실제로 많은 서비스에서 발생합니다. 특히 트랜잭션 안에서 외부 API를 호출하거나 복잡한 계산을 수행하면, 그 동안 데이터베이스 커넥션이 잠겨있어 다른 요청들이 대기하게 됩니다.

또한 트랜잭션이 롤백되지 않고 남아있으면 락이 계속 유지되어 데드락을 유발할 수 있습니다. 바로 이럴 때 필요한 것이 올바른 트랜잭션 패턴과 커넥션 관리입니다.

트랜잭션을 최소한으로 유지하고, 에러 상황에서도 안전하게 정리되도록 보장하는 것이 핵심입니다.

개요

간단히 말해서, 트랜잭션은 여러 쿼리를 원자적으로 실행하지만, 최소한의 시간만 커넥션을 점유해야 합니다. 트랜잭션은 ACID 속성(원자성, 일관성, 격리성, 지속성)을 보장하여 데이터 무결성을 지킵니다.

예를 들어, A 계정에서 돈을 빼고 B 계정에 넣는 송금 작업은 둘 다 성공하거나 둘 다 실패해야 합니다. 하나만 성공하면 돈이 사라지거나 생겨나는 문제가 발생합니다.

하지만 트랜잭션은 비용이 큽니다. 트랜잭션이 활성화된 동안 커넥션은 다른 요청이 사용할 수 없으며, 관련된 행은 락이 걸려 동시 접근이 제한됩니다.

기존에는 트랜잭션 안에서 모든 비즈니스 로직을 수행했다면, 이제는 트랜잭션을 데이터베이스 작업에만 국한시켜야 합니다. 외부 API 호출, 파일 처리, 복잡한 계산 등은 트랜잭션 밖에서 먼저 수행하고, 최종 데이터베이스 갱신만 트랜잭션으로 감쌉니다.

트랜잭션 관리의 핵심 원칙은 네 가지입니다. 첫째, 트랜잭션을 최대한 짧게 유지합니다.

둘째, 격리 수준(isolation level)을 적절히 선택하여 성능과 일관성을 균형있게 조절합니다. 셋째, 에러 처리를 철저히 하여 커넥션 누수를 방지합니다.

넷째, 낙관적 락(optimistic locking)을 활용하여 불필요한 비관적 락을 피합니다. 이러한 원칙들이 높은 동시성과 데이터 무결성을 동시에 달성하게 해줍니다.

코드 예제

// lib/transaction.ts - 안전한 트랜잭션 패턴
import { Pool, PoolClient } from 'pg';
import { getPool } from './db';

interface TransactionOptions {
  isolationLevel?: 'READ COMMITTED' | 'REPEATABLE READ' | 'SERIALIZABLE';
  timeout?: number;
}

export async function withTransaction<T>(
  callback: (client: PoolClient) => Promise<T>,
  options: TransactionOptions = {}
): Promise<T> {
  const pool = getPool();
  const client = await pool.connect();

  const {
    isolationLevel = 'READ COMMITTED',
    timeout = 10000
  } = options;

  try {
    // 트랜잭션 시작
    await client.query('BEGIN');

    // 격리 수준 설정
    await client.query(`SET TRANSACTION ISOLATION LEVEL ${isolationLevel}`);

    // 트랜잭션 타임아웃 설정
    await client.query(`SET LOCAL statement_timeout = ${timeout}`);

    // 비즈니스 로직 실행
    const result = await callback(client);

    // 커밋
    await client.query('COMMIT');

    return result;

  } catch (error) {
    // 에러 발생 시 롤백
    await client.query('ROLLBACK');
    throw error;

  } finally {
    // 커넥션 반환
    client.release();
  }
}

설명

이것이 하는 일: 이 코드는 트랜잭션을 안전하게 실행하는 래퍼 함수로, 시작-커밋-롤백을 자동으로 관리하며 격리 수준과 타임아웃을 설정합니다. 첫 번째로, withTransaction 함수는 고차 함수(higher-order function) 패턴을 사용합니다.

비즈니스 로직을 콜백으로 받아서, 트랜잭션 경계를 자동으로 관리합니다. 이를 통해 개발자는 BEGIN, COMMIT, ROLLBACK을 직접 호출하지 않아도 되며, 실수로 롤백을 잊어버리는 일이 없습니다.

콜백은 PoolClient를 받아서 쿼리를 실행하고, 결과를 반환하기만 하면 됩니다. 두 번째로, 격리 수준을 설정합니다.

PostgreSQL의 기본값은 READ COMMITTED로, 대부분의 경우에 적합합니다. 이는 커밋된 데이터만 읽으며, 동일 트랜잭션 내에서도 다른 트랜잭션의 커밋을 볼 수 있습니다.

REPEATABLE READ는 트랜잭션 시작 시점의 스냅샷을 읽으므로 일관성이 더 높지만, 갱신 충돌이 발생할 수 있습니다. SERIALIZABLE은 가장 엄격하지만 성능이 떨어지므로, 금융 거래 같은 중요한 경우에만 사용합니다.

세 번째로, 트랜잭션별 타임아웃을 설정합니다. SET LOCAL statement_timeout은 현재 트랜잭션에만 적용되며, 커밋이나 롤백 후에는 자동으로 해제됩니다.

기본 10초는 대부분의 트랜잭션에 충분하며, 이를 초과하면 자동으로 롤백됩니다. 이는 데드락이나 긴 락 대기를 방지합니다.

finally 블록은 성공, 실패 여부와 관계없이 항상 실행되어 클라이언트를 풀로 반환합니다. 여러분이 이 함수를 사용하면 안전하고 간결하게 트랜잭션을 작성할 수 있습니다.

예를 들어, 포인트 차감과 주문 생성을 원자적으로 실행하려면: await withTransaction(async (client) => { await client.query('UPDATE users SET points = points - $1 WHERE id = $2', [100, userId]); await client.query('INSERT INTO orders ...'); return orderId; }). 에러가 발생하면 자동으로 롤백되고, 성공하면 자동으로 커밋됩니다.

실전 팁

💡 트랜잭션 밖에서 준비 작업을 모두 마치세요. 예를 들어, 결제 API 호출, 이미지 업로드, 이메일 발송 등은 트랜잭션 밖에서 먼저 수행하고, 최종 상태만 트랜잭션 안에서 저장하세요. 이렇게 하면 트랜잭션 시간을 몇 초에서 몇 밀리초로 줄일 수 있습니다.

💡 낙관적 락을 활용하세요. 버전 컬럼(version 또는 updated_at)을 사용하여, 읽은 시점과 쓰는 시점 사이에 다른 트랜잭션이 변경했는지 확인합니다. UPDATE ... WHERE id = $1 AND version = $2처럼 조건을 추가하면, 충돌 시 0개 행이 갱신되므로 감지할 수 있습니다.

💡 Prisma의 interactive transactions를 활용하세요. prisma.$transaction(async (tx) => { ... })는 위와 동일한 패턴을 ORM 레벨에서 제공합니다. 자동으로 롤백과 커밋을 관리하며, 타입 안전성도 보장합니다. 단, 기본 타임아웃이 5초이므로 옵션으로 조정할 수 있습니다.

💡 데드락을 모니터링하고 분석하세요. pg_stat_databasedeadlocks 컬럼을 정기적으로 확인하고, 데드락 발생 시 log_lock_waits = on으로 설정하여 로그를 수집하세요. 데드락은 보통 락을 획득하는 순서가 다를 때 발생하므로, 항상 동일한 순서로 테이블을 접근하도록 수정하면 해결됩니다.


6. 읽기 복제본 활용 - 읽기 부하 분산으로 성능 향상

시작하며

여러분의 서비스가 성장하면서 데이터베이스 읽기 부하가 증가하고 있나요? 대부분의 애플리케이션은 쓰기보다 읽기가 훨씬 많으며, 하나의 마스터 데이터베이스로는 처리하기 어려워집니다.

이런 문제는 성공적인 서비스에서 필연적으로 발생합니다. 특히 목록 조회, 검색, 통계 같은 읽기 전용 쿼리가 많은 서비스에서는 마스터 데이터베이스의 CPU와 I/O가 포화 상태에 이르며, 결국 응답 시간이 느려집니다.

쓰기 쿼리까지 영향을 받아 전체 서비스가 느려질 수 있습니다. 바로 이럴 때 필요한 것이 읽기 복제본(read replica)입니다.

마스터에서 복제본으로 데이터를 비동기로 복제하여, 읽기 쿼리를 복제본으로 분산시키면 마스터의 부하를 크게 줄일 수 있습니다.

개요

간단히 말해서, 읽기 복제본은 마스터 데이터베이스의 복사본으로, 읽기 전용 쿼리를 처리하여 부하를 분산시킵니다. 읽기 복제본은 마스터-슬레이브 복제 구조를 사용합니다.

마스터에 쓰인 모든 변경사항이 자동으로 복제본에 전파되며, 복제본은 읽기 전용으로만 사용됩니다. 이를 통해 읽기 쿼리를 여러 복제본에 분산시켜 수평적 확장(horizontal scaling)이 가능합니다.

예를 들어, 3개의 복제본을 사용하면 이론적으로 읽기 성능을 3배 향상시킬 수 있습니다. 기존에는 모든 쿼리를 마스터로 보냈다면, 이제는 읽기와 쓰기를 분리하여 적절한 데이터베이스로 라우팅해야 합니다.

하지만 복제 지연(replication lag)을 고려해야 합니다. 마스터에 쓰인 데이터가 복제본에 전파되기까지 수 밀리초에서 수 초가 걸릴 수 있으며, 이 동안 복제본에서 읽으면 오래된 데이터를 볼 수 있습니다.

읽기 복제본의 핵심 전략은 네 가지입니다. 첫째, 읽기와 쓰기를 명확히 구분하여 라우팅합니다.

둘째, 복제 지연이 허용되는 쿼리만 복제본으로 보냅니다. 셋째, 최신 데이터가 필요한 경우 마스터에서 읽습니다.

넷째, 여러 복제본에 로드 밸런싱을 적용하여 부하를 균등하게 분산합니다. 이러한 전략들이 높은 읽기 처리량과 데이터 일관성을 동시에 달성하게 해줍니다.

코드 예제

// lib/db-replica.ts - 읽기 복제본을 위한 풀 관리
import { Pool } from 'pg';

let masterPool: Pool | null = null;
let replicaPools: Pool[] = [];
let currentReplicaIndex = 0;

// 마스터 풀 (쓰기와 중요한 읽기)
export function getMasterPool(): Pool {
  if (!masterPool) {
    masterPool = new Pool({
      host: process.env.DB_MASTER_HOST,
      port: parseInt(process.env.DB_PORT || '5432'),
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME,
      max: 10,
      idleTimeoutMillis: 30000,
    });
  }
  return masterPool;
}

// 복제본 풀들 (읽기 전용)
export function getReplicaPool(): Pool {
  if (replicaPools.length === 0) {
    const replicaHosts = process.env.DB_REPLICA_HOSTS?.split(',') || [];

    replicaPools = replicaHosts.map(host => new Pool({
      host: host.trim(),
      port: parseInt(process.env.DB_PORT || '5432'),
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME,
      max: 15, // 읽기 전용이므로 더 많은 연결 허용
      idleTimeoutMillis: 30000,
    }));
  }

  // 라운드 로빈 로드 밸런싱
  if (replicaPools.length === 0) {
    return getMasterPool(); // fallback
  }

  const pool = replicaPools[currentReplicaIndex];
  currentReplicaIndex = (currentReplicaIndex + 1) % replicaPools.length;

  return pool;
}

// 읽기 전용 쿼리 (복제본 사용)
export async function queryReplica<T>(sql: string, params: any[] = []): Promise<T> {
  const pool = getReplicaPool();
  const result = await pool.query(sql, params);
  return result.rows as T;
}

// 쓰기 쿼리 (마스터 사용)
export async function queryMaster<T>(sql: string, params: any[] = []): Promise<T> {
  const pool = getMasterPool();
  const result = await pool.query(sql, params);
  return result.rows as T;
}

설명

이것이 하는 일: 이 코드는 마스터와 복제본에 대한 별도의 커넥션 풀을 관리하고, 쿼리 유형에 따라 자동으로 적절한 데이터베이스로 라우팅합니다. 첫 번째로, 마스터와 복제본에 대한 분리된 풀을 생성합니다.

getMasterPool()은 쓰기와 중요한 읽기를 위한 마스터 연결을 제공하며, 최대 10개 연결로 제한합니다. getReplicaPool()은 읽기 전용 복제본 연결을 제공하며, 쓰기가 없으므로 15개까지 허용하여 더 많은 동시 읽기를 처리할 수 있습니다.

복제본 호스트는 환경 변수에서 쉼표로 구분된 목록으로 받습니다(예: DB_REPLICA_HOSTS=replica1.example.com,replica2.example.com). 두 번째로, 라운드 로빈(round-robin) 로드 밸런싱을 구현합니다.

currentReplicaIndex를 사용하여 각 요청마다 다음 복제본을 순환하며 선택합니다. 예를 들어, 3개의 복제본이 있다면 첫 번째 요청은 replica1, 두 번째는 replica2, 세 번째는 replica3, 네 번째는 다시 replica1로 돌아갑니다.

이를 통해 모든 복제본에 부하가 균등하게 분산됩니다. 만약 복제본이 없다면 자동으로 마스터로 폴백하여 서비스가 중단되지 않도록 합니다.

세 번째로, 편리한 헬퍼 함수를 제공합니다. queryReplica()는 자동으로 복제본에서 읽기 쿼리를 실행하고, queryMaster()는 마스터에서 쓰기 쿼리를 실행합니다.

개발자는 쿼리 유형에 따라 적절한 함수를 선택하기만 하면 됩니다. 예를 들어, 목록 조회는 queryReplica(), 데이터 생성은 queryMaster()를 사용합니다.

여러분이 이 패턴을 사용하면 API Routes에서 읽기 쿼리를 복제본으로 자동 분산시켜 마스터의 부하를 크게 줄일 수 있습니다. 예를 들어, 인기 게시물 목록을 조회하는 API는 const posts = await queryReplica('SELECT * FROM posts WHERE ...')로 구현하여, 마스터는 쓰기에만 집중하도록 만들 수 있습니다.

복제본을 추가할수록 읽기 처리량이 선형적으로 증가합니다.

실전 팁

💡 복제 지연을 모니터링하세요. PostgreSQL에서는 pg_stat_replication 뷰로 복제 상태를 확인할 수 있으며, replay_lag 컬럼이 지연 시간을 보여줍니다. 지연이 1초 이상이라면 네트워크나 복제본 성능을 점검해야 합니다.

💡 "write-then-read" 패턴에 주의하세요. 사용자가 데이터를 생성한 직후 목록을 조회하면, 복제 지연 때문에 방금 생성한 데이터가 보이지 않을 수 있습니다. 이런 경우 생성 직후의 읽기는 마스터에서 수행하거나, 생성된 데이터를 캐시에서 직접 반환하세요.

💡 Prisma에서는 Read Replicas 확장을 사용하세요. prisma.$extends()로 복제본 설정을 추가하면, Prisma가 자동으로 읽기 쿼리를 복제본으로 라우팅합니다. 트랜잭션 내의 쿼리는 항상 마스터를 사용하므로 일관성이 보장됩니다.

💡 지리적 복제본을 활용하세요. 글로벌 서비스라면 각 지역에 복제본을 배치하여 사용자와 가까운 복제본에서 읽도록 합니다. 이는 네트워크 지연을 크게 줄여 체감 성능을 향상시킵니다. AWS의 경우 Aurora Global Database가 이를 지원합니다.


7. 커넥션 풀 모니터링 - 문제 조기 발견과 최적화

시작하며

여러분은 커넥션 풀이 얼마나 효율적으로 동작하는지 확인하고 계신가요? 설정만 하고 방치하면, 풀이 부족하거나 과도한지 알 수 없으며, 문제가 발생해도 원인을 찾기 어렵습니다.

이런 문제는 실제로 많은 서비스에서 간과됩니다. 평소에는 잘 동작하다가 트래픽이 급증하면 갑자기 커넥션 부족으로 에러가 발생하는데, 로그나 메트릭이 없으면 원인을 파악하기 어렵습니다.

또한 과도하게 큰 풀은 리소스를 낭비하지만, 측정하지 않으면 알 수 없습니다. 바로 이럴 때 필요한 것이 커넥션 풀 모니터링입니다.

풀의 상태를 실시간으로 추적하고, 이상 징후를 조기에 감지하여 문제를 예방할 수 있습니다.

개요

간단히 말해서, 풀 모니터링은 연결 사용 패턴을 추적하여 성능 문제를 조기에 발견하고 최적화 기회를 식별합니다. 모니터링은 관찰 가능성(observability)의 핵심 요소입니다.

시스템의 내부 상태를 외부에서 관찰할 수 있어야, 문제를 진단하고 개선할 수 있습니다. 커넥션 풀의 경우, 총 연결 수, 사용 중인 연결 수, 유휴 연결 수, 대기 중인 요청 수 등의 메트릭을 추적해야 합니다.

예를 들어, 대기 중인 요청이 많다면 풀 크기를 늘려야 하고, 유휴 연결이 많다면 풀 크기를 줄일 수 있습니다. 기존에는 문제가 발생한 후에야 로그를 확인했다면, 이제는 메트릭을 지속적으로 수집하고 시각화하여 트렌드를 파악할 수 있습니다.

대시보드에서 실시간으로 풀 상태를 보면서, 비정상적인 패턴을 즉시 감지할 수 있습니다. 풀 모니터링의 핵심 메트릭은 다섯 가지입니다.

첫째, 총 연결 수(total count)는 현재 풀에 있는 모든 연결의 수입니다. 둘째, 유휴 연결 수(idle count)는 사용되지 않고 대기 중인 연결입니다.

셋째, 사용 중 연결 수(active count = total - idle)는 현재 쿼리를 실행 중인 연결입니다. 넷째, 대기 중 요청 수(waiting count)는 사용 가능한 연결을 기다리는 요청입니다.

다섯째, 연결 획득 시간(acquire time)은 풀에서 연결을 얻는 데 걸린 시간입니다. 이러한 메트릭들이 풀의 건강 상태와 병목 지점을 명확히 보여줍니다.

코드 예제

// lib/db-monitoring.ts - 커넥션 풀 모니터링
import { Pool } from 'pg';
import { getPool } from './db';

interface PoolMetrics {
  totalCount: number;      // 총 연결 수
  idleCount: number;       // 유휴 연결 수
  waitingCount: number;    // 대기 중 요청 수
  activeCount: number;     // 사용 중 연결 수
  maxConnections: number;  // 최대 연결 수
  utilizationPercent: number; // 사용률 (%)
}

// 풀 메트릭 수집
export function getPoolMetrics(): PoolMetrics {
  const pool = getPool();

  const totalCount = pool.totalCount;
  const idleCount = pool.idleCount;
  const waitingCount = pool.waitingCount;
  const maxConnections = pool.options.max || 10;
  const activeCount = totalCount - idleCount;
  const utilizationPercent = (totalCount / maxConnections) * 100;

  return {
    totalCount,
    idleCount,
    waitingCount,
    activeCount,
    maxConnections,
    utilizationPercent,
  };
}

// 메트릭 로깅 (주기적으로 호출)
export function logPoolMetrics(): void {
  const metrics = getPoolMetrics();

  console.log('[Pool Metrics]', {
    active: metrics.activeCount,
    idle: metrics.idleCount,
    waiting: metrics.waitingCount,
    total: metrics.totalCount,
    max: metrics.maxConnections,
    utilization: `${metrics.utilizationPercent.toFixed(1)}%`,
  });

  // 경고: 풀이 거의 가득 참
  if (metrics.utilizationPercent > 80) {
    console.warn('[Pool Warning] Pool utilization is high:', metrics.utilizationPercent);
  }

  // 경고: 대기 중인 요청이 많음
  if (metrics.waitingCount > 0) {
    console.warn('[Pool Warning] Requests waiting for connections:', metrics.waitingCount);
  }
}

// API Routes에서 사용할 모니터링 미들웨어
export function monitorPoolHealth() {
  // 10초마다 메트릭 로깅
  setInterval(() => {
    logPoolMetrics();
  }, 10000);
}

설명

이것이 하는 일: 이 코드는 커넥션 풀의 실시간 상태를 수집하고 분석하여, 성능 문제를 조기에 감지하고 최적화 기회를 식별합니다. 첫 번째로, getPoolMetrics() 함수는 풀 객체에서 직접 메트릭을 읽어옵니다.

pg 라이브러리의 Pool은 totalCount, idleCount, waitingCount 속성을 제공하여 현재 상태를 확인할 수 있습니다. 이를 통해 실시간으로 풀의 사용 패턴을 파악할 수 있습니다.

예를 들어, activeCount는 현재 쿼리를 실행 중인 연결 수이며, 이것이 지속적으로 최대값에 가깝다면 풀 크기가 부족하다는 신호입니다. 두 번째로, 사용률(utilization)을 계산합니다.

이는 현재 연결 수를 최대 연결 수로 나눈 백분율로, 풀이 얼마나 가득 찼는지 보여줍니다. 80% 이상이면 곧 풀이 고갈될 수 있으므로 경고를 출력합니다.

반대로 사용률이 항상 20% 미만이라면 풀 크기를 줄여서 리소스를 절약할 수 있습니다. 이러한 임계값은 서비스 특성에 따라 조정할 수 있습니다.

세 번째로, 주기적인 모니터링을 설정합니다. monitorPoolHealth()는 10초마다 메트릭을 로깅하여 시계열 데이터를 생성합니다.

이 로그를 수집하면 시간에 따른 패턴을 분석할 수 있습니다. 예를 들어, 특정 시간대에 대기 요청이 급증한다면, 해당 시간의 트래픽 패턴이나 느린 쿼리를 조사해야 합니다.

프로덕션에서는 이 로그를 Datadog, CloudWatch, Prometheus 같은 모니터링 시스템으로 전송하여 시각화합니다. 여러분이 이 모니터링을 활성화하면 풀의 건강 상태를 실시간으로 파악할 수 있습니다.

예를 들어, 대기 요청이 자주 발생한다면 풀 크기를 늘리거나, 쿼리를 최적화해야 합니다. 반대로 유휴 연결이 항상 많다면 풀 크기를 줄여서 데이터베이스 서버의 메모리를 절약할 수 있습니다.

또한 갑작스러운 사용률 증가는 트래픽 스파이크나 느린 쿼리를 나타내므로, 알림을 설정하여 즉시 대응할 수 있습니다.

실전 팁

💡 메트릭을 시계열 데이터베이스(Prometheus, InfluxDB)에 저장하여 장기 트렌드를 분석하세요. Grafana로 대시보드를 만들면 시각적으로 패턴을 파악하기 쉽습니다. 예를 들어, 요일별 사용 패턴, 시간대별 피크 시간을 확인하여 캐패시티 플래닝에 활용할 수 있습니다.

💡 연결 획득 시간(acquire time)을 측정하세요. 이는 pool.connect()를 호출한 시점부터 실제 연결을 얻기까지의 시간입니다. 평균이 100ms를 넘어간다면 풀이 부족하거나 쿼리가 느린 것입니다. 히스토그램으로 분포를 보면 대부분 빠르지만 일부가 느린지, 전체적으로 느린지 구분할 수 있습니다.

💡 데이터베이스 서버의 메트릭도 함께 모니터링하세요. CPU, 메모리, 디스크 I/O, 활성 쿼리 수 등을 추적하면, 풀 문제인지 데이터베이스 서버 문제인지 구분할 수 있습니다. PostgreSQL의 pg_stat_activity로 현재 실행 중인 쿼리를 확인하세요.

💡 알림 임계값을 설정하세요. 대기 요청이 5개 이상, 사용률 90% 이상, 연결 획득 시간 500ms 이상 등의 조건에서 Slack, PagerDuty로 알림을 보내면, 문제가 커지기 전에 대응할 수 있습니다. 하지만 너무 민감하게 설정하면 알림 피로(alert fatigue)가 발생하므로 적절한 균형을 찾으세요.


8. PgBouncer를 활용한 외부 풀링 - 서버리스 환경의 완벽한 해결책

시작하며

여러분이 Vercel이나 AWS Lambda 같은 서버리스 환경에서 Next.js를 운영한다면, 각 함수 인스턴스마다 독립적인 커넥션 풀을 가지면서 커넥션이 폭증하는 문제를 겪고 계신가요? 이런 문제는 서버리스 아키텍처의 본질적인 특성입니다.

서버리스 함수는 트래픽에 따라 수백, 수천 개의 인스턴스로 확장되는데, 각 인스턴스가 10개의 연결을 가지면 총 수천 개의 커넥션이 생성됩니다. 이는 데이터베이스의 max_connections를 쉽게 초과하며, 연결 비용도 엄청나게 증가합니다.

바로 이럴 때 필요한 것이 PgBouncer 같은 외부 커넥션 풀러입니다. 모든 함수 인스턴스가 하나의 중앙 풀을 공유하도록 만들어, 실제 데이터베이스 연결은 제한된 수만 유지하면서도 수천 개의 클라이언트를 처리할 수 있습니다.

개요

간단히 말해서, PgBouncer는 애플리케이션과 데이터베이스 사이에 위치하여 커넥션을 중앙에서 관리하는 프록시입니다. PgBouncer는 경량화된 커넥션 풀러로, PostgreSQL 전용으로 설계되었습니다.

수천 개의 클라이언트 연결을 받아서, 실제로는 수십 개의 데이터베이스 연결만 사용하여 처리합니다. 이를 connection multiplexing이라고 하며, 서버리스 환경에서 필수적인 패턴입니다.

예를 들어, 1000개의 serverless function이 각각 PgBouncer에 5개씩 연결하더라도, PgBouncer는 데이터베이스에 50개만 연결합니다. 기존에는 각 애플리케이션 인스턴스가 직접 데이터베이스에 연결했다면, 이제는 PgBouncer를 중간에 두어 연결을 집중 관리합니다.

PgBouncer는 세 가지 풀링 모드를 제공합니다: session, transaction, statement. Session 모드는 클라이언트 연결이 유지되는 동안 동일한 DB 연결을 사용합니다.

Transaction 모드는 트랜잭션이 끝나면 연결을 반환합니다(가장 추천). Statement 모드는 각 쿼리마다 연결을 반환하지만, prepared statements를 사용할 수 없습니다.

PgBouncer의 핵심 장점은 네 가지입니다. 첫째, 연결 수를 극적으로 줄여 데이터베이스 리소스를 절약합니다.

둘째, 연결 재사용으로 오버헤드를 최소화합니다. 셋째, 애플리케이션 코드 변경이 거의 필요 없습니다(연결 문자열만 변경).

넷째, 고가용성을 위한 failover와 load balancing을 지원합니다. 이러한 장점들이 서버리스 환경에서 안정적인 데이터베이스 액세스를 가능하게 합니다.

코드 예제

// docker-compose.yml - PgBouncer 설정 예시
version: '3.8'

services:
  pgbouncer:
    image: edoburu/pgbouncer:latest
    environment:
      # 데이터베이스 연결 정보
      DATABASE_URL: "postgresql://user:password@postgres:5432/mydb"

      # 풀링 모드: transaction (권장)
      POOL_MODE: transaction

      # 최대 클라이언트 연결 수
      MAX_CLIENT_CONN: 1000

      # 데이터베이스당 풀 크기
      DEFAULT_POOL_SIZE: 20

      # 최소 풀 크기
      MIN_POOL_SIZE: 5

      # 유휴 연결 타임아웃 (초)
      SERVER_IDLE_TIMEOUT: 60

    ports:
      - "6432:5432"
    depends_on:
      - postgres

  postgres:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_USER: user
      POSTGRES_DB: mydb
    ports:
      - "5432:5432"

# Next.js에서 PgBouncer 사용
# DATABASE_URL=postgresql://user:password@localhost:6432/mydb?pgbouncer=true

설명

이것이 하는 일: 이 설정은 PgBouncer를 Docker로 실행하여, 애플리케이션과 PostgreSQL 사이에서 커넥션을 중앙 관리하고 multiplexing을 수행합니다. 첫 번째로, PgBouncer는 6432 포트로 서비스되며, 애플리케이션은 PostgreSQL 대신 PgBouncer에 연결합니다.

DATABASE_URL만 변경하면 되므로 코드 수정이 거의 필요 없습니다. PgBouncer는 내부적으로 실제 PostgreSQL(5432 포트)에 연결하여 쿼리를 전달합니다.

클라이언트 입장에서는 PostgreSQL과 직접 통신하는 것처럼 보이지만, 실제로는 PgBouncer가 중간에서 연결을 재사용하고 있습니다. 두 번째로, 풀링 모드를 transaction으로 설정합니다.

이는 서버리스 환경에 가장 적합한 모드로, 트랜잭션이 끝나면 즉시 연결을 풀로 반환하여 다른 클라이언트가 사용할 수 있게 합니다. 예를 들어, 클라이언트 A가 트랜잭션을 시작하고 3개 쿼리를 실행한 후 커밋하면, 그 순간 연결이 풀로 돌아가 클라이언트 B가 즉시 사용할 수 있습니다.

Session 모드는 클라이언트가 연결을 끊을 때까지 유지하므로 서버리스에는 비효율적입니다. 세 번째로, 클라이언트와 서버 연결 수를 분리합니다.

MAX_CLIENT_CONN: 1000은 최대 1000개의 애플리케이션 연결을 받을 수 있지만, DEFAULT_POOL_SIZE: 20은 실제 PostgreSQL에는 20개만 연결합니다. 즉, 1000개의 serverless function이 연결해도 PostgreSQL은 20개 연결만 보게 됩니다.

이는 데이터베이스 부하를 크게 줄이며, 연결 비용도 절약합니다. 여러분이 PgBouncer를 도입하면 서버리스 환경에서의 커넥션 문제를 근본적으로 해결할 수 있습니다.

Vercel에 배포된 Next.js 앱이 1000개 인스턴스로 확장되어도, PgBouncer를 통해 20-50개 정도의 DB 연결만 사용하면 됩니다. 또한 PgBouncer는 연결 재사용으로 연결 생성 오버헤드를 제거하여, 쿼리 성능도 향상시킵니다.

Supabase, Neon, Railway 같은 플랫폼들은 기본으로 PgBouncer를 제공하므로, 별도 설정 없이 사용할 수 있습니다.

실전 팁

💡 Supabase를 사용한다면 Connection Pooling URL을 사용하세요. Supabase는 모든 프로젝트에 PgBouncer를 제공하며, 포트 6543으로 접근합니다. DATABASE_URL-pooler 접미사가 붙은 URL을 사용하면 자동으로 풀링이 적용됩니다.

💡 Prisma와 PgBouncer를 함께 사용할 때는 ?pgbouncer=true 파라미터를 추가하세요. 이는 Prisma가 prepared statements를 비활성화하여 transaction pooling과 호환되도록 합니다. 그렇지 않으면 "prepared statement does not exist" 에러가 발생할 수 있습니다.

💡 PgBouncer의 메트릭을 모니터링하세요. SHOW STATS, SHOW POOLS 명령으로 현재 상태를 확인할 수 있으며, 평균 쿼리 시간, 대기 중인 클라이언트, 풀 사용률 등을 추적하세요. 이를 통해 풀 크기를 최적화할 수 있습니다.

💡 프로덕션에서는 PgBouncer를 고가용성으로 구성하세요. 단일 PgBouncer 인스턴스는 단일 장애점(SPOF)이 될 수 있으므로, 여러 인스턴스를 로드 밸런서 뒤에 배치하거나, AWS RDS Proxy, Google Cloud SQL Auth Proxy 같은 관리형 서비스를 사용하세요.


9. 쿼리 성능 최적화 - 커넥션 점유 시간 최소화

시작하며

여러분이 커넥션 풀을 완벽하게 설정했는데도 여전히 성능 문제가 있다면, 아마도 쿼리 자체가 느린 것일 수 있습니다. 느린 쿼리는 커넥션을 오래 점유하여 다른 요청을 대기시키고, 결국 풀이 고갈됩니다.

이런 문제는 실제로 매우 흔합니다. 인덱스가 없는 테이블 스캔, N+1 쿼리 문제, 비효율적인 JOIN 등은 쿼리 시간을 몇 밀리초에서 몇 초로 늘려버립니다.

하나의 느린 쿼리가 커넥션을 독점하는 동안, 수십 개의 빠른 쿼리가 대기해야 합니다. 바로 이럴 때 필요한 것이 쿼리 성능 최적화입니다.

인덱스 추가, 쿼리 재작성, N+1 해결 등을 통해 쿼리 시간을 줄이면, 동일한 커넥션 풀로 훨씬 많은 요청을 처리할 수 있습니다.

개요

간단히 말해서, 쿼리 최적화는 데이터베이스 작업 시간을 줄여 커넥션 점유 시간을 최소화하고, 전체 처리량을 향상시킵니다. 쿼리 성능은 커넥션 풀 효율성에 직접적인 영향을 미칩니다.

쿼리 시간이 100ms에서 10ms로 줄어들면, 동일한 커넥션으로 10배 많은 요청을 처리할 수 있습니다. 이는 풀 크기를 늘리는 것보다 훨씬 효과적이며, 데이터베이스 서버의 부하도 줄입니다.

예를 들어, 10개 연결 풀에서 각 쿼리가 100ms라면 초당 100개 요청을 처리할 수 있지만, 10ms로 최적화하면 1000개를 처리할 수 있습니다. 기존에는 느린 쿼리를 방치하고 풀 크기만 늘렸다면, 이제는 쿼리 자체를 최적화하여 근본 원인을 해결해야 합니다.

EXPLAIN ANALYZE로 실행 계획을 분석하고, 병목 지점을 찾아 개선합니다. 인덱스 추가, 불필요한 컬럼 제거, JOIN 최적화 등 다양한 기법을 적용할 수 있습니다.

쿼리 최적화의 핵심 전략은 다섯 가지입니다. 첫째, 인덱스를 적절히 추가하여 테이블 스캔을 방지합니다.

둘째, SELECT *을 피하고 필요한 컬럼만 선택합니다. 셋째, N+1 쿼리 문제를 해결하여 쿼리 횟수를 줄입니다.

넷째, 조인을 최적화하고 필요하다면 비정규화를 고려합니다. 다섯째, 무거운 집계는 캐싱하거나 미리 계산합니다.

이러한 전략들이 데이터베이스의 응답 시간을 극적으로 개선하고, 커넥션 효율성을 극대화합니다.

코드 예제

// API Routes에서 쿼리 성능 측정 및 최적화
import { prisma } from '@/lib/prisma';
import { performance } from 'perf_hooks';

// 쿼리 실행 시간 측정 래퍼
async function measureQuery<T>(
  name: string,
  queryFn: () => Promise<T>
): Promise<T> {
  const start = performance.now();

  try {
    const result = await queryFn();
    const duration = performance.now() - start;

    console.log(`[Query] ${name}: ${duration.toFixed(2)}ms`);

    // 느린 쿼리 경고 (100ms 이상)
    if (duration > 100) {
      console.warn(`[Slow Query] ${name} took ${duration.toFixed(2)}ms`);
    }

    return result;
  } catch (error) {
    const duration = performance.now() - start;
    console.error(`[Query Error] ${name} failed after ${duration.toFixed(2)}ms:`, error);
    throw error;
  }
}

// N+1 문제 예시와 해결
export async function getBadPostsWithAuthors() {
  // ❌ 나쁜 예: N+1 쿼리 (1 + N개 쿼리)
  const posts = await prisma.post.findMany();

  // 각 포스트마다 별도 쿼리
  for (const post of posts) {
    post.author = await prisma.user.findUnique({
      where: { id: post.authorId }
    });
  }

  return posts;
}

export async function getGoodPostsWithAuthors() {
  // ✅ 좋은 예: include로 한 번에 (1개 쿼리)
  return await measureQuery('posts-with-authors', async () => {
    return await prisma.post.findMany({
      include: {
        author: {
          select: {
            id: true,
            name: true,
            avatar: true,
            // 필요한 필드만 선택
          }
        }
      },
      // 페이지네이션으로 결과 제한
      take: 20,
      skip: 0,
    });
  });
}

// 인덱스 활용 예시
// migration.sql에서 인덱스 추가
// CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
// CREATE INDEX idx_posts_author_id ON posts(author_id);

export async function getRecentPostsByAuthor(authorId: string) {
  return await measureQuery('recent-posts-by-author', async () => {
    return await prisma.post.findMany({
      where: {
        authorId, // idx_posts_author_id 인덱스 사용
      },
      orderBy: {
        createdAt: 'desc', // idx_posts_created_at 인덱스 사용
      },
      take: 10,
    });
  });
}

설명

이것이 하는 일: 이 코드는 쿼리 실행 시간을 측정하고, N+1 문제를 해결하며, 인덱스를 활용하여 쿼리 성능을 극적으로 개선합니다. 첫 번째로, measureQuery() 래퍼 함수로 모든 쿼리의 실행 시간을 추적합니다.

performance.now()는 고정밀 타이머로 밀리초 단위까지 정확하게 측정합니다. 쿼리가 100ms를 초과하면 자동으로 경고를 출력하여, 느린 쿼리를 즉시 식별할 수 있습니다.

이 로그를 수집하면 어떤 쿼리가 병목인지, 어떤 API 엔드포인트를 최적화해야 하는지 명확히 알 수 있습니다. 두 번째로, N+1 쿼리 문제를 해결합니다.

getBadPostsWithAuthors()는 먼저 모든 포스트를 가져온 후(1개 쿼리), 각 포스트마다 작성자를 별도로 조회합니다(N개 쿼리). 100개 포스트라면 총 101개 쿼리가 실행되며, 각 쿼리마다 네트워크 왕복이 발생하여 느립니다.

반면 getGoodPostsWithAuthors()는 Prisma의 include를 사용하여 JOIN으로 한 번에 가져옵니다. 이는 1개 쿼리로 줄이며, 실행 시간을 10배 이상 단축시킵니다.

세 번째로, 필요한 컬럼만 선택합니다. SELECT *은 불필요한 데이터를 전송하여 네트워크 대역폭과 메모리를 낭비합니다.

select: { id, name, avatar }처럼 필요한 필드만 지정하면, 특히 큰 텍스트 컬럼(content, description)이나 바이너리 데이터가 있을 때 크게 개선됩니다. 또한 take: 20으로 페이지네이션을 적용하여, 수천 개의 결과를 한 번에 가져오지 않도록 합니다.

여러분이 이러한 최적화를 적용하면 쿼리 시간을 극적으로 줄일 수 있습니다. 예를 들어, 인덱스가 없는 테이블에서 1000ms 걸리던 쿼리가 인덱스 추가 후 10ms로 줄어들 수 있습니다.

이는 동일한 커넥션으로 100배 많은 요청을 처리할 수 있음을 의미합니다. 또한 데이터베이스 CPU 사용률도 크게 감소하여, 서버 확장 없이도 성능을 개선할 수 있습니다.

실전 팁

💡 EXPLAIN ANALYZE로 쿼리 실행 계획을 분석하세요. PostgreSQL의 EXPLAIN은 쿼리가 어떻게 실행되는지, 어떤 인덱스를 사용하는지, 어디가 느린지 보여줍니다. Sequential Scan이 보이면 인덱스가 필요하고, Nested Loop이 비효율적이라면 JOIN 순서를 바꿔야 합니다.

💡 Prisma의 쿼리 로그를 활성화하여 실제 SQL을 확인하세요. ORM이 생성한 쿼리가 예상과 다를 수 있으므로, log: ['query']로 확인하고 최적화하세요. 특히 includeselect의 조합이 어떤 JOIN을 만드는지 파악하는 것이 중요합니다.

💡 데이터베이스 캐싱을 활용하세요. 자주 조회되지만 변경이 적은 데이터(카테고리 목록, 설정 등)는 Redis에 캐싱하여 데이터베이스 쿼리를 완전히 피할 수 있습니다. 이는 커넥션을 전혀 사용하지 않으므로 가장 효과적입니다.

💡 Connection pooling과 Query caching을 혼동하지 마세요. Connection pooling은 연결을 재사용하여 연결 생성 비용을 줄이고, Query caching은 쿼리 결과를 저장하여 쿼리 실행 자체를 피합니다. 둘 다 중요하지만 다른 레벨의 최적화입니다.


10. 에러 처리와 복원력 - 장애 상황에서도 안정적인 서비스

시작하며

여러분의 데이터베이스가 일시적으로 연결이 끊기거나 과부하 상태가 되었을 때, 애플리케이션이 어떻게 반응하나요? 에러를 그대로 사용자에게 보여주거나, 전체 서비스가 중단되지는 않나요?

이런 문제는 실제 운영 환경에서 언제든 발생할 수 있습니다. 네트워크 문제, 데이터베이스 재시작, 순간적인 과부하 등은 피할 수 없는 현실입니다.

이런 상황에서 적절한 에러 처리 없이는 사용자에게 500 에러만 보여주거나, 최악의 경우 전체 서비스가 멈출 수 있습니다. 바로 이럴 때 필요한 것이 우아한 에러 처리와 복원력(resilience) 패턴입니다.

재시도, 서킷 브레이커, 폴백 등의 기법을 통해 일시적 장애를 극복하고, 사용자에게는 부분적으로라도 서비스를 계속 제공할 수 있습니다.

개요

간단히 말해서, 복원력 있는 시스템은 장애 상황에서도 우아하게 실패하고, 자동으로 복구를 시도하며, 사용자 경험을 최대한 보호합니다. 복원력은 분산 시스템의 핵심 특성입니다.

모든 컴포넌트가 100% 가용성을 보장할 수 없으므로, 일부가 실패해도 전체 시스템이 동작하도록 설계해야 합니다. 데이터베이스 연결의 경우, 네트워크 지터, 타임아웃, 일시적 과부하 등은 자주 발생하지만 재시도하면 성공할 수 있습니다.

하지만 무한정 재시도하면 상황을 악화시키므로, 지능적인 재시도 전략이 필요합니다. 기존에는 에러를 단순히 throw하고 사용자에게 에러 페이지를 보여줬다면, 이제는 에러 유형을 분석하고 적절히 대응해야 합니다.

재시도 가능한 에러(일시적 네트워크 문제)는 자동 재시도하고, 치명적 에러(잘못된 쿼리)는 즉시 실패하며, 과부하 상황에서는 서킷 브레이커로 데이터베이스를 보호합니다. 복원력 패턴의 핵심 전략은 다섯 가지입니다.

첫째, Retry 패턴으로 일시적 실패를 자동 복구합니다. 둘째, Circuit Breaker 패턴으로 연속 실패 시 요청을 차단하여 시스템을 보호합니다.

셋째, Timeout 패턴으로 무한 대기를 방지합니다. 넷째, Fallback 패턴으로 대체 데이터나 캐시를 제공합니다.

다섯째, Bulkhead 패턴으로 리소스를 격리하여 한 부분의 실패가 전체로 확산되지 않도록 합니다. 이러한 패턴들이 안정적이고 사용자 친화적인 서비스를 만듭니다.

코드 예제

// lib/db-resilience.ts - 복원력 있는 데이터베이스 액세스
import { prisma } from '@/lib/prisma';

// 서킷 브레이커 상태
class CircuitBreaker {
  private failureCount = 0;
  private lastFailureTime = 0;
  private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';

  constructor(
    private threshold: number = 5,     // 연속 실패 임계값
    private timeout: number = 60000,   // 서킷 열림 지속 시간 (ms)
  ) {}

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    // OPEN 상태: 요청 차단
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime > this.timeout) {
        this.state = 'HALF_OPEN';
        console.log('[Circuit Breaker] Attempting recovery (HALF_OPEN)');
      } else {
        throw new Error('Circuit breaker is OPEN');
      }
    }

    try {
      const result = await fn();

      // 성공 시 복구
      if (this.state === 'HALF_OPEN') {
        this.state = 'CLOSED';
        this.failureCount = 0;
        console.log('[Circuit Breaker] Recovered (CLOSED)');
      }

      return result;

    } catch (error) {
      this.failureCount++;
      this.lastFailureTime = Date.now();

      // 임계값 초과 시 서킷 열기
      if (this.failureCount >= this.threshold) {
        this.state = 'OPEN';
        console.error('[Circuit Breaker] OPEN due to repeated failures');
      }

      throw error;
    }
  }
}

const dbCircuitBreaker = new CircuitBreaker();

// 재시도와 서킷 브레이커를 결합한 안전한 쿼리
export async function resilientQuery<T>(
  queryFn: () => Promise<T>,
  options: {
    retries?: number;
    fallback?: T;
  } = {}
): Promise<T> {
  const { retries = 3, fallback } = options;

  return await dbCircuitBreaker.execute(async () => {
    let lastError: Error | null = null;

    for (let attempt = 0; attempt < retries; attempt++) {
      try {
        return await queryFn();

      } catch (error: any) {
        lastError = error;

        // 재시도 가능한 에러인지 확인
        const isRetryable = [
          'ECONNRESET',
          'ETIMEDOUT',
          'ENOTFOUND',
          'P2024', // Prisma: connection pool timeout
        ].some(code => error.code?.includes(code) || error.message?.includes(code));

        if (!isRetryable || attempt === retries - 1) {
          break;
        }

        // Exponential backoff
        const delay = Math.min(100 * Math.pow(2, attempt), 1000);
        console.warn(`[Resilient Query] Retrying after ${delay}ms (${attempt + 1}/${retries})`);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }

    // 모든 재시도 실패 시 fallback 사용
    if (fallback !== undefined) {
      console.warn('[Resilient Query] Using fallback due to errors');
      return fallback;
    }

    throw lastError || new Error('Query failed');
  });
}

설명

이것이 하는 일: 이 코드는 서킷 브레이커와 재시도 로직을 구현하여, 데이터베이스 장애 상황에서도 시스템을 보호하고 자동으로 복구를 시도합니다. 첫 번째로, Circuit Breaker 클래스는 연속된 실패를 추적합니다.

서킷은 세 가지 상태를 가집니다: CLOSED(정상), OPEN(차단), HALF_OPEN(복구 시도). 정상 상태에서 5번 연속 실패하면 OPEN으로 전환되어, 이후 모든 요청을 즉시 거부합니다.

이는 데이터베이스가 과부하 상태일 때 추가 요청을 보내지 않아 복구 시간을 주는 것입니다. 60초 후 HALF_OPEN으로 전환하여 복구를 시도하고, 성공하면 CLOSED로 돌아갑니다.

두 번째로, resilientQuery() 함수는 재시도 로직을 구현합니다. 에러 코드를 분석하여 일시적 네트워크 문제인지 판단합니다.

ECONNRESET(연결 리셋), ETIMEDOUT(타임아웃), P2024(Prisma 풀 타임아웃) 등은 재시도할 가치가 있습니다. 하지만 P2002(unique constraint 위반) 같은 논리적 에러는 재시도해도 실패하므로 즉시 반환합니다.

Exponential backoff를 사용하여 재시도 간격을 점진적으로 늘립니다: 100ms, 200ms, 400ms 등. 세 번째로, fallback 메커니즘을 제공합니다.

모든 재시도가 실패해도 미리 정의된 대체 값을 반환하여 사용자에게는 부분적으로라도 서비스를 제공합니다. 예를 들어, 추천 상품 목록을 가져오지 못하면 빈 배열이나 캐시된 데이터를 반환할 수 있습니다.

이는 전체 페이지가 에러로 실패하는 것보다 훨씬 나은 경험입니다. 여러분이 이 패턴을 적용하면 일시적인 데이터베이스 문제가 사용자에게 노출되지 않습니다.

예를 들어, 네트워크 지터로 첫 연결이 실패해도 자동으로 재시도하여 성공할 수 있습니다. 데이터베이스가 과부하 상태가 되면 서킷 브레이커가 추가 요청을 차단하여 상황을 악화시키지 않으며, 복구 시간을 줍니다.

사용자는 캐시된 데이터나 기본값을 보게 되어, 완전한 에러 페이지보다 나은 경험을 얻습니다.

실전 팁

💡 서킷 브레이커의 임계값과 타임아웃을 서비스 특성에 맞게 조정하세요. 중요한 서비스는 10-20번 실패까지 허용하고, 덜 중요한 서비스는 3-5번으로 낮춰서 빠르게 차단할 수 있습니다. 타임아웃은 데이터베이스 복구 시간을 고려하여 30-120초 사이로 설정하세요.

💡 서킷 브레이커 상태를 모니터링하고 알림을 설정하세요. OPEN 상태로 전환되면 심각한 문제이므로 즉시 대응해야 합니다. Datadog, Prometheus로 상태를 추적하고, Slack이나 PagerDuty로 알림을 받으세요.

💡 에러를 사용자 친화적 메시지로 변환하세요. "Connection pool timeout" 같은 기술적 에러를 사용자에게 보여주지 말고, "일시적으로 서비스에 접속할 수 없습니다. 잠시 후 다시 시도해주세요"처럼 친절하게 안내하세요. 클라이언트에서 자동 재시도 버튼을 제공하면 더욱 좋습니다.

💡 Fallback 데이터는 명확히 표시하세요. 캐시나 기본값을 사용할 때는 사용자에게 "최신 정보가 아닐 수 있습니다" 같은 안내를 추가하여 신뢰성을 유지하세요. 또한 fallback 사용을 로깅하여 얼마나 자주 발생하는지 추적하고 근본 원인을 해결하세요.


#Next.js#ConnectionPooling#DatabaseOptimization#PerformanceTuning#PostgreSQL

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.