가이드 / DB 백업 검증
DB 백업이 진짜 됐는지
검증하는 법
백업 스크립트가 에러 없이 끝났다는 것과 복구할 수 있다는 것은 완전히 다른 이야기입니다. 실제로 사고는 대부분 이런 식으로 납니다:
- 덤프는 만들어졌는데 0바이트거나 헤더만 있음 (DB 접속 실패인데 종료 코드는 0)
- 디스크가 차서 중간에 잘린 파일 — 열어보기 전엔 모름
- 스케줄이 엉뚱한 시간에 돌아 매일 같은 시점을 덮어씀
- 백업은 완벽한데 복구를 한 번도 해본 적이 없음
공통점은 사고가 난 날에야 알게 된다는 것입니다. 아래 5단계를 자동화해두면 그날이 오기 전에 알 수 있습니다.
1단계. 파일이 실제로 생겼는가
가장 기본이지만 빠뜨리기 쉽습니다. set -e를 넣어 중간에 실패하면 즉시 멈추게 하고, 파이프 중간 실패도 잡히도록 set -o pipefail을 함께 씁니다.
#!/bin/bash
set -euo pipefail # pipefail이 없으면 pg_dump 실패를 놓친다
OUT=~/backups/db-$(date +%F).sql.gz
pg_dump -U myuser mydb | gzip > "$OUT"
test -s "$OUT" # 0바이트면 여기서 실패2단계. 크기가 갑자기 변하지 않았는가
데이터가 쌓이는 서비스라면 백업 크기는 완만하게 커집니다. 어제보다 절반 이하로 작아졌다면 뭔가 잘못된 겁니다. 실제 저희 백업 크기 추이를 보면:
3,765 uptime-2026-08-03.sql.gz
37,671 uptime-2026-08-04.sql.gz
61,594 uptime-2026-08-05.sql.gz
89,539 uptime-2026-08-06.sql.gz ← 꾸준히 증가 = 정상어제 파일과 비교해 50% 미만이면 경고를 내도록 해두면, 빈 덤프·잘린 파일을 그날 잡습니다.
PREV=$(ls -t ~/backups/*.sql.gz | sed -n 2p)
if [ -n "$PREV" ]; then
NEW_SIZE=$(stat -c%s "$OUT")
OLD_SIZE=$(stat -c%s "$PREV")
if [ "$NEW_SIZE" -lt $((OLD_SIZE / 2)) ]; then
echo "백업 크기 급감: $OLD_SIZE → $NEW_SIZE" >&2
exit 1
fi
fi3단계. 압축 파일이 깨지지 않았는가
디스크가 차거나 프로세스가 죽으면 파일은 남지만 내용이 잘립니다. gzip은 자체 검사가 가능하니 매번 검사하세요. 수 초면 끝납니다.
gzip -t "$OUT" # 깨졌으면 여기서 실패
# 덤프 끝부분이 정상적으로 닫혔는지까지 확인 (pg_dump는 마지막에 이 줄을 남긴다)
zcat "$OUT" | tail -5 | grep -q "PostgreSQL database dump complete"4단계. 진짜로 복구되는가 (핵심)
여기까지 통과해도 복구는 별개 문제입니다. 확장 기능이 없거나 권한이 달라 복구가 실패하는 경우가 흔합니다. 확실한 방법은 하나뿐 — 임시 DB에 실제로 넣어보는 것. 주 1회 정도면 충분합니다.
RESTORE_DB="restore_check_$(date +%s)"
createdb -U myuser "$RESTORE_DB"
zcat "$OUT" | psql -U myuser -d "$RESTORE_DB" -v ON_ERROR_STOP=1 -q
# 핵심 테이블의 행 수가 원본과 비슷한지 확인 (0이면 사고)
ROWS=$(psql -U myuser -d "$RESTORE_DB" -tAc 'SELECT count(*) FROM "User"')
echo "복구된 사용자 수: $ROWS"
[ "$ROWS" -gt 0 ] || { echo "복구본이 비었다" >&2; exit 1; }
dropdb -U myuser "$RESTORE_DB"ON_ERROR_STOP=1이 중요합니다. 없으면 psql이 오류를 뿜으면서도 끝까지 진행하고 종료 코드 0을 돌려줘서, 실패한 복구를 성공으로 착각하게 됩니다.
5단계. 스크립트가 그날 돌긴 했는가
1~4단계를 아무리 잘 만들어도 스크립트 자체가 안 돌면 전부 무의미합니다. 그리고 이건 서버 안에서는 알 수 없습니다 — 안 돈 일은 로그도 안 남으니까요.
그래서 마지막 줄에 외부로 신호를 보냅니다. 검증까지 모두 통과했을 때만 호출되므로,“오늘 백업이 검증까지 끝났다”는 뜻이 됩니다:
curl -fsS --retry 3 --retry-connrefused \
--connect-timeout 5 --max-time 15 \
https://batch.hkch.co.kr/p/YOUR_TOKEN예정 시각까지 이 신호가 안 오면 디스코드·슬랙·텔레그램으로 알림이 갑니다. 스크립트가 실패해서든, 서버가 꺼져서든, 크론이 안 돌아서든 결과는 하나 — 백업이 없다는 것이고, 그걸 그날 알게 됩니다.
실제로 저희가 당한 사고 두 건
① 백업이 9시간 어긋나 돌고 있었다.클라우드 서버가 UTC라 “새벽 4시 백업”이 실제로는 한국 시간 오후 1시에 돌고 있었습니다. 트래픽이 있는 시간대에 덤프를 뜨고 있었던 셈이죠. 모니터링 알림으로 발견했습니다.
② 고쳤다고 믿은 수정이 사실 안 먹혔다. 위 문제를 CRON_TZ=Asia/Seoul로 고쳤는데 — 우분투 기본 cron은 CRON_TZ를 지원하지 않습니다. 조용히 무시돼서 여전히 UTC로 돌고 있었고, 이번엔 예정 시각에 신호가 안 와서 알림이 왔습니다. 결국 시각을 UTC로 환산해(0 19 * * * = 한국 04:00) 해결했습니다.
두 번 다 서버 로그만 봤으면 몰랐을 일입니다. 자세한 시간대 함정은 crontab이 안 돌아갈 때 체크리스트 8번을 참고하세요.
전체 스크립트
#!/bin/bash
set -euo pipefail
DB=mydb
USER=myuser
OUT=~/backups/db-$(date +%F).sql.gz
# 1. 백업
pg_dump -U "$USER" "$DB" | gzip > "$OUT"
test -s "$OUT"
# 2. 크기 급감 검사
PREV=$(ls -t ~/backups/*.sql.gz | sed -n 2p || true)
if [ -n "${PREV:-}" ]; then
[ "$(stat -c%s "$OUT")" -ge $(( $(stat -c%s "$PREV") / 2 )) ] \
|| { echo "백업 크기 급감" >&2; exit 1; }
fi
# 3. 무결성
gzip -t "$OUT"
zcat "$OUT" | tail -5 | grep -q "PostgreSQL database dump complete"
# 4. 복구 리허설 (주 1회만: 일요일)
if [ "$(date +%u)" = "7" ]; then
T="restore_check_$(date +%s)"
createdb -U "$USER" "$T"
zcat "$OUT" | psql -U "$USER" -d "$T" -v ON_ERROR_STOP=1 -q
dropdb -U "$USER" "$T"
fi
# 5. 오래된 백업 정리 후, 여기까지 왔을 때만 신호
find ~/backups -name "*.sql.gz" -mtime +14 -delete
curl -fsS --retry 3 --retry-connrefused --connect-timeout 5 --max-time 15 \
https://batch.hkch.co.kr/p/YOUR_TOKENcrontab 등록 (서버가 UTC라면 한국 새벽 4시는 0 19):
0 19 * * * ~/bin/backup-verify.sh >> ~/logs/backup.log 2>&1표현식이 헷갈린다면 cron 표현식 검사기에서 다음 실행 시각을 먼저 확인해 보세요.
정리
- set -euo pipefail — 파이프 앞단 실패를 놓치지 않기
- 크기 비교 — 빈 덤프·잘린 파일을 그날 발견
- gzip -t + 종료 문구 확인 — 무결성
- 주 1회 복구 리허설 — “복구된다”를 실제로 증명 (ON_ERROR_STOP 필수)
- 성공 시에만 외부 신호 — 스크립트가 안 돈 날을 잡는 유일한 방법