Skip to content
Published on

GitHub Stacked PR 완전 가이드 — 네이티브 스택 PR이 해결한 것과 그대로 남긴 것

공유하기
Authors

들어가며 — 1,400줄짜리 PR을 리뷰해 달라는 요청

리뷰 요청이 왔는데 변경 파일이 47개, 추가된 줄이 1,400줄입니다. 마이그레이션·리포지토리 계층·API 핸들러·프론트엔드 폼이 한 장에 다 들어 있습니다. 리뷰어가 할 수 있는 선택은 사실상 셋뿐입니다. 이틀을 통째로 비우거나, 대충 승인하거나, "쪼개 주세요"라고 답하는 것. 세 번째를 고르면 이번에는 작성자가 곤란해집니다 — git 위에서 의존 관계가 있는 변경을 여러 PR로 자르고, 리뷰 피드백이 아래쪽 조각에 들어올 때마다 위쪽 전부를 다시 리베이스해야 하기 때문입니다.

2026년 7월 30일, GitHub이 Stacked Pull Requests를 퍼블릭 프리뷰로 공개했습니다. 며칠에 걸쳐 모든 리포지토리로 순차 롤아웃되며, 웹·모바일·CLI에서 모두 동작합니다. Hacker News 스레드는 수백 개의 댓글이 달릴 만큼 반응이 컸는데, 흐름은 "드디어"와 "Phabricator·Gerrit이 10년 전에 하던 것"으로 갈렸습니다. 정확한 투표 수는 시점에 따라 달라지므로 여기서는 숫자를 인용하지 않겠습니다.

이 글은 발표 요약이 아니라 스택 워크플로 자체에 대한 안내서입니다. 스택이 실제로 해결하는 문제, GitHub 구현이 하는 일과 하지 않는 일, 그리고 스택의 진짜 비용인 리베이스 캐스케이드를 순서대로 봅니다. PR을 쪼개는 일반론은 머지되는 PR과 커밋 쓰기 편에서 다뤘으니, 여기서는 스택이라는 구조에 집중합니다.

스택이 해결하는 문제 — 리뷰 단위를 의존 순서대로 자른다

큰 변경을 작게 쪼개라는 조언은 오래됐지만, 실무에서 자주 실패합니다. 이유는 단순합니다. 의존 관계가 있는 변경은 그냥 쪼갤 수 없습니다. 스키마 마이그레이션 없이는 리포지토리 계층이 컴파일되지 않고, 리포지토리 없이는 API 핸들러가 동작하지 않습니다. 브랜치를 셋으로 나누면 두 번째 브랜치는 main이 아니라 첫 번째 브랜치를 베이스로 삼아야 하는데, 그 순간 PR 화면에는 첫 번째 브랜치의 변경까지 전부 섞여 나옵니다.

스택은 이 문제를 "베이스를 트렁크가 아니라 아래 층으로 둔다"는 한 가지 규칙으로 풉니다.

main
 └── feat/schema        PR #1   base: main
      └── feat/repo     PR #2   base: feat/schema
           └── feat/api PR #3   base: feat/repo
                └── feat/ui PR #4  base: feat/api

이 구조가 주는 것은 세 가지입니다.

  • 리뷰 diff의 축소. PR #3을 열면 API 핸들러 변경만 보입니다. 아래 두 층은 이미 베이스에 포함돼 있으므로 diff에서 빠집니다.
  • 리뷰 병렬화. DBA는 #1을, 백엔드 리뷰어는 #2와 #3을, 프론트엔드는 #4를 동시에 봅니다. 순차 대기가 사라집니다.
  • 부분 착륙. #1과 #2가 승인되면 위쪽 리뷰가 끝나기 전에 먼저 병합할 수 있습니다. 리뷰 지연이 전체를 붙잡지 않습니다.

반대로 스택이 만들어 내는 것도 하나 있습니다. 아래 층이 바뀔 때마다 위쪽 전부를 다시 정렬해야 한다는 것 — 뒤에서 따로 봅니다.

GitHub 네이티브 스택이 실제로 하는 일

체인지로그와 gh-stack 저장소 기준으로, 이번 프리뷰가 서버 쪽에서 제공하는 것은 크게 넷입니다.

첫째, 스택 맵입니다. 각 PR 상단에 이 PR이 스택의 어느 층인지, 위아래에 무엇이 있는지 보여 주는 지도가 붙습니다. 리뷰어가 "이 변경이 전체에서 어디쯤인가"를 PR 설명의 수기 체크리스트가 아니라 UI에서 확인합니다. 지금까지 스택 도구들이 PR 본문에 마크다운 목록을 자동 삽입해 흉내 내던 부분이 서버 기능이 됐습니다.

둘째, 층별 diff입니다. 리뷰어가 PR을 열면 그 층의 변경만 표시됩니다. 이건 사실 베이스 브랜치가 아래 층으로 설정돼 있으면 원래 그렇게 동작하지만, 아래 층이 병합되거나 리베이스된 뒤에도 그 상태가 유지되도록 서버가 관리해 준다는 점이 다릅니다.

셋째, 두 가지 병합 방식입니다.

  • 준비된 최상단 PR을 병합하면 그 아래의 미병합 층이 한 번의 작업으로 함께 착륙합니다.
  • 아래쪽 층만 먼저 병합하면 위쪽 PR들이 자동으로 리베이스되고 베이스가 재조준됩니다. 지금까지 손으로 하거나 CLI 도구에 맡기던 부분입니다.

넷째, 기존 보호 규칙과의 호환입니다. 브랜치 보호, 필수 체크, 리뷰 규칙이 스택의 각 PR에 그대로 적용됩니다. 머지 큐 지원은 "몇 주에 걸쳐 점진 롤아웃"이라고만 적혀 있어, 이 글 시점에서 모든 리포지토리에서 동작한다고 단정할 수 없습니다.

여기서 분명히 해 둘 것이 있습니다. GitHub의 스택은 브랜치 단위입니다. 커밋 단위 리뷰가 아닙니다. HN 스레드에서 가장 반복된 비판이 정확히 이 지점이었습니다 — Phabricator나 Gerrit처럼 커밋 하나가 곧 리뷰 단위이고 인터디프(interdiff)를 볼 수 있는 모델을 기대했다면, 이번 프리뷰는 그것이 아닙니다. 층마다 브랜치를 만들어야 하고, 강제 푸시로 diff가 바뀌면 리뷰 코멘트가 사라지는 GitHub의 기존 동작도 그대로입니다.

gh-stack — 명령 단위 워크플로

CLI 확장이 로컬 쪽을 담당합니다. 스택 메타데이터는 .git/gh-stack에 JSON으로 저장되며 커밋되지 않습니다. 즉 스택의 순서 정보는 로컬 상태이고, 서버는 PR의 베이스 관계로 스택을 인식합니다.

# 설치 (gh 2.0 이상)
gh extension install github/gh-stack

# 1) 트렁크에서 스택 시작
git switch main && git pull --ff-only
gh stack init feat/schema
git commit -am "add nullable columns for new pricing model"

# 2) 층 쌓기 — 현재 브랜치 위에 새 브랜치를 만든다
gh stack add feat/repo
git commit -am "repository reads new columns behind a flag"
gh stack add feat/api
git commit -am "expose pricing endpoint"

# 3) 현재 스택 확인
gh stack view

# 4) 세 층을 PR로 만들고 서로 연결
gh stack submit

리뷰 피드백이 아래층에 들어왔을 때가 스택의 본론입니다.

# 아래로 두 칸 내려가 수정
gh stack down
gh stack down
git commit -am "address review: keep columns nullable for one release"

# 페치 → 캐스케이드 리베이스 → 푸시 → PR 상태 동기화를 한 번에
gh stack sync

# 리베이스만 따로 돌리고 싶다면
gh stack rebase

# 층 순서를 바꾸거나 합치기 (선형 히스토리 필요)
gh stack modify

# 준비된 지점까지 원자적으로 병합
gh stack merge

이동 명령이 따로 있다는 점이 실사용에서 생각보다 큽니다. gh stack up / down / top / bottom / trunk로 층 사이를 오가고, gh stack checkout으로 특정 PR 번호나 브랜치로 바로 갑니다. 여러 스택에 동시에 속한 브랜치를 지목하면 명령이 종료 코드 6으로 모호성을 알리므로, 스크립트에서 이 코드를 분기 조건으로 쓸 수 있습니다.

문서에 적힌 제약도 미리 알아 둘 값어치가 있습니다. gh stack push원자적이지 않습니다. 브랜치별로 --force-with-lease를 걸어 순차 푸시하므로, 중간에 한 브랜치가 거부되면 그 브랜치만 실패한 상태로 남고 다시 실행해야 합니다. 로컬 스택과 원격이 크게 갈라진 경우 sync는 대화형 모드에서만 해결을 물어보므로, CI에서 무인 실행할 대상이 아닙니다.

리베이스 캐스케이드 — 스택의 진짜 비용

스택의 비용은 브랜치를 여러 개 만드는 수고가 아닙니다. 아래가 움직이면 위가 전부 흔들린다는 구조 자체입니다.

높이 n짜리 스택에서 바닥 층을 한 번 수정하면, 그 위 n-1개 브랜치를 순서대로 리베이스해야 합니다. 이때 같은 충돌이 층마다 반복해서 나타나는 일이 흔합니다. 바닥에서 함수 시그니처를 바꿨다면 그 함수를 쓰는 모든 상위 층에서 같은 모양의 충돌을 다시 풀어야 하니까요. 도구가 자동화해 주는 것은 리베이스의 실행이지 충돌의 판단이 아닙니다.

이 비용을 줄이는 수단은 도구와 무관하게 셋입니다.

# 1) 같은 충돌을 두 번 풀지 않기 — rerere는 해결 결과를 기록해 재사용한다
git config --global rerere.enabled true

# 2) 리베이스가 중간 브랜치 참조를 함께 옮기게 하기 (Git 2.38+)
git config --global rebase.updateRefs true

# 3) 베이스가 바뀐 경우의 정확한 이식 — 옮길 구간을 명시한다
git rebase --onto origin/main <이전-베이스-커밋> feat/api

rerere는 스택 워크플로에서 체감 차이가 가장 큰 설정입니다. 동일한 충돌 해결을 층마다 반복 입력하던 작업이 사라집니다. rebase.updateRefs는 Git 2.38에서 들어온 옵션으로, 한 번의 리베이스로 스택 전체의 브랜치 포인터를 함께 전진시킵니다 — 사실상 순수 git만으로 하는 최소한의 스택 지원입니다.

그리고 캐스케이드가 발생할 때마다 위쪽 PR의 CI가 전부 다시 돕니다. 높이 5짜리 스택에서 바닥을 세 번 고치면 CI 실행은 15회입니다. 여기에 머지 큐가 붙으면 큐 진입 시 한 번 더 돌 수 있습니다. 스택이 리뷰 시간은 줄이지만 CI 비용은 늘린다는 사실은 도입 전에 계산해 둘 항목입니다.

마지막으로 협업 측면의 함정 하나. 스택 브랜치는 강제 푸시가 일상입니다. 다른 사람이 같은 브랜치에 커밋을 얹었다면 그 커밋이 날아갈 수 있습니다. --force-with-lease가 기본 방어선이지만 만능은 아니므로, 스택 브랜치는 단일 작성자 소유라는 규칙을 팀에서 명시하는 편이 안전합니다.

기존 도구들과 무엇이 다른가

스택은 GitHub이 처음 만든 개념이 아닙니다. 오히려 이 영역은 도구가 과잉 공급된 쪽에 가까웠습니다.

도구리뷰 단위상태를 어디에 두는가캐스케이드 자동화서버 측 통합비용
GitHub 네이티브 + gh-stack브랜치로컬 .git/gh-stack + 서버의 PR 베이스 관계sync·rebase 명령, 하위 병합 시 서버가 자동 재조준스택 맵, 층별 diff, 원자적 병합프리뷰 기간 무료
Graphite브랜치자체 서비스 + 로컬 CLIgt sync·restack로 자동자체 웹 리뷰 UI, 머지 큐유료 SaaS(무료 티어 있음)
ghstack커밋커밋 메시지에 심은 식별자커밋 스택을 통째로 다시 밀어 넣는 방식없음(PR만 생성)오픈소스
git-branchless커밋로컬 이벤트 로그git move, git sync로 부분 트리 이동없음오픈소스
Sapling커밋자체 VCS 상태스택 편집이 기본 동작GitHub PR 생성 지원오픈소스
순수 git브랜치없음rebase.updateRefs없음무료

핵심 차이는 두 줄로 정리됩니다. 커밋 단위 도구(ghstack, git-branchless, Sapling, 그리고 정신적 후계인 jj)는 "커밋 하나가 곧 리뷰 하나"라는 모델을 취하고 브랜치를 사용자에게서 숨깁니다. 브랜치 단위 도구(Graphite, GitHub 네이티브)는 GitHub의 PR 모델을 그대로 두고 그 위에 스택 개념을 얹습니다.

GitHub이 브랜치 단위를 고른 것은 기존 보호 규칙·필수 체크·CODEOWNERS와 충돌 없이 얹을 수 있는 유일한 선택이었기 때문으로 보입니다. 대신 커밋 단위 리뷰의 장점 — 인터디프, 커밋 히스토리 보존, 재작성 후에도 살아남는 리뷰 코멘트 — 은 얻지 못했습니다. HN 스레드의 비판은 대부분 이 교환에 대한 것이었습니다.

이미 Graphite를 쓰고 있다면 당장 옮겨야 할 이유는 크지 않습니다. 자동 리베이스와 스택 시각화는 양쪽 다 하고, Graphite 쪽이 머지 큐와 리뷰 UI에서 더 성숙합니다. 반대로 아직 아무 도구도 없이 순수 git으로 버티고 있었다면, 네이티브 스택은 도입 마찰이 가장 낮은 선택지입니다 — 새 계정도, 새 웹 UI도, 새 권한 승인도 필요 없습니다.

스택이 손해인 경우

스택은 공짜가 아니고, 모든 팀에 이득도 아닙니다. 다음 경우에는 쓰지 않는 편이 낫습니다.

변경이 실제로는 의존적이지 않을 때. 서로 독립적인 세 가지 수정이라면 그냥 PR 세 장을 각각 main 위에 만들면 됩니다. 스택으로 묶는 순간 존재하지 않던 순서 의존이 생기고, 바닥 PR이 리뷰에서 막히면 나머지가 함께 발이 묶입니다.

층의 높이가 3을 훌쩍 넘을 때. 캐스케이드 비용은 높이에 비례하고 CI 비용도 그렇습니다. 높이 8짜리 스택은 대개 "쪼개기를 잘한 것"이 아니라 "설계 단계에서 잘랐어야 할 것을 리뷰 단계에서 자르고 있는 것"입니다. 기능 플래그 뒤에 넣고 main에 직접 착륙시키는 편이 나은 경우가 많습니다.

리뷰어가 한 명이고 여유가 있을 때. 스택의 이득 중 큰 몫은 병렬 리뷰입니다. 어차피 한 사람이 순서대로 볼 것이라면 층을 나눠도 총 리뷰 시간은 줄지 않고, 작성자의 리베이스 부담만 늘어납니다.

브랜치를 여러 명이 공유할 때. 앞서 적은 대로 스택은 강제 푸시를 전제합니다. 페어 작업이나 여러 명이 한 브랜치에 커밋하는 문화라면 스택 대신 롱리빙 피처 브랜치와 주기적 머지가 안전합니다.

스쿼시 병합만 쓰는데 커밋 히스토리가 중요한 프로젝트일 때. GitHub 스택은 각 층을 개별 PR로 병합하므로 층 단위 히스토리는 남지만, 층 안의 커밋은 스쿼시 정책에 따라 사라집니다. 커밋 단위 아카이빙이 필요하다면 Sapling이나 jj 계열을 보는 편이 맞습니다.

정리하면 스택이 잘 듣는 조건은 좁습니다 — 의존 순서가 분명하고, 층이 2개에서 4개이며, 리뷰어가 여럿이고, 각 층이 단독으로도 배포 가능한 변경. 이 조건에서 벗어날수록 이득보다 리베이스 비용이 커집니다.

마치며 — 도구가 자동화한 것은 리베이스의 실행이지 설계가 아니다

GitHub 네이티브 스택은 지금까지 서드파티 도구가 흉내 내던 것들 — 스택 맵, 층별 diff, 하위 병합 후 자동 재조준, 원자적 병합 — 을 서버 기능으로 올렸습니다. 별도 서비스 가입 없이 쓸 수 있다는 점만으로도 도입 장벽이 크게 내려갔습니다.

  • 지금 확인할 것: gh extension install github/gh-stack으로 설치하고 다음 큰 변경 한 건에만 높이 3짜리 스택을 시도해 보세요. 머지 큐 연동은 아직 롤아웃 중이라 팀 리포지토리에서 실제 동작을 먼저 확인해야 합니다.
  • 반드시 켤 것: rerere.enabledrebase.updateRefs. 도구를 무엇으로 쓰든 캐스케이드 비용을 가장 크게 줄여 줍니다.
  • 기대하지 말 것: 커밋 단위 리뷰와 인터디프. 이번 프리뷰는 브랜치 단위 모델이고, 그 위에서 Phabricator·Gerrit의 리뷰 경험을 재현하지는 않습니다.

스택은 큰 변경을 작게 만들어 주지 않습니다. 이미 작게 자를 수 있는 변경을 자를 때 드는 마찰을 줄여 줄 뿐입니다. 잘라야 할 지점을 찾는 일은 여전히 설계의 몫입니다.

참고 자료