Git from Hell 탈출기: checkout 버리고, 좀비 브랜치 자동 청소까지

아직 git checkout 쓰시나요? 현대적 Git 명령어와 좀비 브랜치 자동 청소 alias를 실전 경험 기반으로 정리했습니다.

Git from Hell 탈출기: checkout 버리고, 좀비 브랜치 자동 청소까지

1. 아직도 git checkout 쓰시나요?

git checkout은 Git의 만능 도구였습니다. 브랜치를 전환하고, 파일을 복구하고, 새 브랜치를 만드는 것까지 — 하나의 명령어가 너무 많은 일을 했죠.

# checkout이 했던 일들
git checkout develop          # 브랜치 이동
git checkout -b feature/login # 브랜치 생성 + 이동
git checkout -- src/app.ts    # 파일 변경 취소
git checkout HEAD -- .        # 워킹 트리 복구

문제는 이 명령어들의 의도가 완전히 다르다는 것입니다. "브랜치를 바꾸겠다"와 "파일을 되돌리겠다"는 전혀 다른 작업인데, 같은 checkout으로 처리하니 실수가 잦았습니다. 특히 git checkout -- .은 되돌릴 수 없는 파괴적 명령어인데, 브랜치 전환과 비슷한 문법이라 혼동하기 쉬웠습니다.

Git 2.23(2019년)부터 이 문제를 해결하기 위해 두 가지 명령어가 도입됐습니다.

git switch — 브랜치 전환 전용

# Before: checkout
git checkout develop
git checkout -b feature/login

# After: switch
git switch develop              # 브랜치 이동
git switch -c feature/login     # 브랜치 생성 + 이동 (-c = create)
git switch -                    # 이전 브랜치로 복귀

switch는 브랜치 전환만 담당합니다. 실수로 파일을 날릴 위험이 없어요.

git restore — 파일 복구 전용

# Before: checkout
git checkout -- src/app.ts
git checkout HEAD -- .
git reset HEAD src/app.ts

# After: restore
git restore src/app.ts              # 워킹 트리 변경 취소 (unstaged)
git restore --staged src/app.ts     # 스테이징 취소 (unstage)
git restore --staged --worktree .   # 둘 다 취소
git restore --source=HEAD~3 app.ts  # 3커밋 전 상태로 복구

restore는 파일 복구만 담당합니다. --staged로 unstage, --source로 특정 커밋 버전 복구까지 직관적으로 할 수 있습니다.

핵심 비교

용도 Before (checkout) After
브랜치 이동 git checkout develop git switch develop
브랜치 생성+이동 git checkout -b feat/x git switch -c feat/x
파일 변경 취소 git checkout -- file git restore file
스테이징 취소 git reset HEAD file git restore --staged file
이전 브랜치 git checkout - git switch -

checkout이 사라지는 건 아닙니다. 하위 호환성을 위해 계속 동작하지만, 새 코드에서는 switch/restore를 쓰는 것이 권장됩니다.


2. 브랜치 전략: 무겁지 않게, 그러나 체계적으로

고전적인 Git Flow(main, develop, feature, release, hotfix)는 체계적이지만 무겁습니다. 모든 프로젝트에 5단계 브랜치가 필요한 건 아닙니다.

명명 규칙 — 일관성이 핵심

# 좋은 예
feature/user-auth        # 또는 feat/user-auth
hotfix/login-error
release/v1.2.0
chore/update-deps

# 나쁜 예
my-branch               # 무슨 브랜치인지 모름
fix                     # 너무 모호
john/stuff              # 의미 없음

타입 접두어(feature/, hotfix/, chore/)를 쓰면 브랜치 목적이 한눈에 보입니다. 이슈 번호를 포함하면 더 좋습니다.

git switch -c feat/issue-123-oauth-integration

상황별 전략 선택

전략 적합한 상황 핵심
GitHub Flow 빠른 배포, 소규모 팀 main + feature 브랜치만
Git Flow 릴리스 주기가 있는 제품 main + develop + feature/release/hotfix
Trunk-based CI/CD 성숙, 대규모 팀 main에 직접 커밋 + feature flag

사이드 프로젝트에서는 GitHub Flow가 가장 현실적입니다. main에서 feature 브랜치를 따고, PR로 머지하면 끝입니다. 다만 한 가지 규칙은 지키세요 — main에 직접 코드 커밋하지 않기:

# main에서 실수로 코드를 수정했을 때
git switch -c feature/accidental-changes  # ← 브랜치를 만들어서 이동
git push -u origin feature/accidental-changes
# 그다음 PR을 만들어 머지

3. PR Merge 전략: 히스토리를 어떻게 관리할 것인가

PR을 머지하는 방법은 세 가지입니다. "히스토리를 예쁘게 만드는 것"이 목적이 아니라, 코드 리뷰와 추적을 용이하게 하는 것이 진짜 목적입니다.

Merge Commit (--no-ff)

gh pr merge --merge  # Merge commit 생성
*   Merge PR #40 (main)
|\
| * fix: address review feedback
| * feat: add tool target display
|/
*   Merge PR #39 (main)
  • 각 PR의 커밋 히스토리가 그대로 보존됩니다
  • "이 PR에서 무슨 변경이 있었나"를 추적하기 좋습니다
  • Git Flow에서 feature → develop 머지 시 권장합니다

Squash and Merge

gh pr merge --squash  # 모든 커밋을 1개로 합쳐서 머지
* feat: add tool target display (#40) (main)
* feat: add lines changed widget (#39)
  • typo fix, WIP, debug 같은 자잘한 커밋이 사라집니다
  • main 브랜치가 깔끔해집니다
  • 다만 세부 히스토리를 잃게 됩니다

Rebase and Merge

gh pr merge --rebase  # 커밋을 main 위에 재배치
* fix: address review feedback (main)
* feat: add tool target display
* feat: add lines changed widget
  • 일직선 히스토리를 만듭니다
  • 각 커밋이 보존되지만, 머지 포인트가 없어 PR 경계가 불분명합니다

어떤 전략을 선택할까?

개인적으로 Merge Commit을 사용합니다. 이유는 단순합니다:

  1. PR 단위로 변경사항을 추적할 수 있습니다
  2. git log --merges로 PR 목록을 볼 수 있습니다
  3. 문제가 생겼을 때 PR 단위로 revert할 수 있습니다
# Merge commit이면 PR 단위 revert가 간단
git revert -m 1 <merge-commit-hash>

4. Git Worktree: stash는 이제 그만

긴급 hotfix가 필요할 때, 보통 이렇게 하시죠?

# 구식 방법: stash → 브랜치 이동 → 작업 → 돌아오기
git stash
git switch main
git switch -c hotfix/urgent-bug
# ... 수정 작업 ...
git switch feature/my-work
git stash pop  # ← stash 충돌 가능성!

stash는 임시 저장소지 작업 공간 관리 도구가 아닙니다. 충돌이 나면 골치 아프고, 여러 stash가 쌓이면 어느 게 어느 건지 헷갈립니다.

Worktree로 병렬 작업

git worktree는 하나의 리포지토리에서 여러 브랜치를 동시에 다른 폴더로 체크아웃합니다.

# 현재 작업 중인 feature 브랜치는 그대로 두고
# 옆에 hotfix 작업 공간을 새로 만듦
git worktree add ../my-project-hotfix hotfix/urgent-bug

# 별도 폴더에서 hotfix 작업
cd ../my-project-hotfix
# ... 수정 → 커밋 → 푸시 ...

# 완료 후 정리
cd ../my-project
git worktree remove ../my-project-hotfix

핵심 장점:

  • 컨텍스트 전환 비용 제로: 파일 시스템 수준에서 분리되므로, IDE를 두 개 열어놓고 동시 작업이 가능합니다
  • 빌드 캐시 보존: 기존 브랜치의 node_modules나 빌드 결과물이 그대로 유지됩니다
  • stash 충돌 없음: 각 worktree가 독립된 워킹 트리를 가집니다

Worktree 관리 명령어

git worktree list            # 현재 worktree 목록
git worktree add <path> <branch>  # 새 worktree 생성
git worktree remove <path>   # worktree 제거
git worktree prune           # 삭제된 worktree 참조 정리

Claude Code도 --worktree 옵션으로 에이전트 세션을 격리된 worktree에서 실행할 수 있습니다. 이러면 에이전트가 현재 작업 중인 파일을 건드리지 않습니다.


5. 좀비 브랜치 청소: git gone 만들기

여기서부터가 실전입니다. PR을 머지하면 GitHub에서 remote 브랜치를 삭제하죠. 하지만 로컬 브랜치는 자동으로 삭제되지 않습니다. 프로젝트를 몇 달 하다 보면 이런 상태가 됩니다:

$ git branch
  feature/p1-benchmark-improvements    # ← remote에서 삭제됨
  feature/p2-batch-a-widgets           # ← remote에서 삭제됨
  feat/session-id-and-sessions-list    # ← 작업 중
* main

이 "좀비 브랜치"들이 쌓이면 어느 게 살아있고 어느 게 죽었는지 구분이 안 됩니다.

Step 1: Remote 동기화

$ git fetch --prune
From github.com:user/my-project
 - [deleted]  (none) -> origin/feature/p1-benchmark-improvements
 - [deleted]  (none) -> origin/feature/p2-batch-a-widgets

git fetch --prune(또는 -p)은 remote에서 삭제된 브랜치의 tracking 참조를 정리합니다. 하지만 로컬 브랜치 자체는 아직 남아있습니다.

Step 2: [gone] 브랜치 확인

$ git branch -v
  feature/p1-benchmark-improvements cbac771 [gone] feat: implement P1
  feature/p2-batch-a-widgets        d37d1ce [gone] fix: PR review feedback
  feat/session-id-and-sessions-list 434d736 feat(command): add sessions
* main                              56f23fb Merge pull request #41

[gone]이 핵심입니다. remote tracking branch가 삭제된 로컬 브랜치에 이 마커가 붙습니다.

Step 3: 한 번에 청소

$ git branch -v | grep '\[gone\]' | sed 's/^[+* ]//' | awk '{print $1}' | while read b; do
    git branch -D "$b"
  done

Deleted branch feature/p1-benchmark-improvements (was cbac771).
Deleted branch feature/p2-batch-a-widgets (was d37d1ce).

이 과정을 매번 수동으로 하기는 번거로우니, alias로 만들겠습니다.

git gone Alias 등록

.gitconfig에 직접 작성합니다:

# ~/.gitconfig
[alias]
    gone = "!f() { git fetch -p; for b in $(git branch -v | grep '\\[gone\\]' | sed 's/^[+* ]//' | awk '{print $1}'); do git branch -D $b; done; }; f"

이제 터미널에서 git gone 한 번이면 끝입니다:

$ git gone
Deleted branch feature/p1-benchmark-improvements (was cbac771).
Deleted branch feature/p2-batch-a-widgets (was d37d1ce).

좀비 브랜치가 없으면 아무 출력 없이 조용히 완료됩니다.

자동 prune 설정

매번 --prune을 붙이기 귀찮다면, 글로벌 설정으로 자동화할 수 있습니다:

git config --global fetch.prune true

이 설정을 켜두면 git fetchgit pull 할 때마다 자동으로 prune이 실행됩니다.


6. .gitconfig Alias Escaping — 실제로 삽질한 이야기

git gone alias를 만드는 과정에서 .gitconfig의 escaping 지옥을 경험했습니다. 이 부분은 문서에도 잘 나와있지 않아서 공유합니다.

시도 1: git config 명령어로 등록

git config --global alias.gone \
  '!git fetch -p && git branch -v | grep "\[gone\]" | ...'

결과: expansion of alias 'gone' failed; '!git' is not a git command

git config! 앞에 \를 자동으로 붙여서 \!git이 저장됐습니다.

시도 2: .gitconfig에 직접 작성 (과도한 escaping)

# 잘못된 예 — backslash가 너무 많음
gone = "!f() { ... grep '\\\\[gone\\\\]' ... }; f"

결과: grep에 \\[gone\\]이 전달되어, 리터럴 백슬래시 + [gone]을 찾게 됩니다. 당연히 매칭되지 않습니다.

시도 3: 정확한 escaping

.gitconfig의 double-quoted 값에서 escaping 규칙을 이해해야 합니다:

.gitconfig 파일: \\[gone\\]
       ↓ (git config이 \\ → \ 로 해석)
shell에 전달:    \[gone\]
       ↓ (single quote 안이므로 그대로 grep에 전달)
grep이 받는 값:  \[gone\]  → [를 리터럴로 매칭 ✓
# 정답 — backslash 정확히 2개씩
gone = "!f() { git fetch -p; for b in $(git branch -v | grep '\\[gone\\]' | sed 's/^[+* ]//' | awk '{print $1}'); do git branch -D $b; done; }; f"

Escaping 규칙 정리

.gitconfig 파일 git이 해석한 결과 비고
\\ \ 백슬래시 1개
\" " 큰따옴표
\n 줄바꿈 newline
\t tab

: 복잡한 alias는 git config 명령어 대신 .gitconfig 파일을 직접 편집하는 것이 훨씬 안전합니다. 명령어로 설정하면 shell escaping + git escaping이 이중으로 적용되어 디버깅이 매우 어렵습니다.

macOS 주의사항: xargs -r

Linux에서 흔히 쓰는 패턴이 macOS에서 안 되는 경우가 있습니다:

# Linux (GNU coreutils) — 동작함
... | xargs -r git branch -D    # -r: 입력 없으면 실행하지 않음

# macOS (BSD) — -r 미지원
... | xargs -r git branch -D    # ← 무시되거나 에러

macOS의 BSD xargs-r (--no-run-if-empty) 플래그를 지원하지 않습니다. 대신 for ... in $(...) 루프나 while read 패턴을 사용하세요:

# macOS 호환 패턴
for b in $(git branch -v | grep '\\[gone\\]' | awk '{print $1}'); do
    git branch -D $b
done

7. 협업 워크플로우: Issue → Branch → PR → Cleanup

지금까지 배운 것을 조합하면, 하나의 완성된 워크플로우가 됩니다:

전체 흐름

# 1. 이슈 확인 후 브랜치 생성
git switch -c feat/issue-123-oauth

# 2. 작업 → 커밋
git add src/auth.ts
git commit -m "feat: add OAuth integration"

# 3. 푸시 + PR 생성
git push -u origin feat/issue-123-oauth
gh pr create --title "feat: add OAuth" --body "Closes #123"

# 4. PR 머지 (GitHub 웹 또는 CLI)
gh pr merge --merge

# 5. 로컬 정리
git switch main
git gone  # ← fetch + prune + 좀비 브랜치 삭제

긴급 hotfix가 끼어들 때

# 현재 feature 작업 중인데 hotfix 요청이 옴

# 1. worktree로 별도 작업 공간 생성
git worktree add ../my-project-hotfix -b hotfix/login-error main

# 2. hotfix 작업
cd ../my-project-hotfix
# ... 수정 ...
git commit -m "fix: resolve login error"
git push -u origin hotfix/login-error
gh pr create --title "fix: login error" --body "Closes #456"

# 3. 머지 완료 후 정리
cd ../my-project
git worktree remove ../my-project-hotfix
git gone  # hotfix 브랜치도 같이 정리됨

# 4. 원래 feature 작업 계속 (중단 없었음!)

PR 본문에 이슈 연결

PR 본문에 키워드를 넣으면 머지 시 이슈가 자동으로 닫힙니다:

## Summary
- OAuth 로그인 통합

## Test plan
- [ ] Google OAuth 테스트
- [ ] 토큰 갱신 확인

Closes #123

지원되는 키워드: Closes, Fixes, Resolves (대소문자 무관)


8. 추천 .gitconfig 설정

이 글에서 다룬 내용을 바탕으로, 추천하는 .gitconfig 설정입니다:

[alias]
    # 좀비 브랜치 일괄 삭제
    gone = "!f() { git fetch -p; for b in $(git branch -v | grep '\\[gone\\]' | sed 's/^[+* ]//' | awk '{print $1}'); do git branch -D $b; done; }; f"

    # 깔끔한 로그
    lg = log --oneline --graph --decorate --all -20

    # 마지막 커밋 수정 (주의: push 전에만!)
    amend = commit --amend --no-edit

    # 현재 브랜치명
    current = rev-parse --abbrev-ref HEAD

[fetch]
    # fetch 시 자동 prune
    prune = true

[push]
    # 현재 브랜치만 push
    default = current
    # 태그 자동 push
    followTags = true

[pull]
    # pull 시 rebase (merge commit 방지)
    rebase = true

핵심 개념 정리

개념 명령어 용도
브랜치 전환 git switch checkout 대체
파일 복구 git restore checkout -- 대체
병렬 작업 git worktree add stash 대체
Remote 동기화 git fetch -p 삭제된 remote 정리
좀비 정리 git gone (alias) [gone] 로컬 브랜치 삭제
이슈 자동 닫기 PR에 Closes #N 머지 시 이슈 연동

FAQ

Q: git switchgit checkout을 섞어 써도 되나요?
A: 네, 둘 다 동작합니다. 하지만 새로운 습관을 들이려면 하나로 통일하는 것이 좋습니다. switch/restore가 의도가 더 명확하므로 권장합니다.

Q: git gone이 작업 중인 브랜치를 삭제할 수도 있나요?
A: [gone] 마커는 remote tracking branch가 삭제된 경우에만 붙습니다. remote에 push한 적 없는 순수 로컬 브랜치는 대상이 아닙니다. 다만, 현재 체크아웃된 브랜치는 git branch -D로 삭제할 수 없으므로 안전합니다.

Q: git worktreegit clone의 차이는 뭔가요?
A: clone은 완전히 독립된 리포지토리 복사본을 만들지만, worktree는 같은 .git 디렉토리를 공유합니다. 그래서 worktree는 디스크 공간을 거의 차지하지 않고, 브랜치/커밋/stash가 모든 worktree에서 공유됩니다.

Q: .gitconfig alias에서 !는 왜 필요한가요?
A: !는 alias를 shell 명령어로 실행하라는 뜻입니다. 없으면 git 서브커맨드로 해석되어, 파이프(|)나 && 같은 shell 문법을 사용할 수 없습니다.

Q: fetch.prune = true를 설정하면 git gone에서 fetch -p가 중복 아닌가요?
A: 맞습니다. 하지만 git gone은 독립적으로 동작해야 하므로 fetch -p를 포함시키는 것이 안전합니다. 이미 prune된 상태면 추가 네트워크 요청 없이 빠르게 넘어갑니다.


참고 자료