복구 사전검증이 통과해도 아직 복원할 수 없는 이유

읽는 데 10분
목차

백업 파일의 해시가 맞고 복구 대상 디렉터리도 안전해 보인다면, 이제 파일을 덮어써도 될까. 최근 로컬 프로젝트에서 복구 절차를 나누어 구현하면서 이 질문을 먼저 다뤘다. 당시에는 운영 복구 사고가 발생한 것이 아니었다. 복구 후보를 검증하는 코드를 만드는 과정에서 테스트와 검토가 드러낸 실패 가능성을 차례로 막은 작업이었다.

상태 파일 여러 개를 함께 복원해야 하는 시스템을 생각해 보자. 백업에는 파일마다 존재 여부, 크기, 해시가 적혀 있다. 어떤 파일이 원래 없었다면 복구 후에도 없어야 한다. 단순히 백업에 있는 파일만 기존 디렉터리에 덮어쓰면 이전 세대의 파일이 남는다. 예컨대 과거 권한 정보가 현재 세대에 다시 나타날 수 있다. 따라서 복원은 파일 복사 몇 번이 아니라, 한 세대의 정확한 목록을 현재 상태에 적용하는 작업이다.

복원할 목록부터 확정한다

첫 검증에서는 복구 입력이 하나의 완전한 세대인지 확인한다.

확인 항목통과 조건
세대호출자가 지정한 세대와 manifest가 일치
파일 목록프로그램이 관리하는 전체 파일 목록과 정확히 일치
실제 바이트기록된 길이와 SHA-256에 일치
스키마현재 프로그램이 지원하는 버전

스키마 표식이 현재 프로그램보다 앞선 버전을 요구하면 멈춘다. 테스트에서는 같은 크기로 내용만 바꾼 파일도 거부되도록 했다. 파일 크기 검사만으로는 이런 변경을 잡아낼 수 없기 때문이다.

존재하지 않는 파일도 목록의 일부다. 백업의 “없음”을 단순한 누락으로 처리하면, 복구 대상에 남아 있는 이전 파일을 지워야 하는지 판단할 근거가 사라진다. 반대로 예상하지 못한 파일이 스테이징 디렉터리에 끼어 있으면 복구 입력을 구성한 사람이 의도한 세대와 실제 디렉터리의 내용이 다르다. 사전검증은 이 두 경우를 모두 입력 불일치로 취급한다.

같은 경로가 같은 파일은 아니다

다음은 경로와 파일의 정체성이다. 검증 시점에 디렉터리의 권한이 안전해 보여도, 경로를 다시 열 때 다른 디렉터리를 가리키면 결과가 달라진다. 실제 회귀 사례에는 쓰기 가능한 상위 디렉터리, 논리 경로의 심볼릭 링크 재지정, 스테이징 파일과 실제 파일의 동일 inode 문제가 있었다.

사전검증은 스테이징과 실제 상태를 서로 다른 디렉터리로 제한하고, 열어 둔 디렉터리에서 파일을 읽으며, 열기 전후의 파일 정체성과 내용을 대조한다. 이 프로젝트의 첫 지원 범위는 하나의 실제 부모 디렉터리와 같은 장치에 있는 별도 스테이징 디렉터리였다. 더 복잡한 디렉터리 배치는 추측해서 처리하지 않고 거부했다.

여기서 핵심 CS 개념은 TOCTOU다. Time of Check와 Time of Use 사이에 같은 이름이 다른 대상을 가리킬 수 있다.

Go 팀의 파일 경로 설명은 심볼릭 링크를 먼저 검사한 뒤 다시 이름으로 파일을 열면, 그 사이 경로가 바뀔 수 있음을 예로 든다. Go의 os.Root 문서는 열린 루트 아래에서만 파일 작업을 수행하는 계약과 플랫폼별 한계를 설명한다.

열린 디렉터리를 기준으로 접근하는 것은 경로 문자열을 한 번 검사해 둔 것보다 강한 경계다. 그러나 모든 상태가 이후에도 그대로 있으리라는 보증은 아니다.

검증 함수가 반환된 뒤의 시간

검증 함수가 성공을 반환한 직후를 보자. 운영자는 결과를 읽고 잠시 뒤 적용 명령을 실행할 수 있다. 그 사이 다른 프로세스가 스테이징 파일을 교체하거나 실제 상태를 변경할 수 있다. 검증 함수가 파일을 열어 확인한 순간과 적용 함수가 파일을 바꾸는 순간은 별개의 사건이다. 같은 함수 안에서 마지막에 한 번 더 해시를 확인해도 반환 후의 시간 간격은 사라지지 않는다.

검증 결과를 “복원 허가증”처럼 저장해 두면 바로 이 틈을 놓친다.

이번 구현에서는 사전검증의 범위를 분명히 했다. 사전검증은 스테이징 세대와 실제 대상의 현재 관측 상태만 보고한다. 잠금 파일이나 복구 일지를 만들지 않으며, 실제 데이터를 바꾸지도 않는다. 테스트는 성공 전후의 파일 내용·권한·inode·디렉터리 항목이 같은지 확인했다. 변경을 주입한 테스트에서는 검증 중 바뀐 payload와 경로 재지정을 거부했다.

저장소의 로컬 검사는 통과했지만, 이는 실제 복원 성공이나 장애 후 재시작을 증명하지 않는다.

적용 단계에 남은 계약

복구를 실행하는 단계에는 별도 계약이 필요하다. 협력하는 쓰기 프로세스를 막는 잠금을 검증 전에 잡고, 그 잠금을 유지한 채 열어 둔 디렉터리와 실제 바이트를 다시 확인해야 한다. 그다음 중간에 멈췄다는 사실을 재시작 후에도 알 수 있는 기록, 일부 파일만 바뀐 상태를 격리하거나 되돌릴 절차가 있어야 한다.

MITRE의 CWE-367 완화 지침도 잠금을 검사 뒤가 아닌 검사 전에 확보하라고 한다. 다만 협력 잠금은 그 규칙을 따르는 프로세스 사이에서만 작동한다. 임의의 외부 쓰기, 원격 파일시스템의 잠금 의미, 전원 손실까지 자동으로 해결하지 않는다.

이 단계는 아직 별도로 남아 있다. 따라서 검증을 통과한 백업이라도 지금의 구현만으로 “안전하게 적용 가능”이라고 말할 수 없다. 검증 가능성과 실행 가능성은 다른 상태다. 두 상태를 한 개의 초록색 체크로 합치면, 도구가 아직 가지지 않은 보장을 사용자에게 약속하게 된다.

배포·이관·삭제에도 같은 틈이 있다

같은 질문은 복구 밖에서도 유용하다. 배포 전에 읽은 이미지 태그가 배포 순간에도 같은 이미지를 가리키는가. 이관 전에 읽은 데이터베이스 행이 쓰기 순간에도 같은 버전인가. 파일 삭제 전에 본 소유자가 삭제 순간에도 소유자인가. 전이 단계에서 검사할 항목은 세 가지다.

무엇을 확인했는가, 그 확인이 언제까지 유효한가, 실제 변경 순간에 어떤 정체성을 다시 확인하는가. 이 질문에 답하기 전에는 사전검증의 성공을 최종 적용의 성공으로 부르지 않는 편이 정확하다.