Git

squash merge가 release마다 cherry-pick이 충돌하는 이유

Kir93 2026. 6. 22. 18:40
728x90
반응형

1. 도입부 (Why This Matters)

릴리즈 브랜치를 만들 때마다 같은 일이 반복된다. 분명 일상 통합 브랜치에서는 깨끗하게 머지됐던 변경인데, 릴리즈 브랜치에 모으려고 cherry-pick을 돌리면 매번 1~3건씩 충돌이 난다. -x, -m, -X theirs 같은 옵션을 바꿔봐도, 전략(recursive/ort)을 바꿔봐도 결과는 비슷하다. 손으로 풀고, 다음 릴리즈에서 또 푼다.

이 글의 결론을 먼저 말하면 이렇다. cherry-pick을 튜닝하는 건 헛수고다. 충돌의 범인은 cherry-pick이 아니라, 그 한참 앞에서 일어난 squash 머지다. squash가 git이 3-way 머지의 기준점으로 쓰는 공통 조상(merge base)을 지워버리기 때문에, cherry-pick은 "이미 적용된 변경"을 알아보지 못하고 매번 충돌을 만든다.

이 글에서는 (1) squash가 공통 조상을 어떻게 파괴하는지 git 동작 수준에서 풀고, (2) merge --no-ff 직접 머지로 전환해 릴리즈 생성 충돌을 0으로 만든 방법과 검증 결과를 다룬다. 읽는 데 약 8분.

git의 머지·cherry-pick 동작은 안정적이지만, 본문 설명·수치는 2026년 6월 기준이다. 동작이 헷갈리면 git merge-base, git patch-id 문서로 직접 확인하길 권한다.

2. 핵심 개념 (What & Why)

git의 머지는 3-way merge다. 비유하자면, 두 사람이 같은 문서를 각자 고쳐 왔을 때 이를 합치는 일이다. 이때 원본이 있으면 "A는 3 문단을, B는 7 문단을 고쳤구나" 하고 각자의 변경을 분리해 합칠 수 있다. 원본이 없으면 두 결과물을 글자 단위로 비교하는 수밖에 없고, 조금만 겹쳐도 "둘 중 뭐가 맞아?"라며 충돌이 난다.

여기서 원본에 해당하는 것이 merge base(공통 조상) 다. 두 브랜치가 갈라지기 직전의 공통 커밋이며, git mergegit cherry-pick 모두 이 기준점을 잡아 "양쪽이 각각 무엇을 바꿨는가"를 계산한다. 좋은 공통 조상이 있으면 겹치는 변경도 자동으로 풀리고, 공통 조상이 어긋나면 충돌이 난다.

세 가지 머지 방식을 한 줄씩 정의하면:

  • merge --no-ff: 머지 커밋을 새로 만들어 양쪽 부모를 모두 기록한다. 히스토리 그래프(조상 관계)가 그대로 보존된다.
  • squash merge: feature의 커밋 여러 개를 단일 커밋으로 압축해 대상 브랜치에 얹는다. 이 커밋은 원본 커밋들과의 조상 링크가 없다.
  • cherry-pick: 특정 커밋의 "부모와의 차이(diff)"를 현재 브랜치에 패치처럼 다시 적용한다. 실제로는 그 커밋의 부모를 base로 삼는 3-way 머지다.

핵심은, 이 세 가지가 merge base를 각각 다르게 다룬다는 점이다. 그리고 그 차이가 릴리즈 충돌의 발생 여부를 가른다.

3. 동작 원리 (How It Works)

3-1. squash가 공통 조상을 지운다

스쿼시 + cherry-pick 릴리즈: 조상 링크가 끊겨 공통 조상을 못 찾고 충돌하는 구조

 

그림 1. feature를 squash로 통합하면 단일 커밋 S가 생기는데, S는 f1·f2·f3와의 조상 관계가 끊겨 있다. 릴리즈 브랜치에 그 변경을 넣으려면 S를 cherry-pick 할 수밖에 없고, 이때 공통 조상이 어긋나 충돌이 난다.

feature 브랜치의 커밋 f1·f2·f3를 squash로 통합 브랜치에 머지하면 단일 커밋 S가 생긴다. 그런데 S의 부모는 통합 브랜치의 직전 커밋 하나뿐이다. f1·f2·f3S 사이에는 git이 추적할 수 있는 조상 관계가 존재하지 않는다. 원본 feature 브랜치의 커밋들은 영구 히스토리 그래프에서 사라지고, 압축된 S 하나만 남는다.

이제 릴리즈 브랜치를 main에서 잘라낸 뒤 그 변경을 넣는다고 해보자. feature 브랜치를 직접 머지하려 해도, 릴리즈와 feature의 공통 조상은 main인데 정작 그 변경은 S에만 있다. 결국 S를 cherry-pick 하는 길밖에 없다.

그리고 cherry-pick SS의 부모를 base로 삼는 3-way 머지다. 문제는 S의 부모(통합 브랜치 tip)에는 릴리즈 브랜치에는 없는 다른 squash 변경들이 섞여 있다는 것이다. base와 릴리즈의 맥락이 어긋나 있으니, 논리적으로는 "그 feature 변경만"인데도 주변 콘텍스트 불일치로 충돌이 난다. 게다가 git이 "이 변경은 이미 적용됐다"를 인식하는 수단(조상 추적, patch-id)이 squash로 깨져 있어, 같은 변경이 다른 경로로 들어와도 중복으로 보지 못하고 다시 적용하려다 또 충돌한다.

3-2. merge --no-ff는 공통 조상을 살린다

merge --no-ff 직접 머지: 공통 조상 main이 살아 있어 자동 3-way로 해소되는 구조

 

그림 2. feature와 릴리즈 모두 main에서 갈라져 공통 조상이 main으로 살아 있다. merge --no-ff는 두 부모를 가진 머지 커밋 M을 만들고, git은 main을 merge base로 잡아 겹치는 변경까지 자동으로 풀어낸다.

해법은 squash와 cherry-pick을 둘 다 버리는 것이다. 모든 feature 브랜치를 main에서 자르고, 릴리즈 브랜치도 main에서 자른다. 그러면 둘의 공통 조상은 항상 main 이다.

릴리즈 브랜치에 feature를 merge --no-ff로 직접 머지하면, 두 부모(릴리즈 tip + f3)를 가진 머지 커밋 M이 생긴다. 이제 git은 main을 merge base로 잡아 정상 3-way 머지를 수행한다. 양쪽이 같은 곳을 건드렸더라도 "둘 다 공통 조상 대비 같은 변경을 했네"를 인식하므로 겹치는 변경까지 자동으로 병합된다. 덤으로 커밋이 압축되지 않아 히스토리가 100% 보존된다(추적성).

요약하면, cherry-pick이 아니라 "원본 feature 브랜치를 그대로 머지" 하는 것이 핵심이다. 원본을 머지해야 공통 조상이 살고, 공통 조상이 살아야 3-way가 충돌을 자동으로 해소한다.

4. 실무 적용 (Practical Examples)

❌ 안티패턴 (Anti-Pattern): squash + cherry-pick 릴리즈

# 통합 브랜치: PR을 squash로 머지 → 커밋 압축, 조상 링크 끊김
git checkout develop
git merge --squash feature/awesome
git commit -m "feat: awesome"

# 릴리즈: main에서 자른 뒤 squash 커밋을 cherry-pick
git switch -c release/x main
git cherry-pick <squash커밋>     # ⚡ 매 릴리즈 1~3건 충돌

# 안 통하는 처방들 — 근본 원인(끊긴 조상)을 못 고친다
git cherry-pick -X theirs <squash커밋>
git cherry-pick -m 1 <squash머지커밋>

왜 문제인가: cherry-pick은 squash 커밋의 부모를 base로 삼는데, 그 base에는 릴리즈에 없는 변경이 섞여 있어 3-way 기준점이 어긋난다. -m, -x, -X theirs 같은 옵션은 충돌을 회피할 뿐 원인인 "끊긴 조상"을 복원하지 못한다.

✅ 권장 패턴 (Good Practice): main 기반 + merge --no-ff 직접 머지

# 모든 feature는 main에서 자른다 (공통 조상 = main)
git switch -c feature/awesome main
# ... 작업, 일상 통합용 PR은 develop으로 ...

# 릴리즈: main에서 자른 뒤, 포함할 feature 브랜치를 직접 --no-ff 머지
git switch -c release/x main
git merge --no-ff feature/awesome     # 공통 조상 main → 자동 3-way
git merge --no-ff feature/another

왜 되는가: 릴리즈와 각 feature의 공통 조상이 main으로 살아 있어, git이 정상 3-way 머지로 겹침을 자동 해소한다. squash 커밋을 패치로 재적용하는 게 아니라 원본 브랜치를 그대로 머지하는 것이 차이의 전부다.

🔍 실행 결과 (한 레포의 측정 사례)

한 프로덕션 프론트엔드 레포에서 전략을 전환한 뒤, 릴리즈 한 건(약 9개 PR 규모)을 생성해 측정한 결과다.

  • 포함 대상 9건 전부 자동 머지 성공, 수동 개입 0.
  • 자동 충돌 해소 2건(package.json, 자동 생성된 타입 선언 .d.ts 파일) — git 3-way가 스스로 처리.
  • 커밋 약 100개 전부 보존(squash였다면 9개로 압축됐을 분량).
  • 릴리즈 생성 충돌 1~3건 → 0건, 생성 체감 시간 약 5분 → 약 30초.

위 수치는 특정 레포·릴리즈 1건 기준의 측정치이며, 코드베이스 규모와 변경 분포에 따라 달라질 수 있다.

5. 장단점 및 고려사항

장점 단점
✓ 릴리즈 생성 충돌 대부분 자동 해소(공통 조상 보존) ✗ 머지 커밋이 늘어 히스토리 그래프가 복잡해 보임
✓ 커밋 히스토리·추적성 100% 보존 ✗ feature 브랜치를 릴리즈 시점까지 보존해야 함
✓ cherry-pick 옵션 튜닝이 필요 없음 ✗ "main은 모든 분기의 깨끗한 베이스" 불변식을 유지해야 함
✓ PR→develop, feature→release, release→main에 동일 전략 일관 적용 ✗ 단일 trunk + squash가 더 잘 맞는 팀에는 과할 수 있음

도입 체크리스트:

  • main을 모든 feature/release 분기의 공통 베이스로 고정한다.
  • 모든 머지를 --no-ff 일반 머지로 통일한다(squash·rebase·cherry-pick 배제).
  • feature 브랜치는 릴리즈에 포함될 때까지 살려두고, 릴리즈 완료 자동화에서 일괄 삭제한다.
  • 릴리즈 완료 후 main을 통합 브랜치로 동기화해 "통합 브랜치 = main + 진행 중 PR" 상태를 유지한다.
  • 히스토리 가독성은 git log --first-parent로 보완한다(머지 커밋만 따라 1차 흐름만 보기).

흔한 함정: "squash가 히스토리를 깔끔하게 해 준다"는 통념이다. 단일 trunk 모델에서는 맞는 말일 수 있지만, 릴리즈를 cherry-pick으로 조립하는 구조라면 그 깔끔함의 청구서가 매 릴리즈 충돌로 돌아온다. 전략은 팀의 릴리즈 방식과 함께 골라야 한다.

6. 결론 및 다음 단계 (Conclusion)

핵심 3줄 요약:

  1. 릴리즈 cherry-pick 충돌의 근본 원인은 cherry-pick이 아니라, 그 앞단의 squash가 공통 조상(merge base)을 지운 것이다.
  2. main 기반 + merge --no-ff 직접 머지로 공통 조상을 살리면, 3-way 머지가 겹치는 변경까지 자동으로 해소한다.
  3. 머지 전략 자체를 바꾸지 않으면 cherry-pick 옵션 튜닝은 부질없다.
반응형