이번 글은 git 병합 방식에 대한 정리다. 팀원의 작업을 검토하다가 병합 방식에 대한 이야기가 나왔고, squash merge로 병합하는 편이 낫다고 판단하여 그렇게 진행했다. 그 과정에서 우리 팀이 병합 방식을 제대로 고른 적이 없었다는 것을 알게 됐다. 그래서 먼저 세 가지 병합 방식(merge, squash, rebase)을 정리하고, 그다음 우리 팀의 상황과 바뀐 점을 적는다.
작업 브랜치(feat/x)의 변경을 dev로 가져오는 방법은 세 가지다. 모두 결과 파일은 같고, dev의 이력이 어떻게 남느냐가 다르다.
예시로, dev에서 갈라진 feat/x에 커밋 3개(F1~F3)가 있고, 그 사이 dev에도 커밋 하나(D2)가 늘었다고 하자.
F1 ─ F2 ─ F3 feat/x
/
D0 ─ D1 ─ D2 dev
두 브랜치의 끝을 부모가 둘인 새 커밋(M) 으로 묶는다. 작업 브랜치의 커밋이 그대로 dev 이력에 들어온다.
F1 ─ F2 ─ F3
/ \
D0 ─ D1 ─ D2 ────── M dev
dev 병합 커밋)까지 전부 dev에 들어온다.git merge --no-ff feat/x.작업 브랜치의 변경 전체를 커밋 하나로 합쳐서 dev 끝에 얹는다. F1~F3은 dev 이력에 들어오지 않고, 합쳐진 새 커밋(S)만 생긴다.
F1 ─ F2 ─ F3 feat/x (dev와 이어지지 않는다)
/
D0 ─ D1 ─ D2 ─ S dev S = F1+F2+F3의 변경을 합친 새 커밋
dev는 한 줄로 쌓이고, PR 하나가 커밋 하나가 된다.S는 F3와 부모·자식 관계가 없다. git은 feat/x를 병합했다고 알지 못한다(아래 "조심할 점").git merge --squash feat/x 뒤 직접 git commit.작업 브랜치의 커밋을 dev의 최신 끝에서 다시 만들어 이어 붙인다. 내용은 같지만 부모가 바뀌므로 커밋 해시가 전부 새로 생긴다(F1'~F3').
D0 ─ D1 ─ D2 ─ F1' ─ F2' ─ F3' dev 한 줄, 병합 커밋 없음
git rebase origin/dev.| 커밋이 남는 방식 | dev 이력 모양 | 해시 | |
|---|---|---|---|
| merge | 전부 남고 병합 커밋이 하나 더 생긴다 | 갈라졌다 합쳐진다 | 유지 |
| squash | 하나로 합쳐져 남는다 | 한 줄 | 새 커밋 1개 |
| rebase | 전부 남는다 | 한 줄 | 전부 새로 생긴다 |
우리 저장소(서버)의 dev 이력을 보면 PR #16부터 #29까지 전부 Merge pull request #N 병합 커밋이다. 한 번도 squash나 rebase를 쓰지 않았다.
이유는 단순했다. 다른 방식을 몰랐다. merge만 알고 있었고 squash를 써 본 적이 없어서, 선택지가 있다는 것 자체를 의식하지 않았다. 의도한 선택이 아니라 관성이었다.
더 큰 문제는, 협업 문서(CONTRIBUTING.md)에는 이미 이렇게 적혀 있었다는 것이다.
병합은 PR 단위로 기록을 남기는 Squash merge를 권장하며, 최종 커밋 제목에도 아래 형식을 사용합니다.
저장소를 만들 때 문서를 정리하면서 적어 둔 문장이다. 하지만 squash가 정확히 무엇인지, 쓰면 dev가 어떻게 달라지는지 잘 모르는 채 적었기 때문에 규칙을 인지하지 못했고, 당연히 지켜지지도 않았다. 문서와 실제가 어긋난 채로 3주 넘게 지나갔다.
W1-8(서버 E2E 테스트와 CI)을 팀원이 끝내서, AI(클로드)를 사용해 작업 결과를 검토했다. 테스트가 충분히 명세를 검증하는지, CI가 분리됐는지 등을 보던 중 커밋 이력 문제가 지적됐다. 사실, 이 부분은 내가 먼저 알아챈 것이 아니라 AI와의 검토에서 나왔다.
그 브랜치의 상태는 이랬다.
dev 기준 커밋 21개. 그중 Merge branch 'dev' into feat/e2e-and-ci 병합 커밋이 2개.의미 있는 단위는 "동기화 E2E와 CI 추가" 하나인데, 단순 merge로 들어가면 임시 실패 테스트와 되돌림까지 dev의 영구 이력이 된다. 따라서 이 PR은 squash로 병합하자는 제안이 나왔고, 그 근거로 CONTRIBUTING.md의 권장 문장과 dev의 실제 이력이 어긋난다는 점도 함께 확인됐다.
dev에는 브랜치 삭제 및 직접 커밋이 안 되고 PR로만 변경할 수 있는 보호 규칙이 걸려 있다. 이런 경우에도 squash나 rebase로 병합할 수 있는지가 의문이었다. 둘 다 feat/x 브랜치의 커밋을 "가져다 붙이는" 게 아니라, 보호 대상인 브랜치에 "새 커밋을 만드는" 방식이니까.
결론부터 말하면, 상관 없었다. squash merge나 rebase merge 모두 보호가 걸린 브랜치에 적용할 수 있었다. 생각해 보면, 단순 merge도 병합했다는 커밋이 새로 생성된다. 보호는 커밋을 만드는 방식을 제한하는 거지, 커밋 자체를 못하게 하는 게 아니었다.
dev에 커밋을 올리는 경로를 막는 설정이다. PR 병합은 정상 경로라 막히지 않는다.PR 화면의 병합 버튼 옵션에서 Squash and merge를 골라 병합했고, 정상적으로 병합까지 되었다.
병합 후 서버 dev의 이력이다(git log --graph --oneline, 필요한 부분만).
* e81c148 test(rules): 전투 규칙 이식 일치 테스트와 기대값 표 추가 (W4-1b) (#31) ← squash
* e8a346f test(e2e): 동기화 E2E와 Tests 워크플로 추가 (W1-8) ← squash
* ae38294 Merge pull request #29 from .../docs/w3-4-sync-sender
|\
| * e2122eb docs: w3-4 완료 반영
|/
* 8ab43bd Merge pull request #28 from .../fix/w4-1-renew-content
|\
| * 7bee954 docs: 서버 콘텐츠 갱신 반영
| * 4c38de6 fix: 실제 콘텐츠 기반 테스트 수정
| * 6601de4 chore: 전투 콘텐츠 갱신(클라이언트에서 가져옴)
|/
* d01dda7 Merge pull request #27 from .../feat/remove-old-progress-api
|\
| * ff44425 docs: W1-7 완료 사항 반영
| * f9b3860 test: 삭제된 revision 열 관련 테스트 제거, ...
| * 40b5ca4 feat: V4 스키마 적용 (옛 API에만 쓰이던 불필요 열 제거)
| * ... (3개 더)
|/
아래는 fork GUI로 확인한 모습이다.

위쪽 두 줄(e8a346f, e81c148)이 squash로 들어온 PR이고, 그 아래가 지금까지의 merge 방식이다. 모양 차이가 한눈에 보인다.
squash 커밋에는 기본적으로 GitHub가 PR 안의 커밋 메시지를 모두 모아 본문에 넣어 둔다. 이번 e8a346f의 경우 병합 화면에서 본문을 다듬지 않아 21개 커밋의 메시지가 그대로 들어갔다. 우선은 기록을 최대한 보존하려고 따로 건드리지 않았지만, 병합할 때 본문을 핵심만 남기도록 정리하는 것도 고려해볼 수 있겠다. (어차피 세부적인 내용은 PR 메시지에 담겨있기도 하니까)
같은 PR(W1-8)을 두 방식으로 병합했을 때를 비교해 보자. 왼쪽은 merge였다면 dev에 어떻게 남았을지(가정), 오른쪽은 실제 결과다.
merge로 병합했다면 (가정) squash로 병합한 실제 결과
* Merge pull request #N * e8a346f test(e2e): 동기화 E2E와
|\ Tests 워크플로 추가 (W1-8)
| * docs: 동기화 현황 반영 * ...(이전 PR)
| * docs: CI 설명에 Tests 반영
| * Revert "CI 실패 확인용 임시 테스트"
| * Merge branch 'dev' into feat/...
| |\
| * | test: CI 실패 확인용 임시 테스트
| * | ci: 테스트 워크플로 추가
| * | test(e2e): 동기화 빈 요청 ...
| * | test(e2e): 동기화 콘텐츠 버전 ...
| * | ... (13개 더, 중복 제목 포함)
|/ /
* ...(이전 PR)
| merge | squash | |
|---|---|---|
dev에 생기는 커밋 | 22개(작업 21 + 병합 1) | 1개 |
| 임시 CI 실패 테스트와 되돌림 | 영구 이력에 남는다 | 남지 않는다 |
dev의 Merge branch 'dev' into ... | 그대로 들어온다 | 들어오지 않는다 |
| 롤백 | 병합 커밋 하나를 되돌린다(-m 1 필요) | 커밋 하나를 git revert하면 끝 |
git log 한 줄 읽기 | PR 하나가 여러 줄 | PR 하나가 한 줄 |
실제로 이전의 커밋 이력들은 복잡하게 얽혀있어 파악이 어려운데, squash merge로 병합을 진행한 이후부터는 깔끔하게 dev 브랜치의 변화 흐름을 확인할 수 있다.
| 이전 히스토리 | 최근 히스토리 |
|---|---|
![]() | ![]() |
dev 이력이 PR 단위로 읽힌다. git log --oneline 한 줄이 곧 PR 하나다. 이번 W1-8처럼 임시 커밋이 섞인 PR도 깔끔해진다.git revert 하면 된다.dev 병합)가 dev를 오염시키지 않는다. 작업 중에는 마음 놓고 커밋해도 된다.dev에서 사라진다. git blame은 PR 하나로 뭉쳐 보이고, git bisect도 PR 단위까지만 좁혀진다. 다만 GitHub의 PR 페이지에는 개별 커밋이 그대로 남아 있어서, 정말 필요하면 PR 번호로 찾을 수 있다.squash는 부모-자식 관계를 만들지 않으므로, 병합이 끝난 작업 브랜치를 git은 "아직 병합되지 않은 브랜치"로 본다.
git branch -d feat/e2e-and-ci # error: not fully merged ← squash 병합한 브랜치도 이렇게 나온다
git branch -D feat/e2e-and-ci # PR이 병합된 것을 확인한 뒤에만 -D로 지운다
따라서 squash 병합이 끝난 브랜치에서 계속 작업하면 안 된다. 그 브랜치의 옛 커밋을 git은 dev에 없는 것으로 보기 때문에, 다음 PR에서 이미 들어간 변경이 충돌로 다시 나타난다. 병합된 뒤에는 브랜치를 지우고 origin/dev에서 새로 만든다. 이건 기존에도 지켜오던 작업 방식이지만, squash를 사용할 경우 항상 병합한 브랜치는 즉시 정리하는 습관을 갖는 것이 좋겠다. (GitHub의 "병합 후 브랜치 자동 삭제" 옵션을 켜 두면 편하다.)
rebase는 이력을 한 줄로 만들면서 커밋을 하나하나 남기는 방식이다. 두 가지 용도가 있다.
PR의 커밋들을 dev 위에 그대로 이어 붙인다. 병합 커밋이 생기지 않는다. 다만 PR 안의 커밋이 전부 새 해시로 다시 만들어져 dev에 들어온다. 우리 W1-8처럼 임시 커밋이 섞여 있으면 그것도 그대로 남으므로, 이번 목적(이력 정리)에는 맞지 않는다.
이 방식이 어울리는 때는 PR 안의 커밋이 하나하나 의미 있고 깨끗할 때다. 예를 들어 "스키마 추가 → 엔티티 추가 → API 추가"처럼 커밋마다 빌드와 테스트가 통과하고 설명이 잘 달려 있는 경우다. 이력은 한 줄이면서 커밋 단위 blame, bisect도 쓸 수 있다.
dev 위로 옮기기작업 브랜치가 dev보다 뒤처졌을 때 git merge origin/dev로 병합 커밋을 만드는 대신 git rebase origin/dev로 내 커밋을 최신 dev 끝으로 옮긴다. 그러면 Merge branch 'dev' into feat/... 같은 병합 커밋이 브랜치에 쌓이지 않는다. 5편에서 겪은 "작업 커밋 사이에 dev 병합이 세 번 끼어든" 그래프가 이런 모양이었다.
rebase는 커밋 해시를 바꾼다. 이미 원격에 올렸고 다른 사람이 받아 갈 수 있는 브랜치를 rebase하면, 원격에는 옛 해시가, 내 쪽에는 새 해시가 있어 강제 푸시(--force) 가 필요하고 같은 브랜치를 쓰는 팀원의 이력이 어긋난다. 그래서 규칙은 단순하다.
혼자 쓰는, 아직 푸시하지 않은(또는 나만 쓰는) 브랜치에서만 rebase한다. 공유하는 브랜치는 하지 않는다.
| 방식 | 한 줄 개념 | 쓰기 좋은 상황 | 주의 |
|---|---|---|---|
| merge | 두 브랜치를 부모가 둘인 병합 커밋으로 묶는다. 모든 커밋이 남는다. | 브랜치가 갈라졌다 합쳐진 역사 자체를 남기고 싶을 때, 장기 브랜치끼리 합칠 때 | 임시·중복 커밋과 dev 병합 커밋까지 dev에 남는다 |
| squash | PR의 변경을 커밋 하나로 합쳐 dev에 얹는다. | 작업 중 커밋이 지저분한 일반 기능·문서 PR. 우리 팀의 기본값 후보 | 커밋 단위 이력이 dev에서 사라진다. 병합한 브랜치는 -D로 지우고 재사용하지 않는다. 본문을 정리한다 |
| rebase | 내 커밋을 최신 dev 끝으로 옮겨 한 줄로 잇는다. 해시가 바뀐다. | 커밋이 하나하나 깔끔한 PR, 혹은 푸시 전 내 브랜치를 최신 dev로 맞출 때 | 푸시한 공유 브랜치에는 쓰지 않는다 |
dev의 이력을 어떤 형태로 남길까"에 대한 선택이다.dev를 PR 단위로 읽고 되돌리고 싶고, 작업 중에는 마음 놓고 커밋하고 싶을 때는 squash가 잘 맞는다. 브랜치에서는 사소한 수정도 이력을 남기며 작업할 수 있고, 병합 후에는 하나의 커밋으로 작업 단위가 묶여 깔끔하게 기록된다. 이번 W1-8(e8a346f)과 W4-1b(e81c148)가 그렇게 병합됐다.CONTRIBUTING.md의 권장 문장은 이제 실제와 같아졌으므로 그대로 둔다.