대중교통을 타고 이동하는 자투리 시간에 Claude랑 개발 용어 어원을 두고 이런저런 이야기를 하고 있었습니다. boolean이라는 타입이 George Boole이라는 사람 이름에서 왔다는 걸 그때 처음 알았거든요.
거기서 이야기가 정보이론의 Claude Shannon으로 넘어가고, daemon·fork·grep 같은 용어들의 유래로 이어졌습니다. 그러다 Claude가 먼저 Git이라는 이름의 유래를 꺼내더라고요. 사실 그전까진 하나도 안 궁금했습니다. 그냥 너무 당연하게 ‘git은 git이지!’ 싶었거든요. 그런데 설명을 듣고 나니 오히려 궁금증이 조금씩 생겼습니다.
그렇게 Git의 내부 구조를 뜯어보고, 나중에는 rebase 실습까지 하게 됐습니다. 잊어버리기 전에 정리해둡니다.
Git은 어쩌다 태어났나
Git은 2005년에 리누스 토르발스가 리눅스 커널을 개발하다가 만들었습니다.
당시 리눅스 커널은 BitKeeper라는 상용 분산 버전관리 도구를 무료로 빌려 쓰고 있었습니다. 그런데 한 개발자가 BitKeeper 프로토콜을 리버스 엔지니어링하는 프로젝트를 시작했고, BitKeeper 측이 이걸 라이선스 위반으로 보고 커널 커뮤니티의 무료 사용 권한을 회수해버렸거든요.
리누스의 대응은 “그럼 직접 만들지”였습니다. 그리고 실제로 이렇게 진행됐다고 합니다.
- 4월 3일: 개발 시작
- 4월 7일: Git이 자기 자신을 Git으로 관리하기 시작 (self-hosting)
- 4월 16일: 리눅스 커널이 실제로 Git으로 전환
열흘 남짓 만에 실사용 가능한 버전이 나온 셈입니다. 초기 Git은 우리가 아는 친절한 버전관리 도구라기보다 ‘콘텐츠 주소 기반 파일시스템(content-addressable filesystem)‘에 가까웠다고 하더라고요. 저수준 명령어(plumbing)만 있었고, git commit이나 git checkout 같은 사용자 친화적 명령어(porcelain)는 나중에 커뮤니티가 얹은 거고요.
이름의 유래도 리누스답습니다. 본인 말로는 “나는 내 프로젝트에 전부 내 이름을 붙이는 이기적인 놈이다. 처음엔 Linux, 이번엔 git”이라고 농담했다고 하네요. 잘 될 때는 ‘Global Information Tracker’, 안 될 때는 영국식 욕설의 약자라는 자학 개그도 곁들였고요.
여담이지만, 리눅스부터 Git까지 진짜 리누스 토르발스는 미친 사람 같아요. 한 사람이 운영체제와 버전관리 시스템을 둘 다 만들어서 각각 업계 표준으로 굳혀버렸다는 게 새삼 대단합니다.
Git은 diff가 아니라 스냅샷을 저장합니다
Git을 처음 배울 때 저는 ‘각 커밋이 변경사항(diff)을 저장한다’고 생각했습니다. 그런데 아니더라고요.
CVS나 SVN 같은 이전 도구들은 실제로 ‘이 줄을 A에서 B로 바꿈’ 같은 변경분을 저장했습니다. 하지만 Git은 커밋 시점마다 전체 스냅샷을 찍습니다.
SVN/CVS 방식: "3번째 줄을 A→B로 바꿈" (변경사항만 저장)
Git 방식: "이 시점의 전체 상태를 통째로 찍음" (스냅샷)
대신 안 바뀐 파일은 이전 것을 가리키는 포인터만 저장해서 용량을 아낍니다. 그래서 Git 내부에는 딱 네 가지 객체만 존재하거든요.
| 객체 | 역할 |
|---|---|
blob | 파일 내용 그 자체 |
tree | 디렉토리 구조 (blob들과 하위 tree들을 묶음) |
commit | tree를 가리키는 포인터 + 메타데이터(작성자, 메시지, 부모 커밋) |
tag | 특정 커밋에 붙이는 이름표 |
이 스냅샷 기반이라는 사실이 뒤에 나오는 모든 이야기의 뿌리가 됩니다.
브랜치는 종잇조각에 불과합니다
다른 버전관리 도구에서 브랜치는 무거운 개념이었습니다. 디렉토리 전체를 복사하는 식이었거든요. 그래서 SVN 시절엔 브랜치 만드는 걸 다들 꺼렸다고 합니다.
Git의 브랜치는 다릅니다. 브랜치는 그저 커밋 하나를 가리키는 40자리 텍스트 파일이에요.
.git/refs/heads/main → "a1b2c3d4..." (이 문자열이 파일 내용의 전부)
Git에서 브랜치, 태그처럼 ‘무언가를 가리키는 이름표’를 통틀어 ref(reference)라고 부릅니다. 본질은 어디를 가리키느냐가 전부고요.
그럼 히스토리는 누가 갖고 있나
여기서 방향을 제대로 이해하는 게 중요했습니다. 히스토리는 브랜치가 아니라 커밋들이 갖고 있거든요. 그리고 각 커밋은 자신의 부모만 기억합니다.
커밋 C3 객체 안: parent = C2의 해시
커밋 C2 객체 안: parent = C1의 해시
재밌는 건 C2는 자기가 C3의 부모라는 사실을 전혀 모른다는 점입니다. 화살표는 오직 과거 방향(자식 → 부모)으로만 저장되거든요.
저장 방향: C1 ← C2 ← C3 (각자 "내 부모는 누구"만 앎)
브랜치: main → C3 (그냥 맨 끝을 가리키는 이름표)
그럼 우리가 보는 그래프는
git log --graph나 GitHub 네트워크 그래프처럼 우리가 보는 예쁜 그래프는 어디 저장되어 있는 게 아니었습니다. 필요할 때마다 실시간으로 계산해서 그리는 거더라고요.
1. 모든 브랜치/태그가 가리키는 커밋에서 출발
2. 각 커밋의 parent를 따라 계속 거슬러 올라감
3. 그 결과를 보기 좋게 그림으로 그림 (방향은 사람이 보기 편하게 뒤집어서)
가계도에 비유하면 이해가 쉬운데, 사실 인간이랑은 좀 반대입니다. 사람은 보통 부모보다 자식을 더 챙기잖아요. Git의 커밋은 그 반대라서, 인간과 달리 자식은 안중에도 없고 무조건 부모만 기억하는 효자들이 뭉쳐서 만드는 게 깃 그래프라고 보면 될 것 같아요. 각 커밋은 자기 부모만 아는데, 모든 커밋의 부모 정보를 모으면 거꾸로 추적해서 완전한 그래프를 그릴 수 있거든요. 손 빠른 제작자가 매번 “당신 부모가 누구예요?”를 물어 그래프를 새로 그려내는 셈이고요.
브랜치 생성이 공짜인 이유
가계도 비유에는 한 가지 한계가 있습니다. 사람은 태어나는 순간 고유한 존재지만, Git 브랜치는 이름표(ref)와 실제 데이터(commit)가 완전히 분리되어 있거든요.
git branch feature
이 명령이 하는 일의 전부는 이렇습니다.
.git/refs/heads/feature 파일을 새로 만들고
그 안에 40자리 해시값 한 줄을 씀
바이트로 치면 40바이트 남짓입니다. 기존 커밋의 파일 내용, 디렉토리 구조, blob 데이터는 단 1바이트도 다시 쓰지 않아요.
‘복사’라는 단어가 주는 착각을 조심해야 했습니다. 물리적인 책 복사를 떠올리면 “리소스를 엄청 먹겠지” 싶은데, Git의 브랜치 생성은 책 자체를 복사기로 인쇄하는 게 아니라 이미 있는 책의 특정 페이지에 포스트잇을 하나 더 붙이는 쪽에 가깝거든요.
책 1권 (실제로는 이것 하나만 존재)
├─ main ← 포스트잇 1
└─ feature ← 포스트잇 2 (같은 페이지를 가리킴)
그래서 브랜치를 100개 만들어도 .git 용량은 거의 안 늘어납니다. 리누스가 “브랜치는 공짜여야 한다”는 철학으로 설계한 결과라고 하네요.
진짜 분기는 언제 생기나
브랜치를 만든 직후엔 아무것도 갈라지지 않았습니다. 같은 커밋에 이름표 두 개가 붙었을 뿐이거든요. 고유한 가지는 오직 커밋을 할 때 생깁니다.
1단계: git branch feature → 가짜 분기 (포인터만 복제)
2단계: feature에서 실제로 commit → 이제 진짜 새로운 노드 탄생
똑같은 책을 세 권 복사해두고 그중 한 권에만 낙서를 하는 것과 같습니다. 복사 자체(git branch)는 정보량 증가가 0이고, 낙서(커밋)가 들어가야 비로소 새로운 정보가 생기는 거고요.
Merge는 세 개의 스냅샷을 비교합니다
두 브랜치를 합칠 때 Git은 3-way merge를 수행합니다. 이걸 이해하려면 먼저 merge base(공통 조상) 개념이 필요했습니다.
A---B---C (main)
\\
E---F (feature)
여기서 두 브랜치가 갈라지기 직전의 공통 커밋 B가 merge base입니다. git merge-base main feature로 찾을 수 있고, 각 브랜치에서 parent를 거슬러 올라가다가 처음 겹치는 지점이거든요.
Merge는 이 세 스냅샷을 비교합니다.
B (공통 조상, base)
C (main의 최신 상태)
F (feature의 최신 상태)
B와 C를 비교 → main이 뭘 바꿨는지
B와 F를 비교 → feature가 뭘 바꿨는지
→ 두 변경을 동시에 적용
- 아무도 안 건드린 줄 → 그대로
- 한쪽만 건드림 → 그쪽 반영
- 양쪽이 같은 내용으로 건드림 → 그대로 (충돌 아님)
- 양쪽이 다른 내용으로 같은 곳을 건드림 → 충돌(conflict), 사람이 개입
parent가 2개인 특별한 커밋
병합이 끝나면 새 커밋 M이 생기는데, 이 커밋은 부모가 2개입니다.
A---B---C---M (main)
\\ /
E---F
M의 parent = [C, F]
기존 커밋들은 하나도 안 건드려집니다. M이라는 새 노드 하나가 두 갈래를 가리키며 추가될 뿐이에요.
실습하면서 알게 된 디테일도 몇 개 있었습니다. main이 갈라진 후 아무 커밋도 추가되지 않았다면 3-way 비교조차 필요 없이 그냥 main 포인터를 F로 옮기기만 하면 되는데, 이걸 fast-forward라고 부르더라고요. 새 커밋도 안 생기고요. parent가 3개 이상인 커밋(octopus merge)도 만들 수 있지만 충돌 시 처리가 복잡해서 실무에선 거의 안 쓴다고 합니다. 그리고 merge commit은 parent 줄이 개수만큼 더 들어가서 일반 커밋보다 수십 바이트 정도 미세하게 큽니다.
커밋 객체의 실제 구조
commit 객체 내부를 텍스트로 까보면 이렇게 생겼습니다.
tree a1b2c3... → 이 시점의 전체 스냅샷 포인터 (diff 아님)
parent d4e5f6... → 부모 커밋 해시 (merge면 여러 줄)
author 이름 <이메일> 시각
committer 이름 <이메일> 시각
(author와 다를 수 있음 — 예: rebase 시)
커밋 메시지
commit이 담고 있는 건 ‘diff + parent’가 아니라 ‘tree(스냅샷) + parent’입니다. diff는 어디에도 저장되어 있지 않아요.
그럼 git show나 git diff에서 보이는 diff는 어디서 나오느냐. 그 자리에서 즉석으로 계산됩니다.
1. 현재 커밋의 tree(스냅샷 A)
2. 부모 커밋의 tree(스냅샷 B)
3. A와 B를 그 순간 비교해서 diff 생성
이게 merge commit에서 오히려 유용하더라고요. parent가 2개니까 각 parent와 비교한 diff를 각각 보여줄 수 있거든요. 만약 diff 자체를 저장했다면 “어느 parent 기준이냐”가 애매해졌을 겁니다.
그럼 .git 용량은 왜 안 터지나
스냅샷을 매번 통째로 저장한다면서 왜 용량이 감당 가능한지 궁금했는데, 두 가지 장치 덕분이었습니다.
하나는 내용 기반 저장(Content-Addressable Storage)입니다. 파일 내용의 해시를 키로 쓰기 때문에 내용이 같으면 한 번만 저장되거든요. 1000개 커밋 동안 안 바뀐 파일은 전부 같은 blob 하나를 가리킵니다.
다른 하나는 pack 파일 압축이고요. 일정량 쌓이면 loose object들을 pack 파일로 묶으면서, 비슷한 파일끼리 delta(차이)만 저장하도록 재압축합니다.
그러니까 개념적으로는 스냅샷이지만 물리적 저장은 사실상 diff처럼 효율적으로 압축되는 거예요. 개념과 저장 방식이 다르다는 게 포인트였습니다.
Rebase는 커밋을 재생합니다
Merge가 ‘두 최종 상태를 비교해 합치는 것’이라면, rebase는 접근이 완전히 다릅니다. 커밋을 하나씩 다시 재생(replay)하거든요.
A---B---C (main)
\\
E---F (feature)
feature에서 git rebase main을 하면 이렇게 진행됩니다.
1. feature 고유 커밋 나열: E, F
2. main 최신 지점 C로 이동
3. E의 diff를 C 위에 재적용 → 새 커밋 E' 생성 (parent = C)
4. F의 diff를 E' 위에 재적용 → F' 생성
결과: A---B---C---E'---F'
왜 해시가 바뀌나
커밋 해시는 내용, parent, 메타데이터(시각 등) 전체를 해시한 값입니다.
E'의 diff는 E와 같지만
parent가 B → C로 바뀌었고, 커밋 시각도 다름
→ 해시가 완전히 다른 값이 됨
그러니까 rebase는 커밋을 ‘수정’하는 게 아니라 ‘완전히 새로운 커밋으로 대체(replace)‘하는 거였습니다. 원래 커밋 E, F는 삭제되는 게 아니라 아무도 안 가리키게 되어 나중에 GC 대상이 되고요.
공유된 커밋을 rebase하면 안 되는 이유
다른 사람이 이미 E, F를 pull 받아 갖고 있는데 내가 rebase해서 E’, F’를 push하면 이렇게 됩니다.
내 로컬: A-B-C-E'-F'
상대 로컬: A-B-E-F (원본 그대로)
같은 작업인데 해시가 완전히 다른 두 세트가 생겨서, Git은 이게 같은 작업이라는 걸 알 수가 없습니다. 그래서 대량 충돌이나 중복 커밋이 발생하고요. “이미 공유된 히스토리는 rebase 금지”가 Git 커뮤니티의 거의 절대 규칙인 이유였습니다.
Rebase의 진짜 존재 이유
“히스토리를 깔끔하게 하려고”는 rebase의 목적으로는 부족했습니다. 실제 rebase의 본질은 ‘이미 만든 커밋을 사후에 재구성하는 범용 메커니즘’이고, ‘다른 base로 옮기기’는 그중 한 활용법일 뿐이더라고요.
git rebase -i로 할 수 있는 것만 봐도 그렇습니다. pick(유지), reword(메시지 수정), edit(내용 수정), squash·fixup(합치기), drop(삭제), 순서 변경까지 가능하거든요.
이걸 왜 필요로 하는지, 세 가지 실질적인 이유를 알게 됐습니다.
첫째로 git bisect가 제대로 작동하려면 커밋이 원자적이어야 합니다. bisect는 버그가 생긴 커밋을 이진탐색으로 찾는데, 각 커밋이 독립적으로 빌드·실행 가능한 완결 단위여야 의미가 있거든요. “WIP → 오타수정 → 진짜완성” 같은 중간 커밋을 bisect가 가리키면 무의미하고요.
둘째로 코드 리뷰 단위가 곧 커밋 단위인 워크플로우에서는, 리뷰어에게 시행착오(WIP, 오타 수정 등)가 노이즈입니다. ‘무엇을 왜 바꿨는지’만 의미 있는 정보인데, squash로 이 노이즈를 정리하는 게 유일한 공식 수단이거든요. Merge는 기존 커밋을 안 건드리니까 이 문제를 못 풀고요.
셋째로 merge commit이 암시하는 ‘동시성’이 대부분 거짓 정보였습니다. merge는 “두 흐름이 병렬로 진행되다 합쳐졌다”를 인코딩하는데, 실제로 제가 한 작업은 대부분 “내 변경을 최신 main 위에 얹고 싶다”는 의도일 뿐 우연히 브랜치를 언제 열었는지는 의미 없는 정보였거든요.
그러니까 ‘깔끔함’은 이 세 가지의 결과물이지 목적이 아니었습니다. 진짜 목적은 미래에 이 히스토리를 읽을 사람과 도구에게 최대한 유용한 형태로 정보를 재구성하는 것이고, 그 대가로 ‘정확히 어떤 순서·시각으로 일어났나’를 일부 희생하는 거고요.
리누스의 실제 입장
여기서 흔한 오해가 있었습니다. 리누스는 무조건 rebase파도, 무조건 merge파도 아니더라고요. 정확히는 두 상황을 나눠서 봅니다.
| 상황 | 입장 |
|---|---|
아직 아무도 안 본 로컬 커밋을 정리(rebase -i)해서 깔끔한 패치로 만들기 | 지지, 심지어 권장 |
| 이미 공개된 프로젝트 히스토리를 rebase로 밀어 억지로 일직선화 | 반대 (merge commit이 오히려 유의미한 정보) |
리누스는 GitHub의 무조건 squash & merge나 강제 선형 히스토리에 비판적입니다. 히스토리를 인위적으로 평평하게 만드는 게 오히려 정보 손실이라는 입장이거든요. 그래서 안전하게 요약하면 이렇습니다.
로컬에만 있는 커밋 (아직 push 안 함) → rebase로 자유롭게 정리 (거의 만장일치로 OK)
이미 push되어 남과 공유된 커밋 → rebase 금지 (진영 갈림 + 사고 위험)
Sourcetree로 직접 rebase 해보기
개념만으로는 부족해서 GUI 도구(Sourcetree)로 직접 rebase를 해봤습니다. 여기서 이론이 실제 충돌로 이어지는 순간을 직접 겪었거든요.
재배치 vs 쌍방향 재배치
Sourcetree 한국어 메뉴가 좀 헷갈렸습니다.
‘재배치…’는 git rebase <target>으로, 현재 브랜치를 다른 지점 위로 이동시키는 자동 진행이었습니다. ‘OO의 자식 커밋을 쌍방향 재배치…’는 git rebase -i인데, ‘쌍방향’은 오역에 가깝고 원문은 interactive(대화형)더라고요. 양방향으로 뭘 주고받는 게 아니라 “사용자가 하나하나 확인하며 진행”한다는 뜻이었습니다.
또 하나 중요한 구분이 있었습니다. 사이드바의 브랜치 이름을 우클릭하면 그 이름이 가리키는 최신 지점(동적)이 대상이 되고, 그래프의 커밋 점을 우클릭하면 그 특정 커밋(정적)이 대상이 됩니다. 후자는 지금 우연히 어떤 브랜치와 같은 위치일 뿐, 그 브랜치가 이동하면 달라지고요.
Interactive Rebase 창의 버튼들
| 버튼 | Git 개념 | 동작 |
|---|---|---|
| 초기화 | reset | 변경 취소 |
| 메시지 편집 | reword | 메시지만 수정 |
| 이전 커밋과 합치기 | squash/fixup | 바로 위(더 오래된) 커밋에 흡수 |
| 삭제 | drop | 커밋 통째로 제거 |
| ↑ / ↓ | 순서 변경 | 목록 내 이동 |
| Amend Commit? 체크 | edit | 그 커밋에서 멈춰서 직접 수정할 기회를 줌 |
여기서 Amend Commit?(edit)이 핵심이었습니다. 다른 버튼들이 ‘이 창에서 미리 지정’하는 거라면, 이건 ‘rebase 실행 도중 그 커밋에서 멈춰서 내가 직접 코드를 만지겠다’는 뜻이거든요. 파일을 빠뜨렸거나 그 커밋의 코드 자체를 리팩토링하고 싶을 때 씁니다. 멈춘 뒤 작업하고 ‘Continue Rebase’를 눌러야 다음으로 진행되고요.
실제로 겪은 충돌
연습용 커밋 3개(rebase1, rebase2, rebase3)를 만들어서 하나로 squash를 시도했습니다. 그런데 이런 에러가 났어요.
Rebasing (1/3)
CONFLICT (content): Merge conflict in SomeController.java
error: could not apply ... test: rebase3
“3개를 합치는데 왜 충돌이 나지?” 하는 생각이 들었는데, 여기서 앞에서 본 이론이 빛을 발했습니다.
squash는 ‘최종 결과물끼리 비교’가 아니라 각 커밋을 diff로 순차 재생하는 거였거든요. Rebasing (1/3)이 그 증거였습니다.
1. rebase1 적용
2. rebase2의 diff를 그 위에 적용
3. rebase3의 diff를 그 위에 적용 ← 여기서 실패
rebase3의 diff는 원래 ‘rebase1·2 적용 전’의 파일을 기준으로 만들어진 patch입니다. 그런데 rebase1이 이미 같은 파일의 근처 영역을 건드려놨다면, Git이 patch를 적용할 때 보는 주변 문맥(context lines, 기본 3줄)이 어긋납니다. Git은 ‘몇 번째 줄’이 아니라 ‘주변 문맥’으로 위치를 판단하기 때문에, 문맥이 흔들리면 “여기 맞는지 확신 못 하겠다”며 충돌로 처리하더라고요.
이때 선택지는 세 가지였습니다.
git rebase --continue # 충돌 해결 후 계속
git rebase --skip # 이 커밋 건너뛰기
git rebase --abort # 시작 전 상태로 완전 복구
분기와 충돌은 다른 것이었다
이번에 배운 것 중 가장 중요한 구분이 이거였습니다.
분기(diverge)는 같은 커밋에서 서로 다른 방향으로 커밋이 쌓인 구조적 상태입니다. 그 자체로는 아무 문제 없어요. 에러도, 경고도 없습니다. 그냥 그래프가 그렇게 생긴 것뿐이거든요.
충돌(conflict)은 분기된 두 흐름을 합치려 할 때, 같은 파일 같은 부분을 양쪽이 다르게 고쳤을 경우에만 발생하는 사건이고요.
분기됐지만 안 합치면 → 충돌은 평생 안 일어남
합치려는데 겹치는 수정이 있으면 → 그때 비로소 충돌
분기는 상태고, 충돌은 그 상태를 통합하려 할 때 조건부로 발생하는 사건입니다. 이 구분이 rebase랑 merge를 이해하는 데 결정적이었어요.
실습하다 나온 질문들
실습 중에 자연스럽게 나온, 실무에 바로 쓸 만한 질문들도 정리해둡니다.
revert 커밋과 원본 커밋을 rebase로 함께 삭제해도 되나
...--a--b--c(a를 되돌리는 revert)--...
여기서 drop a, drop c를 하고 싶을 때. 결론은 ‘c가 a의 순수한 역연산이냐’에 달려 있었습니다.
c가 충돌 없이 깔끔하게 만들어진 순수 revert라면, c의 diff는 a의 diff를 정확히 뒤집은 것이고 충돌 없이 revert됐다는 사실 자체가 a와 b가 안 겹친다는 증거입니다. 이 경우 drop a, pick b, drop c의 결과는 이미 검증된 현재 상태(base + b)와 바이트 단위로 동일하거든요. 재검증이 사실상 과잉인 거죠.
반대로 revert 중 b와 충돌해서 손으로 코드를 같이 고친 경우라면, c에는 ‘순수 revert + b를 위한 추가 수정’이 섞여 있습니다. c를 통째로 drop하면 b를 위한 수정까지 날아가고요. 이건 위험합니다.
판단은 git show <c>로 c의 diff가 딱 a의 위치와 일치하는 라인만 건드리는지 확인하면 됩니다. 무관한 파일이나 라인이 섞여 있으면 후자로 간주하고 조심하는 게 맞고요.
여기서 한 가지 꼭 기억해둬야 할 게 있습니다. Git은 텍스트 레벨 충돌만 감지합니다. “이 코드가 저 함수를 필요로 한다”는 의미적 의존성은 몰라요. a가 만든 함수를 b가 (전혀 다른 파일에서) 호출하는데 a를 drop하면, 충돌 없이 rebase가 성공해도 코드가 깨집니다. ‘충돌 없음 = 안전’이 아니라는 거죠…
rebase와 cherry-pick은 같은 것인가
메커니즘상 같은 엔진(sequencer)을 씁니다. 실제로 git rebase가 내부적으로 하는 일이 ‘각 커밋을 순서대로 cherry-pick’하는 거더라고요.
git rebase main ≈ git checkout main; cherry-pick <커밋1>; cherry-pick <커밋2>; ...
차이는 대상 선택의 자동화 정도였습니다.
| Rebase | 새 브랜치 + Cherry-pick | |
|---|---|---|
| 대상 선택 | 브랜치 조상 관계로 자동 계산 | 사람이 하나씩 선택 |
| 기존 ref | 브랜치 이름이 새 지점으로 이동 | 기존 브랜치는 안 건드림, 새 브랜치 생성 |
| 신뢰 모델 | ”기존 구조가 정상”이라는 전제 | 그 전제를 안 믿고 수동 검증 |
같은 엔진, 다른 신뢰 모델이라고 보면 정확합니다. 브랜치 상태가 불확실할 때는 자동화된 rebase보다, 깨끗한 지점에서 새 브랜치를 만들어 신뢰하는 커밋만 하나씩 cherry-pick하는 게 오히려 안전하고요.
새 SHA 생성이 목적이면 rebase도 되나
됩니다. 하지만 함정이 있었어요. 브랜치가 이미 master 바로 위에 있고 master도 안 움직였다면, git rebase master는 “up to date”라며 아무것도 안 합니다(no-op). SHA도 그대로고요.
SHA를 강제로 새로 만들려면 이렇게 해야 했습니다.
git rebase -i <이전 커밋> # todo를 전부 pick으로 둬도, -i 진입 자체가 재생을 강제함
git rebase --onto master <base> <브랜치> # base를 명시해 강제 재생
그리고 ‘SHA만 바뀌는 것’과 ‘브랜치 이름·계보까지 바뀌는 것’은 다릅니다. in-place rebase는 SHA는 바뀌지만 브랜치 이름은 그대로고, 새 브랜치 + cherry-pick은 SHA도 브랜치 이름도 완전히 새것이거든요. 외부 추적 시스템이 SHA 기준이면 전자로 충분하지만, 브랜치 이름·계보를 키로 잡고 있다면 후자가 더 확실한 초기화였습니다.
마치며
boolean의 어원 하나가 궁금했을 뿐인데 하루가 이렇게 다 갔네요. 그래도 많은 걸 배워서 뿌듯합니다. 요즘은 코드 기술 그 자체보다 이런 정보가 더 필요한 거라고 생각하거든요.
매일 쓰던 git merge, 그리고 어떻게 작동하는지 정확히 몰라서 늘 겁내던 git rebase가 내부에서 무슨 일을 하는지 알고 나니, 충돌이 왜 나는지, 언제 위험한지가 훨씬 선명해졌습니다. 특히 ‘충돌 없이 성공했다’가 ‘코드가 정상이다’와 같은 말이 아니라는 것, 그리고 분기는 상태고 충돌은 사건이라는 것. 이 두 가지는 앞으로도 오래 써먹을 것 같아요.
표면적인 명령어 사용법만 알 때는 도구가 예상 밖으로 동작하면 그냥 당황했는데, 이제는 “아, 지금 diff를 순차 재생하다가 문맥이 어긋난 거구나” 하고 원인을 짚을 수 있게 됐습니다. 자투리 시간에 빠진 rabbit hole치고는 값을 충분히 한 셈이네요.