팀에 새로 온 주니어가 그러더라고요. 브랜치 파서 작업하고 머지(merge, 합치기)했더니 커밋 그래프가 거미줄처럼 됐다고요. 저도 처음엔 그게 원래 그런 건 줄 알았어요. 리베이스(rebase)를 배우고 나서야 히스토리를 한 줄로 깔끔하게 펴는 방법이 있다는 걸 알았죠. 오늘은 이 리베이스를 말로만 하지 않고 실제 명령으로 하나씩 돌려볼게요.
75.1 리베이스가 뭐가 다른가요?
머지와 리베이스는 목적지는 같아요. 갈라졌던 두 브랜치를 하나로 합치는 거죠. 그런데 합치는 방식이 달라요. 머지는 두 갈래를 이어붙이는 합침 커밋(merge commit)을 새로 하나 만들어요. 실제로 머지하면 이런 출력이 나와요.
$ git merge feature
Merge made by the 'ort' strategy.
s.js | 1 +
1 file changed, 1 insertion(+)
그래프로 보면 두 줄기가 만나는 지점에 합침 커밋이 생겨요. 갈래가 많아지면 이 만남이 계속 쌓여서 아까 말한 거미줄이 되는 거죠.
* Merge branch 'feature'
|\
| * feat: 검색
* | fix: 버그 수정
|/
* feat: 초기화
리베이스는 접근이 달라요. 내 브랜치 커밋들을 떼어내서 상대 브랜치의 최신 지점 위에 하나씩 다시 얹어요. 마치 내가 처음부터 최신 코드 위에서 작업한 것처럼 일직선으로 정리되죠. 합침 커밋이 안 생겨요. 히스토리를 읽을 때 누가 언제 뭘 했는지가 위에서 아래로 쭉 읽혀서 훨씬 편해요.
75.2 실제로 리베이스 해볼까요?
상황을 만들어볼게요. main에서 로그인까지 작업한 뒤 feature 브랜치를 땄어요. feature에서 검색과 필터를 만드는 동안, 다른 동료가 main에 버그 수정을 하나 올렸고요. 지금 그래프를 보면 이렇게 갈라져 있어요.
$ git log --oneline --all --graph
* 2379413 fix: 로그인 버그 수정
| * 7f10c69 feat: 필터 기능
| * f32667b feat: 검색 기능
|/
* aa1d4a2 feat: 로그인 추가
* f4935cd feat: 프로젝트 초기화
이제 feature 브랜치에서 git rebase main을 실행해요. feature의 검색, 필터 커밋을 떼어서 main의 최신 커밋(버그 수정) 위에 다시 얹으라는 뜻이에요.
$ git rebase main
Successfully rebased and updated refs/heads/feature.
끝나고 다시 보면 갈래가 사라지고 한 줄이 돼요. 버그 수정 위에 내 검색, 필터가 얹혔죠.
$ git log --oneline --graph
* 84c35d0 feat: 필터 기능
* b1a1944 feat: 검색 기능
* 2379413 fix: 로그인 버그 수정
* aa1d4a2 feat: 로그인 추가
* f4935cd feat: 프로젝트 초기화
여기서 꼭 챙길 게 하나 있어요. 검색 커밋 해시가 원래 f32667b였는데 리베이스 뒤엔 b1a1944로 바뀌었죠. 커밋을 새 위치에 다시 만들었기 때문이에요. 내용은 같아도 완전히 새 커밋이라는 걸 기억해두세요. 이게 뒤에 나올 주의사항의 핵심이거든요.
75.3 커밋을 하나로 합칠 수 있나요?
작업하다 보면 이런 커밋 부끄럽지 않으세요? wip: 오타 수정, wip: 또 오타 같은 거요. 리뷰 올리기 전에 이런 자잘한 커밋을 하나로 뭉치고 싶을 때 대화형 리베이스(interactive rebase)를 써요. 지금 커밋 세 개가 이래요.
$ git log --oneline
56e8f9e wip: 또 오타
d11c267 wip: 오타 수정
7b1f7fe feat: 기능 작업
최근 세 개를 손보려고 git rebase -i HEAD~3를 실행하면 편집기가 열려요. 맨 위 pick은 그대로 두고, 뒤에 붙일 두 줄의 pick을 squash로 바꿔요. squash는 위 커밋에 흡수시키라는 뜻이에요.
pick 7b1f7fe feat: 기능 작업
squash d11c267 wip: 오타 수정
squash 56e8f9e wip: 또 오타
저장하면 커밋 메시지를 새로 적으라고 물어봐요. feat: 기능 작업 완료라고 깔끔하게 적고 끝내면, 세 개가 한 개로 뭉쳐요.
$ git log --oneline
b5493d5 feat: 기능 작업 완료
72bfec5 docs: 초기화
리뷰어 입장에서 wip 커밋 열 개보다 의미 있는 커밋 하나가 훨씬 읽기 좋아요. 저는 PR 올리기 전에 이 정리를 습관처럼 해요.
75.4 공유 브랜치는 왜 리베이스하면 안 되나요?
이게 오늘 제일 중요한 이야기예요. 앞에서 리베이스는 커밋 해시를 새로 만든다고 했죠. 만약 내가 이미 원격에 push한 브랜치를, 동료도 받아서 쓰는 상태에서 리베이스하면 어떻게 될까요? 내 쪽 커밋 해시는 다 바뀌는데 동료 쪽엔 옛날 해시가 그대로 남아 있어요. push하려 하면 거부당하고, 억지로 강제 push(force push)하면 동료의 작업이 꼬여버려요. 같은 커밋이 두 벌씩 보이거나, 동료가 다음에 받을 때 충돌이 폭발하죠.
그래서 규칙은 딱 하나로 외우면 돼요. 남과 공유한 브랜치는 리베이스하지 않는다. 리베이스는 아직 push 안 한 내 로컬 커밋에만 쓰세요. main이나 develop 같은 공용 브랜치는 절대 리베이스 대상이 아니고요. 저는 신입 때 공용 브랜치를 리베이스했다가 동료 다섯 명 작업을 반나절 날린 적이 있어요. 그날 이후로 git push --force는 손이 덜덜 떨려서 못 칩니다.
정 강제 push가 필요하면 --force-with-lease를 쓰세요. 그냥 --force는 남의 새 작업까지 덮어쓰지만, --force-with-lease는 내가 마지막으로 본 상태와 원격이 다르면 멈춰줘요. 안전벨트라고 생각하면 돼요.
75.5 리베이스 중 충돌이 나면요?
리베이스는 커밋을 하나씩 다시 얹는다고 했죠. 그 얹는 커밋과 상대 브랜치가 같은 줄을 서로 다르게 고쳤으면 충돌(conflict)이 나요. 겁먹을 필요 없어요. Git이 어디서 막혔는지 친절하게 알려주거든요.
$ git rebase main
CONFLICT (content): Merge conflict in cfg.txt
error: could not apply 10c9fa4... feat: 검색 제목
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add <conflicted_files>", then run "git rebase --continue".
충돌 난 파일을 열면 <<<<<<<, =======, >>>>>>> 표시로 내 코드와 상대 코드가 나뉘어 있어요. 둘 중 맞는 걸 골라 남기고 표시를 지운 뒤 저장해요. 그 다음 git add로 해결됐다고 알리고 git rebase --continue로 이어가면 돼요.
$ git add cfg.txt
$ git rebase --continue
중간에 도저히 못 풀겠다 싶으면 git rebase --abort를 치세요. 리베이스 시작 전 상태로 깔끔하게 되돌려 줘요. 아무 일도 없던 것처럼요. 그러니 리베이스하다 꼬여도 코드가 영영 사라지는 건 아니에요. abort라는 되돌아갈 문이 늘 열려 있거든요.
75.6 리베이스, 이것만 기억해요
정리해볼게요. 첫째, 리베이스는 히스토리를 일직선으로 정리하는 도구예요. 머지처럼 합침 커밋을 만들지 않아요. 둘째, git rebase main은 내 브랜치 커밋을 상대 최신 위에 다시 얹고, 그 과정에서 커밋 해시가 바뀌어요. 셋째, git rebase -i의 squash로 자잘한 커밋을 하나로 뭉쳐 리뷰를 깔끔하게 만들 수 있어요.
넷째, 그리고 이게 제일 중요한데, 이미 공유한 브랜치는 리베이스 금지예요. 아직 push 안 한 내 로컬 커밋에만 쓰는 거예요. 이 선만 안 넘으면 리베이스는 무섭기는커녕 아주 든든한 친구가 돼요. 다음에 브랜치가 지저분해 보이면, 겁내지 말고 조용히 한 줄로 펴보세요.