WordPress DB 복원 뒤 첨부파일 누락을 찾는 검사 만들기
사이트 품질 점검에서 기존 롤백 백업을 다시 보니 DB dump와 plugins archive는 있었지만, uploads의 원본 첨부파일까지 복원됐는지 판정할 근거는 없었다. DB 복원 확인과 별개로 원본 첨부파일의 누락을 찾는 검사가 필요했다. 격리 환경에서 DB import는 종료 코드 0으로 끝났지만, uploads를 넣기 전 의도적인 DB-only 대조군에서는 첨부 참조 373개가 모두 missing_file이었다. 대응하는 uploads를 복사하고 같은 manifest로 다시 검사하자 373개를 확인했고 실패는 0개였다. 해결은 DB import 성공과 원본 미디어 검사를 서로 다른 통과 조건으로 두는 것이었다.
이 글은 2026년 9월 16일 KST에 공개 고정 커밋·테스트·로그와 WordPress 공식 문서를 대조하고, Python 3.12.3의 새 venv --without-pip에서 합성 fixture를 직접 재실행한 기능 구현기다. 공개 fixture로 재현할 수 있는 범위는 텍스트 첨부파일 1개의 누락과 일치 판정이다. 373개 결과는 공개된 운영 기록을 검토한 값이며 원시 DB와 미디어는 공개되지 않았으므로 같은 수치를 독자가 독립 재현할 수는 없다.
롤백 자료를 전체 복구 증거로 부르지 않았다
WordPress Backups 공식 문서는 일반적인 사이트 백업을 database와 files로 구분한다. uploads는 core, themes, plugins, wp-config.php, .htaccess 등과 함께 files 쪽에 속한다. SQL dump를 문제없이 import했다는 사실은 데이터베이스를 읽을 수 있다는 뜻이지, uploads까지 돌아왔다는 뜻이 아니다.
WordPress가 첨부 URL을 만들 수 있다는 사실도 파일 복원의 증거가 되지 않는다. wp_get_attachment_url()은 attachment post의 _wp_attached_file 값을 읽어 현재 uploads의 base URL과 결합하고, 필요하면 GUID로 대체한다. DB에 경로가 남아 URL 문자열이 계산되더라도 그 경로에 파일이 있는지, 원본과 같은 바이트인지는 별도로 확인해야 한다.
여기서 버린 선택은 두 가지였다. 기존 DB·플러그인 롤백 자료를 전체 재해복구 세트로 재정의하지 않았고, 복원된 uploads 자체에서 기대 해시를 새로 만들지도 않았다. 후자는 이미 빠졌거나 바뀐 파일을 정상값으로 승인하는 순환 검증이 될 수 있기 때문이다. 대신 대응 시점의 DB 참조 목록과 독립된 원본 uploads를 기준으로 삼았다.
검사는 snapshot과 audit으로 책임을 나눴다
기능 요구사항은 좁게 잡았다. “DB가 참조하는 원본 첨부파일이 복원 uploads에 존재하고 원본과 같은 바이트인지 확인하되, 전체 사이트 복구 성공을 주장하지 않는다”는 것이다. 이 요구사항과 구현, 테스트, 합성 fixture, 격리 복구 기록은 1d641891... 커밋에 함께 들어갔다.
첫 단계인 snapshot은 DB 스냅샷에서 얻은 첨부 상대경로 목록과 그 시점에 대응하는 원본 uploads를 받는다. 각 원본 파일의 상대경로, 바이트 수, SHA-256을 manifest에 쓴다. 원본부터 빠져 있으면 source_attachment_missing으로 실패하므로, 없는 파일을 기대 목록에서 조용히 제외하지 않는다.
둘째 단계인 audit은 고정된 manifest를 복원한 uploads와 비교한다. 파일이 없으면 missing_file, 크기나 SHA-256이 다르면 content_mismatch로 나눈다. 모두 일치하면 종료 코드 0, 하나라도 검증에 실패하면 종료 코드 1이다. 결과 파일은 open('x')로 생성해 같은 경로의 기존 증거를 덮어쓰지 않는다. 전체 구현은 고정된 audit_backup_media.py에서 확인할 수 있다.
manifest가 곧 신뢰 기준이 되므로 입력 검증도 함께 넣었다. 절대경로, .., 비정규 경로 표현, 역슬래시, NUL, 루트와 하위 경로의 심볼릭 링크, uploads 밖으로 해석되는 경로를 거부한다. schema와 scope, 참조 수, 중복 레코드, 64자리 소문자 16진수 SHA-256, 음수가 아닌 정수 크기도 검사한다. Python에서는 bool이 int의 하위형이므로 크기 값은 type(value) is int로 제한했다. 이 경계들은 고정 단위 테스트에 남아 있다.
같은 manifest가 DB-only 상태와 복원 뒤를 갈랐다
공개 격리 복구 기록에서는 WordPress 7.0.4·MariaDB 10.11.18 원본 관측을 바탕으로, 네트워크와 공개 포트가 없는 MariaDB 10.11.19 대상에 새 DB export를 import했다. import 종료 코드는 0이었고 48개 테이블, 공개 글 9개, 초안 113개, attachment 373개가 확인됐다. 이 숫자는 전체 wp_posts 행 수가 아니다.
그다음 같은 manifest로 상태만 바꿔 비교했다. uploads를 복사하기 전에는 373개가 빠졌고, 복사한 뒤에는 373개 확인·실패 0개가 됐다. 전자는 실제 장애가 아니라 DB만 복원했을 때 검사가 실패하는지 확인하려고 만든 대조군이다. 시간 순서와 원문은 격리 복구 기록에 공개돼 있다.
후속 공개 로그와 이번 직접 재실행에서는 test_symlink_refused를 포함한 단위 테스트 12개가 통과했다. 같은 1파일 manifest에 빈 복원 디렉터리와 fixture uploads를 차례로 대입한 결과도 실패와 성공을 분리했다. 아래 출력에서 가운데 종료 코드 1은 누락을 제대로 잡았다는 예상 결과다. 임시 절대경로는 공개용 표시로 정제했다.
$ python -m unittest tests.test_backup_media -v
...
Ran 12 tests in 0.195s
OK
exit_code=0
$ python scripts/audit_backup_media.py snapshot ...
{"operation": "snapshot", "passed": true, "files": 1, "whole_site_restore_verified": false}
$ python scripts/audit_backup_media.py audit --uploads [빈 복원 디렉터리] ...
{"operation": "audit", "passed": false, "files": 1, "whole_site_restore_verified": false}
$ python scripts/audit_backup_media.py audit --uploads evidence/lab-fixtures/backup-media/uploads ...
{"operation": "audit", "passed": true, "files": 1, "whole_site_restore_verified": false}
snapshot_exit=0 missing_exit=1 matching_exit=0

before.json에는 passed=false와 reason=missing_file, after.json에는 passed=true와 빈 failures가 기록됐다. 두 결과 모두 whole_site_restore_verified=false다. 공식 WordPress 문서가 말하는 DB와 files의 구분은 이 관측과 일치하지만, SHA-256 manifest 형식이나 0/1/0 종료 코드 규칙은 WordPress의 약속이 아니라 이 검사기의 구현 선택이다.
구현 이후 f4bc7b4... 기록은 테스트 재실행 결과와 기존 373개 전후 결과를 고정했다. 이것은 별도의 373파일 복구를 다시 수행한 증거가 아니다. 이어진 635186a... 커밋은 기능 코드를 바꾸지 않고 새 checkout에서 실행할 README를 보강했다.
독자 실행은 합성 fixture에서 시작한다
복사 실행용 전체 절차는 635186a...에 고정된 README를 기준으로 삼는다. 먼저 공개 저장소를 새로 받고 검증한 revision을 checkout한다. 저장소 clone과 Python 준비에는 네트워크가 필요할 수 있다.
git clone https://github.com/sungpyo9053/blog.git
cd blog
git checkout 635186a4d3f6c9546ec0edd3f9d0e10ae670d26e
Python 3.11 이상이 설치돼 있어야 한다. 기본 python3가 더 오래된 버전이면 실제 설치된 인터프리터를 BACKUP_PYTHON으로 지정한다. 다음 블록은 고정 README의 두 번째 Bash 블록 원문 전체이며, 블록 SHA-256은 e8a4b647ffdce225c6fb9a58deacc6fdaad5c7293834feb4dd2b371e204e02d2다. 기존 .venv를 재사용하지 않고 새 상대 디렉터리 ./backup-demo.XXXXXX에 pip 없는 venv를 만든다. fixture 실행 자체에는 외부 Python 패키지나 인증정보가 필요 없다.
(
set -eu
backup_python="${BACKUP_PYTHON:-python3}"
command -v "$backup_python" >/dev/null 2>&1 || {
printf '%s\n' 'Select an installed Python 3.11+ using BACKUP_PYTHON.' >&2
exit 1
}
"$backup_python" -c 'import sys; print("Python", sys.version.split()[0]); sys.exit(0 if sys.version_info >= (3, 11) else "Python 3.11+ required; select BACKUP_PYTHON and retry")'
test -f scripts/audit_backup_media.py || {
printf '%s\n' 'Run this block from the blog repository root.' >&2
exit 1
}
backup_demo_dir=$(mktemp -d ./backup-demo.XXXXXX)
"$backup_python" -m venv --without-pip "$backup_demo_dir/venv"
backup_runner="$backup_demo_dir/venv/bin/python"
"$backup_runner" -c 'import sys; assert sys.version_info >= (3, 11)'
if "$backup_runner" scripts/audit_backup_media.py snapshot \
--uploads evidence/lab-fixtures/backup-media/uploads \
--input evidence/lab-fixtures/backup-media/references.json \
--output "$backup_demo_dir/manifest.json"; then
backup_snapshot_exit=0
else
backup_snapshot_exit=$?
fi
test "$backup_snapshot_exit" -eq 0 || exit 1
if "$backup_runner" scripts/audit_backup_media.py audit \
--uploads "$backup_demo_dir/missing-uploads" \
--input "$backup_demo_dir/manifest.json" \
--output "$backup_demo_dir/before.json"; then
backup_missing_exit=0
else
backup_missing_exit=$?
fi
test "$backup_missing_exit" -eq 1 || exit 1
if "$backup_runner" scripts/audit_backup_media.py audit \
--uploads evidence/lab-fixtures/backup-media/uploads \
--input "$backup_demo_dir/manifest.json" \
--output "$backup_demo_dir/after.json"; then
backup_matching_exit=0
else
backup_matching_exit=$?
fi
test "$backup_matching_exit" -eq 0 || exit 1
printf 'snapshot_exit=%s missing_exit=%s matching_exit=%s\n' \
"$backup_snapshot_exit" "$backup_missing_exit" "$backup_matching_exit"
printf 'Evidence directory: %s\n' "$backup_demo_dir"
)
2026년 9월 16일 KST에 이 블록을 새 HTTPS checkout 루트에서 문자 그대로 실행했다. Python 3.12.3과 revision 635186a4d3f6c9546ec0edd3f9d0e10ae670d26e에서 전체 종료 코드는 0이었고, 실제 출력은 다음과 같았다. 마지막 상대 디렉터리명은 실행할 때마다 달라진다.
Python 3.12.3
{"operation": "snapshot", "passed": true, "files": 1, "whole_site_restore_verified": false}
{"operation": "audit", "passed": false, "files": 1, "whole_site_restore_verified": false}
{"operation": "audit", "passed": true, "files": 1, "whole_site_restore_verified": false}
snapshot_exit=0 missing_exit=1 matching_exit=0
Evidence directory: ./backup-demo.Xn00Ib
whole_block_exit=0
정상 판정은 블록 전체 종료 코드 0과 위에 나온 단계별 종료 코드 0·1·0을 함께 확인하는 것이다.
가운데 1만 예상한 실패다. 함께 생성되는 before.json에서는 missing_file을, after.json에서는 passed: true와 빈 failures를 확인한다. 두 파일의 whole_site_restore_verified가 모두 false인지도 봐야 한다. 다른 비정상 종료를 누락 검사의 예상 실패로 간주해서는 안 된다.
실제 백업에 적용할 때는 순서를 바꾸지 않는다.
- 복구 대상과 대응하는 DB 스냅샷에서 원본 첨부 상대경로 목록을 구한다.
- 같은 시점에 대응하는 독립된 원본 uploads에서 manifest를 만든다.
- 복원한 별도 uploads 루트를
audit으로 읽기 전용 검사한다. missing_file이나content_mismatch가 하나라도 있으면 원본 첨부파일 복원은 실패로 판정한다.- failures가 0이어도 전체 사이트 복구 검증은 별도로 계속한다.
설치마다 uploads 위치가 같다고 가정해서도 안 된다. wp_upload_dir()은 upload_path, UPLOADS, WP_CONTENT_DIR, 연월 폴더 설정 등에 따라 경로와 URL이 달라질 수 있음을 보여준다. 실제 설치에서 결정된 uploads 루트를 검사기에 전달해야 한다.
원본 파일 0건 실패 뒤에도 확인할 것이 남는다
이 검사가 확인하는 것은 DB 참조 목록에 포함된 원본 첨부파일의 존재, 크기, SHA-256뿐이다. wp_get_attachment_metadata()가 별도 sizes 정보로 다루는 생성 이미지 크기와 원격 저장소로 옮긴 미디어는 포함하지 않는다. 테마, 플러그인, 설정, 서버 호환성, URL 재작성, WordPress/PHP 시작, 브라우저 렌더링, 실제 HTTP 제공도 검사 대상이 아니다.
더 근본적인 한계도 있다. 검사기는 DB에서 뽑은 참조 목록이 완전한지, manifest를 만든 원본의 신원이 맞는지 인증하지 않는다. DB transaction만으로 파일 시스템까지 같은 순간에 동결되는 것도 아니므로 DB와 files를 일관된 시점에 캡처하는 책임은 백업 절차에 남는다.
따라서 이 도구를 붙일 위치는 “전체 복구 성공” 버튼이 아니라 DB import 다음의 원본 미디어 통과 조건이다. 고정 README의 합성 fixture로 먼저 0/1/0을 재현한 뒤, 운영 백업에는 같은 시점의 DB 참조와 독립된 원본 uploads로 별도 manifest를 만든다. 그 결과가 0건 실패여도 WordPress 시작과 HTTP 이미지 제공 검사는 다음 단계로 남겨야 한다.
제휴·협찬은 없다.