backend
MERGED 배지는 거짓말할 수 있다 — 16초 차이로 사라진 PR
· Ascendy Engineering
TL;DR
- 리뷰까지 끝나고 GitHub에서 MERGED로 표시된 PR이 있었다. 그 코드는
main에 하나도 없었다. - 배지가 거짓말한 건 아니다. 배지의 뜻은 head가 이 PR의 base로 머지됐다는 것이지, main에서 도달 가능하다는 것이 아니다. base가
main이 아니면 그 사이에 갭이 생긴다. - 경위는 16초였다. PR-A의 base는
main이 아니라 다른 기능 브랜치(PR-B의 head)였는데, 그 브랜치는 PR-A가 머지되기 16초 전에 이미 squash로main에 올라간 뒤였다. 이후 아무도 그 브랜치를 다시 머지하지 않았으니, PR-A의 변경은main으로 갈 경로 자체가 없었다. - 탐지는 머지 커밋이
main의 조상인지를 물으면 된다. 단 이건 히스토리에 대한 질문이라 어느 쪽도 완전한 답은 아니다 — 거짓이면 이 사고의 강한 신호이고(정상적인 스택 PR도 거짓이 나오긴 한다), 참이어도 이후 되돌려졌을 수 있어 “지금 트리에 있다”까지는 아니다. 그리고 더 값싼 건 예방이다: 머지하려는 PR의 base가main이 아니면, 그 base가 이미 머지됐는지 먼저 확인하면 된다.
이 글에 대하여. 백엔드 팀 인테이크를 정제해 쓴 사후 기록이다. 유실됐던 변경은 이미 후속 PR로 재착륙(복구)된 상태다. 그 변경의 성격과 도메인, 관련 상수, 내부 브랜치명·PR 번호는 일반화했다 — 교훈은 그 세부 없이 온전하다.
초안의 인과 설명은 “squash가 얹혀 있던 커밋을 떨어뜨렸다”였는데, 적대적 검수에서 그게 git 동작과 맞지 않는다는 지적을 받고 커밋 그래프를 다시 확인해 고쳤다. 실제 원인은 아래 본문의 순서 문제였다. 검수가 없었다면 틀린 사고 모델을 발행할 뻔했다.
한 가지 더 고백하면, 아래 탐지법도 한 번 뒤집혔다. 중간에 “머지 커밋 도달 가능성은 squash에서 판별력이 없다”는 지적을 받고 그 절을 통째로 들어냈는데, 다음 라운드에서 반대 지적이 왔고 실제 PR들에 직접 돌려보고서야 원래 방법이 맞다는 게 확정됐다. 검수 의견을 검증 없이 받아들인 대가였다. 같은 결의 단순한 변경 하나가 12라운드를 간 이유, 그리고 “성공 신호가 실제로 무엇을 쟀는가”의 배포 판인 배포했는데 아무것도 배포되지 않았다와 이어진다.
증상 — 초록불인데 코드가 없다
리뷰가 끝났고, GitHub은 그 PR을 MERGED로 표시하고 있었다. 보라색 배지, 닫힌 상태, 완료.
그런데 그 PR이 추가했어야 할 파일이 main에 없었다.
버그 리포트도 아니고 실패한 CI도 아니다. 완료됐다고 표시된 일이 실제로는 안 된 것이다. 이 종류의 사고가 고약한 건, 아무도 이상하다고 느끼지 않는다는 데 있다. 배지를 보고 다음 일로 넘어가면 그만이니까.
배지는 거짓말하지 않았다 — 우리가 다른 질문의 답으로 읽었다
먼저 GitHub을 탓하기 전에, 배지가 실제로 무엇을 뜻하는지 정확히 봐야 한다.
MERGED는 “이 PR의 head가 이 PR의 base로 머지됐다”는 뜻이다. 그 base가 무엇이냐는 별개 문제다. base가 main이면 두 문장은 같은 뜻이 된다. 그런데 base가 다른 기능 브랜치면, “머지됨”과 “main에 있음” 사이에 갭이 생긴다.
우리가 알고 싶었던 건 “이 변경이 프로덕션 브랜치에 들어갔나” 였는데, 배지가 답하고 있던 건 “이 변경이 자기 base 브랜치에 들어갔나” 였다. 둘은 대부분의 경우 일치해서, 다를 수 있다는 사실 자체가 잘 안 보인다.
진짜 순서 — 16초
여기에 두 번째 조각이 붙는다. 그리고 이 대목이 처음 우리가 잘못 이해했던 부분이다.
상황은 흔한 스택 PR이었다. PR-B가 먼저 있고, 그 위에 이어지는 작업 PR-A가 PR-B의 브랜치를 base로 올라갔다. 여기까지는 정상적인 작업 방식이다.
문제는 머지된 순서였다. 커밋 그래프를 되짚어보니 이랬다.
07:56:51 PR-B → main 으로 squash-merge (base 브랜치가 main으로 떠남)
07:57:07 PR-A → PR-B의 브랜치로 merge (16초 뒤, 이미 떠난 곳으로)
PR-A는 자기 base 브랜치가 이미 main으로 올라간 뒤에 그 브랜치로 머지됐다. 브랜치는 여전히 존재했으니 머지는 정상적으로 성공했고, 배지도 정직하게 MERGED가 됐다. 다만 그 브랜치를 main으로 실어 나를 다음 머지가 다시는 오지 않았다. PR-B는 이미 머지를 마쳤으니까.
즉 PR-A는 막다른 길로 머지된 것이다. 16초만 순서가 반대였다면 PR-A의 변경은 PR-B의 diff에 포함돼 함께 main에 올라갔을 것이다.
여기서 squash의 역할도 정확히 짚어야 한다. squash가 얹혀 있던 커밋을 떨어뜨린 게 아니다. 만약 PR-A가 먼저 브랜치에 들어간 뒤 PR-B가 squash됐다면, PR-A의 변경도 PR-B의 diff에 포함돼 함께 올라갔을 것이다 — squash는 커밋 계보를 지울 뿐 파일 변경을 임의로 빼지 않는다.
squash가 한 일은 다른 것이다. squash는 브랜치의 커밋을 main에 이어붙이는 대신 결과를 새 커밋으로 다시 쓴다. 그래서 그 브랜치는 main의 조상이 아니게 되고, “이 브랜치는 이미 반영됐다”는 사실이 커밋 그래프상으로는 전혀 드러나지 않는다. 브랜치는 겉보기에 멀쩡히 살아 있고, 거기에 머지하는 것도 아무 경고 없이 성공한다.
배지 말고 커밋 그래프에 물어보라
이 사고를 겪고 나서 필요한 건 “완료됐다”를 사람 눈이 아니라 기계가 판정하는 방법이다.
가장 쓸모 있는 한 줄은 이것이다 — 그 PR의 머지 커밋이 main의 조상인가.
git merge-base --is-ancestor \
"$(gh pr view <N> --json mergeCommit -q .mergeCommit.oid)" main \
&& echo "머지가 main 히스토리에 올라감" \
|| echo "확인 필요 — main에서 도달 불가"
여기서 헷갈리기 쉬운 지점을 짚어야 한다. squash-merge를 쓰면 브랜치의 원래 커밋들은 버려지는데, GitHub이 알려주는 mergeCommit은 버려진 head가 아니라 머지 시점에 새로 만들어진 그 squash 커밋이다. 그 커밋은 base 브랜치 위에 놓인다. 그래서 base가 main이었다면 이 검사는 정상적으로 참이 된다.
우리 사고의 세 PR에 그대로 돌려보면 이렇게 갈린다.
정상 squash (base=main) → 조상이다 ✅
유실된 PR (base=기능 브랜치) → 조상이 아니다 ⚠️
재착륙 PR (base=main) → 조상이다 ✅
다만 이 검사가 무엇에 대한 질문인지는 정확히 알고 써야 한다. 이건 커밋 히스토리에 대한 질문이지 현재 트리 내용에 대한 질문이 아니다.
- 참이면 그 PR의 머지가
main의 히스토리에 들어간 것이다. 다만 그 뒤 되돌려졌을 수도 있으니 “지금 트리에 그 코드가 있다”까지는 말해주지 않는다. - 거짓이면 이 사고의 강한 신호다. 다만 이것도 확정은 아니다 — 정상적인 스택 PR도 거짓이 나온다. 아래 브랜치에 먼저 머지된 뒤 그 브랜치가 squash되면 위 PR의 내용은
main에 잘 들어가지만 그 머지 커밋은 버려진 계보에 남기 때문이다.
요약하면 값싼 선별 도구다. 어느 쪽 결과든 “이 코드가 지금 main에 있나”를 확정해야 한다면 내용을 봐야 한다.
그래서 거짓이 나오면 내용으로 한 번 더 확인한다. 파일을 새로 추가하는 PR이라면 경로 존재 확인이 가장 싼 1차 선별이다.
git ls-tree -r main --name-only | grep '<있어야 할 경로>'
이것도 한쪽으로만 결정적이다. 없으면 확실히 안 들어간 것이지만, 있다고 그 PR의 내용이 들어갔다는 뜻은 아니다 — 다른 PR이 같은 경로를 먼저 만들었거나 자리만 잡아둔 버전이 들어 있을 수 있다. 경로가 있으면 내용까지 봐야 한다.
기존 파일을 고치거나 지우는 PR이라면 최종 내용을 비교하면 안 된다 — 반영된 뒤 main이 같은 파일을 더 고쳤을 때 차이가 나서 오판한다. 그 PR의 패치가 main에 담겼는지를 물어야 한다.
# 역적용은 "현재 워크트리" 기준이다. PR 브랜치에서 그냥 돌리면 자기 패치라
# 항상 성공하니, 반드시 main을 체크아웃한 워크트리에서 물어야 한다.
# 작업 경로는 mktemp로 — /tmp에 고정 이름을 쓰면 남이 미리 만들어 둘 수 있다.
tmp="$(mktemp -d)"; trap 'git worktree remove --force "$tmp/main" 2>/dev/null; rm -rf "$tmp"' EXIT
git worktree add "$tmp/main" main
git diff "$(git merge-base <base> <head>)" <head> -- <경로들> > "$tmp/pr.patch"
git -C "$tmp/main" apply --check --reverse "$tmp/pr.patch" \
&& echo "main이 이 패치를 이미 담고 있다" \
|| echo "판정 불가 — 사람이 내용을 봐야 한다"
이것도 반영 후 파일이 더 바뀌면 실패하므로, 실패가 곧 유실은 아니다.
그리고 사실 가장 값싼 건 사후 탐지가 아니라 예방이다. 머지하려는 PR의 base가 main이 아니라면, 그 base 브랜치가 이미 머지되지 않았는지 먼저 확인하는 것. 이번 건의 트리거가 정확히 그 조건이었고, 머지 전에 한 번 물으면 끝난다.
복구 — 덮어쓰지 않고 재착륙시키기
복구할 때 가장 하기 쉬운 실수는, 잃어버린 브랜치를 통째로 다시 머지해서 그 사이 main에 들어온 변경을 되돌려버리는 것이다.
그래서 파일 단위로 착륙시켰다. 현재 main에서 브랜치를 따고, 잃어버린 브랜치에서 필요한 파일만 가져온다.
git switch -c <복구브랜치> main
# 먼저: 그 파일들이 분기 이후 main에서 바뀌지 않았는지 확인한다.
# 출력이 비어 있어야 안전하다 — 비어 있지 않으면 그 파일은 수동 병합 대상이다.
git diff "$(git merge-base <잃은브랜치> main)" main -- <파일들>
git checkout <잃은브랜치> -- <파일들>
git diff가 비어 있다는 확인이 핵심이다. 이게 없으면 재착륙이 곧 main의 최신 변경을 조용히 되돌리는 두 번째 사고가 된다. 그다음은 평소대로 — 검증하고, 새 PR을 연다.
스택돼 있던 나머지 PR은 반대로 처리했다. base를 main으로 재타겟하고 리베이스해서, 같은 함정에 빠질 경로 자체를 없앴다.
예방 — 아래가 머지되면 위는 즉시 재타겟
정리하면 이렇다.
위 PR은 아래 PR이 main에 반영될 때까지 머지하지 않는다. 아래가 반영되면 위 PR의 base를 main으로 재타겟하고 리베이스한 뒤 머지한다. 이번 사고는 그 반대 순서로 16초가 어긋나면서 났다.
여기서 위험한 건 사람이 순서를 지키느냐에 기대야 한다는 점이다. 두 머지가 16초 간격이었다는 사실이 말해주듯, 이건 부주의라기보다 경고가 없는 경로의 문제다. base 브랜치가 이미 머지됐다는 사실은 브랜치를 봐서는 알 수 없고, 그 브랜치로 머지하는 동작도 아무 저항 없이 성공한다.
그러니 규칙 하나를 더 붙이는 편이 안전하다. 머지 직후 그 브랜치를 지운다. 브랜치가 없으면 거기로 머지할 수 없고, GitHub은 base 브랜치가 지워진 열린 PR을 그 위 base로 자동 재타겟한다. 순서를 사람이 기억하는 대신 경로 자체를 없애는 쪽이다.
한 가지 덧붙이면 — 이 함정은 squash 자체의 결함이 아니다. squash는 히스토리를 깨끗하게 유지하려고 의도적으로 결과를 새로 쓴다. 문제는 그 결과로 “이 브랜치는 이미 반영됐다”가 커밋 그래프에서 사라진다는 것이고, UI가 그걸 알려주지 않는다는 데 있다.
다른 메커니즘, 같은 교훈. 이 글을 쓰는 중에도 같은 계열의 일이 이 블로그 저장소에서 있었다. 정정 커밋을 푸시한 직후 PR이 머지됐는데, 머지 커밋의 부모가 정정 이전 커밋이라 그 정정만 브랜치에 남았다. 스택도 squash도 아니었지만 결과는 같다 — PR은 MERGED인데 그 변경은
main에 없다. 그래서 여기서도 답은 같았다: 배지 말고main의 내용을 직접 확인하는 것.
가져갈 것
- MERGED 배지 ≠
main에 코드 존재. 배지는 “head가 자기 base로 머지됨”을 뜻한다. base가main이 아니면 갭이 생긴다. - 이미 머지된 브랜치로 머지하는 건 막다른 길이다. 머지는 성공하고 배지도 켜지지만, 그 브랜치를
main으로 실어 나를 다음 머지는 오지 않는다. - squash가 커밋을 떨어뜨리는 게 아니다. squash는 결과를 새 커밋으로 다시 쓸 뿐이다. 다만 그 때문에 “이 브랜치는 이미 반영됐다”가 커밋 그래프에서 사라져, 막다른 길이 멀쩡해 보인다.
- 머지 커밋이
main의 조상인지 물어라 — 단 히스토리에 대한 질문임을 알고. 거짓은 이 사고의 강한 신호이고(정상 스택 PR도 거짓이 나온다), 참이어도 이후 되돌려졌을 수 있어 “지금 트리에 있다”는 아니다. 값싼 선별 도구로 쓰고 확정은 내용으로 한다. - 최종 내용 비교로 판정하지 마라. 반영된 뒤
main이 같은 파일을 더 고치면 오판한다. 파일 추가는 존재 확인, 수정·삭제는 패치 역적용을 쓴다. - 재착륙은 파일 단위로, 그전에
main쪽 diff가 비어 있는지 확인하고. 안 그러면 복구가 두 번째 사고가 된다. - 머지 직후 브랜치를 지워라. 순서를 사람이 기억하게 하는 대신 잘못된 경로 자체를 없앤다.
초록불은 언제나 무언가를 재고 있다. 사고는 그 무언가가 우리가 알고 싶던 것과 다를 때 일어난다.
저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.
Tags: git, github, squash-merge, stacked-prs, ci-cd, incident-prevention, postmortem