본문 바로가기
개발 일지 · 팁

현업에서 git tree 한줄로 관리하기 [Fast-Forward**] [merge] [pull] [rebase] [squash-merge]

코딩패트릭 2026. 8. 12.

레거시 프로젝트의 git tree를 열어보면 이런 모습이다.

수십 개의 브랜치 선이 무지개처럼 얽혀 있고, Merge branch 'main' into develop가 끝없이 반복된다. 어떤 커밋이 어떤 작업이었는지, 언제 무엇이 배포됐는지 히스토리만 봐서는 도무지 읽히지 않는다.

신규 프로젝트를 시작하면서 우리 팀은 이 부분을 먼저 손봤다. 거창한 도구를 도입한 게 아니라, git을 다루는 몇 가지 컨벤션을 정하고 그것만 지켰다. 그 결과 지금은 dev / stg / prod 같은 배포 브랜치의 히스토리가 한 줄(single line)로 깔끔하게 유지된다. 히스토리가 한 줄이면 실무에서 얻는 이점이 분명하다. 커밋 하나하나가 어떤 작업 단위였는지 그래프만 봐도 읽히고, 코드 리뷰의 단위가 명확해진다. 문제가 생겼을 때 git bisect로 원인 커밋을 추적하기 쉽고, 특정 작업을 통째로 revert하기도 수월하다.

이 글은 그 컨벤션이 무엇이고, 각각의 git 기능을 어떤 개념 위에서 어떻게 활용하고 있는지를 공유하려고 쓴다.

개발 환경과 브랜치 전략

우리 팀의 환경과 흐름은 아주 단순하다.

  • 배포 환경은 dev / stg / prod 3단계로 나뉜다.
  • 팀원은 각자 맡은 큰 작업 단위마다 feature 브랜치를 따서 작업한다.
  • 작업이 끝나면 feature 브랜치를 dev에 병합하고, 검증을 거쳐 stg, prod로 올라간다.

관건은 "feature를 dev에 어떻게 합치느냐", "원격 변경을 어떻게 당겨오느냐" 같은 순간마다 어떤 선택을 하느냐다. 이 선택들이 쌓여서 git tree의 모양이 결정된다. 아래에서 다룰 개념들은 전부 그 선택의 순간에 쓰이는 도구들이다.

이 글에서 다룰 핵심 개념

각 개념을 "이게 무엇인가"와 "우리가 어떻게 활용하는가" 두 축으로 설명한다.

이 중에서 Fast-forward(git 명령에서는 --ff)가 가장 핵심이다. git tree가 왜 한 줄로 유지될 수 있는지는 결국 Fast-forward라는 개념 하나에서 출발한다. 그래서 다른 무엇보다 Fast-forward의 개념을 제대로 이해하는 것이 가장 중요하다. 이걸 이해하고 나면 나머지는 "어떻게 하면 배포 브랜치의 히스토리를 선형으로 유지할 것인가"의 이야기로 자연스럽게 이어진다. 그 선형 히스토리를 실제로 만들어주는 도구가 rebase다. rebase는 대상 브랜치가 더 진행하지 않았다는 전제에서 내 브랜치를 최신 기준점 위로 옮겨, 합칠 때 Fast-forward가 가능한 상태로 만든다. squash-merge는 결이 조금 다르다. tree를 한 줄로 만드는 도구가 아니라, feature의 여러 커밋을 하나로 압축해 "작업 하나 = 커밋 하나"로 남기는 도구다. 즉 선형 히스토리는 rebase가, 그 위에 올라가는 커밋의 단위 정리는 squash-merge가 맡는다.

  1. Fast-forward — git tree가 한 줄로 유지되는 원리의 출발점이자, 이 글에서 가장 먼저, 가장 확실히 이해해야 할 개념. merge commit 없이 브랜치 포인터만 앞으로 옮기는 방식이 무엇인지, 왜 우리가 이걸 기본값으로 삼는지 살펴본다.
  2. rebase — 히스토리를 한 줄로 다시 세우는 핵심 도구. 브랜치를 최신 기준점 위로 옮겨 붙여 "항상 Fast-forward가 가능한 상태"를 만드는 원리와, 협업에서 rebase를 안전하게 쓰기 위한 우리 팀의 규칙을 공유한다.
  3. squash-merge — feature 브랜치의 여러 커밋을 하나로 압축해 dev에 올리는 방식. 왜 작업 단위 하나를 커밋 하나로 남기려 하는지, 그때 tree와 히스토리가 어떻게 정리되는지 본다.
  4. merge — 일반 merge와 Fast-forward의 차이. merge commit이 생기는 순간과 그때 tree가 어떻게 갈라지는지, 그리고 우리 팀이 배포 브랜치에 merge commit을 남기지 않기 위해 무엇을 하는지 정리한다.
  5. pull — 기본 설정의 git pull은 분기가 생긴 상황에서 merge commit을 만들 수 있다는 이야기. git pullfetch 뒤에 어떤 통합 작업을 하는지, 우리가 왜 rebase 기반 pull을 기본으로 두는지 다룬다.

이 글에서 다루지 않는 것

충돌 해결의 모든 경우의 수는 다루지 않는다. 이 글의 목표는 "우리 팀이 실제로 지키는 최소한의 컨벤션"과 그 밑에 깔린 개념을 공유하는 것이다. 그래서 위 다섯 개념만 붙잡고 간다.

한 가지 짚어둘 점. 여기서 "한 줄"은 모든 브랜치를 포함한 그래프를 말하는 게 아니다. feature 브랜치까지 함께 그리면 작업 중인 브랜치는 당연히 옆으로 갈라져 보인다. 우리가 선형으로 유지하는 대상은 dev / stg / prod 같은 배포 브랜치의 히스토리다. 그래서 이 다섯 개념과 함께, 병합 후 feature 브랜치는 삭제하고 배포 브랜치에는 merge commit을 남기지 않는다는 두 가지 규칙을 함께 지킨다. 이 규칙까지 갖춰졌을 때 배포 브랜치의 히스토리가 한 줄로 정리된다.

Fast-forward — 결국 "포인터를 앞으로 옮기는 것"

Fast-forward를 한 문장으로 요약하면 이렇다. 새로운 merge commit(두 갈래의 히스토리를 하나로 합치며 새로 생기는 커밋)을 만들지 않고, 브랜치 포인터만 앞으로 이동시키는 것.

이 말을 이해하려면 먼저 git 브랜치가 사실은 커밋을 가리키는 포인터라는 점부터 잡아야 한다. dev가 A를 가리키고 있고, A에서 feature를 따서 B, C를 만들었다고 하자. 이때 dev는 여전히 A에 멈춰 있다.

dev
 ↓
 A
  \
   B → C
       ↑
     feature

이 상태에서 featuredev에 합치면 어떻게 될까. dev가 A에 있는 동안 feature는 A에서 출발해 B → C로 갔을 뿐, 두 갈래로 갈라진 게 아니다. 그래서 git은 별도의 merge commit을 만들 필요 없이, dev 포인터를 C까지 앞으로 밀어주기만 하면 된다.

          dev
          ↓
 A → B → C
          ↑
        feature

이게 Fast-forward다. 비디오를 빨리 감듯(fast-forward) 포인터를 앞으로 쭉 밀어주는 것이고, 합칠 충돌 자체가 없으니 merge commit도 생기지 않는다. 히스토리는 A → B → C 한 줄로 남는다.

반대로 Fast-forward가 안 되는 경우도 있다. feature가 B로 가는 동안 dev에도 새 커밋 C가 생겼다면, 두 역사가 실제로 갈라진 것이다.

      B ← feature
     /
 A
     \
      C ← dev

이때는 dev를 그냥 B로 밀 수 없다. dev에만 있는 C를 버릴 수 없기 때문이다. 그래서 git merge feature를 하면 git은 B와 C 두 갈래를 모두 부모로 갖는 merge commit M을 새로 만들고, dev를 그 M으로 옮긴다.

      B ──┐
     /     \
 A          M ← dev
     \     /
      C ──┘
      ↑
    feature

포인터만 앞으로 밀린 게 아니라, 없던 커밋 M이 하나 끼어들면서 tree가 A에서 갈라졌다가 M에서 다시 합쳐지는 마름모 모양이 된다. 작업 하나 합칠 때마다 이런 마름모가 계속 쌓이면, 앞에서 본 레거시의 스파게티 그래프가 된다.

정리하면, git tree를 한 줄로 유지한다는 건 결국 병합의 순간마다 Fast-forward가 가능한 상태를 유지하는 것과 같다. 대상 브랜치가 앞서 나가 갈래가 생기기 전에, 내 브랜치를 항상 그 위에 올려둬서 "포인터만 앞으로 밀면 끝"인 상태로 만드는 것. 뒤에서 다룰 rebase와 squash-merge는 모두 이 상태를 만들기 위한 도구다.

rebase — 갈래를 없애고 다시 한 줄로 세우기

앞에서 Fast-forward가 안 되는 이유는 하나였다. 대상 브랜치(dev)가 앞서 나가면서 A에서 갈래가 생겼기 때문이다. rebase는 이 갈래 자체를 없애는 도구다. 내 브랜치의 출발점을 대상 브랜치의 최신 커밋 위로 옮겨 다시 이어 붙인다.

다시 갈라진 상황을 보자. dev는 C까지 갔고, feature는 옛날 커밋 A에서 출발해 B를 만든 상태다.

      B ← feature
     /
 A → C ← dev

여기서 feature에서 git rebase dev를 하면, git은 feature가 A 이후로 쌓은 커밋(B)을 떼어내, dev의 최신 커밋 C 뒤에 다시 얹는다.

 A → C → B' ← feature
     ↑
    dev

이제 갈래가 사라졌다. feature는 C 위에서 곧장 이어지므로, devfeature를 합칠 때 포인터만 C에서 B'로 밀면 되는 Fast-forward 상태가 된다. rebase가 "항상 Fast-forward가 가능한 상태를 만드는 도구"라는 말이 바로 이 뜻이다.

한 가지 중요한 점. 옮겨진 B는 원래의 B가 아니라 새로 만들어진 B'다. rebase는 커밋을 이동시키는 게 아니라, 같은 변경 내용으로 부모가 다른 새 커밋을 다시 만든다. 그래서 커밋 해시가 바뀐다. 이게 rebase의 힘이자 위험이다.

여기서 rebase의 황금률이 나온다. 이미 여러 사람이 공유하는 브랜치는 rebase하지 않는다. 남들이 옛 B를 기준으로 작업하고 있는데 내가 B를 B'로 갈아치우면, 히스토리가 어긋나 서로의 브랜치가 충돌한다. 그래서 우리 팀의 규칙은 단순하다.

  • rebase는 아직 나만 쓰는 로컬 feature 브랜치를 dev 위로 정리할 때만 쓴다.
  • dev / stg / prod 같은 공유 브랜치는 절대 rebase하지 않는다.
  • dev가 앞서 나갔으면, feature를 dev에 합치기 전에 먼저 git rebase dev로 최신 위에 올려둔다.

이 규칙만 지키면, feature를 합치는 순간은 언제나 Fast-forward가 된다. 갈래가 생길 일이 없으니 배포 브랜치 히스토리는 계속 한 줄로 남는다.

squash-merge — 여러 커밋을 하나로 합치기

먼저 오해를 하나 풀고 가자. squash-merge는 Fast-forward를 만드는 도구가 아니다. tree를 한 줄로 유지하는 것과도 사실 직접적인 관련이 없다. 여기까지 다룬 Fast-forward와 rebase만으로도 배포 브랜치 히스토리는 이미 한 줄로 유지된다.

그럼 squash-merge는 뭘 하는가. 이름 그대로 여러 개의 커밋을 하나로 합치는(squash) 작업이다. 다루는 대상이 다르다. Fast-forward가 "브랜치 포인터를 어디로 옮길까"의 문제라면, squash-merge는 "이 작업을 커밋 몇 개로 남길까"의 문제다.

실무에서 feature 브랜치 하나에는 보통 지저분한 커밋이 잔뜩 쌓인다.

 ... → A ← dev
         \
          b1 → b2 → b3 → b4 ← feature
      (wip, 오타 수정, 리뷰 반영, 다시 수정 …)

이 b1~b4를 그대로 dev에 올리면, 배포 브랜치 히스토리는 한 줄로 유지되더라도 "회원가입 기능 추가" 하나가 의미 없는 커밋 네 개로 흩어져 남는다. squash-merge는 이 네 커밋의 변경 내용을 합쳐, dev 위에 단일 커밋 S 하나로 얹는다.

 ... → A → S ← dev

여기서 S는 merge commit이 아니다. 부모가 둘인 merge commit과 달리, S는 부모가 하나인 평범한 커밋이다. 그래서 tree는 여전히 한 줄이고, 그 한 줄 위에 작업 단위 하나가 커밋 하나로 깔끔하게 남는다.

rebase와 헷갈리기 쉬운데, 둘은 하는 일이 다르다. rebase는 커밋 개수를 유지한 채 출발점만 옮겨 붙인다(b1

b4가 b1'

b4'로 그대로 남는다). squash-merge는 그 커밋들을 하나로 뭉쳐 개수 자체를 줄인다. 그래서 우리 팀은 둘을 함께 쓴다. feature를 dev 위로 rebase해 Fast-forward가 가능한 상태로 만든 다음, 합칠 때 squash로 작업 하나를 커밋 하나로 남긴다.

정리하면 squash-merge가 지켜주는 건 tree의 모양(한 줄)이 아니라 커밋의 단위다. 커밋 하나가 곧 작업 하나이기 때문에, 앞에서 말한 이점 — 코드 리뷰 단위가 명확해지고, 문제가 생긴 작업을 통째로 revert하기 쉬운 것 — 이 여기서 나온다.

merge — 우리는 merge commit을 만들지 않는다

git merge의 기본 동작은 "가능하면 Fast-forward, 갈래가 있으면 merge commit"이다. 즉 브랜치가 갈라진 채 무심코 merge하면 merge commit이 조용히 끼어든다.

git merge --ff-only    # 갈래가 있으면 실패시킨다 → merge commit 원천 차단
git merge --no-ff      # 반대로 항상 merge commit을 만든다 (우리는 안 씀)

그래서 규칙은 하나다. 배포 브랜치(dev / stg / prod)에는 merge commit을 만들지 않는다. 이를 위해 로컬 병합은 git merge --ff-only를 기본으로 두고(갈래가 남아 있으면 실패하니 rebase를 강제하게 된다), PR은 Squash 또는 Rebase merge로만 병합하고 merge commit 옵션은 막아둔다.

pull — 무심코 쓰는 pull이 갈래를 만든다

git pull은 사실 git fetch(원격 변경 받아오기) + 통합 두 단계이고, 통합의 기본값이 merge다. 내가 커밋하는 사이 원격 dev가 앞서 나가면 로컬과 원격이 갈라지고, 이때 git pull은 둘을 잇는 merge commit(Merge branch 'dev' of ...)을 자동으로 만든다. 레거시 그래프의 반복되는 merge commit 상당수가 바로 여기서 나온다.

해법은 pull을 rebase로 고정하는 것이다. 원격 커밋 위에 내 커밋을 다시 얹으니 갈래도, merge commit도 생기지 않는다.

git pull --rebase                        # 이번 pull만 rebase로
git config --global pull.rebase true     # 앞으로 모든 pull을 rebase로

컨벤션의 사실은, rebase 생활화

여기까지 Fast-forward, rebase, squash-merge, merge, pull을 꽤 거창하게 늘어놨지만, 우리 팀이 git tree를 한 줄로 유지하는 비결을 딱 한 문장으로 줄이면 이렇다. Fast-forward를 이해하고, rebase를 생활화하는 것.

앞의 다섯 개념은 결국 이 한 문장으로 모인다. Fast-forward가 왜 tree를 한 줄로 만드는지 원리만 이해하고 나면, 나머지는 "갈래가 생기기 전에 rebase로 미리 정리한다"는 습관 하나로 수렴한다. merge든 pull이든, 무심코 하면 merge commit이 끼어들어 tree가 갈라진다. 그걸 막는 건 대단한 도구가 아니라 rebase를 자주 쓰는 손버릇이다.

그러니 거창한 브랜치 전략을 도입하기 전에, 그냥 병합하고 코드 풀던 습관을 rebase 쪽으로 옮겨보는 것부터 시작해보면 어떨까. pull.rebase를 켜두고, feature를 합치기 전에 git rebase dev를 한 번 더 치는 작은 습관. 팀에 이 문화 하나만 정착시켜도, git tree는 어느새 한 줄로 정리되어 있을 것이다.