노트북을 잃어버리면 무엇이 사라지는가 (3) — 백업을 믿을 수 있게 만든 실전 함정들
지난 두 편에서 저는 노트북과 함께 사라질 자산을 골라내고, 계층마다 단일 실패점이 다른 3계층 백업 구조를 세웠습니다. 구조를 다 그렸으니 이제 끝인가 싶었습니다. 그런데 정작 시간을 가장 많이 잡아먹은 것은 그 구조가 아니라, 백업을 실제로 믿을 수 있게 만드는 일이었습니다.
백업은 "저장했다"로 끝나지 않습니다. "새로 clone해서 진짜로 복호화되는가"까지 확인해야 비로소 백업입니다. 그 사이에는 생각보다 많은 함정이 있었습니다. 이번 편은 그 함정들을 문제 → 원인 → 해결 → 검증의 순서로 정리한 기록입니다. 전부 특정 벤더와 무관한, 도구 차원의 이야기입니다.
함정 1 — "clone하면 되지"의 함정
문제. 오래 방치한 레포 하나를 정리하려다 아찔한 순간이 있었습니다. "어차피 원격에 다 있으니 로컬은 지워도 되겠지"라고 생각하고 삭제하려던 참이었습니다.
원인. git에서 원격에 없는 것이 생각보다 많습니다. 아직 push하지 않은 커밋, stash에 쌓아둔 변경, 그리고 .gitignore로 추적에서 빠진 파일들. 특히 stash는 refs/stash에만 존재하며 push로 원격에 절대 올라가지 않습니다. 저는 오래 방치한 레포에 stash 하나가 남아 있었고, 그대로 지웠다면 영구 소실될 뻔했습니다.
해결·검증. 레포를 지우기 전, 원격에 없는 것이 있는지 반드시 확인합니다.
# 원격에 없는 것이 있는지 — 지우기 전 필수 점검
git log @{u}..HEAD --oneline # 원격(upstream)보다 앞선 = push 안 된 커밋
git stash list # 스택에 쌓인 stash (원격에 절대 안 감)
git status --porcelain # 추적 안 되는/변경된 파일
@{u}는 현재 브랜치의 upstream을 가리킵니다. @{u}..HEAD가 비어 있지 않다면 로컬에만 있는 커밋이 있다는 뜻입니다. 세 명령이 모두 깨끗해야 비로소 "원격에 다 있다"가 참이 됩니다.
함정 2 — 원격 URL에 박힌 평문 토큰
문제. 자산을 훑다가 오래된 레포의 .git/config에서, origin URL에 유효한 액세스 토큰이 평문으로 박혀 있는 것을 발견했습니다. 파일 하나만 열면 그 토큰으로 접근이 가능한 상태였습니다.
원인. 과거에 편의를 위해 토큰을 URL에 심어 clone했던 흔적이었습니다. 그 뒤로는 잊힌 채, 백업 대상 안에 시한폭탄처럼 들어 있었습니다.
해결. 먼저 어떤 레포의 URL에 자격증명이 박혀 있는지 확인합니다. 이때도 값은 가리고 존재만 확인합니다.
# origin URL에 자격증명이 박혀 있는지 — 토큰은 가리고 존재만 확인
git config --get remote.origin.url | sed -E 's#(//[^:]+):[^@]+@#\1:***@#'
발견했다면 URL을 자격증명 없는 형태로 되돌리고, 무엇보다 그 토큰 자체를 즉시 폐기(revoke)해야 합니다. 파일에서 지우는 것만으로는 부족합니다. 이미 어딘가에 복제됐을 수 있으니까요.
검증. 여기서 한 걸음 더 나갑니다. 폐기했다고 믿는 것과 폐기가 확인된 것은 다릅니다. 그 토큰으로 아무 인증 엔드포인트나 호출해보고, 401이 돌아오는지 직접 확인합니다.
# revoke가 실제로 됐는지 재검증 — 401/403이어야 안심입니다.
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer <revoked-token>" \
https://<provider-api>/user
# 200이 나오면 아직 살아 있는 것. 401 또는 403이어야 폐기 확인.
함정 3 — 복호화가 안 되는 키 경로 (macOS)
문제. 백업을 다 짜놓고 시험 삼아 복호화를 걸었더니 실패했습니다. 분명 키는 있는데 이런 메시지가 떴습니다.
Failed to get the data key required to decrypt the SOPS file.
no identity matched any of the recipients
원인. macOS에서 age/SOPS가 키를 찾는 기본 경로가 제 예상과 달랐습니다. 저는 키를 XDG 관례에 따라 ~/.config 아래에 두었는데, 도구는 macOS의 ~/Library/Application Support/... 쪽을 먼저 뒤지고 있었습니다. 키는 멀쩡히 있는데 서로 다른 곳을 보고 있으니 "일치하는 신원이 없다"고 한 것입니다.
해결·검증. 키 파일 경로를 SOPS_AGE_KEY_FILE로 명시합니다. 추측에 맡기지 않는 것이 핵심입니다.
# 키 파일 위치를 명시해 추측을 제거합니다.
export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt"
sops --decrypt secrets.env.enc >/dev/null && echo "복호화 OK"
함정 4 — 설정 파일과 명령행 recipient의 충돌
문제. 작업 폴더 안에서 --age로 수신자를 분명히 지정했는데도 암호화가 거부됐습니다.
no matching creation rules found
원인. 그 폴더에 .sops.yaml이 있었습니다. SOPS는 이 설정 파일을 먼저 읽고 creation rules를 강제하는데, 제가 명령행에서 준 recipient가 이 규칙과 맞지 않아 충돌한 것입니다. 설정 파일이 명령행 옵션을 덮어쓰는 형국이었습니다.
해결. 설정을 아예 무시하고 명령행 옵션만 쓰도록 --config /dev/null로 우회합니다.
# .sops.yaml을 무시하고 명령행 recipient만 사용
sops --config /dev/null --encrypt --age <age-공개키> secrets.env > secrets.env.enc
함정 5 — 개행이 든 값(멀티라인)의 손상
문제. PEM 개인키처럼 개행이 포함된 값이 든 .env를 암호화했다가 복호화하니, 왕복 과정에서 값이 미묘하게 깨지는 경우가 있었습니다.
원인. .env를 키·값으로 파싱하는 dotenv 방식은 한 줄 = 하나의 값이라는 가정을 깔고 있습니다. 값 안에 개행이 들어가면 이 가정이 흔들려 왕복에서 어긋날 수 있습니다.
해결·트레이드오프. 이런 파일은 키·값으로 파싱하지 말고 파일을 통째로(binary) 암호화합니다.
# 개행이 든 값은 파싱하지 말고 파일 전체를 통째로 암호화
sops --config /dev/null --encrypt --age <age-공개키> \
--input-type binary --output-type binary \
private-key.pem > private-key.pem.enc
대가는 있습니다. binary로 암호화하면 키 이름까지 가려져서, 나중에 구조적으로 grep하거나 특정 키만 꺼내 보기가 어려워집니다. 다만 여기서는 편의보다 복원 안전성이 우선이라고 판단했습니다. 열어봤을 때 원본과 한 바이트도 다르지 않은 것이, 필요할 때 grep이 되는 것보다 중요하니까요.
함정 6 — 시크릿 레포의 위생: allowlist와 평문 오염 게이트
문제. 시크릿을 담는 레포에서 가장 무서운 사고는, 실수로 암호화 안 된 평문 파일을 커밋하는 것입니다. 한 번 push되면 원격 히스토리에 영원히 남습니다.
해결. 두 겹으로 막았습니다. 먼저 .gitignore를 allowlist(허용 목록) 방식으로 뒤집었습니다. 전부 무시하고, 암호화 파일만 예외로 추적하는 것입니다.
# 전부 무시하고 .enc 파일만 추적 (allowlist)
cat > .gitignore <<'EOF'
*
!*/
!*.enc
!.gitignore
EOF
그리고 push 전에 평문 오염 게이트를 하나 둡니다. 암호화되지 않은 파일이 하나라도 추적되고 있으면 아예 중단시키는 검사입니다.
# push 전 게이트: 평문 파일이 추적되면 중단
plain=$(git ls-files | grep -vE '\.enc$|^\.gitignore$' || true)
if [ -n "$plain" ]; then
echo "평문 파일이 추적되고 있습니다. 중단합니다:"
echo "$plain"
exit 1
fi
함정 7 — 복구 리허설: 백업은 clone해서 열려야 백업이다
마지막 함정이자, 사실은 이 시리즈 전체의 결론입니다. 앞의 여섯 가지를 다 지켜도, 실제로 복구해보지 않은 백업은 백업이 아닙니다. 저장에 성공했다는 사실과, 그것이 다시 열린다는 사실 사이에는 검증되지 않은 심연이 있습니다.
검증. 그래서 백업을 '믿기' 전에, 완전히 새로운 위치에 clone해서 전 파일이 실제로 복호화되는지 돌려봤습니다. 원래 노트북의 설정에 오염되지 않은 깨끗한 환경에서요.
# 완전히 새 위치에 clone해서 모든 .enc가 복호화되는지 확인
tmp=$(mktemp -d)
git clone <backup-repo-url> "$tmp/restore"
export SOPS_AGE_KEY_FILE=... # L0에서 되살린 age 개인키
find "$tmp/restore" -name '*.enc' -print0 | while IFS= read -r -d '' f; do
if sops --decrypt "$f" >/dev/null 2>&1; then
echo "OK $f"
else
echo "FAIL $f" # 하나라도 FAIL이면 백업은 아직 믿을 수 없음
fi
done
여기서 한 가지 더. 복호화가 "성공"했다고 안심하지 않고, 원본과 해시까지 대조했습니다. 길이만 같고 내용이 어긋나는 경우를 배제하기 위해서입니다.
# 길이 일치로 끝내지 말고 원본과 바이트/해시를 대조
diff <(sops --decrypt "$restored") "$original" >/dev/null \
&& echo "원본과 완전히 일치" \
|| echo "불일치 — 재점검 필요"
저는 전 파일이 새 clone에서 복호화되는 것을 확인하고, 대표적인 몇 개는 바이트 단위로 원본과 일치하는 것까지 본 뒤에야 이 백업을 믿기로 했습니다.
결국 핵심은 백업이 아니라 리허설이었습니다
오래된 격언이 있습니다. 복구를 시도해보기 전까지 백업의 상태는 알 수 없다는, 이른바 '슈뢰딩거의 백업'입니다. 상자를 열어보기 전까지 백업은 살아 있는 동시에 죽어 있습니다. 열어보는 행위, 즉 복구 리허설만이 그 중첩을 무너뜨립니다.
결국 이 모든 작업의 핵심은 백업 자체보다 복구 리허설이라는 생각이 들었습니다. 리허설을 해두지 않으면, 정작 사고가 난 그날 처음으로 복구를 해보게 됩니다. 가장 당황스럽고 가장 시간이 없는 그 순간에 말입니다. 그래서 저는 이 작업을, 미래의 사고 당일에 놓인 저에게 "이미 한 번 해봤다"를 남겨두는 일이라고 생각하기로 했습니다. 그거면 충분하다는 생각이 들었습니다.
관련 글
노트북을 잃어버리면 무엇이 사라지는가 (2) — 전부 무료로 짠 3계층 백업 아키텍처
계층마다 단일 실패점이 다른 곳에 있도록 설계한 3계층 시크릿 백업입니다. 부트스트랩 열쇠는 무료 클라우드 패스워드 매니저에, 본체는 SOPS와 age로 암호화해 private git에, 운영 환경변수는 스냅샷으로. 전부 무료 티어로 짰습니다.
노트북을 잃어버리면 무엇이 사라지는가 (1) — 값은 살아있는데 못 들어가는 역설
노트북 한 대에 모든 개발 자격증명이 몰려 있다는 걸 뒤늦게 깨달았습니다. 시크릿 값의 생존과, 그 값에 닿을 수단의 생존은 전혀 다른 문제였습니다. 값을 한 번도 출력하지 않고 자산을 전수 조사한 기록입니다.
맥북 디스크 정리 — 크기순이 아니라 지웠을 때의 대가순으로 봤습니다
닷새 전에 82GB까지 비운 맥북 디스크가 다시 20GB가 됐습니다. 지우는 일은 명령 한 줄이지만 어려운 건 분류였습니다. 크기순이 아니라 틀렸을 때 치르는 대가순으로 다섯 등급을 가른 기준과, 표준 정리 명령이 로컬 DB를 날릴 뻔한 연쇄 폭파를 정리했습니다.