Stacked PR

Structuring Large Changes as Small, Dependent Reviews

  • Haram Lee
  • 2026-08-24
  • studies / Topics

기능이 크다고 PR도 커야 하는 것은 아니다.

한 작업에서 기반 구조를 바꾸고, 새 기능을 구현하고, 기존 호출부를 마이그레이션하고, 테스트까지 추가하다 보면 PR은 금방 커진다.

text
+3,000 lines
-800 lines
47 files changed

이 정도 크기의 PR에서는 설계와 구현, 사소한 코드 문제가 한꺼번에 섞인다. 리뷰어는 전체 맥락을 머릿속에 올려야 하고, 앞부분의 설계가 바뀌면 뒷부분의 리뷰는 무의미해질 수 있다.

Stacked PR은 이 문제를 작업 순서는 유지하면서 리뷰 단위만 작게 나누는 방식으로 해결한다.

text
main
 └─ PR 1: 빵 준비
     └─ PR 2: 땅콩버터 추가
         └─ PR 3: 잼 추가

PR 2는 PR 1이 아직 main에 merge되지 않았더라도 PR 1의 변경이 이미 존재한다고 가정하고 작성할 수 있다. 작업은 계속 이어가되, 리뷰는 한 단계씩 받는 것이다.

일반 PR과 무엇이 다른가?

일반적으로 서로 독립적인 작업은 모두 main에서 브랜치를 만든다.

text
main
 ├─ feature-a
 ├─ feature-b
 └─ feature-c

각 브랜치가 다른 브랜치의 변경을 필요로 하지 않기 때문에 모든 PR의 base는 main이다.

Stacked PR에서는 후속 브랜치를 바로 앞 브랜치에서 만든다.

text
main
 └─ feature-a
     └─ feature-b
         └─ feature-c

PR의 base도 함께 달라진다.

PR변경 내용BaseHead
PR 1기반 구조 변경mainfeature-a
PR 2기능 구현feature-afeature-b
PR 3테스트 추가feature-bfeature-c

PR 2에서 리뷰할 내용은 feature-afeature-b의 차이다. PR 1에 포함된 기반 구조를 다시 보여주지 않고, PR 2에서 새로 추가한 기능 구현만 보여줄 수 있다.

text
작업 단위: PR 1 → PR 2 → PR 3으로 계속 이어짐
리뷰 단위: 각 PR의 base 이후에 추가된 변경만 표시

왜 사용하는가?

Stacked PR의 목적은 단순히 PR의 파일 수를 줄이는 것이 아니다. 각 PR이 하나의 명확한 질문에 답하도록 만드는 것에 가깝다.

text
PR 1: 이 인터페이스 설계가 적절한가?
PR 2: 구현이 인터페이스의 계약을 지키는가?
PR 3: 기존 파이프라인에 올바르게 연결됐는가?
PR 4: 실패 조건과 회귀 범위가 충분히 검증됐는가?

리뷰어는 매번 전체 기능을 다시 해석하지 않아도 된다. 설계 문제와 구현 문제도 서로 다른 PR에서 다룰 수 있다.

작성자 입장에서도 PR 1의 리뷰를 기다리는 동안 PR 2와 PR 3을 계속 작성할 수 있다. 선행 변경이 확정되기 전까지 전체 작업을 멈추지 않아도 된다는 뜻이다.

text
큰 PR 하나
→ 작업과 리뷰가 한 번에 막힘

짧은 PR 여러 개
→ 앞 PR을 리뷰하는 동안 뒤 PR 작업을 계속 진행

브랜치는 커밋을 가리키는 포인터다

Stacked PR은 별도의 Git 기능이 아니다. 브랜치와 커밋의 관계를 이용한 운영 방식이다.

text
A──B              main
    \
     C──D         feature-a
         \
          E──F    feature-b

각 브랜치는 특정 커밋을 가리키는 이름이다.

text
main      → B
feature-a → D
feature-b → F

feature-bfeature-a의 결과를 별도로 복사해서 가지고 있는 것은 아니다. F에서 부모 커밋을 따라가면 D, C, B, A가 나오기 때문에 앞 브랜치의 변경을 포함한다.

text
F → E → D → C → B → A

GitHub의 PR은 base와 head의 공통 조상 이후 head에 추가된 변경을 보여준다. 따라서 base가 feature-a, head가 feature-b라면 리뷰 대상은 E, F에 해당하는 변경이다. C, D는 base에도 이미 존재하므로 다시 나타나지 않는다.

text
base: feature-a ── D
head: feature-b ── D──E──F

PR diff: E──F

이 관계가 Stacked PR의 핵심이다.

Stack을 직접 만드는 방법

땅콩버터와 잼 샌드위치를 세 단계로 만든다고 해보자.

아래 과정은 GitHub의 stack 기능이나 Graphite 같은 전용 도구를 사용하지 않고, 일반 Git 명령과 PR의 base 설정만으로 stack을 만드는 방법이다.

PR 1. 빵 준비

bash
git switch main
git pull

git switch -c add-bread
# 작업
git add .
git commit -m "Add bread"
git push -u origin add-bread

첫 PR은 main을 base로 만든다.

text
base: main
head: add-bread

PR 2. 땅콩버터 추가

두 번째 브랜치는 main이 아니라 첫 번째 브랜치에서 만든다.

bash
git switch add-bread
git switch -c add-peanut-butter

# 후속 작업
git add .
git commit -m "Add peanut butter"
git push -u origin add-peanut-butter

두 번째 PR의 base도 첫 번째 브랜치로 지정한다.

text
base: add-bread
head: add-peanut-butter

PR 3. 잼 추가

bash
git switch add-peanut-butter
git switch -c add-jelly

# 작업
git add .
git commit -m "Add jelly"
git push -u origin add-jelly
text
base: add-peanut-butter
head: add-jelly

최종 구조는 다음과 같다.

text
main
 └─ add-bread
     └─ add-peanut-butter
         └─ add-jelly

중요한 것은 브랜치를 어디에서 만들었는지와 PR의 base를 어디로 지정했는지가 일치해야 한다는 점이다. 후속 브랜치를 앞 브랜치에서 만들고도 PR의 base를 main으로 두면, 앞 PR의 변경까지 모두 포함된 거대한 diff가 다시 나타난다.

앞 PR이 수정되면 어떻게 되는가?

Stacked PR에서 가장 자주 하는 작업은 앞 브랜치가 바뀐 뒤 뒤 브랜치를 다시 쌓는 것, 즉 restack이다.

PR 1 리뷰에서 빵 종류를 바꾸자는 의견을 받고 add-bread에 커밋 G를 추가했다고 해보자.

text
수정 전

B──C──D          add-bread
      \
       E──F      add-peanut-butter
text
PR 1 수정 후

B──C──D──G       add-bread
      \
       E──F      add-peanut-butter

add-peanut-butter는 여전히 과거의 D에서 갈라져 있다. 최신 PR 1 변경인 G를 포함하려면 후속 커밋을 새 tip 위로 옮겨야 한다.

bash
git switch add-peanut-butter
git rebase add-bread

Git은 E, F의 변경을 떼어낸 뒤 G 위에 다시 적용한다.

text
B──C──D──G──E'──F'

E', F'는 내용이 비슷해도 부모 커밋이 달라진 새로운 커밋이다. 따라서 해시도 달라지고, 이미 원격에 push한 브랜치라면 force push가 필요하다.

bash
git push --force-with-lease

단순 --force보다 --force-with-lease를 사용하는 편이 안전하다. 원격 브랜치가 마지막으로 확인한 상태와 같을 때만 덮어쓰기 때문에, 다른 사람이 그사이에 올린 커밋을 무심코 지우는 일을 막아준다.

Stack이 더 길다면 이 작업을 아래 방향으로 반복한다.

text
PR 1 수정
→ PR 2 restack
  → PR 3 restack
    → PR 4 restack

단, PR 2를 rebase하면 PR 2의 커밋 해시가 바뀐다. PR 3을 옮길 때는 기존 PR 2 이후의 커밋만 선택해서 새 PR 2 위에 올려야 한다. 깊은 stack을 수동으로 관리할 때 git rebase --onto나 전용 Stacked PR 도구가 필요한 이유다.

첫 번째 PR이 merge되면?

PR 1이 main에 들어가면 PR 2의 base를 add-bread에서 main으로 변경한다.

text
기존
base: add-bread
head: add-peanut-butter

변경
base: main
head: add-peanut-butter

이때 후속 브랜치를 어떻게 정리할지는 PR 1의 merge 방식에 따라 달라진다.

Merge commit

Merge commit 방식은 PR 1의 기존 커밋을 그대로 main의 조상으로 포함한다.

text
A──B───────M       main
    \     /
     C───D         add-bread
          \
           E──F    add-peanut-butter

커밋 관계가 유지되므로 보통 PR 2의 base를 main으로 바꾸는 것만으로 충분하다. 팀 규칙상 선형 히스토리가 필요하다면 그다음에 main 위로 rebase할 수 있지만, 중복 변경을 제거하기 위해 반드시 필요한 작업은 아니다.

Rebase and merge

Rebase and merge는 PR 1의 변경을 main 위에 다시 적용한다. 내용은 같지만 add-bread에 있던 커밋과 main에 들어간 커밋의 해시가 달라질 수 있다.

text
add-bread: C──D
main:      C'──D'

후속 브랜치에서는 PR 1 이후의 커밋만 떼어 origin/main 위에 다시 올리는 편이 명확하다.

bash
git fetch origin
git switch add-peanut-butter
git rebase --onto origin/main add-bread add-peanut-butter
git push --force-with-lease

이 명령의 의미는 다음과 같다.

add-bread 이후 add-peanut-butter에만 있는 커밋을 origin/main 위에 다시 올린다.

Squash and merge

Squash and merge는 PR 1의 여러 커밋을 하나의 새로운 커밋으로 합친다.

text
기존 브랜치: C──D
main:        S

S에는 C, D와 같은 최종 변경이 들어 있지만 Git 관점에서는 서로 다른 커밋이다. 커밋의 조상 관계가 끊겼기 때문에 PR 2의 base만 main으로 바꾸면 PR 1의 변경이 다시 diff에 나타날 수 있다.

이때도 PR 2에만 있는 커밋을 명시적으로 옮긴다.

bash
git fetch origin
git switch add-peanut-butter
git rebase --onto origin/main add-bread add-peanut-butter
git push --force-with-lease

git rebase --onto는 처음 보면 복잡하지만 세 지점만 보면 된다.

text
git rebase --onto <새로운 기반> <제외할 이전 기반> <옮길 브랜치>
text
새로운 기반      → origin/main
제외할 이전 기반 → add-bread
옮길 브랜치      → add-peanut-butter

즉, merge 방식이 커밋 해시를 바꿨다면 이미 main에 반영된 PR 1의 커밋은 제외하고 PR 2의 커밋만 이동시키는 것이다.

Merge보다 rebase를 선호하는 이유

앞 브랜치의 변경을 후속 브랜치에 가져올 때 merge를 사용할 수도 있다.

bash
git switch add-peanut-butter
git merge add-bread

하지만 stack을 갱신할 때마다 merge commit이 추가되면 브랜치의 선후관계가 빠르게 복잡해진다.

text
C──D──────G
    \     /
     E──F

rebase는 후속 커밋을 최신 선행 브랜치 위로 이동시켜 구조를 선형으로 유지한다.

text
C──D──G──E'──F'

어느 PR이 어느 PR에 의존하는지 커밋 그래프에서 바로 읽을 수 있다. 그래서 개인이 관리하는 feature stack에서는 보통 rebase가 더 잘 맞는다.

반대로 여러 명이 같은 브랜치에 commit하는 상황이라면 주의해야 한다. rebase와 force push는 공유된 커밋의 해시를 바꾼다. Stacked PR은 각 브랜치의 소유자가 명확하고, force push 규칙에 대한 팀의 합의가 있을 때 운영하기 쉽다.

좋은 Stack을 나누는 기준

Stack은 파일 수를 균등하게 나누는 방식이 아니다.

text
애매한 분리

PR 1: 파일 1~5
PR 2: 파일 6~10
PR 3: 나머지 파일

각 PR은 리뷰 가능한 논리 단위여야 한다.

text
좋은 분리

PR 1: 공통 인터페이스와 타입 정의
PR 2: 기존 구현을 새 인터페이스로 마이그레이션
PR 3: 새로운 기능 구현
PR 4: 실제 호출부에 기능 연결
PR 5: 운영 설정과 문서 추가

분리할 때는 다음 질문을 확인한다.

  • 이 PR의 목적을 한 문장으로 설명할 수 있는가?
  • 선행 PR과의 의존 관계가 명확한가?
  • 이 PR만 checkout해도 빌드와 테스트가 가능한가?
  • 리뷰어가 다음 PR을 읽지 않고도 현재 PR을 판단할 수 있는가?
  • 문제가 생겼을 때 이 PR만 revert할 수 있는가?

테스트를 전부 마지막 PR로 미루는 방식도 피하는 편이 좋다. 각 PR에서 추가하거나 바꾼 동작은 가능하면 같은 PR에서 검증해야 한다. 그래야 중간 stack도 독립적으로 신뢰할 수 있다.

운영 비용도 있다

Stacked PR은 리뷰 비용을 줄이는 대신 작성자에게 브랜치 관리 비용을 넘긴다.

  • 앞 PR이 수정되면 후속 브랜치를 차례대로 restack해야 함
  • 같은 충돌을 stack의 여러 단계에서 해결할 수 있음
  • base를 잘못 지정하면 앞 PR의 변경이 중복해서 보임
  • squash 또는 rebase merge 후 커밋 관계를 다시 정리해야 함
  • 아래 PR부터 순서대로 merge해야 함
  • 앞 PR의 설계가 크게 바뀌면 후속 PR도 다시 작성해야 할 수 있음

따라서 모든 변경을 stack으로 만들 필요는 없다. 서로 독립적인 작업이라면 각각 main에서 브랜치를 만드는 편이 낫다. 의존 관계가 있는 큰 기능을 작은 리뷰 단위로 나눌 때 Stacked PR의 장점이 가장 크다.

Stack이 너무 길어지는 것도 좋지 않다.

text
2~5개 PR
→ 관계를 파악하고 restack하기 쉬움

10개 이상의 PR
→ 앞부분 변경의 영향이 너무 멀리 전파됨

정확한 숫자보다 중요한 것은 선행 PR이 바뀌었을 때 감당할 수 있는 범위인가 하는 점이다.

PR 본문에 Stack 관계 남기기

리뷰어가 Git 그래프를 직접 추적하게 만들 필요는 없다. 각 PR 본문 상단에 현재 위치와 merge 순서를 적어두는 편이 좋다.

markdown
## Stack

1. #101 Add bread
2. #102 Add peanut butter ← this PR
3. #103 Add jelly

## Review guide

Depends on #101.
Only the peanut butter layer is in scope.
Merge order: #101#102#103

후속 PR은 Draft로 먼저 올려도 된다. 선행 PR이 확정되기 전에 CI를 실행하고 전체 방향을 공유할 수 있으며, 지금 리뷰해야 하는 PR과 아직 변경될 수 있는 PR도 구분할 수 있다.

실전 체크리스트

Stack을 만들 때

  • 후속 브랜치를 바로 앞 브랜치에서 생성했는가?
  • 각 PR의 base가 바로 앞 브랜치로 설정됐는가?
  • 각 PR이 하나의 논리적 목적만 가지는가?
  • 중간 PR도 빌드되고 관련 테스트를 통과하는가?
  • PR 본문에 의존 관계와 merge 순서를 적었는가?

앞 PR이 바뀌었을 때

  • 바로 다음 브랜치부터 아래 방향으로 restack했는가?
  • 충돌 해결 후 테스트를 다시 실행했는가?
  • --force 대신 --force-with-lease를 사용했는가?
  • GitHub에서 각 PR의 diff가 자기 변경만 포함하는지 확인했는가?

PR이 merge됐을 때

  • 후속 PR의 base를 main으로 변경했는가?
  • merge 방식이 커밋 해시를 보존하는지 확인했는가?
  • squash 또는 rebase merge라면 후속 커밋만 --onto로 옮겼는가?
  • 다음 PR을 merge하기 전에 CI와 diff를 다시 확인했는가?

정리

Stacked PR은 큰 변경을 여러 브랜치에 복사해두는 방식이 아니다.

text
1. 후속 브랜치를 이전 브랜치에서 만든다.
2. 각 PR의 base를 바로 이전 브랜치로 둔다.
3. 선행 브랜치가 바뀌면 후속 커밋을 다시 쌓는다.

결국 본질은 하나다.

작업은 계속 이어서 하되, 리뷰는 작은 단위로 받는다.

브랜치와 PR의 base를 계단식으로 연결하면 큰 기능도 설계, 구현, 연결, 검증의 순서로 나누어 리뷰할 수 있다. 다만 그 대가로 작성자가 커밋 관계와 restack을 관리해야 한다.

리뷰 비용과 브랜치 관리 비용 중 어느 쪽이 더 큰지 판단하고, 의존 관계가 분명한 큰 변경에 선택적으로 사용하는 것이 좋다.

Discussion