2단계
여러 풀 오케스트레이션
25 분
여러 풀 오케스트레이션
도메인별 DB 분리. 한 앱이 여러 PG 풀을 다루는 실전.
1. 왜 풀을 나누는가
- 도메인 격리 — 백업 · 유지보수 · 장애 영향 분리
- 권한 분리 — 크론 전용 사용자 vs 웹 앱 사용자
- 용량 · 백업 주기 차등 — 크롤러 DB 매일, 블로그 DB 주 1회
- 외부 SaaS 공존 — Supabase CLI 등 자체 대역 포트
2. pg Pool 싱글톤
import { Pool } from "pg";
function requireEnv(name: string) {
const v = process.env[name];
if (!v) throw new Error(`env ${name} missing`);
return v;
}
export const blogPool = new Pool({
host: requireEnv("BLOG_DB_HOST"),
port: Number(process.env.BLOG_DB_PORT ?? 5432),
database: requireEnv("BLOG_DB_NAME"),
user: requireEnv("BLOG_DB_USER"),
password: requireEnv("BLOG_DB_PASSWORD"),
max: 10,
});
export const marketPool = new Pool({ /* MARKET_DB_* */ });
requireEnv 는 환경변수 오타를 런타임 첫 호출에서 잡음.
3. 얇은 쿼리 헬퍼
export async function queryBlog<T>(
sql: string, params: unknown[] = []
): Promise<T[]> {
const { rows } = await blogPool.query<T>(sql, params);
return rows;
}
호출 측 await queryBlog<Post>("SELECT ...") 만. 타입 힌트 · grep 명확.
4. 트랜잭션 래퍼
export async function withPoolClient<T>(pool: Pool, fn: (c: any) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const r = await fn(client);
await client.query("COMMIT");
return r;
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// 사용
await withPoolClient(blogPool, async (c) => {
await c.query("INSERT INTO posts ...");
await c.query("INSERT INTO post_tags ...");
});
release() 누락을 구조적으로 방지.
5. SSL 분기
function sslConfig(mode?: string, connString?: string) {
if (connString?.includes(".supabase.")) return { rejectUnauthorized: false };
if (mode === "require") return { rejectUnauthorized: true };
return false;
}
- 로컬 · docker-to-docker — SSL 없음
- 클라우드 (RDS) — rejectUnauthorized: true
- Supabase pooler — rejectUnauthorized: false (알려진 특수성)
6. graceful shutdown
const pools = [blogPool, marketPool, cachePool];
process.once("SIGTERM", async () => {
await Promise.all(pools.map(p => p.end()));
process.exit(0);
});
Docker stop 시 진행 중 쿼리 완료 후 종료.
7. 라우팅 규칙
/api/blog/* → blogPool
/api/market/* → marketPool
/api/search → (cross-domain) → blog + market 둘 다
cross-domain 쿼리는 application level join 또는 별도 search service.
8. cross-cutting 풀
감사로그 · FCM 토큰 · 세션 같이 모든 도메인에서 쓰는 데이터는 한 풀 (coreDbPool) 에.
"각 도메인마다 audit_logs 따로" 는 초반 1~2년 과도한 분리. 통합 조회가 오히려 가치.
9. 연결 수 튜닝
pg_stat_activity 로 관찰.
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
- 대부분
idle— pool max 줄여도 됨 active많음 — slow query 있나 확인- 자주
idle in transaction— 트랜잭션 미종결 누수
10. 자주 걸리는 자리
- 환경변수 오타 — requireEnv 로 즉시 실패
- max 너무 큼 — PG
max_connections초과. 앱 인스턴스 × max 총합 계산 - 긴 트랜잭션 — 다른 쿼리 대기. 별도 admin 풀 분리 고려
- 스크립트에서 pool.end() 누락 — 프로세스가 안 죽음
하고픈 말
풀 N 개는 "도메인 N 개 = 풀 N 개 + cross-cutting 1 ~ 2 개" 가 장기적으로 편합니다. 더 세분화는 실제 병목이 생긴 뒤.
Next
- 03-pgvector-hnsw