안녕하세요. 오늘은 깃에 대해 이야기를 해보려 합니다.
깃에 대해 이야기하면서 왜 카테고리가 안드로이드냐 .. 라고 하시면
제가 안드로이드 개발을 하다가 공부하게 된거기 때문에 예시 이미지들이 모두 안드로이드 스튜디오라 그랬습니다.
아무튼 시작해보겠습니다.
작업을 하면서 깃을 몇 번 꼬아보니
나 지금 깃을 제대로 알고 사용하고 있는 게 맞나?
라는 의문이 들었습니다.
제대로 알고 있지 못하니 문제가 생겼을 때,
왜 발생했는지, 원인이 무엇인지
어떻게 해결해야 할지
를 전혀 모르겠더라고요.
그럴 때마다 제 자신에 환멸을 느껴서 .. 이렇게 깃에 대해 알아보는 글을 적게 되었습니다.

우선 개발을 시작한 지 어언 2~3년이 되어가면서 아직도 깃을 꼬느냐? 라고 하신다면
이유가 있습니다.
저희 프로젝트에서 지금껏 사용해보지 않은 Git-flow라는 방식을 도입하면서 rebase, squash 등등을 사용하고 있는데 정말 머리가 복잡합니다.
팀원분들이 도와주셔서 지금까지 잘 끌어왔는데 .. 더 이상 이렇게 맨땅에 헤딩을 할 수 없어졌습니다.
서론이 길었네요. 이제 본격적으로 알아가 볼게요. 그렇다고 딥다이브까지는 아니고, 지금껏 우리가 사용하고 있는 개념, 필수 내용 등을 알아보도록 하겠습니다.
Git
사실 간단한 작업만 할 때는 add, commit, push만 알고 계셔도 됩니다.
하지만 협업이 시작되면 이 단순한 명령어 세 개로는 감당이 안됩니다. (마치 지금의 저처럼 ..)
왜일까요?
이유는 간단합니다.
깃은 단순히 파일을 저장하는 도구가 아니라,
시간과 히스토리를 관리하는 시스템이기 때문이에요.
History Graph
깃은 모든 커밋을 하나의 그래프로 관리해요.

이런 식으로요.
즉, 각 커밋은 그 이전 상태(부모 커밋)를 가리키고,
이렇게 서로 연결된 관계가 하나의 히스토리 그래프(History Graph)를 만듭니다.
이걸 조금 더 쉽게 정리해 볼게요.
아래와 같이 하루하루 작업을 진행했다고 합시다.
- 1일 차: 프로젝트 생성 → 커밋 A
- 2일 차: 로그인 기능 추가 → 커밋 B
- 3일 차: 로그아웃 기능 추가 → 커밋 C
그럼 깃 내부에서는 이런 그래프가 만들어집니다.
A ---> B ---> C
여기서 B는 “나는 A 다음에 만들어졌다.”라는 정보를,
C는 “나는 B 다음”이라는 정보를 내부에 가지고 있습니다.
즉, 각 커밋은 이전 커밋의 해시(hash)를 부모 포인터로 저장해요.
Commit
커밋의 해시를 부모 포인터로 저장한다? 어디에?
깃에서 하나의 커밋 객체는 단순히 변경된 코드 묶음이 아닙니다.
아래와 같이 생긴 작은 데이터 객체예요.
commit C
├─ tree: (파일들의 스냅샷)
├─ parent: (이전 커밋의 해시)
└─ message: "feat: 로그아웃 기능 추가"
즉, 깃은 B 다음이 C다라는 연결 관계를 정확히 기억하고 있는 거죠.
그래서 우리가 git log를 찍으면 단순한 시간 순서가 아니라
연결 그래프 순서로 커밋이 출력됩니다.
Branch
그럼 여기서 브랜치는 뭘까요?
브랜치는 이 커밋 그래프 중 어디를 현재 기준점으로 삼을지를 가리키는 단순한 이름표(pointer)입니다.
즉, 브랜치를 생성한다 라는 말의 뜻은
현재 커밋을 가리키는 포인터 하나를 새로 만든다는 뜻인 거죠.
여기서 핵심은 현재 커밋을 기준으로 입니다.
예를 들어 현재 main 브랜치가 아래와 같이 있다고 할게요.
A ---> B ---> C
↑
main 브랜치
이 상태에서 새로운 브랜치를 만들어볼게요.
현재 HEAD가 C를 가리키고 있기 때문에,
새로운 브랜치도 C를 기준으로 시작하게 됩니다.
A ---> B ---> C
↑
main, new
즉, 지금 당장은 두 브랜치가 완전히 같은 커밋(C)을 가리키고 있어요.
둘 다 같은 커밋을 기준으로 출발선에 서 있는 거예요.
아직은 갈라지지 않았고, 그냥 이름표만 하나 더 붙은 상태인 겁니다.
이때 main에서도, new 브랜치에서도 새로운 커밋을 만들면
A ---> B ---> C ---> E ← main
\\
D
↑
new
이렇게 그래프가 갈라집니다.
main 브랜치는 C → E로 이어지는 히스토리
new 브랜치는 C → D로 이어지는 다른 히스토리
즉, 두 브랜치는 같은 조상에서 시작했지만 이제 완전히 서로 다른 타임라인에 존재하게 된 거예요.
이 상태가 바로 우리가 흔히 말하는
브랜치가 분기됐다(divergent)
는 상황입니다.
이걸 조금 더 생각해 보면
main은 배포용 코드가 쭉 이어지는 주 타임라인
new는 새로운 기능을 구현, 도입하는 작업용 타임라인
이라고 생각하시면 됩니다.
그럼 new 브랜치의 코드 구현이 완성되면,
배포용 코드인 주 타임라인, main에 합쳐줘야 하겠죠?
그때 등장하는 명령이 바로 merge 혹은 rebase입니다.
Merge와 Rebase
서로 다른 타임라인으로 갈라진 두 브랜치를 하나로 합치기 위해 두 명령어를 쓴다고 했습니다.
둘 다 브랜치를 합친다는 점에서는 같지만,
그래프를 합치는 방식은 완전히 다릅니다.
Merge
merge는 두 브랜치를 새로운 커밋으로 묶는 방식이에요.
그래프 상으로 보면 “교차점을 만들어준다”라고 생각하면 됩니다.
아래 이미지처럼요.

두 브랜치의 끝을 하나로 묶는 새로운 커밋(=이미지에서의 Merge pull request #177 ~~)을 만들어 냅니다.
이 커밋은 부모가 두 개입니다. - 하나는 main의 마지막 커밋, 하나는 작업 브랜치의 마지막 커밋
즉, “이 시점에서 두 히스토리를 통합했다”는 흔적이 남는 거죠.
이 흔적이 장점이 될 수도 있고, 단점이 될 수도 있습니다.
장점으로 보자면
누가 언제 어느 브랜치를 병합했는지가 명확히 남는다는 점,
기존 커밋을 수정하거나 재배열하지 않아 안전하다는 점이 있어요.
그럼 단점으로는 뭐가 있을까요?

이미지를 보시면 그래프가 너무 복잡하죠.
보통 협업을 하면, 여러 사용자가 여러 작업을 병렬적으로 실행하게 됩니다.
그러다 보면 위 이미지보다도 더 복잡한 그래프가 생성될 수 있어요.
아주 지저분합니다.
Rebase
그럼 rebase는 어떨까요?
rebase는 말 그대로 “base(기준)을 다시 세운다(re-)”라는 뜻이에요.
즉, 내 브랜치(new)의 커밋들을
다른 브랜치(main)의 최신 커밋(E) 뒤로 옮겨 붙인다는 개념입니다.
A ---> B ---> C ---> E ← main
\\
D
↑
new
위 상태에서 new 브랜치에서 아래와 같이 rebase를 하면
git rebase main
그럼 깃은 내부적으로 이렇게 그래프를 바꿔버립니다.
A ---> B ---> C ---> E ---> D' (new)
즉, new 브랜치의 커밋(D)이 main 브랜치(E) 뒤에 이어 붙여진 거예요.
여기서 중요한 점은,
D’은 D의 복사본이지 완전히 같은 커밋이 아닙니다.
깃은 D를 “새로운 위치에 다시 찍으면서”
새 해시를 부여하거든요.
그래서 rebase는 커밋의 해시(역사)를 바꾸는 어려운 작업입니다.
그럼 이 rebase의 장점은
히스토리가 깔끔하다는 점.

위 이미지와 같이 그래프가 한 줄, 일렬로 깔끔하게 이어져있습니다.
프로젝트 히스토리가 마치 혼자 작업한 것처럼 깨끗하죠.
불필요한 merge commit도 없어서 한눈에 보기 좋습니다.
하지만 단점은?
위에서 말했다시피 히스토리를 바꾸는 위험한 작업이기 때문에 신중해야 합니다.
잘못하면 꼬여요.
제가 그랬어요. 허허..
그 상황을 말씀드리자면,
저는 develop 브랜치에서 파생된 a 브랜치에서 작업을 하고 PR을 올려둔 채 수정 작업을 하고 있었습니다.
그런데 다른 팀원이 그 와중에 develop에 b 관련 PR을 머지해 둔 거예요.
즉, 제가 작업하던 브랜치의 베이스는 현재 develop보다 커밋이 뒤처져 있었던 거죠.
그래서 git rebase develop을 한 후 push를 했는데 아래 이미지와 같은 경고문이 떴습니다.

이 상태에서 제가 실수로 merge를 눌렀습니다..(원래는 rebase를 눌렀어야 해요.)
그 순간, 깃 그래프가 아래처럼 갈라졌습니다.
E1 → E2 → E3 → E4 (develop 최신)
/
A → B → C → D (내 작업)
develop의 최신 커밋(E1~E4) 위에 제 작업 커밋(D)이 있지 않고,
중간에 다른 가지가 하나 더 생긴 상태가 된 거예요.
그래서 merge를 하면, 깃은 새로운 병합 커밋을 만들고
그래프가 아래와 같이 만들어졌습니다.
E1 → E2 → E3 → E4
/ \\
A → B → C → D ------------------- M (merge commit)
너무 지저분해졌죠..
저희 브랜치 전략에 따라 그래프가 한 줄로 깔끔했었는데, 저의 실수로 인해 순식간에 더러워졌습니다.
어떡하지 ?..
그때 ! 친절하신 팀원분들이 돌릴 방법을 알려주셨습니다.
바로 cherry-pick !
Cherry-pick
꼬인 브랜치는 그대로 두고,
새 브랜치를 만들어서 필요한 커밋만 골라오자.
즉, 말 그대로 맛있는 것만 골라 먹는 방법입니다.
1. 실제로 저는 꼬인 브랜치에서 새 브랜치를 하나 팠습니다.
a 브랜치에서 a-backup 브랜치를요.
2. 기존 브랜치의 꼬인 그래프를 리셋했어요.

이미지와 같이 브랜치가 분기되기 직전의 base(기준)으로 리셋해 줍니다.

이런 창이 뜰 텐데 reset hard 해주시면 됩니다. 해당 기준 이후의 커밋을 다 hard 하게 날려버려요.
3. 이제 a-backup 브랜치에서 필요한 커밋만 cherry-pick 합니다.
해당 브랜치에서 작업한 커밋 중 실제 필요한 커밋들만 체리픽해요.

이렇게 체리픽해주면

짜잔 ~ 한 줄로 아주 깔끔해집니다.
⭐️ 이때 중요한 점은 완전히 자동적으로 체리픽이 동작하지 않을 수 있습니다.
그럼 conflict를 해결해 주시고,
이미 원격 PR이 올라가 있었기 때문에 강제 push를 통해 현재 로컬에서 정리된 히스토리를 강제로 PR에 다시 올려줘야 해요.
이렇게 merge, rebase 그리고 그 과정에서 꼬인 깃을 푸는 방법까지 알아봤습니다.
근데 제가 이해를 돕기 위해 첨부한 이미지들을 보시며 의문이 든 점이 있을 것 같습니다.
바로 분명히 커밋들을 체리픽했으면 develop에서의 최신 커밋 다음에 그 커밋들이 있어야 하지 않나?
왜 PR 이름으로 된 커밋 하나만 최신 커밋 뒤에 있지?
라고 생각하신다면,
바로 저희 팀은 Git-flow 방식을 채택해, squash를 사용하고 있기 때문입니다.
Squash
squash란 무엇일까요?
처음 들어보는 분들이 많을 것 같습니다.
우선, squash란 여러 개의 커밋을 하나로 합치는 작업입니다.
개발 중에는 여러 commit으로 쪼개서 작업을 하는 것이 권장됩니다.
이 commit들이 여러 개발자에 의해 합쳐지면 역시 너무 많아지고 길어지기 때문에
최종 PR을 내보낼 때 하나의 의미 있는 커밋으로 합치는 겁니다.
예를 들어 feature 브랜치에서 아래와 같은 커밋들이 있을 때
feat: 로그인 UI 작성
fix: 오타 수정
chore: 로그 추가
fix: null 체크 보완
이걸 그냥 다 그대로 PR에 올려 merge를 해버리면
히스토리 상에 중간 수정 흔적 + 잡다한 커밋들이 다 남아버려서
너무 지저분해집니다. 그래프가 잡다해지는 거죠.
그래서 저희는 PR을 올린 후 해당 PR이 머지될 때 squash를 해주기로 했습니다.

이미지처럼 merge 할 때 squash and merge를 눌러주시면 되는데, 레포 설정을 통해 애초에 squash and merge를 default로 둘 수도 있습니다.(저희는 그렇게 하고 있어요.)
아무튼 이러면 최종 커밋은 PR 이름을 가진 커밋 하나가 됩니다.
“한 PR = 한 커밋”이 되는 거죠.

그럼 딱 어떤 작업을 진행한 것인지 그래프를 볼 때 명확하고 깔끔하게 이해가 가능해집니다.
이렇게 깃에 대해 알아보았습니다.
너무 생각 없이 쓰고 있지 않았나 하는 반성과 함께 제대로 알게 되어 이제 더 이상 깃을 꼬지 않을 것 같습니다.
꼬더라도 잘 해결하여 팀원들에게 해를 끼치지 않을 자신도 있구요.
여러분도 이번 글을 통해 깃 고수가 되어 자유롭게 rebase, squash를 사용하는 멋쟁이들이 될 수 있기를 바랍니다.

'🤖안드로이드🤖' 카테고리의 다른 글
| [안드로이드] CoroutineContext (0) | 2025.11.05 |
|---|---|
| [안드로이드] async와 Deferred (0) | 2025.10.29 |
| [안드로이드] Custom Convention Plugin (1) | 2025.10.12 |
| [안드로이드] 코루틴 빌더와 Job (1) | 2025.10.03 |
| [안드로이드] CoroutineDispatcher (0) | 2025.10.02 |