내부 private repo 작업을 바탕으로 썼다. 외부 접근이 불가한 저장소라 링크 없이, 조직·시스템·사람을 식별할 수 있는 정보는 걷어내고 구조만 남긴다.

GitHub merge queue — 우리 자리의 임계점 을 쓸 때는 merge queue 가 “머지 순간의 충돌” 을 어떻게 다루는지가 관심이었다. 이번에는 머지 순간이 3주 뒤였다. 그 3주 동안 PR 을 어떻게 살려두는지는 merge queue 가 답해주지 않는다.

TL;DR

  • 초기 리뷰를 마친 PR 을 거의 3주 열어둬야 했다. 외부 환경에서만 최종 확인할 수 있어 배포 시각을 여러 조직이 맞춰야 했고, 그 시각이 약 3주 뒤로 정해졌다. 승인은 대기 중에 리뷰가 이어지며 배포 전날에야 다 모였다.
  • 그 사이 정기 배포가 없어졌다. “수요일 열차” 가 사라지고 “개발자 점검 + 기획 확인 후 각자 배포” 가 됐다. 대기하는 PR 은 아무도 대신 밀어주지 않는다.
  • 살려두기 위해 한 일: Draft 전환(승인 유지), 푸시 횟수 최소화(푸시마다 봇 승인 리셋), git merge-tree 로 충돌 예보, 브랜치 체크아웃 없이 공유 QA 브랜치에 반영, 세션이 바뀌어도 이어지는 인계 메모.
  • 마지막 날 충돌은 났다. 내 다른 PR 과였다. 저녁에 발견해서 그 자리에서 풀었고, 당일 아침 예보는 깨끗했다 — 예보는 시점의 스냅숏이지 보증이 아니다.

왜 3주였나

카드 발급이 배치에서 실시간으로 바뀌는 작업이었다. 우리 쪽 구현과 첫 번째 리뷰 반영은 8월 넷째 주에 대부분 끝났다. 그런데 카드사 쪽은 외부 환경에서만 최종 확인할 수 있고, 여러 조직의 변경이 같은 시각에 켜져야 사용자 흐름이 끊기지 않는다. 배포 시각은 카드사가 정했고, 약 3주 뒤였다.

머지하면 곧 배포되는 구조라 머지를 미뤄야 했다. 예전이면 “다음 정기 배포 열차에 태우지 않는다” 로 끝났을 텐데, 그 사이 정기 배포가 폐지됐다. ‘우리는 TBD 다’ 라는 말 다음에 필요한 것 에서 적었듯 main 에 들어가면 곧 운영이다. 열차가 없으니 대기실도 없다. PR 이 대기실이다.

승인은 살려두고, 머지는 막기

첫 번째 걱정은 “누가 실수로 머지하면” 이었다. PR 을 Draft 로 전환했다. Draft 는 merge queue 에 넣을 수 없고, 받아둔 승인은 그대로 남는다. Ready 로 되돌리면 승인이 살아난 채로 큐에 넣을 수 있다. 제목에 배포 날짜를 적어두는 것보다 확실하다.

두 번째 걱정은 승인 자체였다. 이 저장소는 사람 리뷰어 외에 자동 리뷰 봇이 승인을 남기는데, 봇의 승인은 새 푸시가 있으면 리셋된다. 사람 승인도 설정에 따라 stale 이 된다. 3주 동안 자잘한 수정을 그때그때 푸시하면 매번 봇을 다시 부르고, 봇이 응답하지 않으면 채널에서 다시 멘션해야 한다.

그래서 푸시를 비용으로 봤다. 리뷰에서 나온 작은 수정(상수 이름, 주석, 테스트 케이스 추가)은 로컬에 모아뒀다가 한 번에 밀었다. 리뷰 중 같은 복귀 흐름에서 발견된 보안 문제(복귀 주소의 authority escape)도 함께 수정해 한 번의 푸시에 포함했다. 3주 동안 내 푸시는 리뷰 반영 묶음 하나, 실기기 결함 수정 하나, 마지막 충돌 해소 하나 정도였다.

충돌 예보

main 은 그 사이 200 커밋 넘게 움직였다. 이 저장소에는 열린 PR 브랜치에 main 을 자동으로 머지해 넣는 봇이 있어서, 에픽 브랜치에는 봇의 머지 커밋이 200건 안팎 쌓였다. 봇이 머지에 성공하는 동안은 걱정할 게 없다. 문제는 봇이 실패했을 때 인데, 실패는 조용하다. 그래서 배포가 다가오면 직접 확인했다.

git fetch origin main epic/feature-x
git merge-tree --write-tree origin/main origin/epic/feature-x

merge-tree --write-tree 는 워킹 트리를 건드리지 않고 두 브랜치를 가상으로 머지한 결과 트리를 만든다. 충돌이 있으면 종료 코드가 1 이고 충돌 파일 목록이 나온다. 체크아웃도, stash 도 필요 없다. 브랜치를 전환하지 않아도 되는 게 중요했는데, 이유는 아래에 있다.

배포 전날 아침에 돌렸을 때는 깨끗했다. 같은 날 배포 예정인 다른 팀 PR 과의 파일 겹침도 0 이었다. 그런데 저녁에 충돌이 났다. 상대는 다른 팀이 아니라 내가 같은 랜딩 페이지에 낸 다른 PR 이었다. 오후에 그 PR 이 main 에 들어갔고, 한 유틸 파일에 두 PR 이 각각 함수를 추가한 상태였다. 봇은 이 머지에서 멈췄다. 양쪽을 다 남기는 머지 커밋 하나로 끝났고, 테스트도 양쪽 것이 다 돌았다.

예보는 돌린 시점의 답이다. 그 뒤에 main 이 또 움직이면 답도 바뀐다. 아침 예보만 믿고 저녁에 안 봤으면 배포 당일 아침에 발견했을 것이다.

공유 QA 브랜치에 브랜치 전환 없이 반영하기

QA 환경은 qa 라는 공유 브랜치에서 배포된다. 여러 팀이 자기 브랜치를 여기에 머지해서 올린다. 3주 동안 몇 번 우리 변경을 다시 올려야 했다. 누군가 qa 를 최신 main 으로 갈아엎었을 때(합의된 운영 방식이다), QA 팀이 다시 요청했을 때, 실기기 결함을 수정한 뒤.

제약이 하나 있었다. 같은 저장소에서 다른 세션이 동시에 다른 브랜치로 작업 중이라 내가 브랜치를 체크아웃하면 그쪽이 깨진다. 그래서 워킹 트리를 건드리지 않고 원격 브랜치를 갱신하는 방법이 필요했다.

# 1. 가상 머지로 결과 트리를 만든다
TREE=$(git merge-tree --write-tree origin/qa origin/epic/feature-x)

# 2. 그 트리로 머지 커밋 객체를 만든다 (부모 둘)
MERGE=$(git commit-tree "$TREE" -p origin/qa -p origin/epic/feature-x -m "Merge epic/feature-x into qa")

# 3. 커밋 SHA 를 원격 qa 로 밀어넣는다
git push origin "${MERGE}:refs/heads/qa"

체크아웃이 없다. 로컬 qa 브랜치도 없다. 원격 qa 의 끝에 머지 커밋이 하나 붙을 뿐이다. 다른 세션은 아무것도 모른다. 1번과 3번 사이에 누군가 qa 를 먼저 밀면 마지막 push 가 non-fast-forward 로 거부되는데, 그때는 처음부터 다시 하면 된다. force 는 쓰지 않는다.

마지막 반영 때는 전체 머지가 다른 팀 파일에서 충돌했다. 우리 변경과 무관한 충돌을 내가 풀 이유는 없다. 필요한 커밋 하나만 cherry-pick 하려는데 이것도 체크아웃이 필요하다. 임시 워크트리를 스크래치 디렉터리에 만들어 거기서 했다.

git worktree add /tmp/scratch/qa-wt origin/qa
git -C /tmp/scratch/qa-wt cherry-pick <sha>
git -C /tmp/scratch/qa-wt push origin HEAD:refs/heads/qa
git worktree remove /tmp/scratch/qa-wt

한 가지 함정. zsh 에서 git push "$MERGE:refs/heads/qa":r 을 modifier 로 해석해 참조가 깨진다. "${MERGE}:refs/heads/qa" 처럼 중괄호로 감싸야 한다. 처음 한 번 실패하고 배웠다.

솔직히 말하면 commit-tree 까지 내려가는 건 읽기 어렵다. 같은 목적이면 위의 worktree 방식이 대개 더 낫고, 이 저수준 명령은 “워킹 트리를 절대 건드리면 안 된다” 는 제약이 있을 때의 선택지다.

공유 브랜치에 force push 는 하지 않았다. 갈아엎는 건 팀 합의로 한 사람이 하고, 나머지는 위에 얹기만 한다.

초록 체크가 통과는 아니었다

PR 체크 중 정적 분석 게이트가 초록이었는데 열어보니 “Polling timeout” 이었다. 워크플로가 continue-on-error: true 라 실패해도 초록으로 보인다. 게이트 서버 쪽 일시적 문제였고, 마지막 푸시에서 제대로 통과한 것을 로그로 확인하고 나서야 안심했다. 체크 목록의 색이 아니라 로그를 보는 습관은 빌드는 초록이었고, 컨테이너는 부팅하자마자 죽었다 이후 두 번째로 값을 했다.

세션이 바뀌어도 이어지게

3주면 작업 세션이 여러 번 끊긴다. 에이전트와 같이 일할 때는 대화 컨텍스트가 압축되거나 새 세션이 열리기도 한다. 티켓 디렉터리에 인계 메모 하나를 두고 상태가 바뀔 때마다 갱신했다. PR 번호, 승인 상태, QA 브랜치에 무엇이 올라가 있는지, 배포 날짜, 남은 일. 새 세션은 이 파일부터 읽는다. 내가 사흘 뒤에 돌아와도 같은 파일을 읽는다.

메모에 넣지 않은 건 코드가 이미 말하는 것이다. 어떤 파일을 왜 바꿨는지는 커밋과 PR 본문에 있다. 메모는 “지금 어디까지 왔고 다음이 뭔지” 만 적는다.

배운 것

  1. 대기 중인 PR 은 Draft 로 둔다. 승인은 남고 머지는 막힌다. 제목의 “머지 금지” 보다 확실하다.
  2. 푸시는 비용이다. 승인이 리셋되는 구조에서는 작은 수정을 모아서 민다.
  3. 충돌은 예보하되, 예보는 스냅숏이다. git merge-tree --write-tree 한 줄은 워킹 트리를 건드리지 않으니 언제든 돌릴 수 있다. 자동 동기화 봇이 있어도 봇의 실패는 조용하므로, 배포 직전에는 직접 돌린다. 특히 자기 PR 이 main 에 들어간 직후에.
  4. 공유 브랜치는 체크아웃 없이 갱신할 수 있다. merge-treecommit-treepush <sha>:refs/heads/<branch>, 또는 임시 worktree. 같은 작업 디렉터리를 병렬 세션이 공유하는 환경에서 유용했다.
  5. 정기 배포가 없어지면 대기 비용은 PR 이 진다. 열차가 대신 기다려주지 않는다. 그만큼 PR 을 살려두는 절차가 필요하다.

읽을 거리