Git 심화 — rebase, cherry-pick, 브랜치 전략
공유 브랜치에서
git reset --hard를 날리면 어떤 일이 벌어질까? 그리고 rebase와 merge는 히스토리를 왜 다르게 만드는 걸까?
Git 심화 — rebase, cherry-pick, 브랜치 전략
add, commit, push만으로는 부족합니다. rebase와 merge의 차이, conflict 해결, 브랜치 전략까지 — Git의 내부 구조부터 실무에서 중요한 범위를 한번 정리해볼게요.
Git 내부 구조 — 객체 모델
Git은 단순한 버전 관리 도구가 아니라 content-addressable filesystem이에요. 내부적으로 모든 데이터를 객체로 저장하고, SHA-1 해시로 식별합니다.
핵심 객체 3가지
| 객체 | 역할 | 내용물 |
|---|---|---|
| blob | 파일 내용 저장 | 파일 이름 없이 순수 내용만 담김 |
| tree | 디렉토리 구조 | blob과 하위 tree를 참조 (파일 이름은 여기서 관리) |
| commit | 스냅샷 + 메타데이터 | tree 참조, 부모 커밋, author, committer, 메시지 |
커밋을 하면 Git은 이런 과정을 거칩니다:
- 변경된 파일의 내용으로 blob 객체 생성
- 현재 디렉토리 구조를 나타내는 tree 객체 생성
- tree를 가리키는 commit 객체 생성 (부모 커밋 포함)
각 객체는 내용의 SHA-1 해시가 곧 식별자예요. 같은 내용이면 같은 해시가 나오니까, Git은 중복 저장을 자연스럽게 피합니다. "커밋은 diff가 아니라 스냅샷이다"라는 말이 여기서 나오는 건데요. 매 커밋이 전체 프로젝트의 tree를 가리키고 있기 때문이에요.
commit 3a7b2c...
├── tree d8f4a1...
│ ├── blob 9e1c3b... (README.md)
│ ├── blob 7f2d4e... (main.java)
│ └── tree a1b2c3... (src/)
│ └── blob ...
└── parent commit f5e6d7...
한 줄로 정리하면, Git은 스냅샷을 저장하지만 packfile로 압축할 때는 delta compression을 씁니다.
Staging Area (Index)
Git에는 Working Directory, Staging Area(Index), Repository 세 영역이 있어요.
Working Directory → git add → Staging Area (Index) → git commit → Repository (.git)
Staging Area가 왜 필요하냐면, 커밋 단위를 세밀하게 제어할 수 있기 때문입니다. 파일 10개를 수정했는데 그 중 3개만 커밋하고 싶을 때, staging 없이는 불가능해요. SVN 같은 도구는 이런 개념이 없어서 전부 커밋하거나, 아예 안 하거나 둘 중 하나였습니다.
git diff는 Working Directory와 Staging Area 사이의 차이를 보여주고, git diff --staged는 Staging Area와 마지막 커밋 사이의 차이를 보여줍니다. 이걸 헷갈려하는 분들이 꽤 많은데, 세 영역의 관계를 이해하면 자연스럽게 풀려요.
merge vs rebase
둘 다 브랜치를 합치는 작업인데, 방식과 결과가 완전히 달라요.
merge
git checkout main
git merge feature
feature 브랜치의 변경사항을 main에 합치면서 merge commit 이 하나 생깁니다. 히스토리에 "여기서 합쳤다"라는 기록이 남아요.
A---B---C (feature)
/ \
D---E-----------F (main, merge commit)
rebase
git checkout feature
git rebase main
feature의 커밋들을 main의 최신 커밋 뒤로 재배치 합니다. 마치 main에서 방금 분기한 것처럼 히스토리가 일직선이 돼요.
# rebase 전
A---B---C (feature)
/
D---E---F (main)
# rebase 후
A'--B'--C' (feature)
/
D---E---F (main)
주의할 점은, rebase 후의 A', B', C'는 원래의 A, B, C와 다른 커밋 이라는 겁니다. 내용은 같아도 SHA-1 해시가 바뀌어요. 부모 커밋이 달라졌으니까요.
비교
| 항목 | merge | rebase |
|---|---|---|
| 히스토리 | 비선형 (분기/합류 보임) | 선형 (깔끔) |
| merge commit | 생성됨 | 없음 |
| 기존 커밋 변경 | 없음 | 커밋 해시가 바뀜 |
| 충돌 해결 | 한 번에 | 커밋마다 한 번씩 |
| 적합한 경우 | 공유 브랜치 | 로컬 브랜치 정리 |
**황금률 **: 이미 push한 커밋, 특히 다른 사람과 공유 중인 브랜치에서는 rebase 하지 마세요. 다른 사람의 히스토리가 엉망이 됩니다. 로컬에서 작업하던 브랜치를 정리하는 용도로 쓰는 게 맞아요.
Fast-forward vs 3-way merge
merge를 하면 항상 merge commit이 생기는 건 아닙니다.
Fast-forward merge
main 브랜치에서 분기한 이후에 main에 새로운 커밋이 없다면, Git은 main 포인터를 그냥 앞으로 옮기기만 해요. 이게 fast-forward입니다.
# fast-forward 가능한 상황
D---E (main)
\
A---B---C (feature)
# merge 후
D---E---A---B---C (main, feature)
merge commit이 안 생기니까 히스토리가 깔끔하긴 한데, "여기서 feature를 합쳤다"라는 흔적이 남지 않아요. 그래서 일부러 --no-ff 옵션을 써서 merge commit을 강제로 만드는 팀도 있습니다.
git merge --no-ff feature
3-way merge
main에도 새 커밋이 있고, feature에도 새 커밋이 있으면 fast-forward가 불가능합니다. 이때 Git은 ** 공통 조상(common ancestor)**, **main의 최신 **, feature의 최신 세 지점을 비교해서 합쳐요. 그래서 3-way merge라고 부릅니다. 이 경우에는 반드시 merge commit이 생겨요.
cherry-pick
특정 커밋 하나(또는 여러 개)만 골라서 현재 브랜치에 적용하는 명령입니다.
git cherry-pick abc1234
이게 언제 필요하냐면:
- hotfix 브랜치에서 수정한 버그픽스를 release 브랜치에도 적용해야 할 때
- 다른 브랜치에서 작업하다가 실수로 잘못된 브랜치에 커밋한 걸 옮길 때
- 전체 브랜치를 merge하긴 이른데, 특정 기능만 먼저 가져오고 싶을 때
cherry-pick도 새로운 커밋을 만듭니다. 원래 커밋과 내용은 같지만 해시가 달라요. 남용하면 같은 변경사항이 여러 브랜치에 중복으로 존재하게 되니까, merge나 rebase로 해결할 수 있으면 그쪽이 낫습니다.
# 여러 커밋을 한번에
git cherry-pick abc1234 def5678
# 범위 지정 (abc1234는 제외, 그 다음부터 def5678까지)
git cherry-pick abc1234..def5678
rebase -i (Interactive Rebase)
rebase의 진짜 힘은 interactive 모드에 있어요. 커밋 히스토리를 PR 올리기 전에 정리할 때 씁니다.
git rebase -i HEAD~4
에디터가 열리면서 최근 4개 커밋 목록이 나옵니다:
pick a1b2c3d feat: 사용자 인증 기능 추가
pick e4f5g6h fix: 오타 수정
pick i7j8k9l fix: 빌드 에러 수정
pick m0n1o2p feat: 인증 테스트 추가
여기서 pick을 다른 명령어로 바꿔서 히스토리를 조작합니다:
| 명령어 | 동작 |
|---|---|
pick | 커밋 그대로 사용 |
squash | 이전 커밋에 합침 (메시지 편집 가능) |
fixup | 이전 커밋에 합침 (메시지 버림) |
reword | 커밋 메시지만 수정 |
edit | 커밋 내용을 수정할 수 있게 멈춤 |
drop | 커밋 삭제 |
실무에서 가장 많이 쓰는 패턴은 이래요:
pick a1b2c3d feat: 사용자 인증 기능 추가
fixup e4f5g6h fix: 오타 수정
fixup i7j8k9l fix: 빌드 에러 수정
pick m0n1o2p feat: 인증 테스트 추가
"오타 수정", "빌드 에러 수정" 같은 의미 없는 커밋들을 앞의 커밋에 합쳐버리는 거예요. PR 리뷰어 입장에서 커밋 하나하나가 의미 있는 단위면 리뷰하기 훨씬 편합니다.
순서를 바꾸면 커밋 순서도 변경돼요. 다만 순서를 바꾸면 conflict가 발생할 수 있으니 주의하세요.
Conflict 해결
같은 파일의 같은 부분을 두 브랜치에서 다르게 수정하면 conflict가 발생합니다. Git이 자동으로 합칠 수 없는 상황이에요.
<<<<<<< HEAD
현재 브랜치의 내용
=======
합치려는 브랜치의 내용
>>>>>>> feature
해결 전략
- ** 수동 해결 **: 마커를 보고 직접 원하는 코드를 남기고 나머지를 지웁니다
- ours / theirs: 한쪽을 통째로 선택
# merge 중 conflict — 현재 브랜치 내용으로 선택
git checkout --ours path/to/file
# merge 중 conflict — 합치려는 브랜치 내용으로 선택
git checkout --theirs path/to/file
- **merge tool 사용 **:
git mergetool로 비주얼 도구(VSCode, IntelliJ, vimdiff 등) 연결
rerere (Reuse Recorded Resolution)
반복적으로 같은 conflict가 발생하는 경우에 유용합니다. 한 번 해결한 conflict 패턴을 기록해두고, 다음에 같은 패턴이 나오면 자동으로 적용해줘요.
git config --global rerere.enabled true
long-lived branch를 운영하면서 주기적으로 merge하는 환경에서 특히 유용합니다. 다만 잘못된 해결을 기록해버리면 이후에도 계속 잘못 적용되니까, rerere 캐시를 정리하는 법도 알아둬야 해요: git rerere forget path/to/file.
reset vs revert
둘 다 "되돌리기"인데, 방식이 완전히 달라요.
reset
커밋 히스토리 자체를 뒤로 되돌립니다. 해당 커밋 이후의 기록이 사라져요.
git reset --soft HEAD~1 # 커밋만 취소, 변경사항은 staged 상태로 유지
git reset --mixed HEAD~1 # 커밋 취소 + unstage (기본값)
git reset --hard HEAD~1 # 커밋 취소 + 변경사항 완전 삭제
| 옵션 | HEAD | Staging Area | Working Directory |
|---|---|---|---|
--soft | 이동 | 유지 | 유지 |
--mixed | 이동 | 초기화 | 유지 |
--hard | 이동 | 초기화 | 초기화 |
--hard는 진짜 위험합니다. 커밋하지 않은 변경사항까지 날아가요. reflog로 복구할 수 있긴 하지만, 커밋 안 한 건 복구 못합니다.
revert
새로운 커밋을 만들어서 이전 커밋의 변경사항을 되돌립니다. 히스토리가 보존돼요.
git revert abc1234
** 공유 브랜치에서는 반드시 revert를 써야 합니다.** reset으로 히스토리를 바꿔버리면, 이미 pull 받은 다른 팀원들의 로컬과 충돌이 나요. revert는 "이 변경을 취소합니다"라는 새 커밋이 추가되는 거니까 안전합니다.
핵심만 정리하면, 실수로 main에 잘못 push했을 때는 revert로 되돌려야 해요. reset은 공유 브랜치에서 히스토리를 변경하면 다른 팀원에게 영향을 주기 때문에 쓰지 않습니다.
stash
작업 중인 변경사항을 임시로 저장하고 working directory를 깨끗하게 만들어주는 기능이에요.
git stash # 현재 변경사항 임시 저장
git stash list # 저장된 stash 목록 확인
git stash pop # 가장 최근 stash 적용 후 삭제
git stash apply # 가장 최근 stash 적용 (삭제하지 않음)
git stash drop # 가장 최근 stash 삭제
pop은 적용하면서 stash에서 제거하고, apply는 적용만 합니다. 여러 브랜치에 같은 변경사항을 적용하고 싶을 때는 apply가 유용해요.
급하게 다른 브랜치로 넘어가서 hotfix를 처리해야 하는데, 현재 작업은 아직 커밋하기 애매한 상태일 때 stash를 쓰면 됩니다. 메시지를 붙여서 저장할 수도 있어요:
git stash push -m "로그인 기능 작업 중"
-u 옵션을 추가하면 untracked 파일도 포함해서 저장합니다. 기본적으로 stash는 tracked 파일의 변경사항만 저장하니까요.
브랜치 전략
팀에서 브랜치를 어떻게 운영할 건지에 대한 규칙이에요. 실무에서 자주 다루는 주제이기도 합니다.
Git Flow
Vincent Driessen이 2010년에 제안한 모델로, 가장 전통적인 전략이에요.
main ──────────────────────────────────── (프로덕션)
└── develop ─────────────────────────── (개발 통합)
├── feature/login ──┘ (기능 개발)
├── feature/signup ─┘
└── release/1.0 ──── main에 merge (릴리스 준비)
└── hotfix ─ (긴급 수정)
- main: 프로덕션에 배포된 코드
- develop: 다음 릴리스를 위한 개발 브랜치
- feature/: develop에서 분기, 기능 완료 후 develop에 merge
- release/: develop에서 분기, QA 후 main과 develop에 merge
- hotfix/: main에서 분기, 수정 후 main과 develop에 merge
장점은 릴리스 주기가 명확하고 안정성이 높다는 거예요. 단점은 브랜치가 많아서 복잡하고, CI/CD와 잘 안 맞습니다. 릴리스 주기가 긴 프로젝트(2~4주)에 적합해요.
GitHub Flow
Git Flow가 너무 복잡하다는 반성에서 나온 경량 모델입니다.
- main은 항상 배포 가능한 상태
- 새 작업은 main에서 브랜치를 만들어서 진행
- 수시로 push하고, PR을 올려서 코드 리뷰
- 리뷰 통과하면 main에 merge하고 즉시 배포
브랜치가 main과 feature 둘뿐이라 단순해요. 지속적 배포(CD)를 하는 팀에 적합합니다. 다만 릴리스 버전 관리가 별도로 필요하고, main의 안정성은 전적으로 CI/CD 파이프라인과 코드 리뷰에 의존합니다.
Trunk-Based Development
모든 개발자가 하나의 브랜치(trunk, 보통 main)에 직접 커밋하거나, 아주 짧은 수명의 브랜치를 쓰는 전략이에요.
- 브랜치 수명: 1~2일 이내
- Feature Flag로 미완성 기능을 숨김
- 작은 단위로 자주 merge
Google, Facebook 같은 대규모 조직에서 사용합니다. long-lived branch가 없으니까 merge conflict가 적고, CI/CD와 궁합이 좋아요. 단점은 Feature Flag 관리가 복잡해질 수 있고, 팀 전체의 코드 품질 의식이 높아야 한다는 점입니다.
비교표
| 항목 | Git Flow | GitHub Flow | Trunk-Based |
|---|---|---|---|
| 복잡도 | 높음 | 낮음 | 낮음 |
| 릴리스 주기 | 길다 (2~4주) | 수시 | 수시 |
| 브랜치 수명 | 길 수 있음 | 중간 | 매우 짧음 (1~2일) |
| CI/CD 궁합 | 보통 | 좋음 | 매우 좋음 |
| 적합한 팀 | 릴리스 기반 | 웹 서비스 | 대규모, 높은 성숙도 |
.gitignore와 .gitattributes
.gitignore
Git이 추적하지 않을 파일을 지정합니다.
# 빌드 결과물
/build/
/dist/
*.class
# IDE 설정
.idea/
.vscode/
*.iml
# 환경 변수
.env
.env.local
# OS 파일
.DS_Store
Thumbs.db
# 의존성
node_modules/
이미 tracked 된 파일은 .gitignore에 추가해도 계속 추적됩니다. 추적을 중단하려면:
git rm --cached path/to/file
.gitattributes
파일별로 Git의 동작 방식을 제어합니다. 줄바꿈 처리(CRLF vs LF)가 대표적인 용도예요.
# 텍스트 파일 자동 줄바꿈 변환
* text=auto
# 특정 파일은 항상 LF
*.sh text eol=lf
*.bat text eol=crlf
# 바이너리 파일로 취급
*.png binary
*.jar binary
Windows와 macOS/Linux 개발자가 섞인 팀에서 .gitattributes 없이 작업하면 줄바꿈 관련 diff가 미친 듯이 나옵니다. 프로젝트 초기에 설정해놓는 게 좋아요.
주의할 점
detached HEAD
HEAD가 브랜치가 아니라 특정 커밋을 직접 가리키는 상태입니다. git checkout abc1234 같이 커밋 해시를 직접 체크아웃하면 이 상태가 돼요.
이 상태에서 커밋을 하면, 그 커밋은 어떤 브랜치에도 속하지 않습니다. 나중에 다른 브랜치로 이동하면 그 커밋을 가리키는 참조가 없어져서, GC에 의해 정리될 수 있어요. 해결법은 해당 커밋에서 바로 브랜치를 만드는 겁니다:
git checkout -b new-branch
reflog로 실수 복구
git reflog는 HEAD가 이동한 모든 기록을 보여줍니다. git log에서는 보이지 않는 커밋도 reflog에는 남아있어요.
git reflog
# abc1234 HEAD@{0}: reset: moving to HEAD~3
# def5678 HEAD@{1}: commit: 중요한 작업
# ...
git reset --hard def5678 # 실수로 reset한 거 복구
reflog 기록은 기본 90일간 유지됩니다. --hard로 날려버린 커밋도, push 전에 실수로 rebase한 것도 reflog로 복구 가능해요. 다만 한 번도 커밋하지 않은 변경사항은 Git이 알 길이 없으니 복구 불가입니다.
git bisect
특정 버그가 어느 커밋에서 도입됐는지 이진 탐색으로 찾아주는 도구예요.
git bisect start
git bisect bad # 현재 커밋에 버그가 있음
git bisect good v1.0 # v1.0에는 버그가 없었음
# Git이 중간 커밋을 체크아웃 → 테스트 → good/bad 판정을 반복
100개 커밋 사이에서 범인을 찾을 때, 일일이 확인하면 100번이지만 bisect를 쓰면 7번이면 됩니다(log2(100)). 테스트 스크립트를 연결하면 완전 자동화도 돼요:
git bisect run ./test.sh
monorepo 전략
하나의 저장소에 여러 프로젝트를 담는 방식이에요. Google이 수십억 줄의 코드를 단일 repo에서 관리하는 것으로 유명합니다.
장점은 코드 공유와 리팩토링이 쉽고, 의존성 관리가 단순해진다는 점이에요. 단점은 repo가 커지면 clone, checkout이 느려지고, CI/CD가 복잡해진다는 점입니다.
Git에서 monorepo를 쓸 때 도움이 되는 기능:
- sparse-checkout: 필요한 디렉토리만 체크아웃
- shallow clone:
git clone --depth 1로 최신 커밋만 가져오기 - git-lfs: 대용량 바이너리 파일을 별도 저장소에서 관리
파생 개념
| 개념 | 연관 포인트 |
|---|---|
| CI/CD | 브랜치 전략과 밀접. Trunk-Based Development가 CI/CD에 최적화됨 |
| ** 코드 리뷰** | PR 기반 워크플로우에서 브랜치 전략, squash merge 등과 연결 |
| ** 협업** | conflict 해결, 브랜치 네이밍 컨벤션, 커밋 메시지 규칙 |
핵심 정리
- Git은 스냅샷 기반이고, blob/tree/commit 세 가지 객체로 이루어져 있습니다.
- merge는 히스토리를 보존하고, rebase는 히스토리를 깔끔하게 만들어요. 공유 브랜치에서는 rebase 금지입니다.
- cherry-pick은 특정 커밋만 가져올 때 쓰지만, 남용하면 중복 히스토리가 생겨요.
- 공유 브랜치에서 되돌리기는 revert, 로컬에서는 reset을 씁니다.
- 브랜치 전략은 팀 규모와 배포 주기에 맞춰 선택해요. 정답은 없습니다.
- reflog는 Git에서의 최후의 안전망이에요. 왠만하면 복구 가능합니다.