지난 글에서 PostgreSQL을 S3에 백업하는 기본 파이프라인을 만들었다. 하지만 그게 끝이 아니었다.
진짜 백업 시스템은 다음 세 가지가 더 있어야 완성된다.
- 오래된 백업 자동 정리 (라이프사이클)
- 복구가 진짜 되는지 검증 (복구 테스트)
- 매일 자동 실행 (cron)
이번에 이 셋을 다 끝냈다. 그리고 그 과정에서 또 몇 번 부딪혔다.
라이프사이클 — 백업 권한 없이 자동 삭제
오래된 백업은 30일 후 자동으로 지우고 싶었다. 처음 떠올린 방법은 "스크립트가 30일 지난 객체를 찾아서 aws s3 rm으로 지우게 한다"였다.
그런데 이 방법은 두 가지 문제가 있다.
- 백업 사용자에게 DeleteObject 권한을 줘야 한다. 일부러 빼놨던 권한
- 자격증명 노출 시 백업이 통째로 날아갈 수 있다. 랜섬웨어 시나리오에 다시 노출.
S3 라이프사이클이 이 문제를 정확히 해결한다. 사람이나 스크립트의 권한 없이 AWS가 직접 객체를 정리한다.
콘솔에서 설정한 규칙:
규칙 이름: delete-old-postgres-backups
접두사: postgres/
현재 버전: 30일 후 만료
이전 버전: 7일 후 영구 삭제
postgres/ 접두사 덕분에 다른 백업(예: 이미지) 영역에 영향이 없다. 버전 관리를 켜뒀기 때문에 "현재 버전 만료"와 "이전 버전 영구 삭제"를 분리해서 설정해야 한다.
이렇게 두면 백업 사용자는 영원히 PutObject, GetObject만 가지고 있으면 된다. 삭제는 AWS의 책임 영역으로 위임한 셈이다.
Discord 알림 — 실패해도 알아야 한다
자동화의 가장 무서운 시나리오는 백업이 1년 동안 멈춰있었는데 모르고 있는 것이다.
스크립트에 Discord 웹훅 알림을 붙였다. 성공/실패 모두 같은 채널로.
send_discord() {
local color=$1
local title=$2
local description=$3
[ -z "${DISCORD_WEBHOOK_URL:-}" ] && return 0
curl -s -H "Content-Type: application/json" \
-d "{
\"embeds\": [{
\"title\": \"${title}\",
\"description\": \"${description}\",
\"color\": ${color},
\"fields\": [
{ \"name\": \"호스트\", \"value\": \"$(hostname)\", \"inline\": true },
{ \"name\": \"DB\", \"value\": \"${PG_DATABASE}\", \"inline\": true }
],
\"footer\": { \"text\": \"홈서버 백업\" },
\"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"
}]
}" \
"$DISCORD_WEBHOOK_URL" > /dev/null 2>&1 || true
}
설계에서 신경 쓴 점.
호스트/DB 필드 분리 — 한 채널에 여러 서버 알림이 모이는 경우, 메시지 본문 안 봐도 어디서 온 알림인지 한눈에 보인다.
|| true 처리 — 알림 실패가 백업 스크립트 전체를 중단시키면 안 된다. Discord가 잠시 죽어도 백업은 정상 진행되어야 한다.
에러 라인 번호 포함 — trap 'on_error $LINENO' ERR로 어느 라인에서 실패했는지 알림에 박는다. 디버깅 시간이 줄어든다.
테스트로 한 번 돌려보니 임베드 카드가 잘 도착했다.
✅ DB 백업 완료
파일: appdb-20260518-161658.sql.gz
크기: 4.0K
소요시간: 2초
위치: postgres/2026/05/18/...
호스트: lab-main DB: appdb
홈서버 백업 • 오후 4:17
복구 테스트
여기가 핵심이다. 복구 안 해본 백업은 그냥 디스크 낭비다.
흔히 빠지는 함정이 있다. 매일 백업이 잘 만들어진다고 안심하고 있다가, 진짜 사고 났을 때 백업 파일이 깨져있다는 걸 발견하는 시나리오. 1년 동안 차곡차곡 쌓인 디스크 낭비.
이걸 막으려면 복구 자체를 자동화해야 한다. 그래서 복구 스크립트도 같이 만들었다.
흐름은 단순하다.
- S3에서 백업 파일 다운로드
- SHA256 메타데이터로 체크섬 검증
- 격리된 테스트 DB(appdb_restore_test) 생성
- 복원 실행
- 원본 DB와 row count 자동 비교
- 결과 표로 출력
핵심은 5번 row count 비교다. 단순히 "복원이 에러 없이 끝났다"가 아니라 "원본과 같은 행 수가 들어갔다"까지 검증해야 진짜 복구다.
# 복구된 테이블 순회하면서 양쪽 count 비교
while IFS= read -r table; do
RESTORED_COUNT=$(... "$TARGET_DB" -tAc "SELECT COUNT(*) FROM ${table};")
ORIGINAL_COUNT=$(... "$PG_DATABASE" -tAc "SELECT COUNT(*) FROM ${table};")
if [ "$RESTORED_COUNT" = "$ORIGINAL_COUNT" ]; then
STATUS="✅"
else
STATUS="❌"
ALL_MATCH=false
fi
printf " %-30s %15s %15s %s\n" \
"$table" "$RESTORED_COUNT" "$ORIGINAL_COUNT" "$STATUS"
done <<< "$TABLES"
첫 시도 — 또 pg_hba.conf
~/scripts/restore-postgres.sh postgres/2026/05/18/appdb-20260518-064458.sql.gz
[3/5] 타겟 DB 재생성: appdb_restore_test...
psql: error: FATAL: no pg_hba.conf entry for host "100.109.34.112",
user "appuser", database "appdb_restore_test"
또 그 에러. 이번엔 appdb_restore_test라는 새 DB명에서 막혔다.
pg_hba.conf에 적어둔 규칙을 다시 보자.
host appdb appuser 100.64.0.0/10 scram-sha-256
appdb라는 DB명을 명시했기 때문에 다른 이름의 DB로는 접근이 막힌 거였다. 복구 테스트는 매번 다른 이름의 임시 DB를 만들 텐데, 그때마다 규칙을 추가하는 건 비현실적이다.
해결책은 두 가지였다.
옵션 A: 매번 새 DB명을 pg_hba.conf에 추가 옵션 B: DB명 부분을 all로 변경
host all appuser 100.64.0.0/10 scram-sha-256
운영 환경이라면 옵션 A(명시적 매칭)가 보안상 맞다. 하지만 Tailscale 사적 네트워크는 어차피 신뢰 영역이고, 새 DB를 만들 때마다 설정 파일을 건드는 건 운영 부담이 크다.
이건 보안과 운영 편의의 트레이드오프다. MVP 단계니까 옵션 B로 갔다. 사용자 규모가 커지면 백업 전용 사용자(backupuser)를 별도로 만들고 그 사용자만 모든 DB 접근 가능하게 분리하는 게 다음 단계.
두 번째 시도 — 성공
~/scripts/restore-postgres.sh postgres/2026/05/18/appdb-20260518-064458.sql.gz
═══════════════════════════════════════════════════════
PostgreSQL 복구 시작
Source: s3://exhibition-platform-backups-dev/postgres/2026/05/18/...
Target: appdb_restore_test @ 100.111.88.20:5432
═══════════════════════════════════════════════════════
[1/5] S3에서 다운로드... → 4.0K
[2/5] SHA256 체크섬 검증... → 일치 (a6c4843203c0fbb9...)
[3/5] 타겟 DB 재생성: appdb_restore_test... → 준비 완료
[4/5] 복원 중... → 완료
[5/5] 데이터 검증...
→ 복원된 테이블 + row count:
TABLE RESTORED ORIGINAL
------------------------------ --------------- ---------------
flyway_schema_history 1 1 ✅
social_accounts 0 0 ✅
users 0 0 ✅
═══════════════════════════════════════════════════════
✅ 복구 성공 (3초)
모든 테이블의 row count가 원본과 일치
═══════════════════════════════════════════════════════
모든 테이블 ✅. 백업과 복구가 자동으로 검증되는 시스템이 완성됐다.
이게 진짜 백업이다. 막연히 "백업 잘 돌아가겠지" 하는 게 아니라, 매번 검증된 상태로 백업이 쌓인다.
cron 등록 — 매일 자동화
마지막으로 cron 등록.
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# 매일 03:00 KST 백업
0 3 * * * source /home/max/.backup-env && /home/max/scripts/backup-postgres.sh >> /home/max/logs/backup.log 2>&1
cron 함정 두 가지를 미리 피했다.
SHELL과 PATH 명시 — cron은 user 환경을 안 읽는다. 명시 안 하면 aws, pg_dump 같은 명령어가 not found로 죽는다.
절대경로 사용 — cron은 home 디렉토리 모른다. ~/scripts/... 같은 경로 대신 /home/max/scripts/...로 적어야 한다.
>> ~/logs/backup.log 2>&1로 stdout과 stderr 모두 로그 파일로 보낸다. cron이 메일로 에러를 보내는 기본 동작 대신 파일로 모으는 게 깔끔하다.
정리 — 백업 시스템의 진짜 모습
지난 글의 기본 파이프라인에 이번 글의 세 가지(라이프사이클, 복구 테스트, 자동화)가 더해진 최종 모습.
컴포넌트 역할
| 매일 자동 백업 | RPO 24시간 보장 |
| SHA256 + 사이즈 검증 | 백업 무결성 |
| 자동 복구 테스트 | 복구 가능성 검증 |
| IAM에서 DeleteObject 제외 | 랜섬웨어 방어 |
| S3 라이프사이클 30일 | 운영 부담 0 |
| Discord 알림 | 5분 이내 장애 감지 |
| Tailscale 기반 접근 | 외부 노출 0 |
| 월 비용 | $0.10 미만 |
이번에 가장 크게 배운 건 두 가지다.
보안과 운영 편의는 자주 충돌한다. pg_hba.conf에서 DB명을 명시하면 보안은 강해지지만 새 DB 만들 때마다 수정해야 한다. 어디서 균형 잡을지는 결국 단계와 신뢰 도메인에 따라 다르다.
자동화는 검증까지 자동화해야 의미 있다. 백업이 매일 도는 것보다 매번 복구 가능한지 확인되는 게 훨씬 중요하다. 검증 안 된 자동화는 가짜 안정감만 준다.
다음 단계는 이거다.
- AWS Standby 환경에 PostgreSQL Logical Replication (사용자 1000명+ 단계)
- Route 53 헬스체크 + Failover Routing
- 백업 전용 IAM 사용자 분리 (운영 단계)
- 복구 테스트 자체도 주 1회 자동 실행 (cron으로 검증 강화)
'인프라' 카테고리의 다른 글
| REST는 되는데 채팅만 안 됐다 — nginx WebSocket 문제 (0) | 2026.05.30 |
|---|---|
| 미니 페일오버 VPC 설계 (0) | 2026.05.22 |
| PostgreSQL 자동 백업 시스템 구축기: 홈서버 DB를 S3로 (0) | 2026.05.18 |