- 0. git 내부 사알짝
- 1. Merge vs Rebase
누구나 한번 쯤 merge, rebase, pull 하다가 git한테 백초크 잡혀서 가진거 다 뺏기고 remote 햄 커밋부터 다시 시작한적 있을것이다.
나 또한 최근 이 개새끼한테 잡혀서 머리채 다 뜯기고 겨우 탈출할 수 있었는데, 걍 pull-merge-push 만 쳐하던 과거의 나와 풀스파링 뜨고싶은 심정이었다.
AI 학습을 위해서 값비싼 장비가 필요하며 임대기간은 한정적이며 매우 짧다. 학습코드를 개씹완벽하게 짜서 걍 손빨기만 하면 되는 상황이 아니라면 우리는 학습을 돌리며 끊임없이 수정하고, 마이그레이션하고 서커스하고 해야한다.
- 안그러면 나보다 연봉더 높은 GPU를 놀게 해야한다. 차라리 내가 노는게 낫지 GPU는 절대 놀게 두면 안된다…
시간이 촉박한 상황에서 팀원들이 동일 출발지인 main부터 새로 브랜치 파서 작업하다 합쳐나갈 때, 문제는 이 때 발생한다
- 각각의 task 는 우선순위도 다르며, 난이도도 다르며, 각자의 코딩 실력(속도)도 다르다.
- 내작업은 쉬운데 오래걸리고 검증이 중요함, 다른애 작업은 어렵지만 빨리 끝낼수있고 등등
- 검증이 중요한 내 작업에
main기준으로 짜다가 다른애가 완성을 먼저 했다면…- 근데 문제는 디버그 한다고 소스코드 외에 존나 긴 config 파일, test case, 추가한 dummy 파일등을 수정/생성한 상황에서 그거 빼고 코드 부분만 먼저 합쳐서 push 해야한다던가 등등
0. git 내부 사알짝
gemini쿤이 설명을 너무 고봉밥으로 해줘서 핵심만 짚어본다.
문뜪 든 생각인데, 프로젝트 안에 코드 약간 바꾼것들 다 기록해두고 글로 갈 수 있고 그런거 생각해보면 도대체 어떻게 파일관리를 하는지 신기하긴했었음. 그거에 대한 맛을 살짝 볼껀데,,,
git은 어떤 파일 시스템이 아니라 ‘key-value’ 데이터 시스템이다.
아무 git 프로젝트루트로 가서 lf -Fl .git을 때려보자. 그러면 아래와같은 형식이 나오는데 objects 안에 중요한게 저장됨.
.git/
├── HEAD # 현재 Checkout된 브랜치를 가리키는 포인터
├── config # 설정 파일
├── description
├── hooks/ # 훅 스크립트
├── index # Staging Area (바이너리 파일)
├── objects/ # ★ 모든 데이터(컨텐츠)가 저장되는 DB
└── refs/ # * 브랜치, 태그 등의 포인터 저장소
git은 모든 폴더명, 파일명, (파일안에)내용물 전~~부 40자짜리 hash 로 매핑한다음에 관리함.
SHA-1 (Secure Hash Algorithm 1)으로 어떤 내용이든 40글자짜리 로 매핑해보림.
echo '이건 어떤 파일안애 코드들임' | git hash-object --stdin
70f87608c4a738b3ef128377a2444a3c366dfb04
echo '이건 어떤 파일안애 코드들임' | git hash-object --stdin
9b1ce651b639d0ce33c2c18244d5d7c7e5d844dd
그 어떤거든 일단 hash로 바꿔놓고 그걸 파일명으로 해서 그 안에 바이너리화된 컨텐츠를 집어넣는다. objects 안에 들어가서 파일들을 한번 쭉보면 00 ~ ff 폴더안에 여러 hash값들이 들어있는걸 볼 수 있는데 그중 하나 까보면 이진화된 컨텐츠들이 들어있음.
- hash로 바꾸고 앞
2자는 폴더명, 그뒤에38자는 파일명 - 까보면 안에 이진화 된 컨텐츠.
objects % tree .
.
├── 00
│ ├── 7f59616d1216e34abc1684d3c4b62cf4a2dcda
│ └── b7b7f5a6b9a0e0cb1979fc06ec7ec8649d3033
├── 01
│ └── 11ef6072b688ec0c290f8454656037b41bc30b
├── 03
│ └── 89f46c85e52de3f3cfd1666097add72f8097d5
├── 04
│ └── d7773bc6f4333a07bbc9ff5f66ed95f2f5a002
├── 05
│ └── ff7382984d6bfa1a2f9ce04cf7f1a29842c867
이런식으로 저장 하기 때문에 내가 a.py, b.py, c.py ~~ 100만개 파일 만들어놓고 안에 동일한 내용 박아놔도 컨텐츠 파일은 한개만 유지됨.
- Filename (12aedf…): 이 객체의 ID (Key).
- File Content: 이 객체의 실제 데이터 (Value).
import torch...같은 코드 내용이 zlib으로 눌려있음.
이렇게 한 프로젝트에 있는 모든 폴더, 파일, 컨텐츠를 hash로 만들어둘 수 있고, 프로젝트 내에 모든 파일명, 권한, hash 를 다 모아서 다시한번 SHA-1 돌려서 root에 대한 hash 값을 얻을 수 있다고 함.
만약 내가 파일명을 쪼금 바꾸거나, 콘텐츠 안에 글자 하나만 수정해도 root 의 hash값이 달라져버리는거임.
# 1. 가장 최근 커밋의 해시 확인
git log -1 --pretty=format:%H
> 7bf4b5488...
# 2. 그 커밋 객체의 내용물 뜯어보기
git cat-file -p 7bf4b5488...
> tree acdac7e527...
> parent a5d587ef54...
> author gkalstn000 ...
> committer gkalstn000 ...
# 3. 그 커밋이 가리키는 Tree 뜯어보기
$ git cat-file -p acdac7e527...
> 100644 blob 3e7bcd0d0c... run.py
> 100644 blob 65f903b238... requirements.txt
# 4. 소스코드(Blob) 내용 확인
$ git cat-file -p 3e7bcd0d0c...
> import torch
> ...
1. Merge vs Rebase
그렇다면 git 은 모든 것을 hash로바꿔서 .git/objects 안에 쳐박아두고 관리한다는 것을 알았다.
- commit, branch 싹다.
- mapping 만 되어있으면 알아서 가져와서 재현해낼 수 있음.
당연히 branch도 hash로 관리하고 까보면 또 tree 어쩌고 뜨고 따라가다보면 실물이 나옴.
(base) minsuha@minsuhaui-MacBookPro refs % tree .
.
├── heads
│ └── master
├── remotes
│ └── origin
│ ├── HEAD
│ └── master
└── tags
5 directories, 3 files
(base) minsuha@minsuhaui-MacBookPro refs % cat heads/master
057775f80ae24e9980f4d5eff270710f97382cc2
- 개인정보 지우기 귀찮아서 걍 안지우고 오픈한다.
이 때 rebase 이새끼가 문제가 되는데, 좀만 잘못하면 바로 테이크다운 들어오는 치마예프 같은 새끼다. 조심히 써야하는 이유를 파악해보자.
# 상황설정.
# 팀원 (main)과 내가 (feature) 갈라져서 작업 중.
A---B (feature)
/
C1--C2--C3--C4 (main)
1.1 Merge
Merge 는 기존 커밋들을 (hash값들) 안건들고 수동으로 하나하나 고쳐서 새로운 커밋 (hash)를 만들어낸다. 매우 안전함.
A---B------M (feature -> merged)
/ /
C1--C2--C3--C4-/ (main)
- Node M: 부모가 두 개(B, C4)인 특별한 커밋.
- Pros: 일어난 사실(Fact)을 그대로 보존함. 누가 언제 합쳤는지 명확함.
- Cons: 브랜치가 많아지면 “기차 선로(Railroad Tracks)”가 꼬여서 역사 파악이 불가능해짐. (Spaghetti History).
이게 여러명이서 작업하다보면 merge 존나쌓여서 5~6줄씩 선이 생기는데 먼가 버그 터져서 다시 이전상태로 되돌리려면 골아파 지는 상황이 나온다.
하지만 골이 아파질뿐 모든 기록이 남아있기 때문에 뜨개질 하다보면 가능은 하다.
1.1.1 The Algorithm: 3-Way Merge
Merge 과정은 3개의 commit을 비교하며 이루어짐.
- Base: 공통 조상 (C2)
- Ours: 내 브랜치 끝 (B)
- Theirs: 상대 브랜치 끝 (C4)
Conflict Logic: Base에서는 “Hello”였는데, Ours는 “Hi”, Theirs는 “Hola”로 바꿨다면? 기계적 판단 불가 -> Conflict(충돌) 발생.
1.2 Rebase
# 상황설정.
# 팀원 (main)과 내가 (feature) 갈라져서 작업 중.
A---B (feature)
/
C1--C2--C3--C4 (main)
Rebase는 말 그대로 Base를 다시 설정하는 건데, 이 때 기존 feature commit을 파괴하고 새로운 commit (hash)을 만들어 낸다.
# rebase 동작
# 기존 A, B 어디갔노?
A'--B' (feature)
/
C1--C2--C3--C4 (main)
- Transformation: 원래 있던 A, B 커밋은 사라지고(Garbage), 내용만 똑같은 새로운 커밋 A’, B’가 C4 뒤에 붙음.
- Result: 그래프가 일직선(Linear)로 깔끔해짐.
- Pros: 역사가 깔끔함. git bisect로 버그 찾을 때 매우 유리함.
- Cons: 커밋 해시가 바뀜. (A $\neq$ A’). 이미 공유된 브랜치에서 하면 대재앙 발생.
1.2.1 The Algorithm: Cherry-pick Loop
- Diff 계산:
C2->A의 변경사항(Patch)을 저장. - Reset: feature 브랜치를
C4로 강제 이동. - Replay (Apply Patch): 저장해둔 Patch를
C4위에 적용 ->A'생성.- merge 같은 느낌.
- 여기서 충돌 나면 멈춤. 해결하면
B적용 시도. - Repeat:
B에 대해서도 반복 ->B'생성.
기존 commit 을 지워버리고 새로 만든다. 이 지우는 행위가 어떤 대재앙을 초래하는지 살표보자.
1.2.2 Rebase가 위험한 이유 (The Golden Rule)
“Do not rebase commits that exist outside your repository.”
핵심은 동료와 동일 branch를 공유하며 작업 중일 때, 동료가 pull을 받았는가?
1.2.2.1 상황1: 동료의 발판을 없애버리는 상황
(실제론 거의 없겠지만)동일 branch에 여러명이 낑겨서 작업하는 케이스
- 팀장님: 오늘안에
RMSNorm추가해놔라 - 나:
feature/RMSNorm브랜치 파서 커밋 2번 걸었음- 내 branch 상태:
main -- A -- B
- 내 branch 상태:
- 내가 너무 느려서 개답답한 팀장님 branch에 난입 후 작업 (pull 받음)
- 팀장 branch 상태:
main -- A -- B -- C
- 팀장 branch 상태:
- 오타 있어서 보니까 A커밋 때 부터 있던거임. 그래서 깔끔하게 하려고 rebase 해버림 그리고 push –force
- 내 branch 상태:
main -- A' -- B' - 팀장 branch 상태:
main -- A -- B -- C - remote branch 상태:
main -- A' -- B'
- 내 branch 상태:
- 팀장님 작업 마치고 push 하려는데 거절당해서 pull 했더니 대재앙 발생
- 그래프
A -- B -- C와A' -- B'가 뒤섞여 버림. - 운좋게 Merge 해도 똑같은 내용의 커밋이 두번 중복됨 (
A, B, A', B', C) - 팀장은 자기가 건들지도 않은
A커밋에서 충돌 해결해야하는 억울한 상황 발생.
- 그래프
1.2.2.2 상황2: 브랜치 따로파면 ㄱㅊ은거아님?
이 상황은 실제로 발생할 수 잇는 상황.
main이 너무 구대기라 팀원이 대규모 refactor를 했음 (feat/refactor). 그리고 PR 넣었는데 너무 대규모라 아직 approve 못받음.- 근데 이미 학습도
main버리고feat/refactor로 돌리고 있던 터라 어차피 얘가 당선될꺼긴함.
- 근데 이미 학습도
- 어차피
feat/refactor가 합쳐질꺼기도 하고, PR 기다리기 너무 오래걸리고, 추가작업 할꺼있어서 나는feat/refactor위에 새로운 branch (feat/log-fix)파서 작업하는 상황
main -- A (feat/refactor, 팀원 작업)
\
-- B (feat/log-fix, 내 작업)
팀원이 뭐 수정할 께 있어서 이미 push하고 pr까지 넣은 branch를 rebase로 수정해서 A'만들어 버림.
- Remote 상태:
main -- A' - 내 상태:
main -- A -- B
나는 현재 존재하지 않는 유령 커밋 A를 부모로 두고 있음.
나중에 PR을 날리거나 Merge 하려고 하면, Git은 A와 A’의 충돌을 뿜어냄.
1.2.3 Rebase 핵심.
- branch 는 앵간하면
mainordev를 받아서 작업.- 다른 브랜치에 새 브랜치 만들어할꺼면 미리 말하기
- 이미 push 한 branch에는 절대 rebase 걸면 안된다.
그러면 언제 Merge하고 언제 Rebase 해야 하나?
| 상황 | 추천방식 | 이유 |
|---|---|---|
| 개인 작업 브랜치 | Rebase | main의 최신 변경사항을 내 브랜치로 가져올 때 깔끔하게 붙이기 위함. |
| 공용 브랜치 합칠 때 (feature -> main) | Squash Merge | 자잘한 커밋(typo fix 등)을 하나로 압축해서 main에 깔끔하게 넣기 위함. |
| 장기 유지보수 브랜치 | Merge | 역사의 흐름(언제 합쳐졌는가)을 보존하는 게 중요함. |
1.2.4 Squash Merge
“오타 수정”, “아 진짜 최종”, “진짜진짜 최종” 이런거 그냥 마지막에 하나로 묶는 commit 방식임.
feature/login 기능을 개발하면서 다음과 같이 작업했다고 치자.
commit A: 로그인 함수 짬 (작동 안 함)commit B: 오타 수정commit C: 아 씨.. 변수명 잘못 씀commit D: 로그인 기능 완료
이걸 그대로 main에 일반 Merge하면? main의 역사가 A, B, C, D, MergeCommit 5개로 도배됨.
# Before (Feature Branch)
(main) ... -- C0
\
A -- B -- C -- D (feature)
# After (Sqush Merge)
(main) ... -- C0 -- S
(A, B, C, D는 역사 속으로 사라지고, 오직 S 하나만 남음)

가운데 Squash and merge 버튼.
1.3 Reset vs. Revert
Reset은 타임머신이고, Revert는 사과문이다.
내 케이스로 예를 들면 내가 새로운 브랜치에서 작업을 막 하는데 계속해서 main이 업데이트 되고 있었음. 그래서 remote 브랜치를 보니 팀원이 작업하는 브랜치가 보여서 내 브랜치에 계속 그사람 브랜치 merge하면서 작업을 했었음.
- 어차피 두 브랜치 다
merge될꺼니까 걍 하면서 하자 마인드 ㅆㅅㅌㅊ?
근데 이게 아~~주 잘못된 방식이라고한다. 그래서 revert 해서 다른 브랜치 (main 제외) 합친거 다 없애라고 해서 그 때 처음 revert 해본적 있음.
reset, revert 둘다 commit 한거 되돌리는 작업인데 우선 얘네를 보기전에 git이 Git이 관리하는 3가지 공간(Three Trees)을 파악해야함.
1.3.1 The Three Trees (Git의 3단 구조)
Git은 파일을 단순히 ‘저장’하는 게 아니라, 3단계의 버퍼를 거쳐 이동시킴. add하고 commit하고 stash하고 등등 해본적 있을꺼임.
| 영역 (Area) | 상태 (status) | 설명 |
|---|---|---|
| Working Directory | Modified (빨강) | 방금 막 수정한 파일. 아직 Git이 관리 안 함. |
| Index (Staging Area) | Staged (초록) | git add로 “이거 커밋할 거야”라고 장바구니에 담은 상태. |
| HEAD (Repository) | Committed | git commit으로 영구 저장된 상태. |
1.3.2 Reset: 포인터 강제 이동 (Pointer Manipulation)
git reset <commit-hash>는 본질적으로 HEAD 포인터를 특정 커밋으로 강제 이동시킴.
/.git/refs/heads이 안에 들어있는거
(상황극)
A -> B -> C순서로 작업하고 각각 다 commit 한 상태.
C커밋을 취소하고B로 돌아가야함.
--soft: “커밋만 취소하고, add 상태는 남겨라”
- 동작: HEAD만 B로 이동. (Index 유지, WorkDir 유지)
- 결과: 커밋 C의 내용이 “Staged (초록색)” 으로 남음.
- 의미: “방금 커밋(C)만 취소하고, 파일들은 git add 된 상태로 둬라.” (바로 다시 커밋 가능).
--mixed (Default): “add도 취소하고, 파일 수정만 남겨라”
- 동작: HEAD 이동 + Index 초기화. (WorkDir 유지)
- 결과: 커밋 C의 내용이 “Unstaged (빨간색)” 상태가 됨.
- 의미: “커밋(C)도 취소하고 git add도 취소하셈. 근데 내가 짠 코드(파일 내용)는 지우지 마라.”
--hard: “싹 다 밀어어라”
- 동작: HEAD 이동 + Index 초기화 + WorkDir 초기화.
- 결과: 커밋 C의 모든 작업 내용이 물리적으로 삭제해버림.
- 의미: “C 시점으로 가기 위해 그 이후에 했던 모든 짓을 없던 일로ㄱㄱ.”
1.3.2.1 reset 상대 참조, 절대 참조
상대 참조 (HEAD~n)
HEAD~1(또는HEAD^): 바로 직전 (부모)HEAD~2: 전전 (조부모)
# [Case 1] 방금 커밋 메시지 오타나서 다시 하고 싶을 때
# 커밋만 풀고, 파일은 Staging 상태로 둠.
$ git reset --soft HEAD~1
# [Case 2] 방금 커밋했는데 로직이 똥이라 다시 짜고 싶을 때
# 커밋도 풀고, Add도 풀어서 빨간색(Unstaged)으로 만듦.
$ git reset HEAD~1 # (--mixed는 생략 가능)
절대 참조: Hash
특정 시점 hash 로 바로 때려박음
# [Case 3] 실험하다 망해서 어제 잘 돌아가던 버전(a1b2c3d)으로 강제 복구
# 주의: 그 사이의 모든 작업물은 삭제됨.
$ git reset --hard a1b2c3d
1.3.3 Revert: 상쇄 커밋 (Inverse Operation)
git revert <commit-hash>는 과거를 지우지 않고, 대신 과거의 행동을 정확히 반대로 수행하는 새로운 커밋을 만듬.
- 이미 push 했을 때는 절대로
reset하면 안된다.
시나리오 A: 나 혼자 작업 중 (Local)
- 상황: 커밋했는데 버그가 있다. 아직 서버(Remote)에는 안 올렸다.
- Action:
git reset(자유롭게 사용). - 이유: 내 로컬 역사니까 찢어버리든 태우든 상관없음.
시나리오 B: 이미 Push 함 (Public)
- 상황: 서버에 올렸는데 치명적 에러가 터짐. 팀원들도 이미 pull 받았을 수 있음.
- Action:
git revert. - 이유: 여기서
reset하고force push하면, 팀원들의 베이스 커밋이 사라지는 대참사가 또 일어남. “내가 똥을 쌌다”는 기록(Revert Commit)을 남겨서 팀원들의 역사를 지켜줘야 함.
# 똥싼 기록 남기면서, 시간 되돌리기
$ git revert a1b2c3d
1.4 비상 탈출: git reflog (The Savior)
실수로 reset --hard 쳐서 코드 다 날아갔는데 퇴사하면 될까?
아니다. Git은 내가 HEAD 포인터를 움직인 모든 기록을 별도의 로그(reflog)에 저장해둔다.
심지어 삭제된 커밋도 가비지 컬렉터(GC)가 돌기 전(기본 30일)까지는 살아있다.
$ git reflog
# 출력 예시
a1b2c3d HEAD@{0}: reset: moving to HEAD^
e4f5g6h HEAD@{1}: commit: Add dangerous feature <-- 방금 지워진 놈
9876543 HEAD@{2}: checkout: moving from main to feature
e4f5g6h가 방금 reset --hard로 날려버린 비운의 커밋임. 하지만 reflog에는 남아있으니, 타임머신을 타고 저기로 다시 가면 된다.
# 지옥에서 살아 돌아오기
$ git reset --hard e4f5g6h
[주의] git gc (Garbage Collection)를 수동으로 돌리거나 시간이 너무 오래 지나면 reflog도 청소되어 영구 삭제되니까 사고 쳤으면 빨리 reflog부터 까보자.
1.5 Cherry-pick & Stash (정밀 타격과 임시 피신)
실험용 브랜치(exp/model-v2)에서 막 코딩하다가 기가 막힌 유틸 함수 하나(utils.py의 calc_loss)를 건졌다고하자.
이거 하나 가져오겠다고 브랜치 전체를 main에 Merge 하면? 쓰레기 코드까지 다 딸려오는데 이 떄 필요한게 Cherry-pick임.
그리고 작업 도중에 급하게 다른 브랜치고 가야하는 상황, 근데 지금 코드 개판으로 짜놔서 commit 해두고 checkout 하기엔 꼬롬한 상황이라면? 이 때는 stash 임.
1.5.1 Cherry-pick: 외과 수술 (Apply Delta)
git cherry-pick <commit-hash>는 특정 커밋 하나를 뚝 떼어내어 내 브랜치 끝에 붙인다.
커밋을 뚝 떼어낸다는게 그 커밋할 때 만들어낸 변경내용만 가져옴.
- 실험브랜치 하나 파서 세팅하는데 기존함수 이름 마음에 안들어서 바꾸고 commit -> 뭐 바꾸고 commit -> 함수 만들고 commit
이 때 마지막 commit 을 cherry-pick 하면 이름바꾸고 뭐하고 이런거 말고 마지막함수 만든 부분만 가져와진다.
# 1. main 브랜치로 이동
git checkout main
# 2. B 커밋만 쏙 빼오기
git cherry-pick <Hash-of-B>
# -> 충돌 나면 해결하고 'git cherry-pick --continue'
근데 내가 씹 변태새끼라 100줄짜리 함수 하나 만드는데 커밋을 한줄당 한번씩 했다고 쳐보자. 커밋 100개 해서 겨우 함수하나 만들고 이게 너무 깔쌈해서 메인에 가져오려고함.
git checkout main
# 100번 째 커밋 체리픽.
git cherry-pick <Commit_100_Hash>
이러면 git은 마지막 줄 하나만가져와버림. 바로 충돌나고 함수이름이고 뭐고 마지막 return out 이것만 가져와버림. 이 떄는 Range Cherry-pick을 써줘야한다.
git cherry-pick <Commit_1_Hash>^..<Commit_100_Hash>
1.6 Stash: 임시 대피소 (The Stack)
한참 model.py 수정해서 코드가 난장판(Modified, Unstaged)인데, 팀장이 와서 “야, 지금 당장 config.yaml 좆버그 났으니까 테스트해보고 수정해라”한다면
- 옵션 A: commit 하기엔 코드가 덜 짜여서 에러 남. (쓰레기 커밋 생성)
- 옵션 B: reset 하면 작업한 거 다 날아감. (퇴사각)
- 정답: stash로 잠깐 치워두기.
Stash는 스택(Stack) 구조라 push로 넣고 pop으로 뺼 수 있음.
# 1. 하던 작업 임시 저장 (Working Dir가 깨끗해짐)
git stash push -m "모델 레이어 수정 중"
# 2. 급한 불 끄기 (Hotfix)
git checkout hotfix
... (작업 후 커밋/푸시) ...
# 3. 다시 돌아와서 작업 복구
git checkout feature
git stash pop
직접 써보면 존나게 편한 기능임.
주요 명령어 (Command Set)
git stash list: 저장된 스택 목록 확인 (stash@{0}, stash@{1}…).git stash pop: 가장 최근(@{0}) 내용을 복구하고 스택에서 제거.git stash apply: 내용을 복구하되 스택에 남겨둠.git stash drop: 복구 안 하고 그냥 버림.