Stacked PR
Structuring Large Changes as Small, Dependent Reviews
- Haram Lee
- 2026-08-24
- studies / Topics
기능이 크다고 PR도 커야 하는 것은 아니다.
한 작업에서 기반 구조를 바꾸고, 새 기능을 구현하고, 기존 호출부를 마이그레이션하고, 테스트까지 추가하다 보면 PR은 금방 커진다.
+3,000 lines
-800 lines
47 files changed이 정도 크기의 PR에서는 설계와 구현, 사소한 코드 문제가 한꺼번에 섞인다. 리뷰어는 전체 맥락을 머릿속에 올려야 하고, 앞부분의 설계가 바뀌면 뒷부분의 리뷰는 무의미해질 수 있다.
Stacked PR은 이 문제를 작업 순서는 유지하면서 리뷰 단위만 작게 나누는 방식으로 해결한다.
main
└─ PR 1: 빵 준비
└─ PR 2: 땅콩버터 추가
└─ PR 3: 잼 추가PR 2는 PR 1이 아직 main에 merge되지 않았더라도 PR 1의 변경이 이미 존재한다고 가정하고 작성할 수 있다. 작업은 계속 이어가되, 리뷰는 한 단계씩 받는 것이다.
일반 PR과 무엇이 다른가?
일반적으로 서로 독립적인 작업은 모두 main에서 브랜치를 만든다.
main
├─ feature-a
├─ feature-b
└─ feature-c각 브랜치가 다른 브랜치의 변경을 필요로 하지 않기 때문에 모든 PR의 base는 main이다.
Stacked PR에서는 후속 브랜치를 바로 앞 브랜치에서 만든다.
main
└─ feature-a
└─ feature-b
└─ feature-cPR의 base도 함께 달라진다.
| PR | 변경 내용 | Base | Head |
|---|---|---|---|
| PR 1 | 기반 구조 변경 | main | feature-a |
| PR 2 | 기능 구현 | feature-a | feature-b |
| PR 3 | 테스트 추가 | feature-b | feature-c |
PR 2에서 리뷰할 내용은 feature-a와 feature-b의 차이다. PR 1에 포함된 기반 구조를 다시 보여주지 않고, PR 2에서 새로 추가한 기능 구현만 보여줄 수 있다.
작업 단위: PR 1 → PR 2 → PR 3으로 계속 이어짐
리뷰 단위: 각 PR의 base 이후에 추가된 변경만 표시왜 사용하는가?
Stacked PR의 목적은 단순히 PR의 파일 수를 줄이는 것이 아니다. 각 PR이 하나의 명확한 질문에 답하도록 만드는 것에 가깝다.
PR 1: 이 인터페이스 설계가 적절한가?
PR 2: 구현이 인터페이스의 계약을 지키는가?
PR 3: 기존 파이프라인에 올바르게 연결됐는가?
PR 4: 실패 조건과 회귀 범위가 충분히 검증됐는가?리뷰어는 매번 전체 기능을 다시 해석하지 않아도 된다. 설계 문제와 구현 문제도 서로 다른 PR에서 다룰 수 있다.
작성자 입장에서도 PR 1의 리뷰를 기다리는 동안 PR 2와 PR 3을 계속 작성할 수 있다. 선행 변경이 확정되기 전까지 전체 작업을 멈추지 않아도 된다는 뜻이다.
큰 PR 하나
→ 작업과 리뷰가 한 번에 막힘
짧은 PR 여러 개
→ 앞 PR을 리뷰하는 동안 뒤 PR 작업을 계속 진행브랜치는 커밋을 가리키는 포인터다
Stacked PR은 별도의 Git 기능이 아니다. 브랜치와 커밋의 관계를 이용한 운영 방식이다.
A──B main
\
C──D feature-a
\
E──F feature-b각 브랜치는 특정 커밋을 가리키는 이름이다.
main → B
feature-a → D
feature-b → Ffeature-b가 feature-a의 결과를 별도로 복사해서 가지고 있는 것은 아니다. F에서 부모 커밋을 따라가면 D, C, B, A가 나오기 때문에 앞 브랜치의 변경을 포함한다.
F → E → D → C → B → AGitHub의 PR은 base와 head의 공통 조상 이후 head에 추가된 변경을 보여준다. 따라서 base가 feature-a, head가 feature-b라면 리뷰 대상은 E, F에 해당하는 변경이다. C, D는 base에도 이미 존재하므로 다시 나타나지 않는다.
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. 빵 준비
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로 만든다.
base: main
head: add-breadPR 2. 땅콩버터 추가
두 번째 브랜치는 main이 아니라 첫 번째 브랜치에서 만든다.
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도 첫 번째 브랜치로 지정한다.
base: add-bread
head: add-peanut-butterPR 3. 잼 추가
git switch add-peanut-butter
git switch -c add-jelly
# 작업
git add .
git commit -m "Add jelly"
git push -u origin add-jellybase: add-peanut-butter
head: add-jelly최종 구조는 다음과 같다.
main
└─ add-bread
└─ add-peanut-butter
└─ add-jelly중요한 것은 브랜치를 어디에서 만들었는지와 PR의 base를 어디로 지정했는지가 일치해야 한다는 점이다. 후속 브랜치를 앞 브랜치에서 만들고도 PR의 base를 main으로 두면, 앞 PR의 변경까지 모두 포함된 거대한 diff가 다시 나타난다.
앞 PR이 수정되면 어떻게 되는가?
Stacked PR에서 가장 자주 하는 작업은 앞 브랜치가 바뀐 뒤 뒤 브랜치를 다시 쌓는 것, 즉 restack이다.
PR 1 리뷰에서 빵 종류를 바꾸자는 의견을 받고 add-bread에 커밋 G를 추가했다고 해보자.
수정 전
B──C──D add-bread
\
E──F add-peanut-butterPR 1 수정 후
B──C──D──G add-bread
\
E──F add-peanut-butteradd-peanut-butter는 여전히 과거의 D에서 갈라져 있다. 최신 PR 1 변경인 G를 포함하려면 후속 커밋을 새 tip 위로 옮겨야 한다.
git switch add-peanut-butter
git rebase add-breadGit은 E, F의 변경을 떼어낸 뒤 G 위에 다시 적용한다.
B──C──D──G──E'──F'E', F'는 내용이 비슷해도 부모 커밋이 달라진 새로운 커밋이다. 따라서 해시도 달라지고, 이미 원격에 push한 브랜치라면 force push가 필요하다.
git push --force-with-lease단순 --force보다 --force-with-lease를 사용하는 편이 안전하다. 원격 브랜치가 마지막으로 확인한 상태와 같을 때만 덮어쓰기 때문에, 다른 사람이 그사이에 올린 커밋을 무심코 지우는 일을 막아준다.
Stack이 더 길다면 이 작업을 아래 방향으로 반복한다.
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으로 변경한다.
기존
base: add-bread
head: add-peanut-butter
변경
base: main
head: add-peanut-butter이때 후속 브랜치를 어떻게 정리할지는 PR 1의 merge 방식에 따라 달라진다.
Merge commit
Merge commit 방식은 PR 1의 기존 커밋을 그대로 main의 조상으로 포함한다.
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에 들어간 커밋의 해시가 달라질 수 있다.
add-bread: C──D
main: C'──D'후속 브랜치에서는 PR 1 이후의 커밋만 떼어 origin/main 위에 다시 올리는 편이 명확하다.
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의 여러 커밋을 하나의 새로운 커밋으로 합친다.
기존 브랜치: C──D
main: SS에는 C, D와 같은 최종 변경이 들어 있지만 Git 관점에서는 서로 다른 커밋이다. 커밋의 조상 관계가 끊겼기 때문에 PR 2의 base만 main으로 바꾸면 PR 1의 변경이 다시 diff에 나타날 수 있다.
이때도 PR 2에만 있는 커밋을 명시적으로 옮긴다.
git fetch origin
git switch add-peanut-butter
git rebase --onto origin/main add-bread add-peanut-butter
git push --force-with-leasegit rebase --onto는 처음 보면 복잡하지만 세 지점만 보면 된다.
git rebase --onto <새로운 기반> <제외할 이전 기반> <옮길 브랜치>새로운 기반 → origin/main
제외할 이전 기반 → add-bread
옮길 브랜치 → add-peanut-butter즉, merge 방식이 커밋 해시를 바꿨다면 이미 main에 반영된 PR 1의 커밋은 제외하고 PR 2의 커밋만 이동시키는 것이다.
Merge보다 rebase를 선호하는 이유
앞 브랜치의 변경을 후속 브랜치에 가져올 때 merge를 사용할 수도 있다.
git switch add-peanut-butter
git merge add-bread하지만 stack을 갱신할 때마다 merge commit이 추가되면 브랜치의 선후관계가 빠르게 복잡해진다.
C──D──────G
\ /
E──Frebase는 후속 커밋을 최신 선행 브랜치 위로 이동시켜 구조를 선형으로 유지한다.
C──D──G──E'──F'어느 PR이 어느 PR에 의존하는지 커밋 그래프에서 바로 읽을 수 있다. 그래서 개인이 관리하는 feature stack에서는 보통 rebase가 더 잘 맞는다.
반대로 여러 명이 같은 브랜치에 commit하는 상황이라면 주의해야 한다. rebase와 force push는 공유된 커밋의 해시를 바꾼다. Stacked PR은 각 브랜치의 소유자가 명확하고, force push 규칙에 대한 팀의 합의가 있을 때 운영하기 쉽다.
좋은 Stack을 나누는 기준
Stack은 파일 수를 균등하게 나누는 방식이 아니다.
애매한 분리
PR 1: 파일 1~5
PR 2: 파일 6~10
PR 3: 나머지 파일각 PR은 리뷰 가능한 논리 단위여야 한다.
좋은 분리
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이 너무 길어지는 것도 좋지 않다.
2~5개 PR
→ 관계를 파악하고 restack하기 쉬움
10개 이상의 PR
→ 앞부분 변경의 영향이 너무 멀리 전파됨정확한 숫자보다 중요한 것은 선행 PR이 바뀌었을 때 감당할 수 있는 범위인가 하는 점이다.
PR 본문에 Stack 관계 남기기
리뷰어가 Git 그래프를 직접 추적하게 만들 필요는 없다. 각 PR 본문 상단에 현재 위치와 merge 순서를 적어두는 편이 좋다.
## 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은 큰 변경을 여러 브랜치에 복사해두는 방식이 아니다.
1. 후속 브랜치를 이전 브랜치에서 만든다.
2. 각 PR의 base를 바로 이전 브랜치로 둔다.
3. 선행 브랜치가 바뀌면 후속 커밋을 다시 쌓는다.결국 본질은 하나다.
작업은 계속 이어서 하되, 리뷰는 작은 단위로 받는다.
브랜치와 PR의 base를 계단식으로 연결하면 큰 기능도 설계, 구현, 연결, 검증의 순서로 나누어 리뷰할 수 있다. 다만 그 대가로 작성자가 커밋 관계와 restack을 관리해야 한다.
리뷰 비용과 브랜치 관리 비용 중 어느 쪽이 더 큰지 판단하고, 의존 관계가 분명한 큰 변경에 선택적으로 사용하는 것이 좋다.