여러 PostgreSQL 풀 한 앱에서 관리하기
여러 PostgreSQL 풀 한 앱에서 관리하기
관리자 플랫폼 · 모노레포 백오피스는 한 앱이 여러 도메인 DB 에 직접 접속해야 할 때가 있습니다. 블로그 DB · 마켓 DB · 운영 로그 DB · 외부 호환 SaaS (Supabase) 네 개를 한 프로세스가 CRUD 하는 식. HTTP 경유 대신 풀 직접 접속을 고를 때 고려할 자리들.
1. 왜 풀을 나누는가
한 DB 에 모두 담지 않는 이유.
- 도메인 격리 — 마켓 데이터 백업이 블로그 글 편집을 막지 않음
- 권한 · 계정 분리 — DB 단위 role 이 도메인 역할과 1:1
- 용량 · 백업 주기 분리 — 크롤러 DB 는 매일, 블로그 DB 는 주 1 회 같은 차등 정책
- 외부 SaaS 와 공존 — Supabase 같은 외부 관리 PG 는 자체 대역 포트 (54332 등) 를 쓰기 때문에 자연히 분리
한 DB 에 스키마로 나누는 길 (schema=marketplace; schema=blog) 도 가능하지만 컨테이너 리소스 · 백업 · 권한 분리가 아쉬울 때 풀 분리가 단순합니다.
2. 싱글톤 풀 — node-postgres 예
// db.ts
import { Pool } from 'pg';
export const dmddkslPool = new Pool({
host: process.env.DMDDKSL_DB_HOST!,
port: Number(process.env.DMDDKSL_DB_PORT ?? 5432),
database: process.env.DMDDKSL_DB_NAME!,
user: process.env.DMDDKSL_DB_USER!,
password: process.env.DMDDKSL_DB_PASSWORD!,
ssl: sslConfig(process.env.DMDDKSL_DB_SSL_MODE),
max: 10, // 풀당 커넥션 상한
});
export const pryzeetPool = new Pool({ /* PRYZEET_DB_* */ });
export const cachePool = new Pool({ /* CACHE_DB_* */ });
export const codingstairsPool = new Pool({ /* CODINGSTAIRS_DB_* */ });
export const da2ariPool = new Pool({ /* DA2ARI_DB_* */ });
환경변수 prefix (DMDDKSL_DB_* · PRYZEET_DB_* 등) 로 도메인을 분리하는 게 .env 읽기에 친절. 각 풀은 프로세스 생명주기 동안 1 개 싱글톤.
3. 얇은 쿼리 헬퍼
풀 직접 호출은 타입 유추가 약합니다. 각 풀마다 얇은 래퍼를 두면 IDE 힌트 · 코드 리뷰 · grep 모두 편해집니다.
// codingstairs-db.ts
export async function queryCodingstairs<T>(
sql: string, params: unknown[] = []
): Promise<T[]> {
const { rows } = await codingstairsPool.query<T>(sql, params);
return rows;
}
export async function queryOneCodingstairs<T>(
sql: string, params: unknown[] = []
): Promise<T | null> {
const rows = await queryCodingstairs<T>(sql, params);
return rows[0] ?? null;
}
pool.query(sql) 직접 사용은 grep 시 의도 파악이 어렵고, 타입 주석을 호출 측마다 달아야 합니다. 헬퍼로 묶으면 호출 측이 await queryCodingstairs<Post>(...) 만.
4. 트랜잭션 — connect() + try / finally
여러 테이블을 원자적으로 쓰려면 풀에서 클라이언트를 체크아웃.
export async function createPostWithTags(post, tags) {
const client = await codingstairsPool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
'INSERT INTO posts (...) VALUES (...) RETURNING id',
[...]
);
const postId = rows[0].id;
for (const tag of tags) {
await client.query(
'INSERT INTO post_tags (post_id, tag) VALUES ($1, $2)',
[postId, tag]
);
}
await client.query('COMMIT');
return postId;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release(); // 누락하면 풀 고갈
}
}
release() 누락이 풀 고갈의 대표 원인. withPoolClient<T>() 같은 헬퍼로 감싸 두면 실수를 줄일 수 있습니다.
5. SSL 설정
Supabase · RDS · 클라우드 PG 에 붙을 때 SSL 필요.
export function sslConfig(
mode?: string,
connectionString?: string
): Pool['options']['ssl'] {
const isSupabase = connectionString?.includes('.supabase.');
if (isSupabase) return { rejectUnauthorized: false };
if (mode === 'require') return { rejectUnauthorized: true };
return false; // 로컬 / Docker 간 SSL 미사용
}
클라우드는 rejectUnauthorized: true 가 안전. Supabase 는 연결 풀러가 자체 인증서를 쓰는 특수성 때문에 false 가 관례. 로컬 컨테이너 간은 SSL 없음.
6. 풀 해지 — graceful shutdown
const pools = [dmddkslPool, pryzeetPool, cachePool, codingstairsPool, da2ariPool];
process.on('SIGTERM', async () => {
await Promise.all(pools.map((p) => p.end()));
process.exit(0);
});
Next.js 서버 재시작 · Docker stop 시 요청을 끝까지 처리한 후 풀을 닫아 커넥션 누수를 방지.
7. 도메인 라우팅 규칙
"이 API 가 어느 풀을 써야 하는가" 가 명확하지 않으면 혼선. 몇 가지 규칙으로 묶어 두면 리뷰가 쉬워집니다.
/api/pryzeet/**→pryzeetPool/api/codingstairs/**→codingstairsPool/api/da2ari/**→da2ariPool- 감사로그 · 세션 · FCM 토큰 등 전역 →
dmddkslPool·cachePool같은 "cross-cutting" 풀
audit_logs 같은 전역 테이블은 한 풀에 고정하는 편이 단순. "도메인 마다 audit_logs 를 따로 두자" 는 확장은 첫 1 ~ 2 년은 오히려 과도한 경우가 많음.
8. 자주 걸리는 자리
환경변수 오타 — DMDDKSL_DB_HOST 를 DMDDSKL_DB_HOST 로 철자 실수하면 빈 문자열 → pg 가 localhost 로 연결 시도 → 이해 안 되는 에러. requireEnv() 헬퍼로 부재 시 즉시 throw.
풀 max 너무 낮음 / 높음 — 기본 10 이 많으나 동시 요청이 많은 관리자 페이지는 금방 대기. 반대로 50 + 이면 PG 쪽이 과부하. 운영 로그의 pg_stat_activity 로 실제 사용량 측정 후 조정.
같은 풀에 너무 긴 트랜잭션 — 마이그레이션 + 대량 INSERT 가 10 초 넘게 잡히면 다른 요청이 커넥션 대기. 별도 admin 풀 (max:2) 을 따로 두거나, 장시간 작업은 별도 프로세스 · 백그라운드 잡으로 분리.
풀을 import 만 하고 종료 안 함 — 로컬 스크립트 실행 시 await pool.end() 를 부르지 않으면 Node 프로세스가 커넥션 대기로 안 죽음. 스크립트 끝에 명시적 종료.
하고픈 말
여러 풀을 들고 가는 패턴은 단일 DB 보다 운영 복잡도가 확실히 높습니다. 하지만 도메인이 셋 이상이고 각기 다른 라이프사이클 (크롤링 DB 백업 · 블로그 DB revalidate · Supabase 외부 관리) 을 가지면 분리가 오히려 단순합니다. 시작은 "도메인 수 = 풀 수" 로, 필요 시 전역 cross-cutting 풀 1 ~ 2 개를 추가하는 순서가 자연스럽습니다.
Next
- postgres-first
- postgres-deep
- backend/09-audit-log-pattern
node-postgres · Supabase Connection Pooling · AWS RDS Best Practices 를 참고합니다.