Theme:

공유 브랜치에서 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은 이런 과정을 거칩니다:

  1. 변경된 파일의 내용으로 blob 객체 생성
  2. 현재 디렉토리 구조를 나타내는 tree 객체 생성
  3. tree를 가리키는 commit 객체 생성 (부모 커밋 포함)

각 객체는 내용의 SHA-1 해시가 곧 식별자예요. 같은 내용이면 같은 해시가 나오니까, Git은 중복 저장을 자연스럽게 피합니다. "커밋은 diff가 아니라 스냅샷이다"라는 말이 여기서 나오는 건데요. 매 커밋이 전체 프로젝트의 tree를 가리키고 있기 때문이에요.

PLAINTEXT
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 세 영역이 있어요.

PLAINTEXT
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

BASH
git checkout main
git merge feature

feature 브랜치의 변경사항을 main에 합치면서 merge commit 이 하나 생깁니다. 히스토리에 "여기서 합쳤다"라는 기록이 남아요.

PLAINTEXT
      A---B---C  (feature)
     /         \
D---E-----------F  (main, merge commit)

rebase

BASH
git checkout feature
git rebase main

feature의 커밋들을 main의 최신 커밋 뒤로 재배치 합니다. 마치 main에서 방금 분기한 것처럼 히스토리가 일직선이 돼요.

PLAINTEXT
# 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 해시가 바뀌어요. 부모 커밋이 달라졌으니까요.

비교

항목mergerebase
히스토리비선형 (분기/합류 보임)선형 (깔끔)
merge commit생성됨없음
기존 커밋 변경없음커밋 해시가 바뀜
충돌 해결한 번에커밋마다 한 번씩
적합한 경우공유 브랜치로컬 브랜치 정리

**황금률 **: 이미 push한 커밋, 특히 다른 사람과 공유 중인 브랜치에서는 rebase 하지 마세요. 다른 사람의 히스토리가 엉망이 됩니다. 로컬에서 작업하던 브랜치를 정리하는 용도로 쓰는 게 맞아요.


Fast-forward vs 3-way merge

merge를 하면 항상 merge commit이 생기는 건 아닙니다.

Fast-forward merge

main 브랜치에서 분기한 이후에 main에 새로운 커밋이 없다면, Git은 main 포인터를 그냥 앞으로 옮기기만 해요. 이게 fast-forward입니다.

PLAINTEXT
# fast-forward 가능한 상황
D---E  (main)
     \
      A---B---C  (feature)

# merge 후
D---E---A---B---C  (main, feature)

merge commit이 안 생기니까 히스토리가 깔끔하긴 한데, "여기서 feature를 합쳤다"라는 흔적이 남지 않아요. 그래서 일부러 --no-ff 옵션을 써서 merge commit을 강제로 만드는 팀도 있습니다.

BASH
git merge --no-ff feature

3-way merge

main에도 새 커밋이 있고, feature에도 새 커밋이 있으면 fast-forward가 불가능합니다. 이때 Git은 ** 공통 조상(common ancestor)**, **main의 최신 **, feature의 최신 세 지점을 비교해서 합쳐요. 그래서 3-way merge라고 부릅니다. 이 경우에는 반드시 merge commit이 생겨요.


cherry-pick

특정 커밋 하나(또는 여러 개)만 골라서 현재 브랜치에 적용하는 명령입니다.

BASH
git cherry-pick abc1234

이게 언제 필요하냐면:

  • hotfix 브랜치에서 수정한 버그픽스를 release 브랜치에도 적용해야 할 때
  • 다른 브랜치에서 작업하다가 실수로 잘못된 브랜치에 커밋한 걸 옮길 때
  • 전체 브랜치를 merge하긴 이른데, 특정 기능만 먼저 가져오고 싶을 때

cherry-pick도 새로운 커밋을 만듭니다. 원래 커밋과 내용은 같지만 해시가 달라요. 남용하면 같은 변경사항이 여러 브랜치에 중복으로 존재하게 되니까, merge나 rebase로 해결할 수 있으면 그쪽이 낫습니다.

BASH
# 여러 커밋을 한번에
git cherry-pick abc1234 def5678

# 범위 지정 (abc1234는 제외, 그 다음부터 def5678까지)
git cherry-pick abc1234..def5678

rebase -i (Interactive Rebase)

rebase의 진짜 힘은 interactive 모드에 있어요. 커밋 히스토리를 PR 올리기 전에 정리할 때 씁니다.

BASH
git rebase -i HEAD~4

에디터가 열리면서 최근 4개 커밋 목록이 나옵니다:

PLAINTEXT
pick a1b2c3d feat: 사용자 인증 기능 추가
pick e4f5g6h fix: 오타 수정
pick i7j8k9l fix: 빌드 에러 수정
pick m0n1o2p feat: 인증 테스트 추가

여기서 pick을 다른 명령어로 바꿔서 히스토리를 조작합니다:

명령어동작
pick커밋 그대로 사용
squash이전 커밋에 합침 (메시지 편집 가능)
fixup이전 커밋에 합침 (메시지 버림)
reword커밋 메시지만 수정
edit커밋 내용을 수정할 수 있게 멈춤
drop커밋 삭제

실무에서 가장 많이 쓰는 패턴은 이래요:

PLAINTEXT
pick a1b2c3d feat: 사용자 인증 기능 추가
fixup e4f5g6h fix: 오타 수정
fixup i7j8k9l fix: 빌드 에러 수정
pick m0n1o2p feat: 인증 테스트 추가

"오타 수정", "빌드 에러 수정" 같은 의미 없는 커밋들을 앞의 커밋에 합쳐버리는 거예요. PR 리뷰어 입장에서 커밋 하나하나가 의미 있는 단위면 리뷰하기 훨씬 편합니다.

순서를 바꾸면 커밋 순서도 변경돼요. 다만 순서를 바꾸면 conflict가 발생할 수 있으니 주의하세요.


Conflict 해결

같은 파일의 같은 부분을 두 브랜치에서 다르게 수정하면 conflict가 발생합니다. Git이 자동으로 합칠 수 없는 상황이에요.

PLAINTEXT
<<<<<<< HEAD
현재 브랜치의 내용
=======
합치려는 브랜치의 내용
>>>>>>> feature

해결 전략

  1. ** 수동 해결 **: 마커를 보고 직접 원하는 코드를 남기고 나머지를 지웁니다
  2. ours / theirs: 한쪽을 통째로 선택
BASH
# merge 중 conflict — 현재 브랜치 내용으로 선택
git checkout --ours path/to/file

# merge 중 conflict — 합치려는 브랜치 내용으로 선택
git checkout --theirs path/to/file
  1. **merge tool 사용 **: git mergetool로 비주얼 도구(VSCode, IntelliJ, vimdiff 등) 연결

rerere (Reuse Recorded Resolution)

반복적으로 같은 conflict가 발생하는 경우에 유용합니다. 한 번 해결한 conflict 패턴을 기록해두고, 다음에 같은 패턴이 나오면 자동으로 적용해줘요.

BASH
git config --global rerere.enabled true

long-lived branch를 운영하면서 주기적으로 merge하는 환경에서 특히 유용합니다. 다만 잘못된 해결을 기록해버리면 이후에도 계속 잘못 적용되니까, rerere 캐시를 정리하는 법도 알아둬야 해요: git rerere forget path/to/file.


reset vs revert

둘 다 "되돌리기"인데, 방식이 완전히 달라요.

reset

커밋 히스토리 자체를 뒤로 되돌립니다. 해당 커밋 이후의 기록이 사라져요.

BASH
git reset --soft HEAD~1   # 커밋만 취소, 변경사항은 staged 상태로 유지
git reset --mixed HEAD~1  # 커밋 취소 + unstage (기본값)
git reset --hard HEAD~1   # 커밋 취소 + 변경사항 완전 삭제
옵션HEADStaging AreaWorking Directory
--soft이동유지유지
--mixed이동초기화유지
--hard이동초기화초기화

--hard는 진짜 위험합니다. 커밋하지 않은 변경사항까지 날아가요. reflog로 복구할 수 있긴 하지만, 커밋 안 한 건 복구 못합니다.

revert

새로운 커밋을 만들어서 이전 커밋의 변경사항을 되돌립니다. 히스토리가 보존돼요.

BASH
git revert abc1234

** 공유 브랜치에서는 반드시 revert를 써야 합니다.** reset으로 히스토리를 바꿔버리면, 이미 pull 받은 다른 팀원들의 로컬과 충돌이 나요. revert는 "이 변경을 취소합니다"라는 새 커밋이 추가되는 거니까 안전합니다.

핵심만 정리하면, 실수로 main에 잘못 push했을 때는 revert로 되돌려야 해요. reset은 공유 브랜치에서 히스토리를 변경하면 다른 팀원에게 영향을 주기 때문에 쓰지 않습니다.


stash

작업 중인 변경사항을 임시로 저장하고 working directory를 깨끗하게 만들어주는 기능이에요.

BASH
git stash           # 현재 변경사항 임시 저장
git stash list      # 저장된 stash 목록 확인
git stash pop       # 가장 최근 stash 적용 후 삭제
git stash apply     # 가장 최근 stash 적용 (삭제하지 않음)
git stash drop      # 가장 최근 stash 삭제

pop은 적용하면서 stash에서 제거하고, apply는 적용만 합니다. 여러 브랜치에 같은 변경사항을 적용하고 싶을 때는 apply가 유용해요.

급하게 다른 브랜치로 넘어가서 hotfix를 처리해야 하는데, 현재 작업은 아직 커밋하기 애매한 상태일 때 stash를 쓰면 됩니다. 메시지를 붙여서 저장할 수도 있어요:

BASH
git stash push -m "로그인 기능 작업 중"

-u 옵션을 추가하면 untracked 파일도 포함해서 저장합니다. 기본적으로 stash는 tracked 파일의 변경사항만 저장하니까요.


브랜치 전략

팀에서 브랜치를 어떻게 운영할 건지에 대한 규칙이에요. 실무에서 자주 다루는 주제이기도 합니다.

Git Flow

Vincent Driessen이 2010년에 제안한 모델로, 가장 전통적인 전략이에요.

PLAINTEXT
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가 너무 복잡하다는 반성에서 나온 경량 모델입니다.

  1. main은 항상 배포 가능한 상태
  2. 새 작업은 main에서 브랜치를 만들어서 진행
  3. 수시로 push하고, PR을 올려서 코드 리뷰
  4. 리뷰 통과하면 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 FlowGitHub FlowTrunk-Based
복잡도높음낮음낮음
릴리스 주기길다 (2~4주)수시수시
브랜치 수명길 수 있음중간매우 짧음 (1~2일)
CI/CD 궁합보통좋음매우 좋음
적합한 팀릴리스 기반웹 서비스대규모, 높은 성숙도

.gitignore와 .gitattributes

.gitignore

Git이 추적하지 않을 파일을 지정합니다.

GITIGNORE
# 빌드 결과물
/build/
/dist/
*.class

# IDE 설정
.idea/
.vscode/
*.iml

# 환경 변수
.env
.env.local

# OS 파일
.DS_Store
Thumbs.db

# 의존성
node_modules/

이미 tracked 된 파일은 .gitignore에 추가해도 계속 추적됩니다. 추적을 중단하려면:

BASH
git rm --cached path/to/file

.gitattributes

파일별로 Git의 동작 방식을 제어합니다. 줄바꿈 처리(CRLF vs LF)가 대표적인 용도예요.

GITATTRIBUTES
# 텍스트 파일 자동 줄바꿈 변환
* 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에 의해 정리될 수 있어요. 해결법은 해당 커밋에서 바로 브랜치를 만드는 겁니다:

BASH
git checkout -b new-branch

reflog로 실수 복구

git reflog는 HEAD가 이동한 모든 기록을 보여줍니다. git log에서는 보이지 않는 커밋도 reflog에는 남아있어요.

BASH
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

특정 버그가 어느 커밋에서 도입됐는지 이진 탐색으로 찾아주는 도구예요.

BASH
git bisect start
git bisect bad          # 현재 커밋에 버그가 있음
git bisect good v1.0    # v1.0에는 버그가 없었음
# Git이 중간 커밋을 체크아웃 → 테스트 → good/bad 판정을 반복

100개 커밋 사이에서 범인을 찾을 때, 일일이 확인하면 100번이지만 bisect를 쓰면 7번이면 됩니다(log2(100)). 테스트 스크립트를 연결하면 완전 자동화도 돼요:

BASH
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에서의 최후의 안전망이에요. 왠만하면 복구 가능합니다.
댓글 로딩 중...