Infra

git worktree: 브랜치와 뭐가 다르고, AI 에이전트와 왜 같이 쓰나

Kun Woo Kim 2026. 9. 4. 13:07
728x90
반응형

알고는 있었지만 쓸 일이 없었다

git worktree를 처음 본 건 꽤 오래전이다. Git 2.5에 들어왔으니 2015년부터 있던 기능이고, 어딘가에서 읽고 "이런 게 있구나" 하고 넘어갔던 기억이 있다.

그리고 그게 끝이었다. 회사 프로젝트는 브랜치 기준으로 협업했다. 티켓을 하나 잡고, 브랜치를 파고, 작업하고, PR을 올리고, 머지하고, 다음 티켓으로 넘어간다. 이 흐름에서는 작업 공간이 하나면 충분하다. git switch 한 번이면 되는데 폴더를 따로 만들 이유가 없었다.

바뀐 건 AI 에이전트로 코딩하기 시작하면서였다. 터미널을 여러 개 띄우고 에이전트를 동시에 돌리는 일이 많아졌고, 그러다 이런 메시지를 보게 됐다.

로컬 main은 다른 디자인 작업용 worktree가 사용 중이라, 현재 디렉터리는 안전하게 기능 브랜치에 그대로 두었습니다.

도구가 알아서 worktree를 쓰고 있었다. 그것도 정확히 필요한 이유로.

10년 넘게 존재했는데 왜 하필 지금 쓰게 됐을까. 답은 단순했다. 그동안 나는 한 번에 하나씩 작업했고, 이제는 아니기 때문이다.

worktree가 무엇이고, 왜 AI 에이전트와 함께 쓰이는지 정리해봤다.


브랜치는 코드의 사본이 아니다

worktree를 이해하려면 먼저 브랜치와 작업 파일을 분리해서 봐야 한다. 나는 오랫동안 브랜치를 코드의 사본처럼 어렴풋이 생각하고 있었다.

main 브랜치용 코드
feature 브랜치용 코드
hotfix 브랜치용 코드

완전히 틀린 그림이었다. Git이 브랜치를 만들 때 프로젝트 파일을 복사하지는 않는다.

$ git rev-parse main
a3f2c891d4e5b6a7c8d9e0f1a2b3c4d5e6f7a8b9

main이라는 이름을 해석했더니 커밋 ID 하나가 나왔다. 로컬 브랜치는 그 브랜치의 최신 커밋을 가리키는 이름표다. 새 커밋을 만들면 이 이름표가 새 커밋 쪽으로 옮겨간다. git branch feature/login이 순식간에 끝나는 것도 복사할 게 없기 때문이다.

그럼 우리가 에디터로 열어서 고치는 그 파일들은 무엇일까.


빠져 있던 개념: working tree와 index

우리가 src/, package.json을 열어 수정하는 공간이 working tree다. 현재 checkout한 커밋의 파일들이 디스크에 펼쳐져 있고, 아직 커밋하지 않은 변경사항도 여기 있다.

그 사이에는 index가 있다. git add로 올려둔, 다음 커밋에 들어갈 상태를 담는 공간이다. 그리고 HEAD 가 지금 어느 브랜치나 커밋을 보고 있는지를 기록한다.

working tree  →  index  →  repository
   (파일)      (다음 커밋)   (커밋 객체)

평범한 checkout에서는 이 세 가지가 하나의 작업 폴더에 묶여 있다. 그래서 git switchHEAD를 새 브랜치로 옮기고, index와 추적 파일을 그 브랜치 상태에 맞게 갱신한다.

git switch feature/login

브랜치를 바꿀 때마다 보고 있던 폴더의 내용이 달라지는 이유다.

여기서 이 글의 출발점이 되는 사실이 나온다. git clone을 하면 처음에는 작업 폴더가 하나만 생긴다. 브랜치는 열 개든 백 개든 만들 수 있지만 펼쳐놓을 자리는 하나뿐이라, 우리는 같은 폴더를 계속 재사용하며 브랜치를 갈아끼워 왔다.

한 번에 하나씩 작업하는 동안에는 이게 전혀 불편하지 않다.


worktree는 작업 공간을 하나 더 연결한다

git worktree가 하는 일이 정확히 이것이다. 같은 저장소에 작업 공간을 하나 더 연결한다.

Git 문서는 처음 만들어진 작업 공간을 main worktree, 이후에 추가한 것을 linked worktree라고 부른다.

                  ┌────────────────────────────┐
                  │       공통 Git 데이터       │
                  │  커밋 · 브랜치 · 원격 설정   │
                  └─────────────┬──────────────┘
                ┌───────────────┼───────────────┐
                ▼               ▼               ▼
          ~/project        ~/project-feat   ~/project-hotfix
          HEAD → main      HEAD → feature   HEAD → hotfix
          별도 index       별도 index        별도 index
          별도 파일         별도 파일          별도 파일

정리하면 이렇게 나뉜다.

공유하는 것 — 커밋 객체, 브랜치와 태그 refs, 원격 설정, 저장소 config, 그리고 stash.

worktree마다 따로 갖는 것 — checkout된 실제 파일, index, HEAD, 병합이나 리베이스 중간 상태.

이 구분이 worktree의 전부다. 같은 저장소인데 서로 다른 브랜치를 동시에 열어둘 수 있는 이유가 HEAD와 index가 분리되어 있다는 데 있다.

~/project-feat에서 커밋하면 새 커밋과 브랜치 ref는 공통 저장소에 바로 기록된다. 다만 ~/project에서 인자 없이 git log만 치면 안 보일 수 있다. 그 명령은 현재 HEAD에서 도달 가능한 히스토리만 보여주기 때문이다. 브랜치를 지정하거나 전체를 봐야 한다.

git log feature/login
git log --all --oneline --graph --decorate

clone을 여러 번 하는 것과의 차이

작업 공간을 여러 개 만드는 가장 단순한 방법은 저장소를 여러 번 clone하는 것이다. 그것도 동작한다.

차이는 하나다. clone은 서로 독립된 저장소이고, worktree는 하나의 저장소에 연결된 여러 작업 공간이다. clone한 폴더에서 만든 커밋은 다른 clone에 자동으로 나타나지 않아 fetch 같은 동기화가 필요하다. worktree는 저장소를 공유하니 그 과정이 없다.

에이전트 세 개가 각자 커밋을 쌓고 있을 때, 그 결과를 한곳에서 바로 볼 수 있느냐 없느냐는 꽤 큰 차이다.


실제로 써보자

기존 브랜치를 새 폴더에 열기

git worktree add ../project-feat feature/login

새 브랜치를 만들면서 열기

git worktree add -b feature/login ../project-feat main

마지막 인자가 시작점이다. 생략하면 현재 worktree의 HEAD에서 브랜치가 갈라진다. 다른 기능 브랜치에서 작업하던 중이었다면 엉뚱한 곳에서 분기하게 되니, 기준 브랜치를 명시하는 편이 안전하다.

PR을 실행만 해볼 때

git fetch origin
git worktree add --detach ../project-review origin/feature/payment

여기서 주의할 점이 있다. origin/feature/payment처럼 원격 추적 ref를 넘기면 로컬 브랜치가 만들어지지 않고 detached HEAD 상태가 된다. 실행해보고 지울 목적이라면 오히려 적합하니, 의도를 드러내는 --detach를 붙이는 게 좋다.

리뷰 중에 직접 고치고 커밋할 생각이라면 로컬 브랜치를 만들어야 한다.

git worktree add -b review/payment ../project-review origin/feature/payment

목록 확인

$ git worktree list
/Users/me/project           a3f2c89 [main]
/Users/me/project-feat      b7d1e02 [feature/login]
/Users/me/project-review    c9a4f13 (detached HEAD)

어느 경로에 어떤 브랜치 또는 커밋이 checkout되어 있는지 볼 수 있다.

정리

git worktree remove ../project-feat

수정된 파일이나 추적되지 않은 파일이 남아 있으면 Git은 기본적으로 제거를 거부한다. 필요한 작업을 커밋하거나 백업한 뒤 지우고, 정말 버려도 되는 작업 공간에서만 --force를 쓴다.

worktree를 제거해도 그 브랜치는 남는다. 병합이 끝나 필요 없어졌다면 따로 지운다.

git branch -d feature/login

AI 에이전트와 worktree

여기가 이 글을 쓰게 된 이유다.

왜 지금 필요해졌나

사람은 두 브랜치를 동시에 편집할 일이 거의 없다. 손이 하나고 눈이 한 쌍이니까. 그래서 작업 공간이 하나뿐이라는 게 제약으로 느껴지지 않았다.

에이전트는 다르다. 터미널 세 개에 각각 다른 작업을 맡기면 세 프로세스가 진짜로 동시에 파일을 읽고 쓴다. 여기서 같은 폴더를 공유하면 어떻게 되는가.

세션 A: UserCard.tsx 읽음 → 리팩터링 계획 세움
세션 B: UserCard.tsx 수정함
세션 A: 자기가 읽은 내용 기준으로 덮어씀   ← B의 작업 증발

더 나쁜 경우도 있다. A가 git status를 찍었는데 B가 만든 변경사항이 같이 잡힌다. A는 그걸 자기 작업으로 인식하고 커밋에 포함시킨다. 커밋 하나에 두 작업이 뒤섞이고, 나중에 뭘 되돌려야 하는지 알 수 없게 된다.

worktree는 이걸 파일 경로 수준에서 끊는다. 세션마다 폴더가 다르니 A가 보는 UserCard.tsx와 B가 보는 UserCard.tsx는 물리적으로 다른 파일이다.

실제 구성

수동으로 한다면 이렇게 된다.

git worktree add -b agent/refactor ../project-refactor main
git worktree add -b agent/tests    ../project-tests    main
git worktree add -b agent/docs     ../project-docs     main

각 폴더에서 의존성을 설치하고 에이전트를 띄운다.

Claude Code를 쓴다면 이 과정이 내장돼 있다. --worktree(또는 -w)에 이름을 주면 worktree를 만들고 그 안에서 세션을 시작한다.

claude --worktree refactor

기본 위치는 저장소 루트의 .claude/worktrees/<name>/이고, 브랜치는 worktree-<name>으로 생성된다. 저장소 내부라서 main checkout에서 untracked로 잡히니 .gitignore.claude/worktrees/를 넣어둬야 한다.

주의할 점은 새 worktree의 시작점이다. 2026년 9월 현재 기본값은 현재 로컬 브랜치가 아니라 원격 저장소의 기본 브랜치를 기준으로 삼는다. feature 브랜치에서 실행하면서 "당연히 지금 상태를 복제하겠지"라고 생각하면 어긋난다. 현재 로컬 HEAD에서 출발시키려면 설정을 바꿔야 한다.

{
  "worktree": {
    "baseRef": "head"
  }
}

한 가지 편한 점은 gitignore된 파일 처리다. 새 worktree는 새 checkout이라 .env.local 같은 파일이 따라오지 않는데, 프로젝트 루트에 .worktreeinclude를 두면 자동으로 복사된다. 패턴은 .gitignore 문법을 따르고, 실제로 ignore된 파일만 복사 대상이 된다.

# .worktreeinclude
.env
.env.local

여기에 운영 credential이 든 파일까지 넣지는 않는 편이 좋다. 모든 AI 세션 폴더에 그대로 복사되므로, worktree에는 개발용 값만 넘기는 쪽이 안전하다.

그리고 이건 Git 기능이 아니다. git worktree add로 직접 만든 worktree에는 적용되지 않는다. 수동으로 쓸 때는 그냥 복사하면 된다.

정리 방식도 일반 Git과 다르다는 걸 알아둘 필요가 있다. 뒤에서 보겠지만 git worktree remove는 worktree만 지우고 브랜치는 남긴다. 반면 Claude Code는 세션을 종료할 때 worktree를 유지할지 물어보고, 제거를 선택하면 브랜치와 그 안의 작업까지 함께 삭제한다. 깨끗한 상태의 이름 없는 세션은 묻지 않고 바로 정리한다. push하지 않은 커밋이 있는지 확인하고 답하는 게 좋다.

반대로 에이전트가 도는 중에는 Claude Code가 worktree에 lock을 걸어두기 때문에, 수동으로 git worktree remove를 하면 거부된다. 이때는 git worktree unlock을 먼저 해야 한다.

cp ../project/.env.local .
npm ci

worktree 밖의 자원은 격리되지 않는다

여기서 오해하기 쉬운 지점이 있다. worktree가 분리하는 것은 각 에이전트의 파일과 worktree별 Git 상태(HEAD, index) 다. 그 이상은 아니다.

실행 중에 서로의 파일을 덮어쓰는 문제는 막지만, 나중에 브랜치를 합칠 때 생기는 논리적 충돌이나 merge conflict까지 없어지지는 않는다. 두 에이전트가 같은 파일의 같은 줄을 다르게 고쳤다면 머지 단계에서 평범하게 충돌한다. 오히려 각자 격리된 채로 작업했기 때문에 서로가 뭘 했는지 모른 상태로 충돌이 쌓인다.

파일 밖의 자원도 그대로 공유된다.

격리됨:    파일 · index · HEAD
공유됨:    브랜치 refs · stash · 기본 저장소 config · hooks
격리 안 됨: 포트 · DB · Docker · 외부 API · 테스트 계정

실제로 부딪히는 건 대부분 마지막 줄이다.

포트. 에이전트 셋이 각자 dev 서버를 띄우려 하면 3000번을 두고 싸운다. 스크립트에 포트를 박아두지 말고 환경 변수로 빼두는 게 좋다.

PORT=3001 npm run dev

데이터베이스. 마이그레이션을 돌리는 에이전트가 있다면 다른 세션의 로컬 DB까지 같이 바꾼다. 브랜치별로 DB 이름을 나누거나, 마이그레이션 작업은 한 번에 하나만 돌리는 쪽이 안전하다.

Docker. Compose는 project name으로 컨테이너와 네트워크, 볼륨의 이름 공간을 나눈다. 기본값이 대개 Compose 파일이 있는 디렉터리 이름이라, worktree 폴더명이 다르면 자동으로 분리되는 경우가 많다. 다만 name:이나 COMPOSE_PROJECT_NAME으로 이름을 고정했거나 container_name을 박아뒀다면 다시 겹친다. host port는 project name과 무관하게 충돌한다. 확실히 하려면 폴더마다 따로 준다.

docker compose -p project-refactor up

머지는 여전히 사람 일이다

에이전트를 병렬로 돌릴 때 가장 중요한 건 도구 설정이 아니라 작업을 어떻게 쪼개서 배정하느냐다.

파일과 책임 경계가 겹치지 않게 나누면 통합 비용이 작다. 겹치게 나누면 세 배로 일한 결과가 충돌 해결에 다시 소모된다.

주의할 건 파일만 갈라놓으면 된다고 생각하기 쉽다는 점이다. 서로 다른 파일을 고쳐도 같은 API나 schema, 타입 계약에 의존하면 논리적으로 깨진다. 한 에이전트가 응답 타입을 바꾸는 동안 다른 에이전트가 예전 타입을 전제로 UI를 짜면, 머지는 깔끔하게 되고 빌드가 터진다.

컴포넌트의 public API를 바꾸는 리팩터링과 그 API를 기준으로 테스트를 쓰는 작업은 파일이 달라도 병렬화하기 어렵다. 서로 무관한 결제 모듈 수정과 문서 정리는 안전하다.

합칠 때는 순서를 정해두는 편이 낫다. 셋을 한꺼번에 머지하면 어느 조합에서 깨졌는지 알기 어렵다.

cd ~/project              # main을 잡고 있는 원래 worktree

git merge agent/docs      # 충돌 가능성 낮은 것부터
git merge agent/refactor
git merge agent/tests

여기서 git switch main을 칠 필요는 없다. main은 이미 이 폴더가 쓰고 있고, 다른 worktree에서는 애초에 전환이 막힌다. 병합은 main을 checkout하고 있는 폴더로 가서 하는 게 맞다.

아직 원격에 공유하지 않은 로컬 에이전트 브랜치라면, 다음 브랜치를 합치기 전에 앞선 병합 결과 위로 rebase해두는 편이 좋다. 충돌을 각 통합 단계에서 나눠 다룰 수 있다. 이미 push해서 다른 사람이 보고 있는 브랜치라면 rebase는 커밋을 새로 만드니 얘기가 다르다.

cd ../project-refactor
git rebase main

cd ~/project
git merge agent/refactor

사람이 쓸 때도 값어치가 있다

에이전트가 계기이긴 했지만, 알고 나니 혼자 작업할 때도 쓸 데가 있었다.

브랜치를 바꾸는 것 자체는 빠르다. 문제는 그 주변이다.

  • package.json이나 락파일이 바뀌었으면 npm install을 다시 돌려야 한다
  • 브랜치별 변경 폭이 크면 .next, Vite, Turbopack 캐시를 그대로 재사용하지 못해 다음 dev 서버 기동이 느려진다
  • 진행 중인 변경이 대상 브랜치와 충돌하면 git stash나 임시 커밋이 필요하다
  • 실행 중이던 dev 서버와 에디터의 열린 탭, 검색 결과, LSP 인덱스가 흐트러진다

하루에 한 번이면 대수롭지 않다. 기능 개발 중에 핫픽스를 처리하고, 돌아와서 PR을 실행해보고, 다시 원래 작업으로 복귀하는 일이 반복되면 이야기가 달라진다.

그리고 이 비용 때문에 우리는 무의식적으로 행동을 바꾼다. "지금 브랜치 바꾸기 귀찮으니까 이 리뷰는 나중에 보자" 같은 판단을 하게 된다. 손해는 시간이 아니라 여기서 발생한다.

worktree가 이 비용을 없애주지는 않는다. 새 폴더를 만들 때마다 의존성을 설치하고 캐시를 다시 만들어야 한다. 정확히 말하면 이렇다.

worktree는 반복되는 전환 비용을, worktree를 처음 준비할 때의 설치 비용과 추가 디스크 사용량으로 바꾼다.

한 번 준비하고 나면 브랜치를 오갈 때마다 같은 상태를 다시 만들지 않아도 된다.

핫픽스. 기능 개발 중에 운영 이슈가 터졌을 때, 현재 폴더를 그대로 두고 새 폴더에서 처리한다.

git worktree add -b hotfix/123 ../project-hotfix main

작업이 끝나면 폴더와 브랜치를 정리한다. 원래 폴더의 파일, dev 서버, 열린 탭은 손대지 않은 그대로다.

PR 리뷰. 리뷰용 폴더를 다른 포트로 띄우면 내 작업은 3000번, 리뷰 대상은 3001번에서 동시에 볼 수 있다.

큰 마이그레이션. React 메이저 버전 변경이나 번들러 교체처럼 node_modules가 통째로 달라지는 브랜치는 오갈 때마다 재설치가 발생한다. 분리해둘 가치가 가장 큰 경우다.


알아둬야 할 제약들

기본적으로 같은 브랜치를 두 곳에서 checkout할 수 없다

$ git worktree add ../project-copy main
fatal: 'main' is already used by worktree at '/Users/me/project'

서두에 인용한 메시지가 바로 이 보호 장치였다.

이유는 이렇다. 각 worktree는 HEAD와 index를 따로 갖지만 브랜치 ref는 공유한다. 같은 브랜치를 두 곳에서 열어두면, 한쪽에서 커밋해 브랜치 ref가 앞으로 이동해도 다른 쪽의 파일과 index는 이전 상태에 남는다. 그 폴더에서는 손대지도 않은 파일들이 갑자기 변경된 것처럼 보인다.

--force로 우회할 수는 있지만 일반적인 작업에서 쓸 이유는 거의 없다.

stash는 worktree마다 따로 있지 않다

working tree와 index는 분리되지만 stash는 공통이다. refs/stash에 저장되고 이 ref는 공유되기 때문이다. 한 폴더에서 stash한 것이 다른 폴더의 git stash list에도 뜨고, 실수로 엉뚱한 곳에서 pop할 수도 있다.

worktree를 여러 개 쓰기 시작하면 메시지를 구체적으로 적는 편이 좋다.

git stash push -m "feature/login: 폼 검증 작업 중"

linked worktree의 .git은 파일이다

$ cat ../project-feat/.git
gitdir: /Users/me/project/.git/worktrees/project-feat

디렉터리가 아니라 공통 저장소 안의 전용 관리 디렉터리를 가리키는 포인터 파일이다. 대부분의 도구는 이 구조를 처리하지만, .git이 디렉터리라고 가정하고 짠 스크립트는 문제가 생길 수 있다. 경로가 필요하면 Git에게 물어보는 게 안전하다.

git rev-parse --git-dir          # 현재 worktree가 쓰는 Git dir
git rev-parse --git-common-dir   # 여러 worktree가 공유하는 Git dir

폴더만 지우기보다 git worktree remove를 쓴다

탐색기로 폴더를 지워도 저장소가 망가지지는 않는다. 다만 .git/worktrees/ 아래에 관리 정보가 남는다. 이미 지웠다면 정리해준다.

git worktree prune

위치를 옮길 때도 git worktree move를 쓰고, 이미 수동으로 옮겨 연결이 끊겼다면 git worktree repair로 복구할 수 있다.

디스크는 더 쓴다

커밋 객체는 공유하지만 checkout된 파일과 node_modules, 빌드 캐시는 폴더마다 실재한다. worktree가 세 개면 node_modules도 세 벌이다. 에이전트를 여러 개 돌리는 구성이라면 이게 은근히 쌓인다.

submodule을 쓴다면 먼저 검증한다

Git 공식 문서는 multiple worktree 환경의 submodule 지원이 아직 불완전하다고 경고한다. 프론트엔드 저장소에는 흔치 않지만, submodule을 적극적으로 쓰고 있다면 팀에 권하기 전에 실제 workflow로 먼저 확인하는 편이 좋다.


그래도 대부분은 브랜치로 충분하다

여기까지 읽고 지금 방식을 바꿔야 하나 싶다면, 그럴 필요 없다.

git worktree 자체는 대체로 개인의 로컬 작업 방식이다. 브랜치 전략은 문서로 정하고 온보딩에서 설명하지만, 내가 형제 폴더에 worktree를 하나 더 두었다고 해서 PR이나 CI 구조가 달라지지는 않는다. 팀원 누구도 알 필요가 없다.

다만 .gitignore, .worktreeinclude, setup script, 포트나 DB 이름 규칙을 저장소에 반영하기 시작하면 그 부분부터는 팀 규칙이 된다. 개인 도구로 시작해서 어느 선을 넘으면 공유 자산이 되는 셈이다.

그리고 다음 조건이면 그냥 git switch가 더 단순하다.

  • 작업이 짧다
  • 진행 중인 변경사항이 없다
  • 의존성과 빌드 환경 차이가 작다
  • 다른 브랜치를 동시에 실행할 필요가 없다

오타 하나 고치자고 폴더를 만들 이유는 없다. worktree를 쓰기 시작한 뒤에도 대부분의 브랜치는 여전히 git switch로 처리하게 된다.

핵심은 브랜치를 몇 개 갖고 있느냐가 아니라, 몇 개의 작업 상태를 동시에 살려둬야 하느냐다.


정리

worktree는 브랜치를 대체하는 기능이 아니다. 브랜치는 여전히 커밋을 가리키는 이름표고, 그 역할은 그대로다.

입문 관점에서 worktree가 따로 갖는 핵심은 세 가지다.

별도의 working tree
별도의 index
별도의 HEAD

이 셋을 추가하면서 커밋 객체와 브랜치 refs는 기존 저장소와 공유한다. 그래서 여러 브랜치를 서로 다른 폴더에 동시에 펼쳐둘 수 있다.

10년 넘게 존재했는데 내가 이제야 쓰게 된 이유는 명확하다. 그동안은 한 번에 하나씩 작업했기 때문이다. 브랜치를 바꾼다고 생각했지만 실제로는 하나뿐인 작업 공간을 계속 다른 상태로 갱신하고 있었고, 그걸로 충분했다.

에이전트를 여러 개 돌리기 시작하면서 처음으로 그게 충분하지 않게 됐다. 도구가 알아서 worktree를 쓰고 있던 것도 그래서였다.

그래서 판단 기준은 이 정도로 잡으면 된다.

한 작업을 닫고 다른 작업으로 넘어가는 상황이면 git switch가 단순하다.

두 작업을 동시에 살려둬야 한다면 worktree를 검토할 만하다.

에이전트를 두 개 이상 띄워보려던 참이라면, 아마 이미 두 번째에 해당한다.

728x90
반응형