2025년 11월 12일
프론트엔드 모노레포 배포, simgit-flow로 관리하기
프론트엔드 모노레포를 운영하며 배포 때문에 골머리를 앓아본 적 없으신가요?
- 😵💫 app-A의 간단한 문구 수정본을 급하게 배포해야 하는데... 지금 통합 브랜치에 app-B의 거대한 신규 기능이 머지되어 있네. 아, app-A 커밋들만 골라서 cherry-pick으로 핫픽스 브랜치 새로 파야 하나... 후...
우리 챕터의 목표는 명확했습니다. 다수의 앱과 패키지가 공존하는 모노레포의 장점은 누리면서, 동시에 2주 단위의 안정적인 스프린트 정기 배포 사이클도 확립하고 싶었습니다.
전통적인 git-flow는 release 브랜치 관리 오버헤드가 너무 컸고, git-flow 하에서 develop 브랜치는 배포 병목의 주범이었습니다.
저희는 simgit-flow라는 브랜치 전략을 찾았고 저희의 스프린트 전략에 맞춰 전략을 단순화했습니다.
나아가, 클래식 Git-Flow와 여러 simgit-flow 제안들에서 공통적으로 인간의 실수 영역으로 남아있던 모든 수동 역머지 지점을 github action으로 해결했습니다.
본격적인 설명의 배경으로 저희의 스프린트 사이클에 대한 설명 드리겠습니다.
요일 | 스프린트 일자 | 주요 일정 | 설명 |
|---|---|---|---|
수 (Day 1) | 스프린트 시작 | 🧭 Sprint Planning | Notion 백로그 → GitHub 이슈 생성, 담당자 배정 |
목~월 (Day 2~6) | 개발 기간 | 🛠️ 개발 + 데일리 스크럼 | Task 진행, PR 작성, 커밋 → 이슈 연결 |
화 (Day 5) | 중간 점검 | 📊 Mid-Sprint Check | Story 완료율 확인, QA 준비도 체크 |
수~금 (Day 7~8) | QA 기간 시작 | 🧪 병행 QA 시작 | 완료된 기능부터 QA 착수, 버그 분류 |
월 (Day 9) | QA 마무리 | 🔍 QA 집중 & 핫픽스 | P1~P2 이슈 해결, 릴리즈 후보 확정 |
화 (Day 10) | 종료일 | 🎬 Sprint Review → 🔁 Retrospective → 🚀 배포 | 데모 & 회고, 오후 또는 다음날 오전 배포 |
시점 | 활동 | 설명 |
|---|---|---|
Day 5~6 | QA 설계 | QA 시나리오 정리, 기능별 체크리스트 작성 |
D+ 14 | 테스트 진행 | GitHub 이슈 등록, 우선순위 분류 (P1P5) |
D+5 | QA 마감 | 핵심 이슈 해결, 리뷰 준비 |
이전 스프린트 QA와 다음 스프린트 개발이 병행 되는 것을 확인 할 수 있습니다.
즉, 이러한 스프린트 사이클에서 동시에 두가지 스프린트 브랜치가 활성화 됩니다. 이로인해 브랜치 관리에 복잡도가 생겼습니다.
As-Is: Git-Flow가 열어버린 'Conflict 지옥’
simgit-flow를 도입하기 전, 우리는 일반적으로 사용하는 git-Flow 전략을 사용하고 있었습니다. 하지만 모노레포와 2주 스프린트라는 환경에서, Git-Flow의 핵심인 develop 브랜치는 안정적인 통합이 아닌 거대한 Conflict의 근원지가 되었습니다.
문제점 1: develop 브랜치의 병목 현상
develop 브랜치는 모노레포의 모든 앱과 공유 패키지의 변경 사항이 예외 없이 모이는 '공용 창구'였습니다. 앞에서 언급한 cherry-pick의 고민이 바로 여기서 시작됩니다. app-A의 배포가 급해도, 이미 develop에 머지된 app-B의 미완성 기능 때문에 app-A조차 배포할 수 없는 배포 종속성 문제가 심각했습니다.
문제점 2: 'release'와 'develop'의 conflict 지옥
배포 종속성보다 더 고통스러운 것은 2주 스프린트 주기마다 반복되는 Conflict 지옥이었습니다.
- [Sprint A, 10일 차]
- (QA 시작) develop 브랜치에서 release/A… 브랜치를 생성하고 QA를 시작합니다.
- 동시에, develop 브랜치에서는 sprint B 개발(예: feature/sprint-b)을 시작합니다.
- [Sprint A, 11~14일 차] (브랜치 분기)
- release/A…: QA 중 발견된 버그를 수정합니다. (예: common의 치명적 버그 수정)
- develop: sprint B의 새로운 기능을 머지합니다. (예: common에 신규 기능 추가)
- [Sprint A, 14일 차] (배포 및 역머지)
- release/A…이 main에 머지되어 배포됩니다.
- (👿 지옥의 시작) 이제 이 release/A… 브랜치를 develop 브랜치로 수동 역머지(reverse merge)해야 합니다.
- 결과: develop는 이미 sprint-B의 신규 기능으로 한참 앞서나갔고, release/A…은 main 기준으로 버그만 수정한 상태입니다. common 패키지에서 거대한 Conflict가 발생하게 됩니다.
문제점 3: 'horfix'와 'develop'의 충돌 지옥
릴리스 Conflict는 2주마다 오는 정기적인 고통이었지만, hotfix Conflict는 불시에 찾아오는 더 큰 고통이었습니다.
- main 브랜치에서 긴급 핫픽스(hotfix/critical-bug)를 따서 common를 수정하고 main에 배포합니다.
- (👿 지옥의 시작) 이제 Git-Flow 규칙에 따라 이 hotfix 브랜치를 develop 브랜치로 수동 역머지해야 합니다.
- 결과: hotfix는 main의 오래된 코드를 기반으로 수정되었지만, develop는 sprint B의 최신 기능으로 가득 차 있습니다. common에서 또다시 거대한 Conflict가 발생합니다.
결국, 클래식 Git-Flow의 develop 브랜치는 모노레포 환경에서 안정적인 통합이 불가능했습니다. release든 hotfix든, 배포 후 develop으로 돌아오는 모든 수동 역머지 시점은 우리에게 Conflict 지옥을 맛보게 했습니다.
To-Be: 'develop'을 버리고 'release'를 택한 우리의 전략
Conflict 지옥을 해결하기 위한 우리의 목표는 명확했습니다.
- Conflict 최소화: release나 hotfix 작업 시 발생하는 develop 브랜치와의 충돌을 원천적으로 제거해야 한다.
- 배포 유연성 확보: 2주 단위의 스프린트 정기 배포와 긴급 핫픽스 배포 모두를 유연하고 안전하게 처리해야 한다.
develop 브랜치를 제거하는 유력한 대안으로 이 아티클에서 제안하는 simgit-flow 브랜치 전략(main -> staging -> production)을 검토했습니다. 하지만 우리 팀은 이 전략이 두 가지 치명적인 문제를 가지고 있다고 판단했습니다.
- main 브랜치의 철학 위배: 이 전략에서 main은 개발 통합 브랜치(unstable) 역할을 합니다. 하지만 우리는 main 브랜치는 항상 '배포 가능한(stable)' 상태여야 한다는 철학을 지키고 싶었습니다.
- 수동 역머지 문제의 잔존: 핫픽스 발생 시 production 브랜치에서 수정 후, 이 내용을 개발자가 수동으로 main 브랜치에 역머지해야 합니다. 이는 hotfix -> develop 역머지 문제와 본질적으로 동일한 '인간의 실수' 유발 지점입니다.
우리의 해답: 스프린트 기반 커스텀 simgit-flow
우리는 위 두 가지 문제를 모두 해결하는 우리만의 전략을 구축했습니다.
- 규칙 1: main은 항상 배포 가능한(Production Ready) 상태로 둔다.main은 오직 정기 배포 또는 긴급 핫픽스가 완료되었을 때만 머지되는, 가장 깨끗하고 안정적인 브랜치로 격상시켰습니다.
- 규칙 2: 모든 개발은 release/[스프린트명] 브랜치에서 진행한다.develop 브랜치가 사라지고, 2주간의 모든 feature 및 fix 브랜치는 release/[스프린트명] 브랜치를 base로 생성되고, 다시 release/[스프린트명]으로 머지됩니다. 개발 환경이 main과 완벽하게 격리되었습니다.
- 규칙 3 (Conflict 해결의 핵심): 다음 release는 현재 release에서 생성한다.sprint B 개발이 시작될 때, main이 아닌 release/A… 브랜치에서 release/B… 브랜치를 생성합니다.
- 결과: sprint A과 sprint B는 develop vs release처럼 분기 되는 것이 아니라, 직계 부모-자식 관계를 갖게 됩니다. sprint A의 QA 수정 사항을 sprint B로 머지할 때 거대한 Conflict가 발생하지 않습니다.
- 규칙 4 (배포 유연성): 배포는 main으로의 머지를 의미한다.
- 정기 배포: 2주가 지나 release/[스프린트명]이 안정화되면, main으로 머지하여 정기 배포를 실행합니다.
- 긴급 핫픽스: main에서 hotfix/*를 생성하고, main으로 머지하여 긴급 배포를 실행합니다.
이 전략을 통해 우리는 develop 브랜치와의 Conflict 지옥에서 완전히 벗어났습니다. 하지만 이 전략이 진정으로 무한으로 즐길 수 있게 된 것은, 이 모든 release와 hotfix의 동기화 과정을 자동화하는 CI/CD 파이프라인이 있었기 때문입니다.
불편함을 덜어주는 워크플로우
우리는 Release Deployment Sync라는 GitHub Actions 워크플로우를 구축했습니다.
이 파이프라인의 역할은 다음과 같습니다.
"Merge는 강제하지 않는다. 다만, 잊을 수 없도록 'PR'을 100% 자동으로 생성해 준다."
- A. 모든 배포의 시작점: deploy/prod 브랜치
우리의 워크플로우에서 deploy/prod 브랜치는 배포 승인을 위한 단일 트리거(The Big Red Button)입니다.- 정기 배포 시: release/GV60 브랜치를 deploy/prod로 머지(PR)합니다.
- 긴급 핫픽스 시: hotfix/critical-bug 브랜치를 deploy/prod로 머지(PR)합니다.
💡 Fun Fact: 우리의 스프린트 작명법
우리는 2주 단위 스프린트 브랜치에 전기차(EV) 모델명의 알파벳 순서로 Prefix를 붙입니다.
- release/Kona
- release/Leaf
- release/Model3
- release/Niro
이 작명법은 '다음 릴리스 브랜치 찾기' 자동화에서 핵심적인 역할을 합니다.
- B. 'Release Deployment Sync' 워크플로우 도식화
deploy/prod 브랜치에 push 이벤트가 감지되면, 워크플로우는 아래와 같이 순차적, 병렬적, 조건부로 작업을 실행합니다.

- C. 워크플로우 수도 코드 워크플로우가 실제로 어떻게 수동 역머지를 대체하는지 수도 코드로 살펴보겠습니다.
# .github/scripts/deploy-utils.sh (핵심 로직)
# 2단계: 배포 소스 찾기
Function find_merged_branch(TAG):
# 1. 태그의 커밋 메시지를 읽음
MESSAGE = $(git show --format='%B' $TAG)
# 2. "Merge pull request #... from .../release/Model3" 패턴을 찾음
REGEX_MATCH = $(echo $MESSAGE | grep "from .*/(release|hotfix)/")
# 3. 브랜치 이름("release/Model3")만 추출하여 반환
return $(extract_branch_name $REGEX_MATCH)
# 4단계: 다음 릴리스 찾기
Function find_next_release_branch(MERGED_BRANCH):
# 1. "release/Model3"에서 알파벳 'M'를 추출
CURRENT_LETTER = $(echo $MERGED_BRANCH | sed '...' | cut -c1)
# 2. 'M'의 다음 알파벳 'N'를 찾음
NEXT_LETTER = $(get_next_letter $CURRENT_LETTER)
# 3. 원격 브랜치 목록에서 "release/N"로 시작하는 브랜치(예: "release/Niro")를 찾아 반환
return $(git ls-remote | grep "release/$NEXT_LETTER" | head -1)
# 3, 4단계: PR 생성 (가장 중요!)
Function create_sync_pr_to_branch(SOURCE_BRANCH, TAG, TARGET_BRANCH):
# 1. 동기화용 임시 브랜치 생성 (예: "sync/main-...")
PR_BRANCH = "sync/$TARGET_BRANCH-$(timestamp)"
git checkout -b $PR_BRANCH origin/$TARGET_BRANCH
# 2. 임시 브랜치에 배포 소스(SOURCE_BRANCH)를 머지 시도
if git merge origin/$SOURCE_BRANCH; then
# 3-A. (성공 시) Conflict 없는 PR 생성
git push origin $PR_BRANCH
gh pr create --base $TARGET_BRANCH --head $PR_BRANCH --title "🔄 Sync $TARGET_BRANCH..."
else
# 3-B. (실패 시) Conflict가 발생했음을 알리는 PR 생성
git merge --abort
# 임시 브랜치를 배포 소스 스냅샷으로 덮어쓰기
git checkout -B $PR_BRANCH origin/$SOURCE_BRANCH
git push -f origin $PR_BRANCH
# PR 본문에 "충돌 해결 필요" 메시지 포함
gh pr create --base $TARGET_BRANCH --head $PR_BRANCH --title "🔄 Sync $TARGET_BRANCH (Conflicts to resolve)"
fi
- D. 왜 '자동 머지'가 아닌 '자동 PR'인가?
우리 워크플로우의 핵심은 git merge를 강제로 실행하지 않고, gh pr create를 통해 'Pull Request'를 생성한다는 점입니다.- 인간의 실수 방지: 개발자는 더 이상 핫픽스 배포 후 main이나 다음 릴리스 브랜치에 역머지하는 것을 잊어버릴 수 없습니다. 배포가 끝나면 1분 안에 봇(bot)이 역머지 PR을 생성하여 개발자에게 리뷰를 요청하기 때문입니다.
- 자동화의 위험 방지: 수도 코드의 create_sync_pr_to_branch 함수(3-B)에서 보듯, 만약 핫픽스 내용이 main 브랜치와 'Conflict'가 발생하면 자동 머지는 실패하고 브랜치를 오염시킬 수 있습니다. 하지만 '자동 PR' 전략은, PR 자체가 'Conflict가 발생했음'을 알려주는 안전장치 역할을 합니다. 개발자는 이 PR을 보고 안전하게 Conflict를 해결한 뒤 머지할 수 있습니다.
결론: 우리가 얻은 것, 그리고 다음 과제
이 '커스텀 simgit-flow' 전략과 자동화 파이프라인을 도입한 결과, 우리는 크게 네 가지를 얻었을 수 있었습니다.
- Merge Hell 탈출: release -> release 브랜치 생성 전략으로 스프린트 시작 시점의 고통스러운 Conflict 비용을 제거했습니다.
- 인간의 실수 원천 차단: 클래식 Git-Flow의 모든 '수동 역머지' 지점을 '자동 PR 생성'으로 100% 대체하여 안정성을 확보했습니다.
- 불편함 제거: workflow로 잊지말아야할 반복적인 업무를 자동화 시켰습니다.
- 명확한 역할: main은 항상 배포 가능한 상태로 깨끗하게 유지됩니다.
물론 이 전략이 완벽하진 않습니다. 우리는 지속적으로 서비스 환경에 맞는 브랜치 전략을 고민 할 것이고 더 고도화 된 CI/CD를 구성하여 안정성을 높이고 DX를 향상시키는 것을 다음 목표로 하고 있습니다.
이 글이 모노레포와 잦은 배포 사이에서 고민하는 팀에게 이 글이 작은 힌트가 되기를 바랍니다.
