들어가며
결론부터 말하면, 저는 feature → dev는 스쿼시 머지로, dev → main은 머지 커밋으로 합칩니다. 작업 단위는 커밋 하나로 깔끔하게, 배포 단위는 경계가 보이게 남기기 위해서입니다.
이 글을 쓰게 된 계기는 작은 사고였습니다. 이슈 사이클을 자동화하는 Claude Code 스킬(dev-issue)을 만들던 중, 브랜치 정리 단계에서 AI가 git branch --merged를 썼습니다. 그런데 분명히 머지한 브랜치가 목록에 나오지 않았습니다. 원인은 스쿼시 머지였습니다.
GitHub의 머지 버튼은 클릭 한 번이지만, 어떤 방식을 고르느냐에 따라 이력과 도구의 동작이 달라집니다. 이 글에서는 두 방식의 차이를 제 PR 환경에 맞춰 정리합니다.
내 PR 환경
혼자 여러 SaaS 제품을 만들지만, 작업은 모두 이슈와 PR을 거칩니다. 리뷰어는 없지만, PR은 CI 게이트이자 작업 기록입니다.
|
항목 |
내용 |
|---|---|
|
개발 형태 |
1인 개발, CodeIgniter 4 + PHP 8.4 |
|
브랜치 |
|
|
작업 단위 |
이슈 1개 = feature 브랜치 1개 = PR 1개 |
|
CI |
PR마다 PHPUnit, PHPStan. Ubuntu 서버의 셀프호스티드 러너(Docker 3대) |
|
자동화 |
Claude Code 스킬이 브랜치 생성부터 PR, 머지, 이슈 종료, 브랜치 정리까지 처리 |
|
병렬 작업 |
git worktree로 여러 세션이 다른 이슈를 동시에 진행 |
이 환경의 특징은 PR이 많고 작다는 점입니다. AI가 TDD로 작업하면 feature 브랜치에 "테스트 추가", "수정", "PHPStan 오류 수정" 같은 중간 커밋이 쌓입니다. 이 커밋들을 어떻게 합칠지가 이 글의 주제입니다.
머지 커밋: 이력을 그대로 보존한다
머지 커밋(Create a merge commit)은 feature의 커밋을 모두 그대로 두고, 부모가 둘인 머지 커밋을 하나 더 만듭니다. git merge --no-ff와 같습니다.
* M Merge pull request #42 from feature/42-order-cancel
|\
| * c3 fix: PHPStan 오류 수정
| * c2 feat: 주문 취소 로직 구현
| * c1 test: 주문 취소 테스트 추가
|/
* b (dev) 이전 작업
장점
- 작업 과정이 커밋 단위로 남아, 나중에 "왜 이렇게 했나"를 추적하기 쉽습니다.
- feature 브랜치의 커밋이 대상 브랜치에 그대로 들어가므로
git branch --merged가 정상 동작합니다. - 머지 커밋 하나만
git revert -m 1하면 기능 전체를 되돌릴 수 있습니다. - 같은 커밋을 공유하므로, 이후 같은 브랜치끼리 다시 합칠 때 충돌이 없습니다.
단점
- "오타 수정", "다시 시도" 같은 중간 커밋까지 모두 남습니다.
- PR이 많아지면 그래프가 갈래갈래 얽혀
git log가 읽기 어렵습니다. - 중간 커밋 중 테스트가 깨진 상태가 있으면
git bisect가 그곳에서 걸립니다.
스쿼시 머지: 작업 하나를 커밋 하나로
스쿼시 머지(Squash and merge)는 feature의 변경 내용을 모두 합쳐 새 커밋 하나를 대상 브랜치에 올립니다. 부모는 하나뿐이고, feature의 원래 커밋은 대상 브랜치 이력에 들어가지 않습니다. git merge --squash 후 커밋하는 것과 같습니다.
* S feat: 주문 취소 API 추가 (#42)
* b (dev) 이전 작업
# feature/42-order-cancel 의 c1, c2, c3 는 dev 에 없다
장점
- dev 이력이 "이슈 하나 = 커밋 하나"로 일직선입니다.
git log --oneline이 곧 변경 이력서가 됩니다. - AI가 만든 중간 커밋을 정리할 필요가 없습니다. 더러운 과정은 PR 페이지에만 남습니다.
- dev의 모든 커밋이 CI를 통과한 상태라
git bisect가 깔끔하게 돕니다. - 커밋 메시지에 PR 번호가 붙어, 커밋에서 PR과 이슈로 바로 돌아갈 수 있습니다.
git revert <커밋>하나로 기능 전체를 되돌립니다.
단점
- 커밋 해시가 달라져 Git은 feature 브랜치가 머지됐다는 사실을 모릅니다.
git branch --merged가 통하지 않습니다. - 머지한 feature 브랜치에서 작업을 이어가면, 다음 PR에 이미 들어간 변경이 다시 보이고 충돌이 납니다.
- PR이 크면 커밋 하나가 너무 커져 세부 이력을 잃습니다.
한눈에 비교
두 방식의 차이는 결국 "feature의 커밋이 대상 브랜치에 남느냐"에서 나옵니다.
|
항목 |
머지 커밋 |
스쿼시 머지 |
|---|---|---|
|
대상 브랜치에 남는 커밋 |
feature 커밋 전부 + 머지 커밋 1개 |
새 커밋 1개 |
|
이력 모양 |
갈래가 나뉘고 합쳐짐 |
일직선 |
|
중간 커밋 |
남음 |
PR 페이지에만 남음 |
|
|
동작함 |
동작 안 함 |
|
기능 되돌리기 |
|
|
|
|
중간 커밋에서 걸릴 수 있음 |
깔끔함 |
|
머지 후 같은 브랜치 재사용 |
가능 |
하면 안 됨 |
|
잘 맞는 곳 |
오래 사는 브랜치끼리 (dev → main) |
짧게 사는 브랜치 (feature → dev) |
GitHub에는 세 번째 선택지인 Rebase and merge도 있습니다. 이력은 일직선이지만 중간 커밋이 그대로 남고 해시도 바뀝니다. 커밋을 손수 다듬는 팀에 맞고, AI가 커밋을 쌓는 제 환경에는 맞지 않아 제외했습니다.
내 환경에서의 선택
하나만 고를 필요는 없습니다. 브랜치의 수명에 따라 나눠 씁니다.

feature → dev: 스쿼시 머지. feature 브랜치는 이슈 하나를 처리하고 사라집니다. 그 안의 중간 커밋은 AI의 작업 과정일 뿐, dev 이력에 남길 가치가 낮습니다. 스쿼시하면 dev는 "이슈 번호 + 한 줄 요약"의 목록이 됩니다. 과정이 궁금하면 커밋 메시지의 PR 번호를 따라가면 됩니다.
dev → main: 머지 커밋. dev와 main은 계속 살아 있는 브랜치입니다. 여기서 스쿼시하면 main에는 dev와 다른 해시의 커밋이 생깁니다. 다음 릴리스 PR에서 이미 배포한 변경이 다시 보이고, 결국 충돌이 납니다. 머지 커밋으로 합치면 두 브랜치가 같은 커밋을 공유하고, main의 머지 커밋 하나하나가 릴리스 기록이 됩니다.
정리하면, 짧게 사는 브랜치는 스쿼시하고, 오래 사는 브랜치끼리는 머지 커밋으로 합칩니다.
스쿼시 머지의 함정과 대응
스쿼시 머지를 쓰면 Git은 "이 브랜치가 머지됐다"는 사실을 모릅니다. 아래 세 가지는 모두 여기서 나옵니다.
1. git branch --merged로 브랜치를 정리할 수 없다
feature의 커밋이 dev에 없으니, 머지한 브랜치도 "안 합쳐진 브랜치"로 나옵니다. 그래서 브랜치가 아니라 PR 상태를 기준으로 판단합니다.
# 머지된 PR의 브랜치만 골라 로컬에서 삭제
for b in $(gh pr list --state merged --base dev --json headRefName -q '.[].headRefName'); do
git branch -D "$b" 2>/dev/null
done
# 원격에서 사라진 브랜치 참조 정리
git fetch --prune
제 prune-branch 스킬에는 "브랜치를 지우기 전에 PR이 실제로 머지됐는지 확인하라"고 적어 두었습니다. AI는 관성적으로 --merged를 쓰려 하므로, 저장소의 머지 방식을 스킬이나 CLAUDE.md에 명시하는 것이 핵심입니다.
2. 머지한 feature 브랜치를 다시 쓰지 않는다
머지 후 같은 브랜치에 커밋을 더하면, 다음 PR에 이미 들어간 변경이 다시 나타납니다. 추가 수정은 항상 최신 dev에서 새 브랜치를 따서 진행합니다. 이슈 하나에 PR이 두 개가 되더라도 그렇게 합니다.
3. dev → main에서는 스쿼시하지 않는다
오래 사는 브랜치끼리 스쿼시하면, main에 생긴 새 커밋을 dev가 모릅니다. 릴리스를 두세 번 반복하면 dev → main PR의 diff가 계속 불어나고 충돌이 쌓입니다. 핫픽스를 main에 직접 넣었다면, main을 dev로 머지 커밋으로 다시 내려 두 브랜치를 맞추어 둡니다.
GitHub 저장소 설정 체크리스트
규칙을 정했으면 사람도 AI도 실수하지 않게 설정으로 못박아 둡니다. Settings → General → Pull Requests와 브랜치 규칙에서 확인합니다.
- Allow merge commits, Allow squash merging 둘 다 켜기. dev → main에 머지 커밋이 필요하므로 스쿼시 전용으로 만들 수 없습니다. Rebase merging은 끕니다.
- 브랜치별 머지 방식 제한. 브랜치 규칙(Rulesets)의 PR 규칙에서 dev는 Squash만, main은 Merge만 허용하도록 지정합니다. 저장소 전체 설정만으로는 브랜치별로 나눌 수 없습니다.
- 스쿼시 커밋 메시지 기본값을 Pull request title로. PR 제목을 Conventional Commits 형식(
feat: 주문 취소 API 추가)으로 쓰면 dev 이력이 그대로 변경 이력서가 됩니다. - Automatically delete head branches 켜기. 머지된 원격 feature 브랜치가 자동으로 지워져 재사용 실수를 막습니다. 단, dev와 main은 보호 규칙으로 삭제를 막아 둡니다.
- main에 Require linear history를 켜지 않기. 이 옵션은 머지 커밋을 막습니다. dev에만 켭니다.
- dev, main 모두 CI 통과 필수. 셀프호스티드 러너의 PHPUnit, PHPStan job을 required check로 지정합니다.
하나 더, Closes #42는 기본 브랜치로 머지될 때만 이슈를 닫습니다. 기본 브랜치가 main이면 dev로 가는 PR은 이슈를 닫지 못합니다. 그래서 제 스킬은 머지 후 이슈를 직접 닫습니다. 기본 브랜치를 dev로 바꾸는 방법도 있습니다.
CLI로 머지할 때는 구간별로 옵션을 나눕니다.
# feature → dev
gh pr merge 42 --squash --delete-branch
# dev → main (릴리스)
gh pr merge 57 --merge
마무리
스쿼시 머지와 머지 커밋은 우열의 문제가 아니라 자리의 문제입니다. 작업의 과정을 숨기고 싶은 곳에는 스쿼시를, 브랜치 사이의 관계를 지켜야 하는 곳에는 머지 커밋을 둡니다.
AI와 함께 개발하면 커밋은 더 많아지고 작업은 더 빨라집니다. 그럴수록 이력은 사람이 읽을 수 있는 단위로 남아야 합니다. 그리고 그 규칙은 제 머릿속이 아니라 저장소 설정과 스킬에 적혀 있어야 AI도 같은 판단을 합니다. git branch --merged 한 줄에서 시작한 사고가 가르쳐 준 것은 결국 그것이었습니다.
댓글 0개