글을 작성하게 된 계기
프로젝트에서 Git Rebase 와 Squash Merge 를 사용해 히스토리를 깔끔하게 관리하고 싶었습니다. 이 과정에서 충돌이 발생했는데, 왜 발생했는지, 어떻게 해결했는지에 대해 정리하기 위해 글을 작성하게 되었습니다.
Git도 꽤 익숙해졌다고 생각했는데, 생각지도 못한 이슈가 발생해서 꽤 재미있었네요. 😆
1. 문제 상황
개발 환경에서 개발한 기능들이 내용이 어느정도 완료되어 운영 환경에 배포하게 되었습니다. 원격에서 dev 브랜치를 prod에 병합한 후, 로컬에서 prod 브랜치를 rebase 했는데 충돌이 발생했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ git switch prod # prod 브랜치로 이동
$ git pull origin prod # prod 브랜치 pull
.....
$ git switch dev # dev 브랜치로 이동
$ git rebase prod # prod 브랜치 rebase
# 충돌 발생
Auto-merging .github/workflows/dailyge-api-ci.yaml
CONFLICT (content): Merge conflict in .github/workflows/dailyge-api-ci.yaml
Auto-merging .gitignore
Auto-merging admin-api/src/main/java/project/dailyge/app/core/user/application/usecase/UserCacheWriteUseCase.java
Auto-merging admin-api/src/main/java/project/dailyge/app/core/user/persistence/UserCacheWriteDao.java
.......
이때까지 충돌 없이 잘 배포해 왔는데, 갑자기 충돌이 발생해 의아했습니다. 이유를 찾다 보니 rebase의 기본 동작 원리와 squash merge의 동작 원리를 간과해 발생한 문제였는데, 각 명령어의 동작 원리 와 문제가 발생한 이유 에 대해 정리해 보겠습니다.
- git rebase, git merge –squash의 동작 원리
- 문제가 발생한 이유
2. Git 명령어의 동작 원리
먼저 git rebase와 git merge –squash의 동작 원리에 대해 살펴보겠습니다.
- git rebase
- git merge –squash
2-1. git rebase
git rebase는 현재 브랜치의 커밋 히스토리를 다른 브랜치의 커밋 히스토리 위로 재배치 하는 Git 명령어입니다. 깔끔한 히스토리를 유지하거나, 변경 사항을 특정 기준으로 정렬하려고 할 때 사용됩니다. 간단한 예시를 통해 살펴보겠습니다.
git rebase is a Git command that moves the commits of the current branch onto the history of another branch. It is mainly used to maintain a clean commit history or to reorder changes based on a specific branch.
prod 브랜치에서 커밋 A를 기준으로 dev 브랜치가 분기되었다고 가정해 보겠습니다. 이후 prod 브랜치에는 커밋 B와 C가 추가되었고, dev 브랜치에는 커밋 D와 E가 추가되었습니다. 이에 따라 prod와 dev는 공통 조상 A를 공유하면서, 서로 다른 히스토리를 가지게 됩니다.
1
2
3
4
# prod A로 부터 분기된 dev A
prod: A --- B --- C
\
dev: A --- D --- E
dev 브랜치를 prod의 최신 상태로 rebase하면, dev의 커밋 D와 E는 prod의 최신 커밋 C 이후에 재배치됩니다. 이 과정에서 Git은 D와 E를 새로운 커밋 ID로 복사해 C 이후에 재적용합니다. rebase가 완료된 후, dev 브랜치에서는 추가 커밋 을 할 수도 있습니다. F와 G 처럼요. 즉, dev는 prod의 최신 상태를 반영한 후, 자신의 히스토리를 독립적 으로 이어가는 것입니다.
1
2
3
prod: A --- B --- C
\
dev: D' --- E' --- F --- G (rebase된 dev 브랜치)
2-2. git merge –squash
squash 명령어는 여러 개의 커밋을 하나의 커밋으로 합칠 때 사용됩니다. 마찬가지로 예시를 통해 git merge –squash의 동작 방식을 살펴보겠습니다.
git merge –squash is used to combine multiple commits into a single commit, helping to consolidate changes into a single entry in the commit history for a cleaner, more concise record.
예를 들어, prod 브랜치에서 커밋 A를 기준으로 dev 브랜치가 분기되었다고 가정하겠습니다. 이후 prod 브랜치에는 커밋 B와 C가 추가되었고, dev 브랜치에는 커밋 D와 E가 추가되었습니다. 현재 prod와 dev는 공통 조상 A를 공유하면서 서로 다른 히스토리를 가지고 있습니다.
1
2
3
4
# prod A로부터 분기된 dev A
prod: A --- B --- C
\
dev: A --- D --- E
이때, git merge –squash 명령어를 사용하면 dev 브랜치의 커밋 D와 E를 prod 브랜치에 하나의 커밋으로 병합할 수 있습니다. –squash 옵션을 사용하면, dev 브랜치의 커밋 내용이 prod 브랜치에 병합되지만, 모든 변경 사항은 단일 커밋 으로 기록됩니다. 이를 통해, 여러 커밋을 하나로 통합해 히스토리를 간결하게 유지할 수 있습니다.
1
2
3
4
# git merge --squash 후
prod: A --- B --- C --- F (squash된 커밋)
\ /
dev: A --- D --- E
마찬가지로 squash 병합이 완료된 후에도 dev 브랜치는 여전히 독립적인 히스토리를 이어갈 수 있습니다.
1
2
3
4
# git merge --squash 후
prod: A --- B --- C --- F (squash된 커밋)
\ /
dev: A --- D --- E --- G --- H
3. 문제 발생 과정
Git 명령어의 기본적인 동작 원리에 대해 살펴보았으니, 실제 문제가 발생한 과정과 원인에 대해 살펴보겠습니다.
에러가 발생한 과정은 해당 링크를 통해 재현할 수 있습니다.
3-1. prod를 베이스로 한 dev
먼저 prod 브랜치의 커밋 A를 기준으로 dev 브랜치를 분기했습니다. 이후 prod는 dev 브랜치에서 기능이 어느 정도 완성되면 병합하는 형태로 개발이 진행되었습니다.
1
2
3
4
# prod A로부터 분기된 dev A
prod: A --- B --- C
\
dev: A --- D --- E
3-2. prod에 dev를 squash 병합
이 상태에서 prod 브랜치로 dev 브랜치를 squash 병합하며 prod에는 새로운 커밋 M이 생겼습니다. 즉, 운영 환경에 새로운 기능이 추가된 것이죠.
1
2
3
4
# dev를 squash merge로 병합한 prod
prod: A --- B --- C --- M
\ /
dev: A --- D --- E
3-3. hotfix 브랜치 생성 및 squash 병합
그런데 버그가 발생해 prod 브랜치에서 hotfix 브랜치를 생성하고, 버그를 수정한 F 커밋을 squash로 병합했습니다.
1
2
3
4
# hotfix를 squash merge로 병합한 후 상태
prod: A --- B --- C --- M --- S (squash된 hotfix)
\ /
dev: A --- D --- E
3-4. dev 브랜치에서 prod로 rebase 시도 중 충돌 발생
마지막으로 dev 브랜치가 prod의 최신 상태로 리베이스하려고 시도했습니다. rebase는 prod의 최신 공통 조상 A를 기준으로 하며, prod는 이미 squash된 커밋 S까지 반영된 상태입니다. 이때 충돌이 발생해 문제가 발생했습니다.
1
2
3
4
# rebase 도중 충돌 발생 시 상태
prod: A --- B --- C --- M --- S
\
dev: D' --- E' (rebase된 dev 브랜치, 충돌 발생)
4. 원인
이는 squash 병합으로 dev의 개별 커밋 D와 E가 prod의 단일 커밋(M)으로 합쳐졌기 때문에, Git이 rebase 시 dev의 변경 사항이 이미 포함된 것을 인식하지 못하고 다시 적용 하는 과정에서 충돌이 발생한 것입니다.
1
2
3
4
# D, E의 코드는 M 커밋에 반영 됐지만, 이는 새로운 커밋으로 D, E와 별개로 인식
prod: A --- B --- C --- M --- S (squash된 hotfix)
\ /
dev: A --- D --- E
즉, dev의 커밋 내역 D와 E는 M으로 병합됐기 때문에 prod에 이미 포함 돼 있지만, Git은 squash 병합으로 M 커밋을 만들었기 때문에 D와 E의 변경 사항이 포함되어 있다는 걸 인식하지 못합니다. 따라서 rebase 과정에서 D와 E를 재적용하려고 하며, prod에 있는 동일한 코드와 어떤 것을 선택할지 몰라 충돌이 발생하게 됩니다.
1
2
3
4
# M이 D와 E를 병합한 새 커밋으로 인식되기 때문에, Git은 D와 E가 이미 병합된 것을 인지하지 못함.
prod: A --- B --- C --- M --- S
\
dev: D' --- E' (rebase된 dev 브랜치, 충돌 발생)
5. 문제 해결
해결책은 간단합니다. 이는 squash 병합을 하지 않거나, 둘 중 어떤 것을 선택할지 결정 하면 됩니다. 저희는 커밋이 많지 않았기 때문에 충돌이 발생한 코드를 하나씩 살펴보고 dev의 커밋 내역을 prod에 모두 적용했습니다. 또 하나씩 체크한 후, 추후 발생할 버그를 예방하고 싶기도 했고요. 원리를 설명하는 과정은 길었는데, 해결책은 간단했네요.
6. 정리
rebase와 squash 병합을 함께 사용하며 발생한 문제에 대해 살펴보았습니다. Git은 간단하면서도 문제가 발생하면 꽤 어려운데요, 비슷한 상황이 발생했을 때, 잘 적용해서 빠르게 해결할 수 있으면 합니다.