FDE 캡스톤: 창고가 같은 주문을 세 번 받았다 · 백업과 롤백 리허설 · 이론
실행해 본 적 없는 롤백 — SQLite WAL 백업과 복원 리허설
한 줄 요약
롤백 계획은 실행해 본 적이 없으면 계획이 아니라 희망이다. SQLite 를 WAL 모드로 쓰는 서비스라면 백업은 파일 복사가 아니라 .backup 이나 VACUUM INTO 로 뜨고, 마이그레이션은 한 트랜잭션에 묶고, 복원은 남은 -wal 까지 처리한 뒤 내용 해시로 백업 시점과 같음을 증명해야 끝난다.
왜 이게 필요했나
고객사 배포 전날 검토 회의에서 롤백 계획서가 돌았다. "문제가 생기면 cp /backup/orders.db /srv/app/orders.db 로 되돌린다." 모두 고개를 끄덕였고, 다음 날 마이그레이션이 중간에 실패했다. 계획서대로 복사했더니 서비스가 올라왔고, 한 시간 뒤 고객 지원팀이 물었다. "어제 저녁 주문이 안 보여요." 백업은 서비스가 떠 있는 채로 cp 로 뜬 것이었고, 복원 뒤에는 되돌렸어야 할 급한 수정이 그대로 살아 있었다.
명령 자체가 틀린 게 아니었다. 그 명령이 이 서비스의 파일 배치에서 무엇을 하는지 아무도 실행해 보지 않았다. FDE 가 고객 현장에서 해야 할 일은 계획서를 쓰는 것이 아니라 리허설을 돌리고 증거를 남기는 것이다. 백업은 온전한가, 마이그레이션이 실패하면 흔적이 남는가, 복원하면 정말 그 시점으로 돌아가는가 — 각각을 숫자로 보여 줘야 한다.
어떻게 동작하나
1. WAL 파일은 데이터베이스의 일부다. [WAL 문서](https://www.sqlite.org/wal.html)에 따르면 WAL 모드에서는 변경이 본파일이 아니라 -wal 파일에 덧붙고, 커밋도 거기에 기록된다. 체크포인트가 그 내용을 본파일로 옮기는데, 기본은 WAL 이 1000 페이지에 이르렀을 때 자동으로 돈다. WAL 파일은 연결이 열려 있는 동안 남고, 보통 마지막 연결이 닫힐 때 지워진다. 그리고 문서는 WAL 이 데이터베이스의 영속 상태이므로 복사·이동할 때 함께 다녀야 하며, 떨어지면 커밋된 트랜잭션을 잃거나 파일이 손상될 수 있다고 분명히 적는다.
실측으로 확인하면 무섭다. 주문 500건을 본파일에 넣고, 연결을 연 채(자동 체크포인트 끔) 137건을 더 커밋한 뒤 본파일만 cp 했다. 사본에는 500건이 있었고 PRAGMA integrity_check 는 ok 였다. 파일은 멀쩡하고 커밋만 조용히 빠진다. 검사가 통과했다는 사실이 백업이 온전하다는 증거가 아니다.
2. 온라인 백업은 두 가지. [백업 API 문서](https://www.sqlite.org/backup.html)는 백업을 조금씩 복사하면서 읽는 동안만 잠그고, 결과가 복사를 시작한 시점의 원본과 같은 사본이라고 설명한다. 다른 연결이 도중에 쓰면 보통 처음부터 다시 복사한다. CLI 의 .backup 이 이 API 를 쓴다. [VACUUM 문서](https://www.sqlite.org/lang_vacuum.html)의 VACUUM INTO 는 원본의 일관된 스냅샷을 최소 크기의 새 파일로 쓰는 대안이다. 대상 파일이 이미 있으면(빈 파일이 아니면) 실패하고, 도중에 전원이 나가면 결과가 불완전할 수 있다.
둘은 결과 파일이 다르다(실측: sqlite3 3.45.1). 637건이 든 WAL DB 에서 .backup 사본은 머리가 WAL 모드로 남았고 VACUUM INTO 사본은 롤백 저널(delete) 모드였다. 파일 sha256 도 서로 다르다. 그런데 [CLI 문서](https://www.sqlite.org/cli.html)의 .sha3sum 은 디스크 표현이 아니라 내용의 해시라서 VACUUM 같은 변환에 바뀌지 않는다. --schema 를 주면 스키마까지 넣는다. 두 사본의 .sha3sum --schema 는 같았다. "같은 시점의 같은 데이터인가" 는 파일 해시가 아니라 이 값으로 묻는다.
3. 마이그레이션은 한 트랜잭션, 판은 user_version. [PRAGMA 문서](https://www.sqlite.org/pragma.html)에 따르면 user_version 은 파일 머리 60번 오프셋의 정수이고 SQLite 자신은 쓰지 않는 애플리케이션용 값이다. 스키마 판 번호로 쓰기 좋다. 마이그레이션 내용과 user_version 올리기를 한 트랜잭션에 넣으면 둘이 함께 되거나 함께 안 된다.
여기에 함정이 둘 있다. CLI 문서는 CLI 가 기본적으로 오류 뒤에도 다음 명령을 계속 실행하고, -bail 을 줘야 멈춘다고 적는다. [ON CONFLICT 문서](https://www.sqlite.org/lang_conflict.html)는 기본 해결 방식인 ABORT 가 그 문장만 되돌리고, 같은 트랜잭션의 앞 문장은 남기며 트랜잭션도 살려 둔다고 설명한다. 두 사실이 겹치면 실측에서 이렇게 된다.
| 적용 방법 | 세 번째 문장이 UNIQUE 위반일 때 |
| --- | --- |
| sqlite3 db < migration.sql | 앞의 ALTER·UPDATE 가 남음 |
| BEGIN; …; PRAGMA user_version=3; COMMIT; 를 -bail 없이 | 앞 문장이 남은 채 COMMIT, user_version 도 3 |
| 같은 내용을 sqlite3 -bail 로 | 종료 코드 1, 열·user_version 모두 그대로 |
[트랜잭션 문서](https://www.sqlite.org/lang_transaction.html)의 BEGIN IMMEDIATE 는 쓰기 잠금을 시작할 때 잡으므로, 다른 쓰기가 진행 중이면 중간이 아니라 처음에 실패한다.
4. 복원은 남은 -wal 부터. 서비스가 강제 종료되면 연결이 닫히지 않아 -wal 이 남는다. 그 상태에서 백업을 본파일 위에 cp 하면, 다음에 열 때 남은 WAL 이 복원한 파일 위에 적용된다. 실측에서 user_version 은 백업 값으로 돌아왔는데 되돌렸어야 할 급한 수정 행이 그대로 보였다. 복원 전에 -wal·-shm 을 치우거나 CLI 의 .restore 로 SQLite 를 거쳐 덮고, 끝나면 .sha3sum --schema 를 백업의 값과 비교한다.
리허설 한 바퀴 .backup → integrity_check · 행 수 · .sha3sum --schema · user_version 기록 migrate(N) → user_version N migrate(깨진 N+1) → 실패해야 하고, 내용 해시가 그대로여야 한다 restore(백업) → 내용 해시 = 백업, user_version = 백업현장에서 만나는 모습
"백업은 매일 돌고 있습니다" 라는 말은 대개 cron 이 cp 를 돈다는 뜻이다. 서비스가 조용한 새벽이라 대부분 괜찮았고, 괜찮지 않았던 날은 아무도 몰랐다. 리허설에서 백업 행 수를 서비스가 커밋한 수와 비교해 보이는 순간 대화가 바뀐다.
마이그레이션도 비슷하다. 개발 DB 에서 통과한 파일이 운영 데이터에서 중간에 실패하는 일은 흔하다(고객당 주문이 하나뿐인 개발 데이터에서는 UNIQUE 인덱스가 만들어진다). 그때 흔적이 남지 않는다는 것을 미리 보여 주면, 고객은 "실패하면 되돌리면 된다" 가 아니라 "실패해도 아무것도 바뀌지 않는다" 를 믿고 배포 창을 연다.
실무에서 진짜 중요한 것
- 서비스가 떠 있는 WAL DB 는 파일 복사로 백업하지 않는다. integrity_check 가 ok 여도 커밋이 빠질 수 있다.
- 백업의 증거는 행 수, integrity_check, 내용 해시(.sha3sum --schema), user_version 네 가지를 함께 남긴다. 파일 sha256 은 "그 파일이 안 바뀌었다" 의 증거이지 "같은 데이터다" 의 증거가 아니다.
- 마이그레이션은 내용과 user_version 을 한 트랜잭션에, CLI 는
-bail로. 이미 적용된 판은 아무것도 하지 않고, 앞 판이 아니면 거절한다. - 복원은 남은 -wal 을 처리하고 내용 해시로 증명해야 끝난다.
- 실패 주입이 실패하지 않으면 그 리허설은 아무것도 증명하지 못한다.
다음 실습에서 할 것
WAL 모드로 도는 주문 서비스를 띄워 cp 백업이 무엇을 잃는지 재고, .backup·VACUUM INTO 로 백업한 뒤 기준값을 남긴다. 판 번호를 지키는 migrate.sh 와 망가진 백업을 거절하는 restore.sh 를 만들고, 배포 중 강제 종료가 난 운영 DB 를 실제로 복원한다. 마지막으로 백업부터 복원 증명까지 한 번에 도는 rehearse.sh 를 만들면, 채점기가 -wal 이 남은 새 DB 로 그 리허설을 돌려 본다.