기술

미러 저장소에 결과 파일을 썼더니 git pull 이 멈췄고, 에러는 없었습니다

2026.10.069분 읽기

프로젝트 문서를 코드와 떼어 회사 단위 저장소에 모아 두면, 그 문서를 읽는 소비자가 둘로 갈립니다. 하나는 사람이 쓰는 편집용 장비이고, 다른 하나는 에이전트가 도는 별도 장비입니다. 뒤쪽은 원본이 아니라 미러입니다. 몇 시간 간격으로 도는 잡이 원격에서 당겨와 최신을 맞춥니다.

이 구조에서 에이전트의 답은 '미러가 최신이다'라는 가정 위에 서 있습니다. 미러에 있는 문서를 기준 삼아 답하기 때문에, 미러가 낡으면 답도 같이 낡습니다. 문제는 그 실패가 화면에 아무것도 띄우지 않는다는 점입니다. 며칠 전 기준으로 답한 문장과 오늘 기준으로 답한 문장은 겉모습이 똑같습니다.


동기화가 멈춘 날, 원인은 며칠 전에 있었습니다

사내 문서 지식그래프 검색 도구의 답변 품질을 13회차째 평가하던 중이었습니다. 인증 토큰 파일이 없어서 HTTP 대신 함수를 직접 불러 돌렸고, 그렇게 나온 평가 결과 파일 63개가 그 장비의 문서 미러 안에 미추적 파일로 남았습니다. 스크립트의 기본 출력 경로가 미러 저장소 안의 결과 폴더였습니다.

그때는 아무 일도 일어나지 않았습니다. 미추적 파일은 그 자체로 `git pull --ff-only`(앞으로 감기만 허용하는 당겨오기)를 막지 않습니다. 며칠 뒤 편집용 장비에서 같은 경로의 같은 파일들을 커밋하고 푸시했습니다. 이제 원격에 같은 이름의 추적 파일이 생겼습니다. 다음 동기화가 중단됐습니다. 사유는 병합 거부였습니다. 추적되지 않는 작업 트리 파일을 병합이 덮어써야 하는 상황이라 git 이 멈춘 것입니다.

git 입장에서는 정상 동작입니다. 덮어쓰면 사람이 만든 파일이 사라지니 거부하는 쪽이 맞습니다. 사건을 시간순으로 세우면 갈림길이 어디였는지 보입니다.

원격 장비에서 평가를 돌렸고, 출력이 미러 저장소 안으로 떨어졌습니다. 스크립트 기본값이 그랬습니다.

아무 일도 일어나지 않았습니다. 미러는 그때까지 계속 정상이었습니다.

나중에 다른 장비에서 같은 경로를 커밋하고 푸시했습니다. 원격에 같은 이름의 파일이 생겼습니다.

다음 동기화가 중단됐습니다. 덮어쓸 수 없다는 정상 거부였습니다.

실패 마커 파일과 메신저 알림이 그 사실을 사람에게 올렸습니다.

갈림길은 1번이고 3번이 아닙니다. 3번은 지극히 정상적인 작업입니다. 1번이 미러의 전제를 깨 둔 상태였을 뿐입니다. 쓴 시점과 깨진 시점 사이에 며칠이 끼어 있어서, 둘을 연결해 보는 일이 쉽지 않습니다.

미러 저장소에 결과 파일을 쓴 시점과 동기화가 멈춘 시점 사이에 며칠의 시차가 있음을 보여주는 타임라인


치우는 절차를 지우기가 아니라 대조 후 옮기기로 정했습니다

가장 빠른 복구는 미추적 파일을 전부 지우고 당겨오는 것입니다. 그렇게 하지 않았습니다. 미러에만 있는 결과가 섞여 있을 수 있어서, 지우면 그 평가 회차가 통째로 사라집니다. 원격에 올라간 파일과 미러에 남은 파일이 같은 내용이라는 보장이 없었습니다.

원격을 먼저 fetch 한 뒤, 미추적 파일을 하나씩 원격 저장소의 같은 경로 파일과 비교해 다른 것만 표시합니다.

지우지 않고 홈 아래 백업 폴더로 옮깁니다. 되돌릴 수 있는 상태를 만든 다음에 진행합니다.

그 다음에 당겨옵니다.

순서가 중요합니다. 대조를 먼저 해야 '이건 이미 원격에 있다'와 '이건 여기만 있다'가 갈립니다.


재발을 막는 쪽은 규칙이 아니라 자리였습니다

처음 떠오른 처방은 문서에 한 줄 적는 것이었습니다. '미러 안에 결과를 쓰지 않는다'를 운영 문서에 넣으면 될 것 같았습니다. 그 줄은 사람이 매번 의식해야 성립합니다. 평가를 돌릴 때 사람이 신경 쓰는 것은 평가 결과이고, 출력 경로는 기본값을 그대로 쓰게 됩니다. 그래서 바꾼 것은 문장이 아니라 기본값입니다.

원격 장비에서 돌리더라도 출력은 미러 밖에 씁니다. 끝난 뒤 작업 저장소로 복사해 커밋합니다.

스크립트의 출력 경로를 인자로 받게 두고, 기본값을 미러 안으로 잡지 않습니다.

실패를 마커 파일로 드러내는 장치는 그대로 둡니다. 이 건이 며칠 안에 드러난 유일한 이유였습니다.

정상 여부는 종료값을 따로 잡아서 확인하고, 실패 마커가 실제로 사라졌는지까지 본 다음에 정상이라고 적습니다.

기본값이 규칙을 이깁니다. 사람이 매번 의식해야 하는 규칙은 언젠가 의식 밖으로 나갑니다. 반대로 기본 출력 경로가 미러 밖이면, 아무도 이 사고를 기억하지 못해도 같은 일이 다시 일어나지 않습니다.

스크립트 기본 출력 경로를 미러 저장소 안에서 밖으로 옮긴 변화를 좌우로 비교한 그림


실패할 수 없는 검사는 아무것도 하지 않습니다

복구한 뒤 정상인지 확인하는 과정에서 하나를 더 건졌습니다. 동기화 스크립트를 다시 돌리고 출력 뒤쪽만 보려고 `tail` 에 물렸는데, 그 다음 줄에서 읽은 종료값은 스크립트의 것이 아니라 `tail` 의 것이었습니다. 파이프로 이은 명령의 종료값은 마지막 명령의 것입니다. 스크립트가 실패해도 0 이 나옵니다.

# 종료값이 tail 의 것이 된다
./sync.sh 2>&1 | tail -20
echo $?        # 항상 0

# 스크립트 종료값을 따로 잡는다
./sync.sh > /tmp/sync.log 2>&1; rc=$?
tail -20 /tmp/sync.log
echo "rc=$rc"

같은 자리에 또 하나가 있었습니다. 그 스크립트는 성공하면 출력이 비어 있는 설계입니다. 그래서 '출력이 없다'만으로는 성공과 실패가 갈리지 않습니다. 호출이 아예 안 됐을 때도 출력은 비어 있습니다. 검사를 고르기 전에 먼저 물어야 하는 질문은 하나입니다. 이게 실패라면 무엇이 달라 보이는가입니다. 똑같이 보인다면 그 검사는 아무것도 하지 않습니다.

이 함정은 판정 근거를 잘못 고르는 더 큰 유형에 속합니다. 중복을 막으려고 만든 목록을 완료 판정에 쓰면, 목록에 있다는 사실이 처리가 끝났다는 뜻으로 읽힙니다. 두 경우 모두 값은 정상적으로 나오고, 그 값이 묻는 질문에 대한 답이 아닐 뿐입니다.

파이프에 물린 종료값과 빈 출력이 둘 다 실패를 가리지 못한다는 것을 보여주는 비교 그림


읽기 전용 사본을 운영할 때 보는 것

미러·캐시·복제본·다운스트림 뷰는 성질이 같습니다. 읽기만 한다는 전제로 돌고, 그 전제가 깨진 자리는 한참 뒤에 다른 모습으로 나타납니다. 같은 구조를 운영한다면 아래를 봅니다.

사본 안에 쓰기가 일어나는 경로가 있는지 봅니다. 스크립트 기본 출력 경로가 가장 흔한 자리입니다.

동기화가 멈췄을 때 사람에게 올라오는 경로가 있는지 봅니다. 없으면 멈춘 사실 자체를 모릅니다.

정상 확인에 쓰는 신호가 실패했을 때 달라지는지 봅니다. 파이프 뒤 종료값과 빈 출력은 달라지지 않습니다.

마지막 성공 시각처럼 성공을 적극적으로 확인하는 신호를 같이 둡니다. 마커를 만드는 코드가 안 돌면 마커도 안 생깁니다.

사본을 읽는 소비자가 사람인지 기계인지 봅니다.

마지막 항목이 비용을 가릅니다. 소비자가 사람뿐이면 피해가 작습니다. 사람은 문서가 낡았다는 걸 대개 알아챕니다. 기계가 읽는 사본이라서 조용한 실패가 비싼 것입니다.


문서가 최신인가를 운영 지표로 봅니다

미러에 쓰는 것이 항상 문제가 되지는 않습니다. 원격에 같은 경로가 끝까지 생기지 않으면 미추적 파일은 조용히 공존합니다. 이 사고는 나중에 같은 경로를 커밋하는 일이 겹쳤을 때만 납니다. 그래서 위험을 작게 보기 쉽습니다.

에이전트가 문서를 읽고 답하는 구조에서는 문서 동기화가 정확도의 일부입니다. 모델을 바꾸거나 검색 품질을 올리는 작업과 같은 줄에, 문서가 언제 당겨졌는가가 같이 서 있습니다. 판정 지점이 여럿인 시스템에서 하나를 고치면 나머지가 같이 따라오는지 세어 봐야 하는 것과 같은 이유입니다.

이번 건에서 며칠 안에 드러난 유일한 이유는 실패 마커 파일과 알림이었습니다. 자동화의 실패는 에러 화면이 아니라 아무 일도 일어나지 않음으로 나타납니다. 그래서 실패했다는 사실 자체를 산출물로 남기게 만들어 둡니다. 멈춘 미러는 에러를 내지 않고, 낡은 기준을 그대로 답으로 냅니다.

#git#미러저장소#조용한실패#문서동기화#에이전트운영