이미지 로딩 중...

Connection Pooling과 Keep-Alive 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 13. · 4 Views

Connection Pooling과 Keep-Alive 완벽 가이드

웹 애플리케이션의 네트워크 성능을 극대적으로 향상시키는 Connection Pooling과 Keep-Alive 설정 방법을 실무 중심으로 배워봅니다. 데이터베이스 연결부터 HTTP 클라이언트까지, 실제 프로젝트에 바로 적용할 수 있는 최적화 기법을 다룹니다.


목차

  1. Connection Pooling 기본 개념
  2. Database Connection Pool 설정
  3. HTTP Keep-Alive 설정
  4. Redis Connection Pool
  5. MySQL Connection Pool 튜닝
  6. Connection Pool 모니터링
  7. Pool Size 최적화 전략
  8. Connection Timeout 설정
  9. Pool Exhaustion 해결
  10. Multi-Pool 아키텍처

1. Connection Pooling 기본 개념

시작하며

여러분이 웹 애플리케이션을 운영하면서 데이터베이스 연결이 느려지거나, 동시 사용자가 많아질 때 서버가 먹통이 되는 경험을 하신 적 있나요? 매번 새로운 사용자 요청마다 데이터베이스 연결을 생성하고 끊으면서 엄청난 오버헤드가 발생하고 있을 수 있습니다.

이런 문제는 실제 개발 현장에서 매우 자주 발생합니다. 데이터베이스 연결을 생성하는 과정은 TCP 핸드셰이크, 인증, 세션 초기화 등 많은 단계를 거치기 때문에 평균 100ms 이상 소요됩니다.

사용자가 100명만 동시에 접속해도 연결 생성만으로 10초가 걸릴 수 있죠. 바로 이럴 때 필요한 것이 Connection Pooling입니다.

미리 연결을 만들어두고 재사용함으로써 응답 시간을 90% 이상 단축시킬 수 있습니다.

개요

간단히 말해서, Connection Pooling은 데이터베이스 연결을 미리 여러 개 만들어두고 필요할 때마다 빌려 쓰는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 연결 생성 비용을 획기적으로 줄이고 동시 처리 능력을 극대화할 수 있기 때문입니다.

예를 들어, 전자상거래 사이트에서 주문이 몰리는 시간대에도 안정적인 성능을 유지해야 하는 경우에 매우 유용합니다. 전통적인 방법과의 비교를 해보면 명확합니다.

기존에는 요청마다 연결을 생성하고 닫았다면, 이제는 풀에서 연결을 빌려 쓰고 반납하면 됩니다. 이를 통해 연결 생성 시간을 0에 가깝게 만들 수 있습니다.

Connection Pool의 핵심 특징은 첫째, 연결 재사용으로 오버헤드 제거, 둘째, 동시 연결 수 제한으로 리소스 보호, 셋째, 연결 상태 관리 자동화입니다. 이러한 특징들이 시스템의 안정성과 확장성을 동시에 보장해줍니다.

코드 예제

// PostgreSQL Connection Pool 생성
const { Pool } = require('pg');

// Pool 인스턴스 생성 - 연결을 미리 준비
const pool = new Pool({
  host: 'localhost',
  port: 5432,
  database: 'myapp',
  user: 'admin',
  password: 'secret',
  // 최소 유지 연결 수
  min: 2,
  // 최대 연결 수
  max: 10,
  // 유휴 연결 타임아웃 (30초)
  idleTimeoutMillis: 30000,
  // 연결 획득 타임아웃 (3초)
  connectionTimeoutMillis: 3000
});

// 연결 사용 예시
async function getUser(userId) {
  // 풀에서 연결 빌려오기
  const client = await pool.connect();
  try {
    const result = await client.query('SELECT * FROM users WHERE id = $1', [userId]);
    return result.rows[0];
  } finally {
    // 연결 반납 (닫기 아님!)
    client.release();
  }
}

설명

이것이 하는 일: Connection Pool은 애플리케이션 시작 시 설정된 개수만큼 데이터베이스 연결을 미리 생성하고, 요청이 들어올 때마다 이 연결들을 재사용하여 성능을 최적화합니다. 첫 번째로, Pool 인스턴스를 생성할 때 min과 max 파라미터를 설정합니다.

min은 항상 유지할 최소 연결 수로, 애플리케이션이 시작되면 즉시 이만큼의 연결을 생성합니다. 이렇게 하는 이유는 첫 요청부터 빠른 응답을 보장하기 위함입니다.

두 번째로, pool.connect()를 호출하면 사용 가능한 연결을 즉시 반환받습니다. 만약 모든 연결이 사용 중이면 connectionTimeoutMillis 시간만큼 대기하고, 그래도 연결을 얻지 못하면 에러를 발생시킵니다.

내부에서는 큐 자료구조로 대기 중인 요청들을 관리합니다. 세 번째로, 작업이 끝나면 client.release()로 연결을 풀에 반납합니다.

중요한 점은 연결을 닫는 것이 아니라 재사용 가능한 상태로 만드는 것입니다. idleTimeoutMillis 시간 동안 사용되지 않으면 자동으로 정리되어 리소스를 절약합니다.

여러분이 이 코드를 사용하면 데이터베이스 쿼리 응답 시간이 평균 100ms에서 10ms 이하로 단축되고, 동시 처리 가능한 요청 수가 10배 이상 증가하며, 메모리와 CPU 사용률도 크게 개선됩니다.

실전 팁

💡 min 값은 평균 트래픽을 처리할 수 있는 수준으로 설정하세요. 너무 낮으면 순간적인 부하에 대응하기 어렵고, 너무 높으면 유휴 연결이 리소스를 낭비합니다.

💡 항상 try-finally 블록으로 연결 반납을 보장하세요. release()를 호출하지 않으면 연결이 고갈되어 전체 애플리케이션이 멈출 수 있습니다.

💡 connectionTimeoutMillis는 너무 길게 설정하지 마세요. 3초면 충분하며, 더 길면 사용자 경험이 나빠집니다.

💡 프로덕션에서는 pool.on('error') 이벤트를 리스닝하여 연결 오류를 모니터링하세요. 데이터베이스 장애를 조기에 감지할 수 있습니다.

💡 max 값은 데이터베이스의 max_connections 설정을 고려하여 결정하세요. 여러 인스턴스가 있다면 총합이 max_connections를 넘지 않도록 주의해야 합니다.


2. Database Connection Pool 설정

시작하며

여러분이 PostgreSQL을 사용하는 Node.js 애플리케이션을 개발하면서 "too many connections" 에러를 본 적이 있나요? 또는 트래픽이 증가하면서 데이터베이스 응답이 점점 느려지는 현상을 경험하셨나요?

이는 잘못된 연결 풀 설정 때문일 가능성이 높습니다. 이런 문제는 실제 서비스 운영에서 가장 흔하게 마주치는 장애 원인 중 하나입니다.

연결 풀 크기가 너무 작으면 요청이 대기하면서 타임아웃이 발생하고, 너무 크면 데이터베이스 서버가 과부하에 걸립니다. 적절한 밸런스를 찾는 것이 핵심입니다.

바로 이럴 때 필요한 것이 체계적인 Connection Pool 설정입니다. 서비스 규모와 특성에 맞는 최적의 설정값을 찾아 안정적인 운영을 보장할 수 있습니다.

개요

간단히 말해서, Database Connection Pool 설정은 애플리케이션과 데이터베이스 간의 연결을 얼마나, 어떻게 관리할지를 결정하는 과정입니다. 왜 이 설정이 중요한지 실무 관점에서 보면, 잘못된 설정은 시스템 전체의 병목이 되기 때문입니다.

예를 들어, API 서버가 10대이고 각각 max 연결이 20개라면 총 200개의 연결이 필요한데, 데이터베이스의 max_connections가 100이면 절반의 서버가 연결을 얻지 못하는 상황이 발생합니다. 전통적인 방법과의 비교를 하자면, 과거에는 고정된 값을 사용했다면 현대적인 접근법은 모니터링 데이터를 기반으로 동적으로 조정합니다.

기존에는 "max: 10"처럼 임의의 값을 사용했다면, 이제는 동시 사용자 수, 쿼리 평균 실행 시간, 서버 리소스를 고려한 과학적인 계산을 합니다. 최적의 Pool 설정의 핵심 특징은 첫째, 서비스 특성 반영 (읽기 위주 vs 쓰기 위주), 둘째, 장애 복구 메커니즘 (재연결, 타임아웃), 셋째, 확장성 고려 (서버 증설 시 대응)입니다.

이러한 특징들이 안정적이고 확장 가능한 시스템을 만들어줍니다.

코드 예제

// 프로덕션 환경 PostgreSQL Pool 설정
const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432'),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,

  // 최소 연결: 평균 트래픽 처리용
  min: 5,
  // 최대 연결: 피크 트래픽 대응 (서버 대수 고려)
  max: 20,

  // 유휴 연결 정리 시간 (30초)
  idleTimeoutMillis: 30000,
  // 연결 획득 대기 시간 (2초)
  connectionTimeoutMillis: 2000,

  // 연결 유지 시간 (10분 후 재생성)
  maxLifetimeSeconds: 600,

  // Keep-Alive 설정으로 죽은 연결 감지
  keepAlive: true,
  keepAliveInitialDelayMillis: 10000,

  // SSL 설정 (프로덕션 필수)
  ssl: process.env.NODE_ENV === 'production' ? {
    rejectUnauthorized: false
  } : false
});

// 연결 에러 핸들링
pool.on('error', (err, client) => {
  console.error('Pool에서 예상치 못한 에러 발생:', err);
  // 모니터링 시스템에 알림 전송
});

// 연결 풀 상태 모니터링
pool.on('connect', () => {
  console.log('새 연결이 풀에 추가됨');
});

pool.on('acquire', () => {
  console.log('클라이언트가 풀에서 체크아웃됨');
});

pool.on('remove', () => {
  console.log('클라이언트가 풀에서 제거됨');
});

module.exports = pool;

설명

이것이 하는 일: 프로덕션 환경에서 안정적으로 운영 가능한 완전한 Connection Pool 설정을 제공합니다. 장애 상황 대응, 보안, 모니터링까지 모든 요소를 고려합니다.

첫 번째로, min과 max 값을 신중하게 설정합니다. min: 5는 평균 트래픽을 처리할 수 있는 충분한 연결을 항상 유지하여 갑작스런 부하에 대응합니다.

max: 20은 피크 시간대의 최대 동시 요청을 처리할 수 있는 수치로, 데이터베이스 서버의 max_connections를 고려해야 합니다. 만약 애플리케이션 서버가 3대라면 총 60개의 연결이 필요하므로 DB의 max_connections는 최소 100 이상이어야 합니다.

두 번째로, 타임아웃 설정들이 시스템을 보호합니다. idleTimeoutMillis: 30000은 사용되지 않는 연결을 30초 후 정리하여 리소스를 절약하고, connectionTimeoutMillis: 2000은 연결을 얻지 못할 때 2초 후 빠르게 실패하여 사용자가 무한정 대기하는 것을 방지합니다.

maxLifetimeSeconds: 600은 연결을 10분마다 재생성하여 네트워크 문제나 메모리 누수를 예방합니다. 세 번째로, Keep-Alive 설정이 죽은 연결을 감지합니다.

keepAlive: true로 TCP Keep-Alive를 활성화하고, keepAliveInitialDelayMillis: 10000으로 10초마다 상태를 체크합니다. 이를 통해 방화벽이나 로드밸런서에 의해 끊어진 연결을 빠르게 감지하고 재생성할 수 있습니다.

네 번째로, 이벤트 리스너들이 실시간 모니터링을 가능하게 합니다. 'error' 이벤트로 연결 장애를 감지하고, 'connect', 'acquire', 'remove' 이벤트로 연결 풀의 동작을 추적할 수 있습니다.

이 데이터를 Prometheus나 Datadog 같은 모니터링 도구로 전송하면 문제를 조기에 발견할 수 있습니다. 여러분이 이 설정을 사용하면 99.9% 이상의 가용성을 달성할 수 있고, 장애 상황에서도 빠르게 복구되며, 모니터링을 통해 사전에 문제를 예방할 수 있습니다.

실전 팁

💡 max 값 계산 공식: (DB CPU 코어 수 * 2) + 효과적인 스핀들 수. 예를 들어 4코어 DB라면 max: 10 정도가 적절합니다.

💡 환경변수로 설정값을 관리하세요. 개발/스테이징/프로덕션 환경마다 다른 값이 필요하기 때문입니다.

💡 연결 풀 상태를 health check 엔드포인트에 포함시키세요. pool.totalCount, pool.idleCount, pool.waitingCount를 노출하면 운영이 편해집니다.

💡 프로덕션에서는 반드시 SSL을 활성화하세요. 데이터베이스 연결이 평문으로 전송되면 보안 위험이 있습니다.

💡 connectionTimeoutMillis는 쿼리 타임아웃보다 길게 설정하세요. 그래야 쿼리 실행 중 연결이 끊기지 않습니다.


3. HTTP Keep-Alive 설정

시작하며

여러분이 외부 API를 호출하는 마이크로서비스를 개발하면서 왜 응답이 이렇게 느린지 고민해본 적 있나요? Axios나 Fetch로 API를 수백 번 호출하는데, 매번 TCP 연결을 새로 맺고 끊으면서 엄청난 시간을 낭비하고 있을 수 있습니다.

이런 문제는 특히 마이크로서비스 아키텍처에서 심각합니다. 서비스 간 통신이 빈번할수록 연결 오버헤드가 누적되어 전체 응답 시간의 50% 이상을 차지할 수 있습니다.

단일 요청은 빠르더라도, 연속된 요청들의 합계 시간은 기하급수적으로 증가합니다. 바로 이럴 때 필요한 것이 HTTP Keep-Alive 설정입니다.

한 번 맺은 TCP 연결을 재사용하여 API 호출 성능을 2배 이상 향상시킬 수 있습니다.

개요

간단히 말해서, HTTP Keep-Alive는 HTTP 요청이 끝나도 TCP 연결을 유지하여 다음 요청에서 재사용하는 기법입니다. 왜 이 기법이 필요한지 실무 관점에서 설명하면, TCP 3-way 핸드셰이크와 SSL/TLS 핸드셰이크를 반복하는 것은 매우 비효율적이기 때문입니다.

예를 들어, 결제 시스템에서 카드 검증, 재고 확인, 포인트 차감 API를 순차적으로 호출할 때, Keep-Alive가 없으면 연결을 3번 맺고 끊어야 합니다. 전통적인 방법과의 비교를 해보겠습니다.

기존에는 HTTP/1.0처럼 요청마다 연결을 맺고 끊었다면, Keep-Alive를 사용하면 HTTP/1.1의 persistent connection으로 하나의 연결로 여러 요청을 처리할 수 있습니다. 이를 통해 평균 응답 시간이 200ms에서 80ms로 단축됩니다.

HTTP Keep-Alive의 핵심 특징은 첫째, TCP 연결 재사용으로 핸드셰이크 제거, 둘째, 소켓 수 감소로 시스템 리소스 절약, 셋째, 네트워크 혼잡도 감소입니다. 이러한 특징들이 특히 고빈도 API 호출 환경에서 큰 성능 향상을 가져옵니다.

코드 예제

// Node.js HTTP Agent with Keep-Alive
const http = require('http');
const https = require('https');
const axios = require('axios');

// HTTP Keep-Alive Agent 생성
const httpAgent = new http.Agent({
  keepAlive: true,
  // 소켓 유지 시간 (밀리초)
  keepAliveMsecs: 1000,
  // 최대 소켓 수
  maxSockets: 50,
  // 호스트당 최대 소켓 수
  maxFreeSockets: 10,
  // 소켓 타임아웃 (60초)
  timeout: 60000,
  // 유휴 소켓 타임아웃 (30초)
  freeSocketTimeout: 30000
});

// HTTPS Keep-Alive Agent 생성
const httpsAgent = new https.Agent({
  keepAlive: true,
  keepAliveMsecs: 1000,
  maxSockets: 50,
  maxFreeSockets: 10,
  timeout: 60000,
  freeSocketTimeout: 30000
});

// Axios 인스턴스 생성
const apiClient = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 5000,
  httpAgent: httpAgent,
  httpsAgent: httpsAgent,
  headers: {
    'Connection': 'keep-alive'
  }
});

// 사용 예시
async function fetchMultipleResources() {
  // 동일한 연결로 여러 요청 처리
  const [users, products, orders] = await Promise.all([
    apiClient.get('/users'),
    apiClient.get('/products'),
    apiClient.get('/orders')
  ]);

  return { users: users.data, products: products.data, orders: orders.data };
}

설명

이것이 하는 일: HTTP Agent를 통해 TCP 소켓 풀을 생성하고 관리하여, 동일한 호스트에 대한 여러 요청이 하나의 연결을 재사용하도록 만듭니다. 첫 번째로, Agent 인스턴스를 생성할 때 keepAlive: true를 설정합니다.

이것이 핵심 설정으로, 요청이 끝나도 소켓을 닫지 않고 유지합니다. keepAliveMsecs: 1000은 1초마다 TCP Keep-Alive 패킷을 보내 연결이 살아있는지 확인합니다.

이를 통해 방화벽이나 프록시에 의해 유휴 연결이 끊기는 것을 방지합니다. 두 번째로, maxSockets과 maxFreeSockets가 리소스를 제어합니다.

maxSockets: 50은 동시에 열 수 있는 최대 소켓 수로, 너무 많은 연결로 시스템이 과부하되는 것을 막습니다. maxFreeSockets: 10은 유휴 상태로 유지할 최대 소켓 수로, 메모리를 절약하면서도 빠른 재사용을 가능하게 합니다.

예를 들어 API 서버가 100개의 요청을 받았을 때, 50개씩 두 번에 나눠 처리하게 됩니다. 세 번째로, 타임아웃 설정들이 장애 상황을 관리합니다.

timeout: 60000은 소켓이 데이터를 기다리는 최대 시간으로, 네트워크 장애 시 무한정 대기하지 않도록 합니다. freeSocketTimeout: 30000은 유휴 소켓을 30초 후 정리하여 불필요한 리소스 점유를 방지합니다.

네 번째로, Promise.all과 결합하면 효과가 극대화됩니다. 세 개의 API 요청이 동일한 호스트로 향할 때, 하나의 연결(또는 소수의 연결)을 재사용하여 병렬로 처리됩니다.

Keep-Alive가 없다면 3번의 핸드셰이크(각 100ms)로 300ms가 추가되지만, Keep-Alive를 사용하면 첫 요청만 핸드셰이크하고 나머지는 즉시 데이터를 전송합니다. 여러분이 이 설정을 사용하면 외부 API 호출이 많은 서비스에서 전체 응답 시간이 30-50% 단축되고, 서버의 소켓 사용량이 70% 감소하며, 네트워크 대역폭도 절약됩니다.

실전 팁

💡 글로벌 Agent 대신 서비스별 Agent를 생성하세요. 각 외부 서비스마다 다른 설정(타임아웃, 소켓 수)이 필요할 수 있습니다.

💡 maxSockets는 너무 높게 설정하지 마세요. 운영체제의 파일 디스크립터 한계(ulimit -n)를 고려해야 합니다.

💡 로드밸런서 뒤의 서비스라면 freeSocketTimeout를 로드밸런서의 idle timeout보다 짧게 설정하세요. 그렇지 않으면 끊긴 연결을 사용하려다 에러가 발생합니다.

💡 모니터링을 위해 agent.getCurrentSockets()를 주기적으로 체크하세요. 소켓이 고갈되면 성능이 급격히 떨어집니다.

💡 HTTP/2를 지원하는 서버라면 http2 모듈을 사용하세요. Keep-Alive보다 더 효율적인 멀티플렉싱을 제공합니다.


4. Redis Connection Pool

시작하며

여러분이 Redis를 캐시 서버로 사용하면서 동시 접속이 많아질 때 "Maximum number of clients reached" 에러를 본 적 있나요? 또는 캐시 조회가 데이터베이스 쿼리만큼 느려지는 이상한 상황을 경험하셨나요?

Redis도 연결 풀 관리가 필수입니다. 이런 문제는 Redis를 단순히 빠른 저장소로만 생각하고 연결 관리를 간과할 때 발생합니다.

Redis는 기본적으로 싱글 쓰레드로 동작하기 때문에, 연결 생성/해제가 빈번하면 전체 처리량이 급격히 떨어집니다. 특히 TLS를 사용하는 경우 핸드셰이크 비용이 더욱 커집니다.

바로 이럴 때 필요한 것이 Redis Connection Pool입니다. ioredis 같은 라이브러리를 통해 연결을 효율적으로 관리하고 최대 성능을 끌어낼 수 있습니다.

개요

간단히 말해서, Redis Connection Pool은 Redis 서버와의 연결을 미리 생성하고 재사용하여 캐시 조회 성능을 극대화하는 기법입니다. 왜 이 기법이 필요한지 실무 관점에서 보면, Redis의 핵심 가치인 '빠른 속도'를 제대로 활용하기 위함입니다.

예를 들어, 전자상거래 사이트에서 상품 조회 시마다 Redis에서 가격 정보를 가져온다면, 초당 수천 건의 Redis 연결이 필요합니다. 매번 새 연결을 맺으면 Redis의 속도 이점이 사라집니다.

전통적인 방법과의 비교를 하자면, 과거에는 단일 연결을 공유하거나 매번 새 연결을 만들었다면, 현대적인 접근법은 연결 풀을 사용합니다. 기존에는 연결 경합으로 병목이 발생했다면, 이제는 여러 연결을 동시에 사용하여 병렬 처리가 가능합니다.

Redis Connection Pool의 핵심 특징은 첫째, 자동 재연결로 네트워크 장애 복구, 둘째, Cluster와 Sentinel 모드 지원, 셋째, 파이프라이닝으로 처리량 극대화입니다. 이러한 특징들이 Redis를 프로덕션 환경에서 안정적으로 사용할 수 있게 해줍니다.

코드 예제

// ioredis를 사용한 Redis Connection Pool
const Redis = require('ioredis');

// 단일 Redis 인스턴스용 설정
const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD,
  db: 0,

  // Connection Pool 설정
  // 최대 연결 수 (기본값: 무제한)
  maxRetriesPerRequest: 3,
  // 재연결 전략
  retryStrategy(times) {
    const delay = Math.min(times * 50, 2000);
    return delay;
  },
  // 재연결 최대 시도
  reconnectOnError(err) {
    const targetError = 'READONLY';
    if (err.message.includes(targetError)) {
      return true; // 재연결
    }
    return false;
  },

  // Keep-Alive 설정
  keepAlive: 30000, // 30초

  // 명령어 타임아웃
  connectTimeout: 10000,
  commandTimeout: 5000,

  // 자동 파이프라이닝 (성능 최적화)
  enableAutoPipelining: true,
  autoPipeliningIgnoredCommands: ['ping'],

  // 읽기 전용 모드
  enableReadyCheck: true,
  lazyConnect: false
});

// 연결 이벤트 핸들링
redis.on('connect', () => {
  console.log('Redis에 연결됨');
});

redis.on('ready', () => {
  console.log('Redis 준비 완료');
});

redis.on('error', (err) => {
  console.error('Redis 에러:', err);
});

redis.on('reconnecting', () => {
  console.log('Redis 재연결 중...');
});

// 사용 예시
async function getCachedData(key) {
  try {
    const cached = await redis.get(key);
    if (cached) {
      return JSON.parse(cached);
    }
    return null;
  } catch (err) {
    console.error('Redis 조회 실패:', err);
    return null; // 캐시 실패 시 DB로 폴백
  }
}

async function setCachedData(key, value, ttl = 3600) {
  try {
    await redis.setex(key, ttl, JSON.stringify(value));
  } catch (err) {
    console.error('Redis 저장 실패:', err);
  }
}

module.exports = { redis, getCachedData, setCachedData };

설명

이것이 하는 일: ioredis 라이브러리를 사용하여 Redis 서버와의 연결을 자동으로 관리하고, 장애 복구 및 성능 최적화 기능을 제공합니다. 첫 번째로, retryStrategy 함수가 네트워크 장애에 대응합니다.

Redis 연결이 끊어지면 자동으로 재연결을 시도하는데, 재시도 간격을 지수적으로 증가시킵니다(50ms, 100ms, 150ms... 최대 2초).

이를 통해 일시적인 네트워크 문제는 자동으로 복구되고, 지속적인 장애에는 서버에 과부하를 주지 않습니다. reconnectOnError는 특정 에러 상황에서만 재연결을 수행하여 불필요한 재연결을 방지합니다.

두 번째로, enableAutoPipelining: true가 성능을 극대화합니다. 파이프라이닝은 여러 명령어를 하나의 네트워크 왕복으로 처리하는 기법입니다.

예를 들어 100개의 키를 조회할 때, 파이프라이닝 없이는 100번의 네트워크 왕복이 필요하지만, 파이프라이닝을 사용하면 한 번에 처리됩니다. ioredis는 이를 자동으로 감지하여 적용해줍니다.

세 번째로, 타임아웃 설정들이 응답성을 보장합니다. connectTimeout: 10000은 연결 자체가 10초 내에 완료되지 않으면 실패 처리하고, commandTimeout: 5000은 각 명령어가 5초 내에 응답하지 않으면 타임아웃 처리합니다.

이를 통해 Redis 서버가 느려졌을 때 전체 애플리케이션이 멈추는 것을 방지합니다. 네 번째로, 에러 핸들링이 운영 안정성을 높입니다.

캐시 조회/저장 실패 시 에러를 던지지 않고 null을 반환하거나 조용히 실패하여, 캐시 장애가 전체 서비스 장애로 이어지지 않도록 합니다. 이것이 캐시의 핵심 원칙인 "캐시 장애는 성능 저하일 뿐, 서비스 장애가 아니다"를 구현하는 방법입니다.

여러분이 이 설정을 사용하면 Redis 조회 속도가 1ms 이하로 유지되고, 네트워크 장애 시 자동 복구되며, 캐시 장애가 서비스 전체에 영향을 주지 않습니다.

실전 팁

💡 Redis Cluster를 사용한다면 new Redis.Cluster([{host, port}])로 클러스터 모드를 활성화하세요. 자동 샤딩과 페일오버를 지원합니다.

💡 enableAutoPipelining은 조심히 사용하세요. 트랜잭션(MULTI/EXEC)과 함께 쓸 때는 예상치 못한 동작이 발생할 수 있습니다.

💡 lazyConnect: false로 설정하면 애플리케이션 시작 시 Redis 연결을 즉시 확인합니다. 설정 오류를 조기에 발견할 수 있습니다.

💡 개발 환경에서는 showFriendlyErrorStack: true를 추가하면 더 자세한 에러 스택을 볼 수 있습니다.

💡 대규모 트래픽 환경에서는 별도의 읽기 전용 복제본을 만들고 scaleReads: 'slave' 옵션으로 읽기 부하를 분산하세요.


5. MySQL Connection Pool 튜닝

시작하며

여러분이 MySQL 데이터베이스를 사용하는 대규모 서비스를 운영하면서 피크 시간대에 "Connection timeout" 에러가 폭증하는 경험을 하신 적 있나요? 또는 동시 사용자가 증가하면서 데이터베이스 응답 시간이 점점 길어지는 현상을 목격하셨나요?

MySQL은 특히 연결 풀 튜닝이 중요합니다. 이런 문제는 MySQL의 연결 관리 방식과 관련이 깊습니다.

MySQL은 각 연결마다 별도의 쓰레드를 생성하기 때문에, 연결 수가 많아지면 컨텍스트 스위칭 오버헤드가 급증합니다. 또한 InnoDB 버퍼 풀과 연결 풀의 밸런스가 맞지 않으면 메모리 부족 현상이 발생할 수 있습니다.

바로 이럴 때 필요한 것이 체계적인 MySQL Connection Pool 튜닝입니다. mysql2 라이브러리의 고급 기능들을 활용하여 안정적인 고성능 시스템을 구축할 수 있습니다.

개요

간단히 말해서, MySQL Connection Pool 튜닝은 MySQL 서버의 특성과 워크로드에 맞춰 연결 풀 파라미터를 최적화하는 과정입니다. 왜 이 튜닝이 중요한지 실무 관점에서 설명하면, MySQL은 PostgreSQL과 달리 프로세스 기반이 아닌 쓰레드 기반이라 연결 수에 매우 민감하기 때문입니다.

예를 들어, 소셜 미디어 플랫폼에서 피드를 로딩할 때 여러 테이블을 조인하는 복잡한 쿼리들이 동시에 실행되면, 부적절한 풀 설정은 데이터베이스 서버 전체를 마비시킬 수 있습니다. 전통적인 방법과의 비교를 하자면, 과거에는 고정된 연결 풀 크기를 사용했다면, 현대적인 접근법은 queueLimit, waitForConnections 같은 파라미터로 백프레셔를 구현합니다.

기존에는 연결이 부족하면 무한정 대기했다면, 이제는 큐 크기를 제한하여 빠르게 실패하고 다른 전략(읽기 전용 복제본 사용 등)을 취할 수 있습니다. MySQL Connection Pool 튜닝의 핵심 특징은 첫째, 워크로드 특성 반영 (OLTP vs OLAP), 둘째, 백프레셔 메커니즘으로 시스템 보호, 셋째, 연결 검증으로 죽은 연결 방지입니다.

이러한 특징들이 대규모 트래픽 환경에서도 안정적인 성능을 보장합니다.

코드 예제

// mysql2를 사용한 MySQL Connection Pool 튜닝
const mysql = require('mysql2/promise');

// 프로덕션 최적화 설정
const pool = mysql.createPool({
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '3306'),
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,

  // Connection Pool 크기
  connectionLimit: 10, // CPU 코어 수 기반 계산
  queueLimit: 30, // 대기 큐 최대 크기

  // 연결 동작 설정
  waitForConnections: true, // 연결 대기
  enableKeepAlive: true, // TCP Keep-Alive
  keepAliveInitialDelay: 10000, // 10초

  // 타임아웃 설정
  connectTimeout: 10000, // 연결 타임아웃 10초
  acquireTimeout: 10000, // 풀에서 연결 획득 타임아웃
  timeout: 60000, // 쿼리 타임아웃 60초

  // 연결 재사용 설정
  maxIdle: 10, // 최대 유휴 연결 수
  idleTimeout: 60000, // 유휴 연결 타임아웃 60초

  // 문자셋 및 타임존
  charset: 'utf8mb4',
  timezone: '+00:00',

  // 연결 검증 쿼리
  connectionAttributes: {
    program_name: 'my-app',
    _node_id: process.pid
  },

  // 성능 최적화
  namedPlaceholders: true, // 네임드 플레이스홀더
  dateStrings: false, // 날짜를 Date 객체로
  supportBigNumbers: true, // BIGINT 지원
  bigNumberStrings: false, // 큰 숫자를 Number로

  // 멀티플 스테이트먼트 비활성화 (보안)
  multipleStatements: false,

  // SSL 설정 (프로덕션 환경)
  ssl: process.env.NODE_ENV === 'production' ? {
    rejectUnauthorized: false
  } : null
});

// 연결 풀 이벤트 리스너
pool.on('acquire', (connection) => {
  console.log('Connection %d acquired', connection.threadId);
});

pool.on('connection', (connection) => {
  console.log('Connection %d created', connection.threadId);
});

pool.on('enqueue', () => {
  console.log('Waiting for available connection slot');
});

pool.on('release', (connection) => {
  console.log('Connection %d released', connection.threadId);
});

// 트랜잭션 헬퍼 함수
async function withTransaction(callback) {
  const connection = await pool.getConnection();
  await connection.beginTransaction();

  try {
    const result = await callback(connection);
    await connection.commit();
    return result;
  } catch (err) {
    await connection.rollback();
    throw err;
  } finally {
    connection.release();
  }
}

// Health check 함수
async function checkDatabaseHealth() {
  try {
    const [rows] = await pool.query('SELECT 1 as health');
    return rows[0].health === 1;
  } catch (err) {
    console.error('Database health check failed:', err);
    return false;
  }
}

module.exports = { pool, withTransaction, checkDatabaseHealth };

설명

이것이 하는 일: mysql2 라이브러리의 고급 기능들을 활용하여 대규모 트래픽에도 안정적으로 동작하는 MySQL 연결 풀을 구성하고, 운영에 필요한 헬퍼 함수들을 제공합니다. 첫 번째로, connectionLimit과 queueLimit가 시스템을 보호합니다.

connectionLimit: 10은 MySQL 서버의 CPU 코어 수를 기반으로 계산한 값입니다(일반적으로 코어 수의 2-3배). 이보다 많은 연결은 컨텍스트 스위칭으로 성능이 떨어집니다.

queueLimit: 30은 대기 큐의 최대 크기로, 이를 초과하면 즉시 에러를 반환하여 클라이언트가 빠르게 재시도하거나 다른 전략을 취할 수 있게 합니다. waitForConnections: true와 함께 사용하여 백프레셔를 구현합니다.

두 번째로, 타임아웃 설정들이 다층 방어를 제공합니다. connectTimeout: 10000은 MySQL 서버 자체에 연결하는 시간 제한, acquireTimeout: 10000은 풀에서 연결을 얻는 시간 제한, timeout: 60000은 쿼리 실행 시간 제한입니다.

이 세 가지가 계층적으로 작동하여 어떤 단계에서든 문제가 발생하면 빠르게 실패 처리합니다. 세 번째로, maxIdle과 idleTimeout이 리소스를 최적화합니다.

피크 시간대에는 connectionLimit만큼 연결이 생성되지만, 트래픽이 줄어들면 idleTimeout: 60000에 의해 60초간 사용되지 않은 연결들이 정리됩니다. 단, maxIdle: 10에 의해 최소 10개는 유지하여 다음 피크에 빠르게 대응합니다.

네 번째로, withTransaction 헬퍼 함수가 트랜잭션을 안전하게 관리합니다. try-catch-finally 패턴으로 어떤 상황에서도 연결이 반드시 반납되도록 보장하고, 에러 발생 시 자동으로 롤백합니다.

이를 통해 개발자가 연결 관리나 트랜잭션 롤백을 잊어버리는 실수를 방지합니다. 다섯 번째로, 보안 설정들이 중요합니다.

multipleStatements: false로 SQL 인젝션 위험을 줄이고, namedPlaceholders: true로 가독성 높은 쿼리를 작성하며, connectionAttributes로 각 연결을 추적 가능하게 만듭니다. 프로덕션에서는 SSL을 활성화하여 데이터 전송을 암호화합니다.

여러분이 이 설정을 사용하면 동시 사용자 수가 급증해도 시스템이 안정적으로 동작하고, 연결 관련 에러가 90% 이상 감소하며, 트랜잭션 누락이나 연결 누수 같은 치명적인 버그를 예방할 수 있습니다.

실전 팁

💡 connectionLimit 계산 공식: min(MySQL max_connections / 애플리케이션 인스턴스 수, CPU 코어 * 2-3). 예를 들어 max_connections: 100, 인스턴스: 5대, 코어: 4라면 min(20, 10) = 10입니다.

💡 queueLimit은 connectionLimit의 3배 정도가 적절합니다. 너무 크면 요청이 오래 대기하고, 너무 작으면 에러가 빈번합니다.

💡 읽기 전용 쿼리가 많다면 별도의 읽기 전용 복제본 풀을 만드세요. 마스터 풀은 쓰기 전용으로 사용하여 부하를 분산합니다.

💡 긴 쿼리(리포트 생성 등)는 별도의 풀을 사용하세요. timeout을 길게 설정하고 connectionLimit을 작게 하여 일반 트래픽에 영향을 주지 않도록 합니다.

💡 모니터링 지표로 pool.pool._allConnections.length(총 연결), pool.pool._freeConnections.length(유휴 연결), pool.pool._acquiringConnections.length(대기 중)를 추적하세요.


6. Connection Pool 모니터링

시작하며

여러분이 프로덕션 환경에서 갑자기 "Connection timeout" 에러가 발생했는데, 원인을 찾기 위해 로그를 뒤지고 있나요? 연결 풀이 얼마나 사용되고 있는지, 어떤 쿼리가 연결을 오래 점유하고 있는지 실시간으로 알 수 없어서 답답하신가요?

이런 문제는 실제 운영 현장에서 가장 진단하기 어려운 장애 중 하나입니다. 연결 풀 고갈은 순식간에 발생하고, 발생하면 전체 서비스가 응답 불가 상태가 되기 때문에 사전 감지가 매우 중요합니다.

하지만 대부분의 팀은 장애가 발생한 후에야 연결 풀 문제였다는 것을 깨닫습니다. 바로 이럴 때 필요한 것이 Connection Pool 모니터링입니다.

실시간으로 연결 풀 상태를 추적하고, 임계치를 넘으면 알림을 보내 장애를 예방할 수 있습니다.

개요

간단히 말해서, Connection Pool 모니터링은 연결 풀의 상태를 실시간으로 추적하고 시각화하여 문제를 조기에 발견하는 기법입니다. 왜 이 모니터링이 필요한지 실무 관점에서 설명하면, 연결 풀 문제는 느리게 진행되다가 임계점을 넘는 순간 폭발적으로 장애가 발생하기 때문입니다.

예를 들어, 특정 API 엔드포인트에서 연결을 release()하지 않는 버그가 있다면, 점진적으로 유휴 연결이 감소하다가 어느 순간 모든 연결이 고갈되어 전체 서비스가 멈춥니다. 전통적인 방법과의 비교를 하자면, 과거에는 장애 발생 후 로그를 분석했다면, 현대적인 접근법은 Prometheus, Grafana 같은 도구로 실시간 대시보드를 구축합니다.

기존에는 "연결 수가 부족한가?" 정도만 알 수 있었다면, 이제는 연결별 수명, 대기 시간 분포, 쿼리 패턴까지 상세히 분석할 수 있습니다. Connection Pool 모니터링의 핵심 특징은 첫째, 실시간 메트릭 수집 (연결 수, 대기 큐 길이), 둘째, 임계치 기반 알림 (80% 사용 시 경고), 셋째, 히스토리 추적으로 트렌드 분석입니다.

이러한 특징들이 장애를 예방하고 용량 계획을 가능하게 합니다.

코드 예제

// Prometheus를 활용한 Connection Pool 모니터링
const client = require('prom-client');
const { Pool } = require('pg');

// Prometheus 레지스트리 생성
const register = new client.Registry();

// Connection Pool 메트릭 정의
const poolTotalConnections = new client.Gauge({
  name: 'db_pool_total_connections',
  help: 'Total number of connections in the pool',
  registers: [register]
});

const poolIdleConnections = new client.Gauge({
  name: 'db_pool_idle_connections',
  help: 'Number of idle connections',
  registers: [register]
});

const poolWaitingRequests = new client.Gauge({
  name: 'db_pool_waiting_requests',
  help: 'Number of requests waiting for a connection',
  registers: [register]
});

const poolConnectionErrors = new client.Counter({
  name: 'db_pool_connection_errors_total',
  help: 'Total number of connection errors',
  registers: [register]
});

const poolQueryDuration = new client.Histogram({
  name: 'db_pool_query_duration_seconds',
  help: 'Query execution duration in seconds',
  buckets: [0.001, 0.01, 0.1, 0.5, 1, 5],
  registers: [register]
});

// PostgreSQL Pool 생성
const pool = new Pool({
  host: 'localhost',
  port: 5432,
  database: 'myapp',
  min: 2,
  max: 10,
  idleTimeoutMillis: 30000
});

// 연결 풀 상태 수집 함수
function collectPoolMetrics() {
  poolTotalConnections.set(pool.totalCount);
  poolIdleConnections.set(pool.idleCount);
  poolWaitingRequests.set(pool.waitingCount);
}

// 주기적으로 메트릭 수집 (5초마다)
setInterval(collectPoolMetrics, 5000);

// 연결 에러 추적
pool.on('error', (err) => {
  poolConnectionErrors.inc();
  console.error('Pool error:', err);
});

// 쿼리 실행 래퍼 (성능 측정)
async function executeQuery(query, params) {
  const endTimer = poolQueryDuration.startTimer();

  try {
    const client = await pool.connect();
    try {
      const result = await client.query(query, params);
      endTimer({ status: 'success' });
      return result;
    } finally {
      client.release();
    }
  } catch (err) {
    endTimer({ status: 'error' });
    poolConnectionErrors.inc();
    throw err;
  }
}

// Prometheus 메트릭 엔드포인트
async function getMetrics() {
  collectPoolMetrics(); // 최신 데이터 수집
  return register.metrics();
}

// Health check 함수 (연결 풀 상태 포함)
async function healthCheck() {
  collectPoolMetrics();

  const totalConnections = pool.totalCount;
  const idleConnections = pool.idleCount;
  const waitingRequests = pool.waitingCount;
  const utilizationRate = totalConnections > 0
    ? (totalConnections - idleConnections) / totalConnections
    : 0;

  // 임계치 체크
  const isHealthy = utilizationRate < 0.8 && waitingRequests < 10;

  return {
    status: isHealthy ? 'healthy' : 'degraded',
    pool: {
      total: totalConnections,
      idle: idleConnections,
      active: totalConnections - idleConnections,
      waiting: waitingRequests,
      utilizationRate: (utilizationRate * 100).toFixed(2) + '%'
    },
    alerts: !isHealthy ? ['Pool utilization high or too many waiting requests'] : []
  };
}

module.exports = { pool, executeQuery, getMetrics, healthCheck };

설명

이것이 하는 일: Prometheus 메트릭을 사용하여 연결 풀의 상태를 실시간으로 수집하고, 히스토그램으로 쿼리 성능을 추적하며, health check로 시스템 상태를 판단합니다. 첫 번째로, Gauge 메트릭들이 현재 상태를 나타냅니다.

poolTotalConnections는 풀의 전체 연결 수(생성된 연결), poolIdleConnections는 사용 가능한 유휴 연결, poolWaitingRequests는 연결을 기다리는 요청 수입니다. 이 세 가지 값으로 연결 풀의 건강 상태를 한눈에 파악할 수 있습니다.

예를 들어 총 10개 중 유휴가 0이고 대기가 20이라면 심각한 연결 부족 상태입니다. 두 번째로, Histogram 메트릭이 쿼리 성능을 추적합니다.

poolQueryDuration은 각 쿼리의 실행 시간을 측정하고 버킷별로 분류합니다. 예를 들어 1ms 이하, 10ms 이하, 100ms 이하 등으로 분포를 확인할 수 있어, "전체의 95%는 10ms 이하, 5%가 100ms 이상"처럼 p95, p99 같은 백분위 수 분석이 가능합니다.

세 번째로, setInterval로 5초마다 메트릭을 갱신합니다. Prometheus는 pull 방식이라 /metrics 엔드포인트를 주기적으로 scrape하는데, 그 사이에도 최신 데이터를 유지하기 위함입니다.

pool.totalCount, pool.idleCount, pool.waitingCount는 pg 라이브러리가 제공하는 속성으로, 현재 상태를 즉시 반영합니다. 네 번째로, healthCheck 함수가 서비스 상태를 판단합니다.

utilizationRate(사용률)을 계산하여 80% 이상이거나 대기 요청이 10개 이상이면 'degraded' 상태로 표시합니다. 이를 Kubernetes의 liveness/readiness probe나 로드밸런서의 health check에 연결하면, 자동으로 트래픽을 건강한 인스턴스로 라우팅할 수 있습니다.

다섯 번째로, executeQuery 래퍼가 모든 쿼리를 측정합니다. startTimer로 타이머를 시작하고 쿼리가 끝나면 endTimer로 소요 시간을 기록합니다.

성공/실패 여부도 label로 구분하여, "전체 쿼리 중 몇 %가 에러인가"를 추적할 수 있습니다. 여러분이 이 모니터링을 사용하면 장애 발생 전에 연결 풀 문제를 감지할 수 있고, Grafana 대시보드로 시각화하여 팀 전체가 상황을 파악할 수 있으며, 히스토리 데이터로 용량 계획(언제 스케일 업 해야 하는가)을 수립할 수 있습니다.

실전 팁

💡 Grafana에서 "poolIdleConnections < 2" 같은 조건으로 알림을 설정하세요. Slack이나 PagerDuty로 즉시 통지받을 수 있습니다.

💡 쿼리 히스토그램의 버킷은 서비스 특성에 맞게 조정하세요. API 응답 목표가 100ms라면 [0.01, 0.05, 0.1, 0.5, 1]처럼 세밀하게 설정합니다.

💡 각 API 엔드포인트별로 라벨을 추가하면 어떤 API가 연결을 많이 사용하는지 파악할 수 있습니다. poolQueryDuration에 {endpoint: '/api/users'} 같은 라벨을 추가하세요.

💡 poolConnectionErrors가 급증하면 데이터베이스 자체에 문제가 있을 가능성이 높습니다. DB 서버의 CPU, 메모리도 함께 모니터링하세요.

💡 장기 트렌드 분석을 위해 Prometheus의 retention을 충분히 길게(최소 30일) 설정하고, 주간/월간 리포트를 생성하여 용량 증설 시점을 예측하세요.


7. Pool Size 최적화 전략

시작하며

여러분이 연결 풀 크기를 설정할 때 "일단 100으로 해볼까?" 같은 임의의 값을 사용하고 있지 않나요? 또는 동시 사용자 수가 1000명이니까 연결도 1000개 필요하다고 생각하시나요?

잘못된 풀 크기는 오히려 성능을 악화시킵니다. 이런 문제는 "많을수록 좋다"는 잘못된 직관에서 비롯됩니다.

연결 수가 너무 많으면 데이터베이스 서버에서 컨텍스트 스위칭이 폭증하고, 메모리 부족이 발생하며, 락 경합이 심해져 오히려 처리량이 감소합니다. 반대로 너무 적으면 요청들이 대기하면서 응답 시간이 길어집니다.

바로 이럴 때 필요한 것이 과학적인 Pool Size 최적화 전략입니다. 워크로드 특성, 하드웨어 리소스, 쿼리 실행 시간을 모두 고려한 공식과 테스트를 통해 최적값을 찾을 수 있습니다.

개요

간단히 말해서, Pool Size 최적화는 애플리케이션의 워크로드와 데이터베이스 서버의 리소스를 분석하여 최적의 연결 수를 계산하는 과정입니다. 왜 이 최적화가 중요한지 실무 관점에서 보면, 잘못된 풀 크기는 비용과 성능 모두를 낭비하기 때문입니다.

예를 들어, e커머스 사이트에서 주문 처리 시 평균 쿼리 실행 시간이 50ms라면, 초당 1000건을 처리하기 위해 필요한 연결 수는 50개입니다(1000 * 0.05 = 50). 하지만 100개로 설정하면 50개는 항상 유휴 상태로 리소스를 낭비합니다.

전통적인 방법과의 비교를 하자면, 과거에는 경험에 의존했다면, 현대적인 접근법은 Little's Law 같은 수학적 공식을 사용합니다. 기존에는 "대충 10개면 되겠지"였다면, 이제는 "TPS * 평균 응답시간 = 필요 연결 수"로 정확히 계산합니다.

Pool Size 최적화의 핵심 특징은 첫째, 워크로드 기반 계산 (OLTP vs OLAP), 둘째, 부하 테스트로 검증, 셋째, 동적 조정 가능성입니다. 이러한 특징들이 리소스를 효율적으로 사용하면서도 충분한 성능을 보장합니다.

코드 예제

// Pool Size 최적화 유틸리티
const { Pool } = require('pg');

// Pool Size 계산 함수
function calculateOptimalPoolSize(config) {
  // Little's Law: 필요 연결 수 = TPS * 평균 응답시간(초)
  const {
    targetTPS = 100,           // 목표 초당 트랜잭션 수
    avgQueryTimeMs = 50,       // 평균 쿼리 실행 시간 (ms)
    dbCpuCores = 4,            // DB 서버 CPU 코어 수
    appInstances = 3,          // 애플리케이션 인스턴스 수
    dbMaxConnections = 100     // DB max_connections 설정
  } = config;

  // 1. Little's Law 기반 계산
  const requiredConnections = Math.ceil((targetTPS * avgQueryTimeMs) / 1000);

  // 2. CPU 기반 계산 (일반적으로 코어 수 * 2~3)
  const cpuBasedConnections = dbCpuCores * 2;

  // 3. 인스턴스당 할당 가능한 최대 연결
  const maxPerInstance = Math.floor(dbMaxConnections / appInstances * 0.8); // 80%만 사용

  // 세 가지 계산 중 중간값 선택
  const calculations = [requiredConnections, cpuBasedConnections, maxPerInstance];
  calculations.sort((a, b) => a - b);
  const optimalSize = calculations[1]; // 중간값

  console.log('Pool Size 계산 결과:');
  console.log(`  Little's Law 기반: ${requiredConnections}`);
  console.log(`  CPU 기반: ${cpuBasedConnections}`);
  console.log(`  DB 한계 기반: ${maxPerInstance}`);
  console.log(`  권장 크기: ${optimalSize}`);

  return {
    min: Math.max(2, Math.floor(optimalSize * 0.2)), // 최소 20%
    max: optimalSize,
    queueLimit: optimalSize * 3 // max의 3배
  };
}

// 동적 Pool 생성 함수
function createOptimizedPool(config) {
  const poolSize = calculateOptimalPoolSize(config);

  return new Pool({
    host: process.env.DB_HOST,
    port: parseInt(process.env.DB_PORT || '5432'),
    database: process.env.DB_NAME,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,

    min: poolSize.min,
    max: poolSize.max,

    idleTimeoutMillis: 30000,
    connectionTimeoutMillis: 3000
  });
}

// 부하 테스트 함수
async function loadTest(pool, duration = 60000) {
  console.log(`부하 테스트 시작 (${duration}ms)...`);

  const startTime = Date.now();
  const results = {
    totalQueries: 0,
    successQueries: 0,
    failedQueries: 0,
    totalResponseTime: 0,
    maxResponseTime: 0,
    minResponseTime: Infinity,
    connectionErrors: 0
  };

  // 동시에 여러 쿼리 실행
  const executeQueries = async () => {
    while (Date.now() - startTime < duration) {
      const queryStart = Date.now();

      try {
        await pool.query('SELECT pg_sleep(0.05), $1::int as id', [Math.floor(Math.random() * 1000)]);

        const responseTime = Date.now() - queryStart;
        results.totalQueries++;
        results.successQueries++;
        results.totalResponseTime += responseTime;
        results.maxResponseTime = Math.max(results.maxResponseTime, responseTime);
        results.minResponseTime = Math.min(results.minResponseTime, responseTime);
      } catch (err) {
        results.totalQueries++;
        results.failedQueries++;
        if (err.message.includes('timeout') || err.message.includes('connection')) {
          results.connectionErrors++;
        }
      }

      // 약간의 간격을 두고 다음 쿼리 실행
      await new Promise(resolve => setTimeout(resolve, 10));
    }
  };

  // 10개의 동시 워커로 부하 생성
  await Promise.all(Array(10).fill(null).map(() => executeQueries()));

  // 결과 출력
  const avgResponseTime = results.totalResponseTime / results.successQueries;
  const tps = results.totalQueries / (duration / 1000);

  console.log('\n부하 테스트 결과:');
  console.log(`  총 쿼리: ${results.totalQueries}`);
  console.log(`  성공: ${results.successQueries} (${(results.successQueries/results.totalQueries*100).toFixed(2)}%)`);
  console.log(`  실패: ${results.failedQueries} (연결 에러: ${results.connectionErrors})`);
  console.log(`  TPS: ${tps.toFixed(2)}`);
  console.log(`  평균 응답시간: ${avgResponseTime.toFixed(2)}ms`);
  console.log(`  최소/최대 응답시간: ${results.minResponseTime}ms / ${results.maxResponseTime}ms`);

  return results;
}

// 사용 예시
const pool = createOptimizedPool({
  targetTPS: 200,
  avgQueryTimeMs: 50,
  dbCpuCores: 4,
  appInstances: 3,
  dbMaxConnections: 100
});

// 부하 테스트 실행 (선택적)
// loadTest(pool, 30000).then(() => pool.end());

module.exports = { calculateOptimalPoolSize, createOptimizedPool, loadTest };

설명

이것이 하는 일: 수학적 공식과 시스템 리소스를 고려하여 최적의 연결 풀 크기를 계산하고, 부하 테스트로 실제 성능을 검증합니다. 첫 번째로, calculateOptimalPoolSize가 세 가지 방법으로 연결 수를 계산합니다.

Little's Law는 큐잉 이론의 공식으로, "시스템 내 평균 항목 수 = 도착률 * 평균 체류 시간"입니다. 예를 들어 초당 100건 요청이 오고(TPS), 각 쿼리가 50ms 걸린다면 100 * 0.05 = 5개의 연결이 동시에 필요합니다.

CPU 기반 계산은 데이터베이스가 효율적으로 처리할 수 있는 동시 쿼리 수를 추정하고, DB 한계 기반은 전체 max_connections를 여러 인스턴스가 나눠 쓸 때 각자의 몫을 계산합니다. 두 번째로, 세 가지 계산 결과의 중간값을 선택합니다.

이는 보수적이면서도 효율적인 접근법입니다. 예를 들어 Little's Law가 5, CPU 기반이 8, DB 한계가 26이라면 중간값인 8을 선택합니다.

최솟값을 선택하면 피크 시간대에 부족할 수 있고, 최댓값을 선택하면 과도한 연결로 리소스를 낭비하기 때문입니다. 세 번째로, min 값을 max의 20%로 설정합니다.

이는 평균 트래픽과 피크 트래픽의 비율을 고려한 것입니다. 평소에는 min개의 연결로 충분히 처리하고, 피크 시간대에만 max까지 증가시켜 리소스를 절약합니다.

queueLimit을 max의 3배로 설정하여 일시적인 트래픽 스파이크를 흡수합니다. 네 번째로, loadTest 함수가 실제 환경을 시뮬레이션합니다.

10개의 워커가 동시에 쿼리를 실행하여 연결 풀에 부하를 가하고, 성공률, TPS, 응답 시간을 측정합니다. SELECT pg_sleep(0.05)로 50ms 쿼리를 시뮬레이션하여 실제 워크로드와 유사한 조건을 만듭니다.

이를 통해 계산한 풀 크기가 실제로 적절한지 검증할 수 있습니다. 다섯 번째로, 결과 분석이 중요합니다.

만약 failedQueries가 5% 이상이거나 connectionErrors가 빈번하다면 풀 크기를 늘려야 합니다. avgResponseTime이 목표치의 2배를 넘으면 대기 시간이 너무 길다는 뜻이므로 역시 증가가 필요합니다.

반대로 pool.idleCount가 항상 높다면 풀 크기를 줄일 수 있습니다. 여러분이 이 전략을 사용하면 추측이 아닌 데이터 기반으로 풀 크기를 결정할 수 있고, 리소스 낭비 없이 최적의 성능을 달성하며, 시스템 확장 시 과학적으로 풀 크기를 조정할 수 있습니다.

실전 팁

💡 프로덕션에 적용하기 전 스테이징 환경에서 반드시 부하 테스트를 실행하세요. 실제 워크로드와 다르면 예상치 못한 문제가 발생할 수 있습니다.

💡 avgQueryTimeMs는 실제 프로덕션 데이터에서 p95 또는 p99 값을 사용하세요. 평균값은 느린 쿼리를 무시하기 때문에 부정확합니다.

💡 OLTP(짧고 빈번한 쿼리)와 OLAP(길고 복잡한 쿼리)는 별도의 풀을 사용하세요. 둘의 최적 크기가 완전히 다릅니다.

💡 피크 시간대를 분석하여 targetTPS를 결정하세요. 평균 TPS가 100이어도 피크가 500이라면 500을 기준으로 계산해야 합니다.

💡 3개월마다 재계산하세요. 트래픽 패턴, 쿼리 복잡도, 데이터 크기가 변하면 최적 풀 크기도 달라집니다.


8. Connection Timeout 설정

시작하며

여러분이 네트워크가 불안정한 환경에서 데이터베이스 연결이 갑자기 끊어지거나, 쿼리가 무한정 대기하면서 애플리케이션이 응답 불가 상태가 된 경험이 있나요? 또는 느린 쿼리 하나가 모든 연결을 점유하여 전체 서비스가 마비된 적이 있으신가요?

이런 문제는 적절한 타임아웃 설정이 없을 때 발생하는 전형적인 장애 시나리오입니다. 타임아웃이 없으면 문제가 있는 연결이나 쿼리가 무한정 리소스를 점유하고, 새로운 요청들은 대기하면서 전체 시스템이 고착 상태(deadlock)에 빠집니다.

특히 분산 시스템에서는 한 서비스의 타임아웃 부재가 연쇄적으로 전파되어 전체 장애를 일으킬 수 있습니다. 바로 이럴 때 필요한 것이 계층적인 Connection Timeout 설정입니다.

연결, 쿼리, 트랜잭션 각 단계에 적절한 타임아웃을 설정하여 장애를 격리하고 빠르게 복구할 수 있습니다.

개요

간단히 말해서, Connection Timeout 설정은 연결과 쿼리의 각 단계에서 최대 대기 시간을 정의하여, 문제가 발생했을 때 빠르게 실패하고 복구하도록 만드는 기법입니다. 왜 이 설정이 중요한지 실무 관점에서 설명하면, "빠른 실패(fail fast)"가 "느린 성공"보다 낫기 때문입니다.

예를 들어, 결제 API에서 데이터베이스 쿼리가 5초 걸린다면 사용자는 이미 떠난 후일 가능성이 높습니다. 1초 후 타임아웃하고 재시도하거나 에러 메시지를 보여주는 것이 더 나은 사용자 경험입니다.

전통적인 방법과의 비교를 하자면, 과거에는 무한 대기하거나 매우 긴 타임아웃(30초, 60초)을 사용했다면, 현대적인 접근법은 짧은 타임아웃(1-5초)과 재시도 로직을 결합합니다. 기존에는 "언젠가는 응답이 오겠지"였다면, 이제는 "1초 내에 안 오면 다른 방법을 찾자"입니다.

Connection Timeout 설정의 핵심 특징은 첫째, 계층적 타임아웃 (연결/쿼리/트랜잭션), 둘째, 서킷 브레이커와 연동, 셋째, 재시도 전략과 결합입니다. 이러한 특징들이 장애를 국소화하고 시스템 전체의 회복탄력성(resilience)을 높입니다.

코드 예제

// 계층적 Timeout 설정
const { Pool } = require('pg');
const { retry } = require('./retry-utils');

// Timeout 설정을 명시적으로 정의
const TIMEOUTS = {
  // 1. TCP 연결 타임아웃 (데이터베이스 서버에 도달하는 시간)
  connectionTimeout: 5000, // 5초

  // 2. Pool에서 연결 획득 타임아웃 (대기 큐 시간)
  acquireTimeout: 3000, // 3초

  // 3. 쿼리 실행 타임아웃 (SQL 실행 시간)
  statementTimeout: 10000, // 10초

  // 4. 유휴 연결 타임아웃 (사용되지 않는 연결 정리)
  idleTimeout: 30000, // 30초

  // 5. 트랜잭션 타임아웃 (트랜잭션 전체 수행 시간)
  transactionTimeout: 60000 // 60초
};

// Pool 생성
const pool = new Pool({
  host: process.env.DB_HOST,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,

  max: 10,

  // 1단계: TCP 연결 타임아웃
  connectionTimeoutMillis: TIMEOUTS.connectionTimeout,

  // 4단계: 유휴 연결 타임아웃
  idleTimeoutMillis: TIMEOUTS.idleTimeout,

  // 2단계: 쿼리 실행 전 타임아웃 (pg 8.x+)
  query_timeout: TIMEOUTS.statementTimeout,

  // 연결 시 statement_timeout 설정
  options: `-c statement_timeout=${TIMEOUTS.statementTimeout}`
});

// 타임아웃이 적용된 쿼리 실행 함수
async function executeWithTimeout(query, params, options = {}) {
  const {
    timeout = TIMEOUTS.statementTimeout,
    retries = 2,
    retryDelay = 1000
  } = options;

  // 재시도 로직과 함께 실행
  return retry(async () => {
    const client = await pool.connect();

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

      // 쿼리 실행
      const result = await client.query(query, params);

      return result;
    } catch (err) {
      // 타임아웃 에러 확인
      if (err.code === '57014') { // query_canceled
        console.error(`Query timeout after ${timeout}ms: ${query}`);
        throw new Error(`Query timeout: ${query.substring(0, 50)}...`);
      }

      throw err;
    } finally {
      // 타임아웃 설정 초기화
      await client.query(`SET statement_timeout = ${TIMEOUTS.statementTimeout}`);
      client.release();
    }
  }, {
    retries,
    retryDelay,
    onRetry: (attempt, err) => {
      console.log(`Query retry ${attempt}/${retries}: ${err.message}`);
    }
  });
}

// 트랜잭션용 타임아웃 래퍼
async function executeTransaction(callback, options = {}) {
  const {
    timeout = TIMEOUTS.transactionTimeout
  } = options;

  const client = await pool.connect();

  // 트랜잭션 타이머
  const timer = setTimeout(() => {
    // 타임아웃 시 연결 강제 종료
    client.query('ROLLBACK');
    client.release();
    throw new Error(`Transaction timeout after ${timeout}ms`);
  }, timeout);

  try {
    await client.query('BEGIN');

    // 사용자 코드 실행
    const result = await callback(client);

    await client.query('COMMIT');
    clearTimeout(timer);

    return result;
  } catch (err) {
    await client.query('ROLLBACK');
    clearTimeout(timer);
    throw err;
  } finally {
    client.release();
  }
}

// 재시도 유틸리티 (간단한 구현)
async function retry(fn, options = {}) {
  const { retries = 2, retryDelay = 1000, onRetry } = options;

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt < retries) {
        if (onRetry) onRetry(attempt + 1, err);
        await new Promise(resolve => setTimeout(resolve, retryDelay * (attempt + 1)));
      } else {
        throw err;
      }
    }
  }
}

// 사용 예시
async function example() {
  // 일반 쿼리 (10초 타임아웃)
  const users = await executeWithTimeout('SELECT * FROM users WHERE id = $1', [123]);

  // 빠른 쿼리 (1초 타임아웃, 캐시 조회용)
  const cached = await executeWithTimeout(
    'SELECT * FROM cache WHERE key = $1',
    ['user:123'],
    { timeout: 1000, retries: 0 }
  );

  // 긴 쿼리 (30초 타임아웃, 리포트 생성용)
  const report = await executeWithTimeout(
    'SELECT * FROM orders WHERE created_at > $1',
    [new Date('2024-01-01')],
    { timeout: 30000, retries: 0 }
  );

  // 트랜잭션 (60초 타임아웃)
  const result = await executeTransaction(async (client) => {
    await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [100, 1]);
    await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [100, 2]);
    return { success: true };
  });
}

module.exports = { executeWithTimeout, executeTransaction, TIMEOUTS };

설명

이것이 하는 일: 데이터베이스 작업의 각 단계에서 명확한 타임아웃을 설정하고, 타임아웃 발생 시 적절히 처리하며, 필요한 경우 자동으로 재시도합니다. 첫 번째로, TIMEOUTS 객체가 모든 타임아웃 값을 중앙에서 관리합니다.

connectionTimeout은 네트워크 지연, acquireTimeout은 연결 풀 대기, statementTimeout은 쿼리 실행, idleTimeout은 유휴 연결 정리, transactionTimeout은 트랜잭션 전체를 각각 제어합니다. 이렇게 계층을 나누는 이유는 각 단계의 실패 원인과 대응 방법이 다르기 때문입니다.

예를 들어 connectionTimeout 실패는 네트워크 문제이므로 재시도가 유효하지만, statementTimeout 실패는 쿼리 자체가 느린 것이므로 쿼리 최적화가 필요합니다. 두 번째로, executeWithTimeout 함수가 쿼리별로 동적 타임아웃을 설정합니다.

SET statement_timeout으로 현재 세션의 타임아웃을 변경하여, 중요한 캐시 조회는 1초, 일반 쿼리는 10초, 대용량 리포트는 30초처럼 차별화된 설정이 가능합니다. err.code === '57014'로 타임아웃 에러를 감지하여 적절한 에러 메시지를 제공하고, 로깅/모니터링 시스템에 전달할 수 있습니다.

세 번째로, retry 함수가 일시적 장애를 자동으로 복구합니다. 네트워크 끊김이나 일시적인 DB 과부하로 인한 실패는 재시도하면 성공할 가능성이 높습니다.

retryDelay를 지수적으로 증가시켜(1초, 2초, 4초) 연속된 재시도가 DB에 과부하를 주지 않도록 합니다. onRetry 콜백으로 재시도 상황을 로깅하여 문제를 추적할 수 있습니다.

네 번째로, executeTransaction 함수가 트랜잭션 전체에 타임아웃을 적용합니다. setTimeout으로 타이머를 시작하고, 타임아웃 시 ROLLBACK을 실행하여 부분 커밋을 방지합니다.

이는 매우 중요한데, 트랜잭션이 오래 실행되면 다른 트랜잭션들이 락을 기다리면서 전체 데이터베이스가 느려질 수 있기 때문입니다. clearTimeout으로 정상 종료 시 타이머를 정리하여 메모리 누수를 방지합니다.

다섯 번째로, 사용 예시에서 볼 수 있듯이 용도에 따라 타임아웃을 조정합니다. 캐시 조회처럼 빠른 응답이 필수인 경우는 1초 타임아웃과 retries: 0(재시도 없음)으로 설정하여, 타임아웃 시 캐시 미스로 처리하고 DB에서 조회합니다.

리포트 생성처럼 긴 시간이 필요한 경우는 30초 타임아웃으로 설정하되, 이런 쿼리는 별도의 워커나 백그라운드 잡으로 분리하는 것이 더 좋습니다. 여러분이 이 설정을 사용하면 느린 쿼리가 전체 시스템을 마비시키는 것을 방지할 수 있고, 일시적 네트워크 장애에서 자동으로 복구되며, 각 기능의 SLA에 맞춰 타임아웃을 차별화하여 중요한 기능은 빠르게, 무거운 작업은 충분한 시간을 주는 유연한 시스템을 구축할 수 있습니다.

실전 팁

💡 타임아웃 값은 p95 응답 시간의 2배로 설정하세요. p95가 500ms라면 타임아웃은 1000ms입니다. 이렇게 하면 95%는 정상 처리되고 5%만 타임아웃됩니다.

💡 프로덕션에서는 타임아웃 에러를 Sentry나 Datadog APM으로 추적하세요. 어떤 쿼리가 자주 타임아웃되는지 파악하여 최적화할 수 있습니다.

💡 읽기 전용 복제본에는 더 긴 타임아웃을 설정하세요. 마스터는 쓰기 작업으로 바쁘므로 짧은 타임아웃이 필요하지만, 복제본은 분석 쿼리를 위해 여유가 필요합니다.

💡 재시도 로직에는 jitter(무작위 지연)를 추가하세요. 여러 인스턴스가 동시에 재시도하면 thundering herd 문제가 발생할 수 있습니다.

💡 타임아웃과 서킷 브레이커를 함께 사용하세요. 타임아웃이 연속 3회 발생하면 서킷을 오픈하여 일정 시간 동안 요청을 차단하고, DB가 복구될 시간을 줍니다.


9. Pool Exhaustion 해결

시작하며

여러분이 프로덕션 환경에서 갑자기 "Connection pool exhausted" 에러가 발생하고, 모든 사용자가 "503 Service Unavailable"을 받는 최악의 상황을 경험하신 적 있나요? 연결 풀이 고갈되면 새로운 요청들은 처리되지 못하고, 시스템 전체가 사실상 다운된 상태가 됩니다.

이런 문제는 연결 누수(connection leak), 느린 쿼리, 갑작스런 트래픽 증가 등 다양한 원인으로 발생합니다. 가장 흔한 원인은 개발자가 client.release()를 호출하지 않아 연결이 영구적으로 점유되는 것입니다.

단 하나의 코드 경로에서 release를 잊어도, 시간이 지나면서 점진적으로 연결이 고갈되어 결국 장애로 이어집니다. 바로 이럴 때 필요한 것이 체계적인 Pool Exhaustion 해결 전략입니다.

연결 누수 감지, 자동 복구, 백프레셔 메커니즘을 통해 연결 고갈을 예방하고, 발생 시 빠르게 복구할 수 있습니다.

개요

간단히 말해서, Pool Exhaustion 해결은 연결 풀이 고갈되는 원인을 찾아 제거하고, 고갈 상황에서도 시스템이 우아하게 성능 저하(graceful degradation)되도록 만드는 기법입니다. 왜 이 해결책이 중요한지 실무 관점에서 설명하면, 연결 고갈은 "all or nothing" 장애이기 때문입니다.

연결이 충분할 때는 완벽하게 동작하다가, 마지막 연결이 소진되는 순간 모든 요청이 실패합니다. 예를 들어, 뉴스 사이트에서 속보가 나가면 트래픽이 10배 증가하는데, 연결 풀이 고갈되면 아무도 기사를 볼 수 없게 됩니다.

전통적인 방법과의 비교를 하자면, 과거에는 장애 발생 후 서버를 재시작했다면, 현대적인 접근법은 자동 감지와 복구 메커니즘을 구축합니다. 기존에는 "왜 연결이 고갈됐지?" 하며 코드를 뒤졌다면, 이제는 연결 수명 추적, 스택 트레이스 캡처로 정확한 원인을 파악할 수 있습니다.

Pool Exhaustion 해결의 핵심 특징은 첫째, 연결 누수 자동 감지, 둘째, 큐 기반 백프레셔로 과부하 방지, 셋째, 우선순위 기반 연결 할당입니다. 이러한 특징들이 시스템을 견고하게 만들고, 장애 시에도 핵심 기능은 유지하도록 합니다.

코드 예제

// Pool Exhaustion 방지 및 해결 메커니즘
const { Pool } = require('pg');
const EventEmitter = require('events');

// 연결 추적을 위한 래퍼 클래스
class TrackedPool extends EventEmitter {
  constructor(config) {
    super();

    this.pool = new Pool(config);
    this.activeConnections = new Map(); // 연결 ID -> 메타데이터
    this.maxWaitTime = config.maxWaitTime || 5000; // 최대 대기 시간
    this.exhaustionThreshold = config.max * 0.9; // 90% 사용 시 경고

    // 연결 이벤트 추적
    this.pool.on('acquire', (client) => {
      this.trackConnection(client, 'acquire');
    });

    this.pool.on('release', (client) => {
      this.trackConnection(client, 'release');
    });

    this.pool.on('remove', (client) => {
      this.trackConnection(client, 'remove');
    });

    // 주기적으로 연결 상태 체크
    this.healthCheckInterval = setInterval(() => {
      this.checkPoolHealth();
    }, 10000); // 10초마다
  }

  // 연결 추적
  trackConnection(client, event) {
    const connectionId = client.processID;

    if (event === 'acquire') {
      // 연결 획득 시 메타데이터 저장
      this.activeConnections.set(connectionId, {
        acquiredAt: Date.now(),
        stack: new Error().stack, // 어디서 획득했는지 추적
        released: false
      });

      // 타임아웃 설정 (연결 누수 감지)
      setTimeout(() => {
        const meta = this.activeConnections.get(connectionId);
        if (meta && !meta.released) {
          console.error(`Connection leak detected! Connection ${connectionId} held for >30s`);
          console.error('Acquired at:', meta.stack);
          this.emit('connection-leak', { connectionId, meta });
        }
      }, 30000); // 30초 이상 점유 시 경고

    } else if (event === 'release' || event === 'remove') {
      const meta = this.activeConnections.get(connectionId);
      if (meta) {
        meta.released = true;
        meta.releasedAt = Date.now();
        meta.duration = meta.releasedAt - meta.acquiredAt;

        // 오래 점유한 연결 로깅
        if (meta.duration > 5000) {
          console.warn(`Long-held connection: ${meta.duration}ms`);
        }
      }

      this.activeConnections.delete(connectionId);
    }
  }

  // 연결 풀 건강 상태 체크
  checkPoolHealth() {
    const total = this.pool.totalCount;
    const idle = this.pool.idleCount;
    const waiting = this.pool.waitingCount;
    const active = total - idle;

    const utilizationRate = total > 0 ? active / total : 0;

    // 상태 로깅
    console.log(`Pool status - Total: ${total}, Active: ${active}, Idle: ${idle}, Waiting: ${waiting}, Utilization: ${(utilizationRate * 100).toFixed(1)}%`);

    // 임계치 체크
    if (active >= this.exhaustionThreshold) {
      console.warn(`Pool exhaustion warning! ${active}/${total} connections in use`);
      this.emit('pool-exhaustion-warning', { total, active, idle, waiting });
    }

    // 대기 요청이 많으면 경고
    if (waiting > 10) {
      console.error(`Too many waiting requests: ${waiting}`);
      this.emit('high-wait-queue', { waiting });
    }

    // 연결 누수 의심 (모든 연결이 오래 점유됨)
    const longHeldConnections = Array.from(this.activeConnections.values())
      .filter(meta => !meta.released && Date.now() - meta.acquiredAt > 10000);

    if (longHeldConnections.length > total * 0.5) {
      console.error(`Possible connection leak! ${longHeldConnections.length} connections held >10s`);
      this.emit('possible-leak', { count: longHeldConnections.length });
    }
  }

  // 안전한 쿼리 실행 (자동 release 보장)
  async query(text, params, options = {}) {
    const { priority = 'normal', timeout = this.maxWaitTime } = options;

    // 대기 큐가 너무 길면 빠르게 실패
    if (this.pool.waitingCount > 50) {
      throw new Error('Connection pool overloaded, please retry later');
    }

    // 타임아웃과 함께 연결 획득
    const client = await Promise.race([
      this.pool.connect(),
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error('Connection acquire timeout')), timeout)
      )
    ]);

    try {
      const result = await client.query(text, params);
      return result;
    } finally {
      // 반드시 release
      client.release();
    }
  }

  // 트랜잭션 실행 (자동 release 및 rollback 보장)
  async transaction(callback) {
    const client = await this.pool.connect();

    try {
      await client.query('BEGIN');
      const result = await callback(client);
      await client.query('COMMIT');
      return result;
    } catch (err) {
      await client.query('ROLLBACK');
      throw err;
    } finally {
      client.release();
    }
  }

  // 풀 상태 조회
  getStatus() {
    return {
      total: this.pool.totalCount,
      idle: this.pool.idleCount,
      waiting: this.pool.waitingCount,
      active: this.pool.totalCount - this.pool.idleCount
    };
  }

  // 정리
  async end() {
    clearInterval(this.healthCheckInterval);
    await this.pool.end();
  }
}

// 사용 예시
const pool = new TrackedPool({
  host: 'localhost',
  database: 'myapp',
  max: 10,
  maxWaitTime: 3000
});

// 이벤트 리스너
pool.on('connection-leak', ({ connectionId, meta }) => {
  // 알림 전송 (Slack, PagerDuty 등)
  console.error('ALERT: Connection leak detected!', connectionId);
});

pool.on('pool-exhaustion-warning', ({ active, total }) => {
  // 스케일 아웃 트리거 또는 알림
  console.warn('ALERT: Pool near exhaustion!', `${active}/${total}`);
});

// 안전한 사용
async function safeExample() {
  try {
    // query 메서드는 자동으로 release
    const users = await pool.query('SELECT * FROM users LIMIT 10');
    console.log(users.rows);

    // 트랜잭션도 자동 관리
    await pool.transaction(async (client) => {
      await client.query('UPDATE accounts SET balance = balance - 100 WHERE id = 1');
      await client.query('UPDATE accounts SET balance = balance + 100 WHERE id = 2');
    });

  } catch (err) {
    console.error('Query failed:', err.message);
  }
}

module.exports = TrackedPool;

설명

이것이 하는 일: 기본 Pool을 래핑하여 모든 연결을 추적하고, 연결 누수나 과도한 점유를 자동으로 감지하며, 안전한 API를 제공하여 개발자 실수를 방지합니다. 첫 번째로, TrackedPool 클래스가 모든 연결의 생명주기를 추적합니다.

activeConnections Map에 각 연결의 획득 시간, 획득 위치(스택 트레이스), 해제 여부를 저장합니다. new Error().stack으로 현재 스택을 캡처하여, 연결 누수 발생 시 정확히 어느 코드에서 획득했는지 알 수 있습니다.

이는 디버깅에 결정적인 도움이 됩니다. 두 번째로, 30초 타이머가 연결 누수를 자동으로 감지합니다.

연결을 획득한 후 30초가 지나도 release되지 않으면 경고를 발생시킵니다. 정상적인 쿼리는 대부분 1초 이내에 완료되므로, 30초는 명백한 누수 또는 문제가 있는 상황입니다.

'connection-leak' 이벤트를 발생시켜 모니터링 시스템으로 즉시 알림을 보낼 수 있습니다. 세 번째로, checkPoolHealth 함수가 10초마다 전체 상태를 점검합니다.

utilizationRate(사용률)을 계산하여 90% 이상이면 'pool-exhaustion-warning' 이벤트를 발생시킵니다. 이는 완전한 고갈이 임박했다는 신호로, 스케일 아웃을 트리거하거나 비필수 기능을 일시적으로 비활성화하는 등의 조치를 취할 수 있습니다.

네 번째로, query 메서드가 try-finally로 반드시 release를 보장합니다. 개발자가 직접 connect/release를 관리하지 않고 이 메서드를 사용하면, 어떤 상황(에러, 예외, 조기 return 등)에서도 연결이 반납됩니다.

이것이 연결 누수를 근본적으로 방지하는 가장 효과적인 방법입니다. 또한 waitingCount > 50 체크로 대기 큐가 너무 길면 즉시 실패하여 더 이상의 요청 누적을 막습니다.

다섯 번째로, Promise.race를 사용한 타임아웃이 무한 대기를 방지합니다. pool.connect()가 연결을 얻지 못해 계속 대기하는 상황에서, timeout 시간이 지나면 'Connection acquire timeout' 에러를 던집니다.

이를 통해 사용자에게 빠르게 에러 응답을 보내고, 다른 전략(캐시 사용, 읽기 전용 복제본 사용 등)을 시도할 수 있습니다. 여러분이 이 메커니즘을 사용하면 연결 누수를 사전에 발견하고 수정할 수 있으며, 연결 고갈 상황에서도 시스템이 완전히 멈추지 않고 일부 요청은 처리할 수 있으며, 장애 원인을 스택 트레이스로 정확히 파악하여 재발을 방지할 수 있습니다.

실전 팁

💡 개발 환경에서는 maxWaitTime을 1초로 짧게 설정하세요. 연결 누수가 있다면 즉시 발견되어 빠르게 수정할 수 있습니다.

💡 'connection-leak' 이벤트를 Sentry나 Bugsnag에 전송하세요. 스택 트레이스가 포함되어 있어 버그 수정이 쉽습니다.

💡 프로덕션에서는 pool.getStatus()를 /health 엔드포인트에 포함시켜 로드밸런서가 과부하 인스턴스를 제외하도록 하세요.

💡 연결 고갈이 빈번하다면 장기적으로 풀 크기를 늘리거나, 읽기 전용 복제본으로 부하를 분산하거나, 캐싱을 강화하세요.

💡 longHeldConnections 리스트를 주기적으로 분석하여 느린 쿼리를 찾아 최적화하세요. 이것이 근본 원인을 해결하는 방법입니다.


10. Multi-Pool 아키텍처

시작하며

여러분이 하나의 데이터베이스 연결 풀로 모든 작업(읽기, 쓰기, 리포트, 배치 작업)을 처리하면서 성능 문제를 겪고 있나요? 무거운 리포트 쿼리 하나가 전체 서비스의 응답 속도를 떨어뜨리거나, 배치 작업이 실시간 API에 영향을 주는 상황을 경험하셨나요?

이런 문제는 서로 다른 특성의 워크로드를 하나의 연결 풀로 처리할 때 필연적으로 발생합니다. 빠른 트랜잭션 처리(OLTP)와 대용량 분석 쿼리(OLAP)는 완전히 다른 리소스 요구사항을 가지고 있습니다.

하나의 풀로 관리하면 중요한 비즈니스 로직이 느린 분석 쿼리에 의해 블로킹될 수 있습니다. 바로 이럴 때 필요한 것이 Multi-Pool 아키텍처입니다.

용도별로 연결 풀을 분리하여 각 워크로드에 최적화된 설정을 적용하고, 상호 간섭을 방지할 수 있습니다.

개요

간단히 말해서, Multi-Pool 아키텍처는 하나의 데이터베이스에 대해 용도별로 여러 개의 연결 풀을 생성하여, 각 워크로드가 독립적으로 동작하도록 격리하는 기법입니다. 왜 이 아키텍처가 필요한지 실무 관점에서 설명하면, 워크로드의 우선순위와 특성이 다르기 때문입니다.

예를 들어, 결제 API는 200ms 이내에 응답해야 하지만, 월간 매출 리포트는 30초 걸려도 괜찮습니다. 이 둘이 같은 풀을 공유하면 리포트가 모든 연결을 점유하는 순간 결제가 실패합니다.

전통적인 방법과의 비교를 하자면, 과거에는 단일 풀 또는 완전히 별도의 데이터베이스를 사용했다면, Multi-Pool은 중간 지점입니다. 기존에는 "하나로 다 처리하자" 또는 "완전히 분리하자"의 양극단이었다면, 이제는 "같은 데이터베이스지만 논리적으로 분리"하여 비용은 절감하고 성능은 보장합니다.

Multi-Pool 아키텍처의


#Node.js#ConnectionPooling#KeepAlive#NetworkOptimization#Performance#connection limit 설정을 통한 네트워크 성능 향상

댓글 (0)

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