HashiCorp의 공동창업자인 Mitchell Hashimoto가 얼마 전 Merge vs. Rebase vs. Squash라는 글을 작성했다.
Git에서 Merge, Rebase, Squash에 관한 질문을 자주 받아서 정리했다고 하는데 요약하면 다음의 내용이다.
- 셋 중 특정 전략이 100% 정답이라고 말하는 사람은 틀렸고 각 전략은 상황에 따라 다르다.
- Merge 커밋을 만드는 Merge가 히스토리를 가장 잘 표현한다고 생각해서 Merge를 선호한다.
- Merge 커밋이 있으면 쉽게 되돌릴 수 있다.
- 모든 커밋이 빌드된다면 커밋이 많을수록 git bisect가 좋아진다.
- 이상적으로 커밋은 +50/-50 정도로 유지하는 것이 좋다.
- PR에 많은 WIP 커밋이 있지만 하나의 목표라면 Squash를 한다.
- Squash할 때 Git과 GitHub이 제공하는 기본 메시지는 좋지 않아서 Squash를 할 때 커밋 메시지를 새로 작성한다.
- PR에 WIP가 많은데 각 커밋의 차이가 크다면 인터랙티브 Rebase로 커밋을 합치고 순서를 변경한다.
- 개발자들이 커밋 관리에 신경 쓰기를 기대하지만, 많은 개발자가 Git에 익숙하지 않다.
- 대규모로 인터랙티브 Rebase를 할 때는 Git GUI를 사용한다. macOS에서는 Tower를 쓰고 있다.
대부분의 내용을 공감해서 공유하면서 나는 어떻게 생각하고 있지를 고민하다 보니 그동안 개발을 하면서 내가 선호하는 방법도 다양하게 바뀌었다는 것을 깨달았다. 개발하면서 Git에 어울리게 쓰고 싶어서 한 것들도 있고 협업하다 보니내 취향과는 다르게 절충하게 된 것들도 있어서 트위터에서 글을 쓰다가 블로그에 정리하게 되었다.
Merge
Git을 배우면 처음에 Merge에 관해 배우기 마련이다. Git에서는 브랜치를 만들어서 작업하는 게 일반적이기 때문에 작업 후에 이 브랜치를 기본 브랜치에 합치는 Merge는 아주 중요하다.
Merge하면 그래프가 위처럼 만들어지고 Merge branch '브랜치 이른'
의 메시지를 가진 Merge 커밋이란 게 만들어진다. 이 그래프처럼 Merge 커밋이 있으면 작업할 때 브랜치가 분리되었다가 합쳐졌다는 게 시각적으로나 구조적으로 구분되고 히스토리에도 남게 된다.
물론 더 정확히 얘기하면 머지할 때 fast-forward 머지가 아닐 때만 이 머지 커밋이 생긴다. fast-forward 머지가 가능할 때는 머지커밋 없이 바로 생기고 fast-forward와 상관없이 머지 커밋을 만들려면 git merge --no-ff
옵션을 사용해야 한다.
초기에 Git을 배우고 익숙해지면서 주변 사람들도 Merge 커밋 선호자와 비선호자로 나뉘게 되었는데 나는 Merge 커밋이 좋은가? 안 좋은가를 많이 고민했다. 그래도 시간이 지나면서 머지 커밋을 안 남기는 것을 더 좋아하게 되었다.
당시 내가 가장 좋아하던 Node.js 프로젝트는 Git 히스토리를 일직선으로만 만들었다. 당시 GitHub의 Pull Request는 무조건 --no-ff
로 머지했기 때문에 항상 머지 커밋이 남게 된다. 그래서 머지커밋을 안 남기려면 Pull Request 브랜치를 로컬에 가져와서 fast-forward로 머지한 뒤에 이를 푸시하는 방식으로 머지해야만 가능했기에 메인테이너들이 아주 바빠지게 되지만 Node.js는 항상 이렇게 했다. 그래서인지 일직선으로 유지되는 히스토리가 더 멋지다고 생각했고 머지 커밋은 나한테는 불필요한 정보가 남는 걸로 보여서 장황하게 느껴졌다.
물론 브랜치를 나누어서 작업한다는 것이 보통 하나의 작업 단위가 되기 때문에 머지 커밋을 남기면 이 작업 단위에 커밋이 여러 개 있다고 하더라도 이를 분리해서 볼 수 있고 분리되어 있으므로 필요하다면 한꺼번에 revert 할 수도 있다. 머지 커밋이 없는데 revert 한다면 커밋을 일일이 찾아야 한다. 하지만 현실에서는 머지를 통째로 revert 하는 일이 많지 않기도 하고 머지를 통째로 revert 하기보다는 수정 커밋이 새로 올라가는 게 더 자주 있는 일이라고 생각했다.
혼자 할 때는 얼마든지 내가 원하는 대로 히스토리를 관리할 수 있지만 협업하면 얘기가 달라진다. 얘기했듯이 GitHub의 Pull Request를 사용하면 항상 머지 커밋을 남길 수밖에 없었다. Squash로 머지할 수 있는 기능은 2016년에 와서야 추가되었다.
그렇다 보니 머지 커밋을 싫어하는 내 취향과 관계없이 GitHub에서 협업할 때는 머지 커밋을 남기게 되었다. GitHub 사이트에서 머지 버튼 누르는 게 훨씬 편하고 모두가 Git을 잘 다루는 것도 아니니까 "머지는 버튼으로 안 하고 로컬에 받아서 fast-forward로 머지합니다"라고 할 수도 없어서 어쩔 수 없이 그냥 머지 커밋을 쓰게 되었다.
Rebase
Rebase는 Git의 아주 훌륭한 기능이지만 Git을 배울 때 가장 어려워하는 기능이기도 하다. 예전에 Git 강의를 할 때도 Rebase를 알려주는 게 나을지 고민을 정말 많이 하곤 했다. 처음부터 Rebase 익히지 않는 게 나을 수도 있지만 Git을 제대로 쓰려면 Rebase를 필수라고 생각하고 있긴 하다.
Git을 쓰면서 히스토리 관리를 중요하게 생각하는 편이라 코드 리뷰할 때 커밋 메시지도 리뷰하고는 했다. 영어를 잘하는 건 아니니까 커밋 메시지의 문법을 보는 건 아니지만 WIP 라던가 아무 의미 없는 커밋 메시지를 가진 커밋이 쌓이는 건 싫어했다.
내가 커밋할 때도 Pull Request를 올리면서 모든 커밋은 인터랙티브 Rebase로(git rebase -i
)로 히스토리를 다시 정리했다. 작업을 하면서 A
-B
-C
순으로 작업을 하는데 놓친 부분이나 수정할 게 생기면 A
-B
-C
-A'
-B'
-A''
-C'
형태로 커밋 히스토리가 복잡해 지는 게 인터랙티브 Rebase를 써서 순서를 바꾸고 커밋을 합쳐주면 다시 A-B-C 처럼 만들 수 있다.(--fixup
와 --autosquash
를 쓰면 편하다.) 그래서 결과적으로 보면 처음부터 모든 경우를 고려해서 순서대로 작업한 것 같은 히스토리가 만들어지게 되는데 커밋 메시지는 모든 작업 과정을 다 담는 것이 아니라 나중에 이해할 수 있게 정리된 히스토리여야 한다고 생각하기 때문이다.
머지에서 피곤하게 느낀 건 위와 같은 상황이었다. Pull Request를 올리려고 작업을 하다 보면 작업 시간이 몇 시간에서 며칠이 걸리기도 하니까 다른 사람들의 작업도 기본 브랜치에 머지되면서 브랜치의 각 커밋이 흩어지게 된다. Git 도구를 쓰면 못할건 아니지만 머지 커밋은 상단에 바로 보이지만 그 브랜치에서 진행된 커밋을 보려고 하면 저 아래에 있어서 보기 쉽지 않은 게 싫었다. 당연히 협업자가 많으면 머지 커밋은 더 많이 생기고 히스토리는 복잡해진다.
이 문제를 해결하려면 Rebase를 하면 된다. 머지 하기 전에 Rebase를 하면 기본 브랜치를 대상으로 커밋을 다시 하게 되므로 자연히 커밋이 모이게 되고 이후에 머지를 하면 머지 커밋과 해당 브랜치의 커밋이 한 곳에 모이게 된다.
하지만 이것도 위에 Rebase로 커밋 히스토리를 정리하자는 것과 같은 상황이기 때문에 나 혼자서는 지킬 수 있는 규칙이지만 협업할 때 모두가 이렇게 하라고 하는 건 어려운 문제가 된다. 그래서 처음에는 Rebase 요청이나 커밋 히스토리에 대한 정리에 대한 요청도 많이 하곤 했지만, 시간이 지나면서 점점 커밋 히스토리를 너무 신경 쓰지 않으려고 노력하게 되었다.
Pull Request에서 Rebase를 적극적으로 사용하지 않게 된 이유가 또 하나 있는데 코드 리뷰 때문이었다. 예를 들어 변수명에 오타가 있다가 있다고 코드 리뷰를 받았을 때 이를 수정해서 올리면 새로 올라온 커밋만 보고 리뷰대로 오타가 수정되었구나!' 할 수 있다. 하지만 내가 이걸 수정한 다음 인터랙티브 Rebase로 커밋을 원래 커밋에 다시 합친 뒤에 force push를 하면 리뷰한 사람 입장에서는 전체 diff를 다시 보면서 제대로 수정되었는지 확인하기가 쉽지 않아진다. 내가 가진 Rebase 습관이 Git 관점에서는 맞는다고 생각하지만, 협업 관점에서는 오히려 문제가 되기 때문에 Rebase를 너무 적극적으로 안 하기 시작했다.
Squash
GitHub Pull Request가 Merge 커밋을 남기게만 동작하다가 2016년 4월에 Squash and merge 기능이 나오고 2016년 9월에는 Rebase and merge가 나왔다.
Squash는 Pull Request의 모든 커밋을 합쳐서 하나의 커밋으로 만드는 기능이고 Rebase는 타겟 브랜치를 기준으로 모든 커밋을 다시 커밋(rebase)해서 fast-forward 머지가 가능한 상태로 만든 뒤에 머지하는 기능이다. Rebase and merge는 앞에서 말한 대로 내가 기다리던 기능이었기 때문에 나는 이쪽을 더 선호했고 그 덕에 커밋 히스토리를 한 줄로 만들 수 있게 되었다. 하지만 여전히 Pull Request에서 코드 리뷰와 관련해서 불필요한 커밋을 남길 수밖에 없는 문제는 해결되지 않았다.
그런 고민을 하던 중 지인이 자기 팀에서는 Squash를 기본으로 사용한다는 것을 알고 Squash에 관심을 가지게 되었다. Squash를 사용해 보면서 팀 단위로 협업할 때는 Squash를 선호하게 되었다.(여전히 개인 프로젝트에서는 Rebase and merge를 선호한다.) 아까 말한 대로 머지 커밋을 사용하지 않을 수 있으면서 코드 리뷰를 위해 Pull Request 브랜치에서는 히스토리 조정을 하지 않고 머지할 때는 하나의 커밋으로 합쳐서 최종 히스토리는 깔끔하게 유지할 수 있다. 협업 때 규칙을 합의 보고 지키기 위해서는 규칙이 너무 복잡하지 않아야 하는데 그런 부분에서도 Squash는 장점이 많다. 가끔 까먹을 때도 있지만 Squash로 합칠 때 커밋 메시지는 다시 조정하는 편이다. 안 그러면 커밋 메시지에 Pull Request에 포함된 모든 커밋 메시지가 다 포함된다.
더군다나 Pull Request의 변경 사항을 너무 크게 만들지 않게 하는 데도 유용하다. 기본적으로 코드 리뷰를 원활하게 하기 위해서는 Pull Request의 변경 사항은 너무 크지 않아야 한다. 그동안 Rebase로 히스토리 조정을 많이 해오면서 두 가지 목적의 작업은 거의 한 커밋에 안 섞는 편이다. 코드 수정을 하다가 리팩토링 할게 보인다거나 컨벤션 일부를 수정해야 해서 스타일 설정 파일을 수정해야 한다고 했을 때 이 부분만 따로 작업해서 Pull Request를 올리고 기능 추가나 버그 수정은 해당 변경 사항만 담기도록 노력하고 있다.
그렇기에 Squash를 기본 규칙으로 가게 하면서 하나의 커밋(Pull Request 내에서는 여러 커밋이 있지만 결국은 하나로 합쳐지므로)에는 너무 많은 변경 사항이 담기지 않도록 서로 노력하는 게 더 쉬웠고 문제가 생겼을 때 Revert 할 단위도 커밋 단위로 만들 수 있게 되었다. 결국 Pull Request 하나가 하나의 원자적 단위가 되는데 Mitchell Hashimoto 말대로 모든 커밋이 빌드할 수 있어야 하는 건 중요하고 그래야 어느 커밋으로도 되돌아갈 수 있는데 Pull Request에서 모든 커밋에서 CI를 다 돌리진 않지만, Squash로 한다면 모든 커밋에서 CI가 통과했다는 보장을 할 수 있게 된다.
Pull Request를 잘게 쪼개는 건 아주 중요하고 이건 연습이 좀 필요하다고 생각한다. 코드 리뷰를 한다는 것은 언제 머지될지 모른다는 얘기가 되므로 Pull Request를 쪼개기 시작하면 Pull Request를 올려두고 이어진 작업을 하기 위해 이전 Pull Request가 필요하게 된다. 이를 Stacked Changes 혹은 Stacked Pull Request라고 부른다. 나는 손이 느려서 그런지 예전부터 쪼개는 것에 익숙해져서인지 Stacked Pull Request는 잘 사용하지 않지만, Stacked Pull Request가 필요하다면 Graphite같은 서비스도 도움이 된다. 관련한 도구에서는 제일 잘 만들지 않았나 싶다.
Comments