여러 PostgreSQL 풀 한 앱에서 관리하기
관리 콘솔이나 백오피스는 한 프로세스에서 콘텐츠·카탈로그·운영 로그처럼 서로 다른 DB에 접근해야 할 수 있습니다. HTTP 경유 대신 풀 직접 접속을 선택할 때는 연결 수, 권한, 장애 격리를 함께 설계해야 합니다.
목차
관리 콘솔이나 백오피스는 한 프로세스에서 콘텐츠·카탈로그·운영 로그처럼 서로 다른 DB에 접근해야 할 수 있습니다. HTTP 경유 대신 풀 직접 접속을 선택할 때는 연결 수, 권한, 장애 격리를 함께 설계해야 합니다.
1. 왜 풀을 나누는가
- 도메인 격리 — 카탈로그 백업이 콘텐츠 편집을 막지 않음
- 권한·계정 분리 — DB role이 도메인 역할과 대응
- 용량·백업 주기 분리 — 수집 DB는 매일, 콘텐츠 DB는 주 1회처럼 운영
- 외부 관리형 DB 공존 — 외부 풀러와 로컬 컨테이너의 수명주기를 분리
스키마(content, catalog)만 나누는 방법도 있지만 컨테이너·백업·권한까지 분리해야 한다면 풀 분리가 더 명확합니다.
2. 싱글톤 풀 — node-postgres 예
import { Pool } from 'pg';
export const contentPool = new Pool({
host: process.env.CONTENT_DB_HOST!,
port: Number(process.env.CONTENT_DB_PORT ?? 5432),
database: process.env.CONTENT_DB_NAME!,
user: process.env.CONTENT_DB_USER!,
password: process.env.CONTENT_DB_PASSWORD!,
ssl: sslConfig(process.env.CONTENT_DB_SSL_MODE),
max: 10,
});
export const catalogPool = new Pool({ /* CATALOG_DB_* */ });
export const operationsPool = new Pool({ /* OPERATIONS_DB_* */ });
도메인 prefix를 환경변수에 넣으면 .env를 읽기 쉽습니다. 각 풀은 프로세스 생명주기에서 하나만 만들고, 총 max 합계가 PostgreSQL의 연결 한도를 넘지 않는지 계산합니다.
3. 얇은 쿼리 헬퍼
export async function queryContent<T>(
sql: string,
params: unknown[] = [],
): Promise<T[]> {
const { rows } = await contentPool.query<T>(sql, params);
return rows;
}
export async function queryOneContent<T>(
sql: string,
params: unknown[] = [],
): Promise<T | null> {
return (await queryContent<T>(sql, params))[0] ?? null;
}
호출자가 어느 DB를 쓰는지 함수 이름으로 드러나고, SQL 파라미터·반환 타입을 한 경계에서 검증할 수 있습니다.
4. 트랜잭션 — connect() + try/finally
const client = await contentPool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
'INSERT INTO posts (...) VALUES (...) RETURNING id',
[...],
);
await client.query('COMMIT');
return rows[0].id;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
release() 누락은 풀 고갈의 대표 원인입니다. withPoolClient() 헬퍼와 트랜잭션 timeout을 함께 두면 누락과 장기 점유를 줄일 수 있습니다.
5. SSL과 종료
- 로컬·Docker 내부 통신은 환경에 맞춰 SSL을 끌 수 있음
- 클라우드는 CA 검증과
rejectUnauthorized: true를 기본으로 함 - 외부 풀러의 인증서 정책은 공급자 문서에 맞춰 별도 설정
process.on('SIGTERM', async () => {
await Promise.all([contentPool, catalogPool, operationsPool].map((pool) => pool.end()));
process.exit(0);
});
6. 도메인 라우팅 규칙
/api/content/**→contentPool/api/catalog/**→catalogPool/api/operations/**→operationsPool- 감사 로그·세션처럼 횡단하는 데이터 → 고정된 operations pool
경로와 풀의 매핑을 표로 고정하면 리뷰에서 잘못된 DB 접근을 빨리 찾을 수 있습니다. 다른 풀을 가로지르는 업무는 분산 트랜잭션으로 착각하지 말고 outbox·보상 작업·재처리 상태를 설계합니다.
7. 자주 걸리는 자리
환경변수 오타 — 누락된 host가 localhost로 조용히 대체되지 않도록 requireEnv()로 즉시 실패시킵니다.
풀 합계 초과 — 풀마다 max: 10을 주면 replica 수만큼 연결이 곱해집니다. replicas × Σmax + 관리 여유를 DB 한도와 비교합니다.
긴 트랜잭션 — 대량 작업은 별도 worker 또는 작은 전용 풀로 격리하고, 목록 API가 같은 연결을 기다리지 않게 합니다.
스크립트 종료 누락 — 일회성 명령은 마지막에 await pool.end()를 호출합니다.
하고픈 말
여러 풀은 단일 DB보다 운영 복잡도가 높습니다. 도메인별 백업·권한·장애 격리가 실제 요구일 때만 나누고, 먼저 연결 수 예산과 복구 시나리오를 문서화하세요.
Next
- postgres-first
- postgres-deep
- backend/09-audit-log-pattern
이 글에서 만나는 용어
data 카테고리의 다른 글
카테고리 전체 보기 →관련 글
검색을 ILIKE·pg_trgm·마이그레이션으로 최적화하기
콘텐츠 검색은 제목·설명·slug·lesson 본문에서 ILIKE '%검색어%'를 사용한다. 검색어를 escape하고 길이를 제한하는 것은 안전성 계약이고, wildcard 앞뒤 때문에 일반 B-tree 인덱스는 이 쿼리를 충분히 돕지 못한다.
통합 테스트의 마이그레이션 replay를 fail-closed로 만들기
통합 테스트가 스키마를 만들지 못했는데도 entity 테스트를 계속하면 실패가 지연되어 원인을 잃는다. 마이그레이션 디렉터리 누락, SQL 파일 0개, 파일 읽기 실패, 개별 SQL 오류는 모두 테스트 bootstrap 실패다.
기대 멤버 집합의 완전성 검증과 원자 upsert
묶음 결과는 행 하나가 아니라 같은 batch key에 속한 기대 멤버 전체로 판정해야 합니다. 일부 행만 존재하는 상태를 완료로 오인하지 않는 읽기·쓰기 계약을 설명합니다.
콘텐츠 초기화 동시 실행을 막는 PostgreSQL advisory lock
콘텐츠 DB 초기화는 테이블 생성·스키마 보정·정적 시드·notes/courses 업서를 순서대로 묶습니다. 운영자가 버튼을 두 번 누르거나 console replica가 겹치면, 각 단계가 멱등이어도 두 파이프라인이 중간 상태를 서로 읽을 수 있습니다.