02 — Connection Pool
질문: TypeORM의 connection pool은 어디에서 결정되며, 어떤 손잡이를 돌려야 하고, 연결이 새는 단 하나의 이유는 무엇인가? 한 줄 답: TypeORM의 풀은 드라이버(pg-pool, mysql2 등)의 풀을 얇게 감싼 wrapper다. 가장 중요한 손잡이는
poolSize·extra.idleTimeoutMillis·extra.max이고, leak은 거의 100%QueryRunner.release()누락에서 온다.
Why — 왜 풀이 있나
데이터베이스 연결 한 개는 수십 KB의 메모리 + TCP 핸들을 점유한다. 매 요청마다 연결을 새로 열고 닫는 모델은 다음 비용을 낸다:
- TCP handshake — RTT 한 번 (수 ms)
- TLS handshake — RTT 두 번 더
- 인증 protocol —
startup메시지·password challenge·AuthOk - prepared statement 캐시 — 새 연결은 빈 캐시에서 시작
PostgreSQL 기준 연결 한 개 만드는 데 50~200ms가 든다. 그래서 연결을 미리 만들어 두고 빌려 쓰는 pool이 필수다.
How — 풀 설정의 위치
TypeORM은 드라이버 풀 라이브러리를 직접 만들지 않는다. 드라이버 패키지가 가져오는 풀을 그대로 쓴다.
| DB | 드라이버 패키지 | 내장 풀 라이브러리 |
|---|---|---|
| PostgreSQL | pg | pg-pool |
| MySQL / MariaDB | mysql2 | mysql2 자체 |
| MSSQL | mssql | tedious + 내장 |
| Oracle | oracledb | 내장 |
| SQLite | sqlite3 | 풀 없음 (파일 잠금) |
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 API | 50ms | 200/s | 4 | 약 5~10 |
| 무거운 트랜잭션 | 500ms | 100/s | 4 | 약 15~20 |
| 분석 쿼리 혼재 | 2000ms | 50/s | 2 | 약 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을 구조적으로 막는 패턴
대형 코드베이스에서는 팀 규칙으로 다음 둘 중 하나를 정해두는 게 안전하다:
- 금지 룰:
createQueryRunner()를 절대 직접 호출하지 않는다. 모든 트랜잭션은dataSource.transaction()콜백으로만. - 래퍼 강제:
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 connectionsPgBouncer는 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의 풀은 드라이버 풀의 얇은 래퍼다.
poolSize로 시작, 세부는extra로 패스스루.- 메트릭은 드라이버를 직접 찔러야 얻는다 — TypeORM은 노출하지 않는다.
- leak은 거의 항상
QueryRunner.release()누락 —transaction()콜백을 기본 패턴으로 쓰면 발생 불가.- scale-out 시 PgBouncer/ProxySQL을 앱 풀과 DB 사이에 둔다.
다음 문서는 이 풀 위에서 트랜잭션을 표현하는 3가지 방법과 각각의 위치를 다룬다.
다음: 03 — 트랜잭션 3가지 방법 —
transaction()·QueryRunner·@Transactiondeprecated.