브랜치를 갈아끼우지 마세요 (부제: 디렉터리를 하나 더 파면 됩니다)

긴 마이그레이션 브랜치를 하나 들고 있으면서, 동시에 기존 앱에 바로 나가야 할 수정도 쳐내야 하는 시기가 있습니다. 그러면 하루에도 몇 번씩 두 브랜치를 왔다 갔다 하게 됩니다.

여기서 손이 자동으로 이렇게 움직이죠.

git switch fix/urgent-thing

그리고 빌드가 터집니다. git status는 깨끗합니다. diff도 멀쩡합니다. 그런데 빌드가 안 됩니다.

git이 안 보여주는 것들이 브랜치를 안 따라옵니다

git switch추적 중인 파일을 갈아치웁니다. 문제는 프로젝트를 실제로 굴러가게 만드는 물건의 상당수가 추적 대상이 아니라는 겁니다.

React Native로 예를 들면 이런 것들이요.

  • Metro 캐시
  • Watchman 캐시
  • node_modules
  • iOS/Android 빌드 leftover (Pods, DerivedData, .gradle, build/)

전부 .gitignore에 들어 있습니다. 그러니까 브랜치를 바꿔도 얘네는 안 바뀝니다. 소스는 브랜치 B가 됐는데 캐시는 아직 브랜치 A의 것인, 어중간한 잡종 상태가 되는 거죠.

이게 왜 고약하냐면, git이 아무 문제도 없다고 말하기 때문입니다. 워킹 트리는 깨끗하고, 커밋도 정상이고, 코드도 맞습니다. 그런데 번들러는 지워진 모듈을 찾고 있고, Pods는 다른 버전을 물고 있고, 에러 메시지는 이 브랜치에 존재하지도 않는 파일을 가리킵니다. (범인이 git 바깥에 있으니 git으로는 영원히 못 찾습니다. 저는 이 사실을 꽤 여러 번 다시 배웠습니다.)

해결책은 늘 똑같습니다. 캐시 지우고, node_modules 날리고, 다시 설치하고, 다시 빌드. 한 번에 5분에서 15분. 그리고 30분 뒤에 원래 브랜치로 돌아가면 그걸 또 합니다.

체감상 이 문제는 RN 쪽이 유독 잦습니다. 네이티브 빌드 산출물, JS 번들러 캐시, 패키지 매니저 상태가 전부 따로 놀고, 각자 자기만의 캐시를 들고 있으니까요.

여기에 앱을 띄워둔 상태였다면 하나 더 얹힙니다. Metro 같은 번들러는 공격적인 파일시스템 워처로 핫 리로드를 돌리는데, 브랜치 전환으로 수백 개 파일이 한순간에 바뀌면 워처가 그걸 전부 감지합니다. 풀 리로드, 리빌드, 그리고 시뮬레이터에서 여기까지 들어오려고 거쳐온 화면들은 증발합니다.

즉 브랜치 전환의 진짜 비용은 git 명령 자체가 아니라 그 뒤에 딸려오는 복구 시간입니다. 그리고 그 비용은 브랜치를 오갈수록 곱해집니다.

그래서, 디렉터리를 하나 더 팝니다

git에는 이걸 정면으로 해결하는 기능이 있습니다. git worktree입니다.

git worktree add ../feature-branch-B feature/task-B

이 한 줄이 하는 일은 이렇습니다. 같은 저장소에 연결된 두 번째 작업 트리../feature-branch-B 경로에 만듭니다. 그리고 거기에 feature/task-B 브랜치를 체크아웃해둡니다.

핵심은 이겁니다. 각 worktree는 자기만의 작업 디렉터리와 자기만의 인덱스를 갖습니다. 그러니까 한쪽에서 파일을 고치든 스테이징을 하든, 다른 쪽은 아무 영향도 받지 않습니다.

원래 있던 디렉터리는 그대로 있습니다. Metro도 그대로 돌고 있고, 시뮬레이터에 띄워둔 앱도 그대로입니다. 그리고 아까 그 골칫거리들 — Metro 캐시, node_modules, 빌드 산출물 — 도 원래 브랜치의 것으로 그대로 남아 있습니다. 왜냐하면 진짜로 아무 일도 없었으니까요. 새 브랜치는 옆 동네에 열렸거든요.

여기서 정직하게 하나 짚고 갑시다. worktree는 캐시 문제를 없애주지 않습니다. 분리해줄 뿐입니다. 새 worktree는 빈 디렉터리라서 거기서도 한 번은 설치하고 빌드해야 합니다.

차이는 그다음입니다. 브랜치를 오갈 때마다 캐시가 어긋나는 게 아니라, 각 디렉터리의 캐시가 자기 브랜치와 계속 맞아떨어진 채로 남습니다. 설치 비용을 브랜치 전환 횟수만큼 내느냐, worktree 개수만큼 한 번씩만 내느냐의 차이인 거죠. 하루에 열 번 오가는 상황이라면 이 차이가 꽤 큽니다.

clone을 새로 뜨면 되지 않냐고요? 그것도 방법이지만 저장소 히스토리를 통째로 한 벌 더 받아야 하고, 두 클론은 서로 남남이 됩니다. worktree는 같은 저장소를 공유합니다. 커밋도, 브랜치도, 리모트 설정도 한 몸이에요.

겁먹지 않아도 되는 이유: 브랜치는 디렉터리에 살지 않는다

worktree를 처음 쓸 때 가장 많이 하는 걱정이 이겁니다. "이 디렉터리 지우면 브랜치도 날아가는 거 아냐?"

아닙니다. 브랜치 참조는 메인 저장소의 중앙 .git 디렉터리에 저장됩니다. worktree 디렉터리는 그 브랜치를 잠깐 펼쳐놓은 작업 공간일 뿐이에요.

그러니 물리 디렉터리를 지워도 브랜치 자체는 사라지지 않습니다. 이미 푸시해서 올린 PR도 당연히 그대로 남아 있고요.

정리는 이 명령으로 합니다.

git worktree remove --force ../feature-branch-B

다만 --force가 붙어 있으니 조건이 하나 있습니다. 모든 변경이 커밋돼서 working tree가 dirty하지 않을 때에만 안전합니다. 커밋 안 한 작업이 남아 있는데 --force를 때리면, 그건 그냥 지우겠다는 뜻이거든요. (이 "dirty할 때 조심하라"는 이야기는 뒤에서 한 번 더 나옵니다. 제가 이걸로 한 번 데였어서요.)

실제로 쓰는 모양

말로만 하면 감이 안 오니까, 제가 실제로 쓰는 시퀀스를 그대로 옮겨봅니다. 업스트림에 PR 하나 올리는 상황입니다.

git worktree add ../llm-wiki-pr -b pr/feature-branch upstream/main
cd ../llm-wiki-pr
git cherry-pick <commit-hash>
git push origin pr/feature-branch
gh pr create
cd -   # Metro가 그대로 돌고 있는 메인 디렉터리로 복귀

여섯 줄이고, 마지막 줄이 이 글의 전부입니다. cd - 하고 돌아왔을 때 Metro는 여전히 살아 있고, 앱도 아까 그 화면에 그대로 떠 있습니다. PR을 하나 올리는 동안 메인 작업 디렉터리는 단 한 번도 건드려지지 않았습니다.

브랜치를 갈아끼우는 게 아니라 디렉터리를 하나 더 파는 것. 차이는 이게 전부인데, 체감은 꽤 큽니다.

함정: stash는 worktree마다 따로 논다

여기까지가 좋은 이야기고, 이제 제가 직접 데인 이야기를 해야 합니다.

git의 stash 목록은 working tree 단위로 스코프됩니다. 링크된 worktree 안에서 만든 stash는 메인 worktree에서 git stash list를 쳐도 나오지 않습니다.

이게 왜 문제냐면, 도구를 만들 때 이걸 놓치기 딱 좋기 때문입니다.

저는 minimal-git-explorer라는 VS Code 확장을 만들어 쓰고 있습니다. 브랜치랑 stash를 사이드바에서 보여주는 소박한 물건인데요. 이게 한동안 메인 worktree의 stash만 조회하고 있었습니다.

에러가 난 게 아닙니다. 그랬으면 차라리 나았죠. 그냥 조용히, 아무 일 없다는 얼굴로 누락된 목록을 보여줬습니다. 다른 worktree에 처박아둔 stash는 사이드바에 존재하지 않는 물건이 되는 겁니다. (내 도구가 나한테 거짓말을 하고 있었다는 걸 알았을 때의 그 기분, 아시나요.)

그래서 0.2.0에서 고쳤습니다. CHANGELOG의 Fixed 항목에 이렇게 적어뒀습니다.

  • "Collected stashes from every worktree instead of only the main worktree"
  • "Stabilized repository discovery and stash reference handling across worktrees"

worktree를 안 썼으면 평생 몰랐을 버그입니다. 그리고 여러분이 쓰는 다른 git GUI도 이 부분을 어떻게 처리하는지는 한번 확인해보시는 게 좋아요. worktree를 쓰기 시작한 순간부터, "stash 목록이 비어 있다"는 "stash가 없다"와 다른 말이 됩니다.

곁다리: 클릭 한 번은 왜 위험한가

같은 확장을 만들면서 배운 게 하나 더 있는데, worktree랑도 연결되는 이야기라 짧게 붙입니다.

0.1.0에 이런 항목이 있습니다. "Confirmation dialogs before checkout or stash apply when the working tree is dirty" working tree가 더러울 때 checkout이나 stash apply 전에 확인 창을 띄운다는 뜻이죠. 0.2.0에서는 stash apply 쪽 가드를 한 번 더 손봤고요.

왜 이런 걸 넣었냐면, GUI가 마찰을 지워버리기 때문입니다.

git checkout other-branch를 손으로 타이핑하는 동안에는 마음속으로 취소할 틈이 있습니다. "어 잠깐, 나 아까 그거 커밋했나?" 하는 그 0.5초요. 그런데 트리 항목을 클릭하는 데는 그 틈이 없습니다. 손가락이 먼저 도착합니다.

그래서 이렇게 만들었습니다. git status --porcelain으로 확인해서 깨끗하면 아무것도 묻지 않고 그냥 실행합니다. 더러우면 어떤 작업을 하려는 건지, 뭐가 위험한지 명시한 모달을 띄우고, 명시적으로 승인해야만 진행합니다.

여기서 유혹이 하나 있습니다. "항상 확인" 전역 설정으로 퉁치고 싶어지거든요. 구현도 훨씬 쉽고요. 그런데 그러면 안 됩니다. clean일 때의 빠른 경로가 도구를 경쾌하게 만들고, dirty일 때의 정지가 도구를 안전하게 만드는 건데, 하나로 합치면 둘 다 잃습니다. 매번 묻는 도구는 곧 아무도 안 읽는 도구가 되니까요. (확인 창 세 번째부터는 그냥 엔터 치잖아요. 네. 하셨죠?)

정리 — worktree 하나당 관심사 하나

몇 가지만 짚고 닫겠습니다.

worktree는 독립적인 병렬 작업을 위한 도구입니다. A와 B가 서로 상관없을 때 진가가 나옵니다. 서로 의존하는 순차 작업은 stacked PR의 영역이고, 둘은 경쟁 관계가 아니라 같이 쓰는 물건입니다.

그리고 저한테 worktree는 임기응변이 아니라 지정된 구조적 병렬화 수단입니다. 특히 서브에이전트를 여러 개 굴릴 때 그렇습니다. 각자 자기 worktree에서 돌지 않으면 쓰기 충돌이 나거든요. 그래서 규칙은 하나입니다. worktree 하나당 관심사 하나.

멀티패키지 모노레포에서는 부수효과가 하나 더 붙습니다. worktree 하나를 패키지 하나에 스코프해두면, "패키지별로 커밋을 묶는다"는 게 수동 규율이 아니라 구조적으로 강제되는 결과가 됩니다. 다른 패키지 파일이 아예 그 디렉터리에 없으니까 섞을 수가 없어요. 규율을 지키는 것보다 규율을 지킬 수밖에 없게 만드는 쪽이 언제나 싸게 먹힙니다.


정리하면 이렇습니다. 브랜치를 갈아끼우면 작업 디렉터리가 통째로 흔들리고, 그 위에 얹혀 있던 개발 환경이 같이 무너집니다. 그럴 바엔 디렉터리를 하나 더 파세요. 명령 한 줄이면 됩니다.

오늘 당장 해볼 거라면 이 순서를 추천합니다. 지금 열려 있는 프로젝트에서 git worktree add ../tmp-review <아무-브랜치> 한 번 쳐보고, 그쪽에서 뭘 좀 만져본 다음, 원래 디렉터리로 돌아와서 띄워둔 게 아무 일 없이 그대로인지 확인해보세요. 그 순간이 이 글의 전부입니다. 저는 그거 확인하고 한참 실실 웃었어요.

아, 그리고 stash. 그거 잊지 마세요. 저처럼 자기가 만든 도구한테 배신당하고 나서 알게 되면 좀 억울하거든요.