codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
에듀›중앙 관리자 플랫폼 — 여러 도메인을 한 허브에서›7단계

7단계

백업 자동화 — pg_dump + cron

0회 조회

백업 자동화 — 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

← 6단계

감사로그 — logAdminAction

8단계 →

E2E 매니페스트 + 배포