7단계
백업 자동화 — pg_dump + cron
25 분
백업 자동화 — pg_dump + cron
관리자 허브가 책임지는 데이터는 도메인마다 다르지만 "사용자 데이터 테이블 목록" 을 화이트리스트로 명시해 두면 백업 범위가 명확해집니다.
1. 화이트리스트 테이블 SSOT
// src/shared/lib/backup-db.ts
export const USER_DATA_TABLES = [
// blog domain
'posts',
'comments',
'categories',
// market domain
'users',
'posts as market_posts', // alias 로 충돌 회피
'wishlist_items',
'purchases',
'ledger_entries',
'messages',
'reports',
] as const;
상수로 두면 "무엇이 백업 대상인지" 리뷰가 쉽고, 추가 시 PR 에서 명확히 드러남.
2. pg_dump → gzip 파이프
// src/shared/lib/backup-db.ts
import { spawn } from 'node:child_process';
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
export async function backupUserData(): Promise<string> {
const date = new Date().toISOString().slice(0, 10);
const dir = path.join(process.cwd(), 'backups', 'user-data');
await mkdir(dir, { recursive: true });
const file = path.join(dir, `user-data-${date}.sql.gz`);
const out = createWriteStream(file);
const pgdump = spawn('pg_dump', [
'--host', process.env.BLOG_DB_HOST!,
'--port', process.env.BLOG_DB_PORT ?? '5432',
'--username', process.env.BLOG_DB_USER!,
'--dbname', process.env.BLOG_DB_NAME!,
'--no-owner',
'--no-privileges',
...USER_DATA_TABLES.flatMap((t) => ['-t', t]),
], { env: { ...process.env, PGPASSWORD: process.env.BLOG_DB_PASSWORD } });
const gzip = spawn('gzip', ['-c']);
pgdump.stdout.pipe(gzip.stdin);
gzip.stdout.pipe(out);
await new Promise<void>((resolve, reject) => {
gzip.on('close', (code) => code === 0 ? resolve() : reject());
pgdump.on('error', reject);
});
return file;
}
--no-owner --no-privileges: 복원 시 권한 오류 회피pg_dump → gzip파이프 로 메모리 사용량 최소PGPASSWORD는 프로세스 env 로만 전달 (로그에 찍히지 않음)
3. 7일 rolling retention
import { readdir, stat, unlink } from 'node:fs/promises';
export async function pruneOldBackups(dir: string, keepDays = 7) {
const files = await readdir(dir);
const now = Date.now();
for (const f of files) {
if (!f.endsWith('.sql.gz')) continue;
const full = path.join(dir, f);
const s = await stat(full);
const ageDays = (now - s.mtimeMs) / 86400000;
if (ageDays > keepDays) await unlink(full);
}
}
배포 환경의 디스크가 한정적이므로 기본 7일 유지. 클라우드 스토리지 (S3) 로 오프사이트 복제는 별도 파이프라인.
4. cron — node-cron + instrumentation.ts
Next.js 는 instrumentation.ts 가 서버 부팅 시 1 회 실행. 여기에 cron 을 등록.
// src/instrumentation.ts
import cron from 'node-cron';
export async function register() {
if (process.env.NEXT_RUNTIME !== 'nodejs') return;
if (process.env.DISABLE_CRON === '1') return;
cron.schedule('0 2 * * *', async () => {
try {
const { backupUserData, pruneOldBackups } = await import('@/shared/lib/backup-db');
const file = await backupUserData();
await pruneOldBackups(path.dirname(file));
logger.info('backup_ok', { file });
} catch (e) {
logger.error('backup_failed', e);
}
}, { timezone: 'Asia/Seoul' });
}
매일 02:00 KST. 프로덕션 환경변수 DISABLE_CRON=1 로 비활성화 (로컬에서 중복 실행 회피).
5. 백업 관리 UI
// src/app/admin/system/backups/page.tsx
import { readdir, stat } from 'node:fs/promises';
export default async function Page() {
const files = await readdir('backups/user-data');
const rows = await Promise.all(
files.filter((f) => f.endsWith('.sql.gz')).map(async (f) => {
const s = await stat(path.join('backups/user-data', f));
return { name: f, size: s.size, mtime: s.mtime };
})
);
return <BackupsView initialRows={rows.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())} />;
}
다운로드 버튼은 Route Handler 에서 스트림 응답.
// src/app/api/system/backups/[name]/route.ts
export async function GET(_req: NextRequest, { params }) {
const { name } = await params;
if (!/^user-data-\d{4}-\d{2}-\d{2}\.sql\.gz$/.test(name))
return new NextResponse('invalid', { status: 400 });
const file = path.join(process.cwd(), 'backups/user-data', name);
const stream = createReadStream(file);
return new NextResponse(stream as any, {
headers: {
'Content-Type': 'application/gzip',
'Content-Disposition': `attachment; filename="${name}"`,
},
});
}
파일명 검증 (regex) 은 path traversal 방지 필수.
6. 복원 절차
gunzip -c user-data-2026-05-06.sql.gz | \
psql -h localhost -p 5435 -U postgres -d blog
테스트용 DB 컨테이너를 띄워 복원 → 이상 없음 확인 → 운영 반영. 백업이 복원 가능하다는 걸 1 회 이상 검증해야 "진짜 백업".
7. 자주 걸리는 자리
pg_dump버전 ≠ PostgreSQL 서버 버전 → 에러. major 버전 일치 또는 클라이언트 ≥ 서버- 화이트리스트 누락 → 중요 테이블 미백업
- cron 타임존 없음 → UTC 로 잡혀 의도와 다른 시각
- 백업 파일 권한 644 → 다른 컨테이너가 읽어감. 600 으로 제한
하고픈 말
백업은 "설정해 두고 잊어버리는 것" 이 위험합니다. 월 1 회 정도 실제 복원 리허설을 해야 진짜 안전망이 됩니다.
Next
- 08-e2e-and-deploy