02 — Connection Pool

질문: TypeORM의 connection pool은 어디에서 결정되며, 어떤 손잡이를 돌려야 하고, 연결이 새는 단 하나의 이유는 무엇인가? 한 줄 답: TypeORM의 풀은 드라이버(pg-pool, mysql2 등)의 풀을 얇게 감싼 wrapper다. 가장 중요한 손잡이는 poolSize·extra.idleTimeoutMillis·extra.max이고, leak은 거의 100% QueryRunner.release() 누락에서 온다.


Why — 왜 풀이 있나

데이터베이스 연결 한 개는 수십 KB의 메모리 + TCP 핸들을 점유한다. 매 요청마다 연결을 새로 열고 닫는 모델은 다음 비용을 낸다:

  1. TCP handshake — RTT 한 번 (수 ms)
  2. TLS handshake — RTT 두 번 더
  3. 인증 protocolstartup 메시지·password challenge·AuthOk
  4. prepared statement 캐시 — 새 연결은 빈 캐시에서 시작

PostgreSQL 기준 연결 한 개 만드는 데 50~200ms가 든다. 그래서 연결을 미리 만들어 두고 빌려 쓰는 pool이 필수다.


How — 풀 설정의 위치

TypeORM은 드라이버 풀 라이브러리를 직접 만들지 않는다. 드라이버 패키지가 가져오는 풀을 그대로 쓴다.

DB드라이버 패키지내장 풀 라이브러리
PostgreSQLpgpg-pool
MySQL / MariaDBmysql2mysql2 자체
MSSQLmssqltedious + 내장
Oracleoracledb내장
SQLitesqlite3풀 없음 (파일 잠금)

TypeORM은 그 풀에 두 종류의 옵션을 노출한다:

new DataSource({
  type: 'postgres',
  host: '...',
 
  // ✅ TypeORM이 표준화한 옵션 — DB 무관
  poolSize: 10,
 
  // ✅ 드라이버에 그대로 전달되는 패스스루
  extra: {
    max: 10,                          // pg-pool: 최대 연결
    min: 2,                           // pg-pool: 최소 유지
    idleTimeoutMillis: 30_000,        // 30초간 idle이면 닫음
    connectionTimeoutMillis: 5_000,   // acquire 대기 timeout
    statement_timeout: 10_000,        // 단일 쿼리 timeout (PG)
    application_name: 'app-prod',     // pg_stat_activity에 표시
  },
});

핵심: poolSize만으로 부족한 세부 설정은 전부 extra로 넘긴다. extra드라이버 옵션 그대로이므로 각 드라이버 문서를 참조해야 한다.


What — 풀 사이즈를 정하는 공식

흔히 코어 수와 같게 하라는 조언이 돌지만, 그건 DB 서버 측 코어의 이야기다. 앱 서버 측 poolSize는 다음 공식:

poolSize = ceil(피크 동시 트랜잭션 수 × 평균 쿼리 시간 / 인스턴스 수)

예시:

시나리오평균 쿼리피크 RPS인스턴스 수권장 poolSize
일반 CRUD API50ms200/s4약 5~10
무거운 트랜잭션500ms100/s4약 15~20
분석 쿼리 혼재2000ms50/s2약 30~50

과도하게 크면:

  • DB 서버 측 max_connections 한계에 부딪힌다 (PostgreSQL 기본 100).
  • 각 연결이 수십 MB의 work_mem을 점유한다.
  • idle 연결이 많아 좀비처럼 남는다.

과도하게 작으면:

  • acquire 대기로 p99 latency 폭발.
  • Connection terminated due to connection timeout 에러.

일반 가이드: DB의 max_connections × 0.5 ÷ 앱 인스턴스 수 이상으로 가지 마라. PgBouncer 같은 외부 풀러를 앞에 두면 앱 풀은 작아도 된다.


What — Pool 메트릭과 모니터링

PostgreSQL 측:

-- 현재 연결 상태
SELECT application_name, state, count(*)
FROM pg_stat_activity
GROUP BY application_name, state;
 
-- idle in transaction이 보이면 — leak
SELECT pid, application_name, state, query_start, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY query_start;

TypeORM 측 (드라이버를 직접 들여다봐야 한다):

import { DataSource } from 'typeorm';
 
const ds = new DataSource({ /* ... */ });
await ds.initialize();
 
// pg 드라이버의 풀에 직접 접근
const pool = (ds.driver as any).master; // pg.Pool 인스턴스
console.log({
  total: pool.totalCount,    // 풀에 있는 전체 연결
  idle: pool.idleCount,      // idle 상태 연결
  waiting: pool.waitingCount, // acquire 대기 큐
});

TypeORM은 풀 메트릭을 표준 API로 노출하지 않는다. 모니터링이 필요하면 드라이버를 직접 캐스팅하거나 PgBouncer 같은 외부 풀러의 메트릭을 쓰는 게 현실적이다.


What-if — leak이 발생하는 단 하나의 패턴

production에서 connection pool exhausted 에러가 떴다면 — 95% 이상 다음 한 패턴이다.

// ❌ leak 그 자체
async function deductBalance(userId: string, amount: number) {
  const qr = AppDataSource.createQueryRunner();
  await qr.connect();
  await qr.startTransaction();
 
  const user = await qr.manager.findOne(User, { where: { id: userId } });
  if (user.balance < amount) {
    throw new Error('Insufficient balance');
    // ❌ 여기서 throw — release가 호출되지 않는다
    // ❌ 이 QueryRunner는 풀로 돌아오지 않는다
  }
 
  user.balance -= amount;
  await qr.manager.save(user);
  await qr.commitTransaction();
  await qr.release(); // ❌ throw 경로에서는 도달하지 않음
}

이 코드는 insufficient balance가 발생할 때마다 연결 한 개를 영구히 잃는다. 30분만 돌면 풀이 비고 모든 요청이 timeout된다.

정답:

// ✅ try/finally 강제
async function deductBalance(userId: string, amount: number) {
  const qr = AppDataSource.createQueryRunner();
  await qr.connect();
  await qr.startTransaction();
 
  try {
    const user = await qr.manager.findOne(User, { where: { id: userId } });
    if (user.balance < amount) {
      throw new InsufficientBalanceError();
    }
    user.balance -= amount;
    await qr.manager.save(user);
    await qr.commitTransaction();
  } catch (err) {
    await qr.rollbackTransaction();
    throw err;
  } finally {
    await qr.release(); // ✅ 어떤 경로든 반드시 호출
  }
}

더 나은 정답transaction() 콜백을 쓰면 release를 TypeORM이 한다:

// ✅✅ 콜백 패턴 — release를 의식할 필요가 없다
async function deductBalance(userId: string, amount: number) {
  await AppDataSource.transaction(async (manager) => {
    const user = await manager.findOne(User, { where: { id: userId } });
    if (user.balance < amount) throw new InsufficientBalanceError();
    user.balance -= amount;
    await manager.save(user);
  });
}

콜백 안에서 throw가 나면 자동으로 rollback + release. 95%의 경우 이 패턴으로 충분하다 — QueryRunner를 직접 들어야 할 때만 위의 try/finally를 쓴다 (자세한 건 05 — QueryRunner 심화).


What — Leak을 구조적으로 막는 패턴

대형 코드베이스에서는 팀 규칙으로 다음 둘 중 하나를 정해두는 게 안전하다:

  1. 금지 룰: createQueryRunner()절대 직접 호출하지 않는다. 모든 트랜잭션은 dataSource.transaction() 콜백으로만.
  2. 래퍼 강제: createQueryRunner()반드시 일정 헬퍼 함수 안에서만 호출하고, 그 함수가 try/finally를 책임진다.

Insight — TypeORM의 풀은 철학이 아니라 위임이다

TypeORM은 풀 알고리즘 자체에 대해 의견을 거의 가지지 않는다. 드라이버의 풀을 그대로 쓰고, 두 개의 옵션(poolSize, extra)만 노출한다.

이게 좋은 결정인 이유:

  • pg-pool은 Node.js 진영에서 가장 검증된 풀이다 (Heroku가 만들었다).
  • mysql2의 풀은 내장이 더 빠르다는 벤치마크가 있다.
  • TypeORM이 자체 풀을 만들었다면 드라이버 업데이트마다 다시 검증해야 했을 것이다.

이게 제약을 만드는 이유:

  • 풀 메트릭을 표준 API로 못 얻는다 — 드라이버를 캐스팅해야 한다.
  • 풀 동작이 DB마다 다르다 — pg-pool과 mysql2는 acquire 큐 동작이 미묘하게 다르다.
  • extra 옵션이 typed가 아니다any로 통과한다.

TypeORM은 연결 정체성·트랜잭션 표현·메타데이터에 집중하고, 풀은 드라이버에 맡긴다. 이 분업이 0.3의 또 다른 설계 결정이다.


흥미로운 이야기 — Why PgBouncer

production scale에서 앱 풀만으로는 부족한 경우가 흔하다.

  • 앱 인스턴스가 20개인데 각 풀이 10이면 200 연결이 DB로 향한다.
  • PostgreSQL의 max_connections는 기본 100, AWS RDS도 typically 200~500.
  • 인스턴스를 오토스케일하면 예측 불가능한 연결 폭증이 일어난다.

해결책은 앱 풀 앞에 외부 풀러를 두는 것 — PgBouncer(PostgreSQL), ProxySQL(MySQL)이 표준이다.

앱 인스턴스 (poolSize=5) × 20  →  PgBouncer (pool_size=20)  →  PostgreSQL (max_connections=100)
                 100 connections                                   20 connections

PgBouncer는 transaction 단위로 연결을 재사용한다. 트랜잭션이 끝날 때마다 물리 연결을 다른 클라이언트에게 넘긴다. 이게 동작하려면 세션 상태에 의존하는 기능을 쓸 수 없다SET LOCAL, prepared statements, LISTEN/NOTIFY 등.

TypeORM에서 PgBouncer를 쓸 때 주의할 점:

new DataSource({
  type: 'postgres',
  host: 'pgbouncer.internal',
  port: 6432,
  // ⚠️ PgBouncer transaction pooling 모드에서는 prepared statement 비활성화
  extra: {
    max: 5,
    statement_timeout: 0,
  },
  // typeorm 0.3+
  // prepareDatabase: ... 같은 옵션 확인
});

이게 왜 production에서 풀이 두 층이 되는지에 대한 답이다 — 앱 풀은 코드 가까이, 외부 풀러는 DB 가까이에서 각각의 역할을 한다.


요약 (Pyramid Top 재정렬)

TypeORM의 풀은 드라이버 풀의 얇은 래퍼다.

  1. poolSize로 시작, 세부는 extra로 패스스루.
  2. 메트릭은 드라이버를 직접 찔러야 얻는다 — TypeORM은 노출하지 않는다.
  3. leak은 거의 항상 QueryRunner.release() 누락transaction() 콜백을 기본 패턴으로 쓰면 발생 불가.
  4. scale-out 시 PgBouncer/ProxySQL을 앱 풀과 DB 사이에 둔다.

다음 문서는 이 풀 위에서 트랜잭션을 표현하는 3가지 방법과 각각의 위치를 다룬다.

다음: 03 — 트랜잭션 3가지 방법transaction() · QueryRunner · @Transaction deprecated.