같은 커밋에서 일을 시작한 두 작업이 있다. 하나는 설정 파일을 고쳤고, 다른 하나는 문서를 고쳤다. 둘 다 커밋까지 만들었는데 먼저 브랜치를 갱신한 건 한쪽이다. 나머지 한쪽의 커밋이 사라진 건 아니다. 그 커밋이 들어갈 자리를 정하는 사이에 브랜치가 이미 움직였다.
GitHub가 11월 5일 공개한 Git 저장 구조를 읽다가 이 차이가 재미있었다. 커밋을 비롯한 Git 객체는 내용에 따라 이름이 붙고, 한 번 만들어진 객체 자체는 바뀌지 않는다. 반면 refs/heads/main은 지금 어느 커밋을 브랜치 끝으로 볼지 가리키는 이름이다. 객체를 여러 작업자가 동시에 저장할 수 있는 것과, 같은 이름을 동시에 바꿔도 되는 것은 다른 문제다.
새 구조에서 GitHub는 들어온 객체 묶음을 먼저 Azure Blob Storage에 올린다. 그다음 Ref-CAS라는 별도 복제 로그의 한 트랜잭션으로 묶음 목록을 갱신하고 브랜치 참조를 바꾼다. 참조가 예상한 값을 여전히 가리키는지도 이때 비교한다. 서로 다른 브랜치를 갱신하는 작업은 나란히 진행할 수 있지만, 같은 이전 값을 보고 같은 브랜치에 들어오면 먼저 반영된 쪽이 그 값을 바꾼다.
커밋이 있는지, 브랜치가 움직였는지
이 차이는 GitHub 없이도 작은 로컬 저장소에서 확인할 수 있다. 기준 커밋을 A, A에서 따로 만든 두 커밋을 B와 C라고 하자. 처음 참조가 A일 때 git update-ref refs/heads/demo B A는 성공한다. 뒤이어 git update-ref refs/heads/demo C A를 실행하면 실패한다. 여기서 A·B·C는 설명을 위한 이름이고 실제 명령에는 커밋 ID를 넣는다. 두 번째 명령이 실패해도 이 예제에서 이미 만든 C 객체는 조회할 수 있다. Git 공식 매뉴얼에서 말하는 세 번째 인자의 역할이다. GitHub 내부를 재현하는 예제는 아니다.
코딩 에이전트가 예전 TODO를 다시 확인해야 하는 것처럼, 자동화 여러 개가 같은 브랜치에 결과를 남길 때도 기준이 바뀌지 않았는지 확인해야 한다. 그래서 실패했을 때 같은 요청을 그대로 다시 보내는 것부터 피하고 싶다. 먼저 브랜치 끝이 여전히 작업을 시작한 커밋인지 확인한다. 끝이 바뀌었다면 새 기준을 가져와 변경을 다시 맞추거나, 별도 브랜치에 결과를 두고 합칠 순서를 정하는 편이 낫다. 작업이 독립적이라면 처음부터 브랜치를 나눌 수도 있다. 같은 파일을 건드리지 않았다고 해서 같은 참조를 동시에 바꾸는 문제가 사라지지는 않는다.
발표 당시 이 구조는 제한된 비공개 프리뷰였다. 공개 글의 처리량은 GitHub 내부 시험에서 나온 값으로, 내 저장소에서 받을 성능이나 Actions 실행 속도를 뜻하지 않는다. 관련 토론에서도 한 저장소의 쓰기 처리량과 플랫폼 장애를 구분하자는 질문이 나왔다. 작은 팀에서 당장 남는 건 그보다 간단하다. 저장한 결과와 지금 공개할 결과를 나눠 생각하면, 실패했을 때 어느 쪽을 다시 해야 하는지가 보인다.