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

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

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된 상태면 추가 네트워크 요청 없이 빠르게 넘어갑니다.


참고 자료