들어가며

저희 팀은 4개의 React Native 앱을 운영하고 있습니다. 처음에는 각각 독립된 레포지토리로 관리했지만, 앱이 늘어나면서 공통 코드 중복, 의존성 파편화, CI/CD 파이프라인 중복 등의 문제가 반복되었습니다. 이를 해결하기 위해 Nx + pnpm 기반 모노레포로 통합했고, 이 글에서는 통합 이후 빌드 타입 분리, 브랜치 전략, 태그 기반 배포, CodePush 운영 방식을 어떻게 설계했는지 다룹니다.

모노레포 자체의 마이그레이션 과정은 이 글의 범위를 벗어나므로, 빌드와 배포 전략에 집중합니다.


서버 환경 구성

제약 조건

설계에 앞서 몇 가지 제약 조건이 있었습니다.

  • 서버팀 요구사항: 테스트 데이터가 Prod DB에 올라가면 안 됨 → Stage 서버 사용 필수
  • 내부 테스트(QA)와 심사 제출이 별도의 빌드로 이루어져야 함
  • 스토어 심사 제출은 수동으로 진행 (CI에서 빌드까지만)

환경 정의

환경 용도 비고

Dev 로컬 개발 개발자 로컬에서만 사용
Stage 내부 테스트 (QA, 기능 검증) 테스트 데이터 격리
Prod 스토어 릴리즈 / 심사 제출 실 운영 데이터

환경이 3개인 이유는 단순합니다. 로컬 개발 환경과 QA 환경에서 Prod 데이터를 건드리면 안 되고, QA에서 검증된 코드만 Prod 빌드로 만들어야 하기 때문입니다.


빌드 타입과 배포 트랙

빌드 타입 서버 엔드포인트 배포 대상 트리거 시점 배포 방식

Dev Dev 로컬 실행 개발 중 상시 -
Stage Stage 내부 테스트 업로드 QA / 기능 검증 시 CI 자동
Prod Prod 스토어 심사 제출 릴리즈 확정 시 CI 빌드 → 수동 심사 제출

여기서 한 가지 자주 받는 질문이 있습니다.

"Stage에서 테스트한 빌드를 그대로 스토어에 올리면 안 되나요?"

안 됩니다. Stage 빌드와 Prod 빌드는 서버 엔드포인트가 다릅니다. Stage 빌드는 Stage 서버를 바라보고, Prod 빌드는 Prod 서버를 바라봅니다. 동일한 바이너리를 재사용할 수 없으므로 빌드가 2번 발생하는 것은 정상적인 플로우입니다.


브랜치 전략

왜 Trunk-based가 아닌가

모노레포에서 Trunk-based Development(main 단일 브랜치)를 쓰면 Stage/Prod 빌드 분리가 까다로워집니다. main에 머지하는 순간 Prod 배포가 트리거되면, 아직 QA가 끝나지 않은 코드도 함께 나갈 수 있기 때문입니다.

그래서 develop 브랜치를 두고, Stage/Prod 배포 시점을 분리하는 전략을 선택했습니다.

브랜치 종류

브랜치 분기 기준 머지 대상 용도

main - - Prod 릴리즈된 안정 코드
develop main - 개발 통합 브랜치
feat/* develop develop 새 기능 개발
fix/* develop develop 일반 버그 수정
hotfix/* main main + develop Prod 긴급 수정 (CodePush 포함)

브랜치 플로우

시나리오별 상세

1. 기능 개발 / 버그 수정

develop에서 분기 → 개발 → develop에 머지 → Stage 태그

develop에서 분기하고 develop으로만 머지합니다. 머지 후 Stage 태그를 찍어 내부 테스트 빌드를 트리거합니다.

2. QA 완료 후 릴리즈

develop → main 머지 → Prod 태그 → CI 빌드 → 수동 심사 제출

Stage 테스트 완료 후 develop을 main에 머지합니다. main 머지 시점에 Prod 태그를 찍어 빌드를 트리거하고, CI가 빌드 산출물(AAB/IPA)을 생성하면 수동으로 스토어에 심사 제출합니다.

3. Prod 긴급 수정 (hotfix)

main에서 분기 → 수정 → main에 머지 → CodePush 태그
                     → develop에도 머지 (동기화)

main에서 분기하는 이유는 Prod 코드 기준으로 수정해야 하기 때문입니다. main + develop 양쪽에 머지해야 합니다. develop에 안 넣으면 다음 릴리즈에서 수정이 빠집니다.


태그 기반 배포 전략

왜 브랜치가 아니라 태그인가

모노레포에서 앱별 독립 배포를 할 때, 브랜치 기반은 동작하지 않습니다.

브랜치는 레포 전체에 걸리기 때문에 앱별로 독립적인 릴리즈가 불가능합니다. 예를 들어 App A는 아직 Stage 테스트 중인데 App B는 심사를 올려야 하는 상황에서, 하나의 release 브랜치로는 이를 분리할 수 없습니다.

태그는 앱 이름을 포함하므로 앱별로 독립적인 빌드 트리거가 가능합니다.

브랜치 기반 태그 기반

앱별 독립 릴리즈 ❌ 불가 ✅ 가능
브랜치 수 앱 × 환경만큼 증가 develop, main 정도만 유지
빌드 타입 결정 브랜치로 결정 태그 패턴으로 결정

태그 컨벤션

{앱 이름}/{빌드 타입}/{버전}
app-a/stage/1.0.0         → App A Stage 빌드 → 내부 테스트
app-a/prod/1.0.0          → App A Prod 빌드 → 심사 제출
app-a/codepush/1.0.0-cp.1 → App A CodePush → OTA 배포
app-b/stage/2.1.0         → App B Stage 빌드 → 내부 테스트

이 패턴 하나로 어떤 앱을, 어떤 빌드 타입으로, 어떤 버전으로 배포하는지가 결정됩니다.


CodePush (OTA 업데이트)

네이티브 빌드 vs CodePush

네이티브 빌드 CodePush

배포 대상 바이너리 전체 (네이티브 + JS) JS 번들 + 에셋만
심사 필요 ✅ 필요 ❌ 불필요
배포 속도 심사 대기 (1~3일) 즉시 반영
적용 범위 네이티브 코드 변경 포함 JS/에셋 변경만 가능

적용 범위: Prod 빌드에서만 사용

CodePush는 스토어에 릴리즈된 Prod 바이너리에 대한 핫픽스/긴급 패치 용도로만 사용합니다. Stage 환경에서는 CodePush를 사용하지 않고, 내부 테스트는 항상 네이티브 빌드로 진행합니다.

Stage에서 CodePush를 쓰지 않는 이유:

  • Stage는 내부 테스트용이라 네이티브 빌드를 다시 올리면 됨 (심사 대기 없음)
  • CodePush 환경을 Stage/Prod 이중으로 관리하면 복잡도만 증가
  • CodePush의 핵심 가치는 심사 없이 Prod 사용자에게 즉시 배포하는 것

CodePush 판단 기준

긴급 수정이 발생했을 때, CodePush로 배포할 수 있는지 판단하는 기준입니다.

하나라도 "Yes"이면 네이티브 빌드가 필요합니다. 모두 "No"일 때만 CodePush로 즉시 배포할 수 있습니다.


CI/CD 파이프라인

태그 → 빌드 → 배포 흐름

GitHub Actions 설정 예시

태그 패턴으로 앱과 빌드 타입을 파싱하는 방식입니다.

on:
  push:
    tags:
      - 'app-a/**'
      - 'app-b/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: 태그에서 앱/빌드타입/버전 파싱
        run: |
          TAG=${GITHUB_REF#refs/tags/}
          APP=$(echo $TAG | cut -d'/' -f1)
          BUILD_TYPE=$(echo $TAG | cut -d'/' -f2)   # stage, prod, codepush
          VERSION=$(echo $TAG | cut -d'/' -f3)

      # Stage 빌드
      - name: Stage 빌드 → 내부 테스트 업로드
        if: contains(github.ref, '/stage/')
        run: # Stage 엔드포인트로 빌드 후 App Distribution / TestFlight 업로드

      # Prod 빌드
      - name: Prod 빌드 → 산출물 저장
        if: contains(github.ref, '/prod/')
        run: # Prod 엔드포인트로 빌드 후 산출물(AAB/IPA) 저장 (심사 제출은 수동)

      # CodePush (Prod만)
      - name: CodePush → Prod
        if: contains(github.ref, '/codepush/')
        run: # appcenter codepush release --deployment-name Production

전체 배포 플로우 요약

네이티브 빌드 경로 (일반 개발)

  1. 로컬에서 Dev 서버로 개발
  2. PR → 코드 리뷰 → develop 머지
  3. Stage 태그 → 자동 Stage 빌드 → 내부 테스트 업로드
  4. QA 완료 → develop을 main에 머지
  5. Prod 태그 → 자동 Prod 빌드 → 산출물 저장 → 수동으로 심사 제출

CodePush 경로 (Prod 핫픽스)

  1. 스토어 릴리즈 후 Prod에서 긴급 이슈 발견 (JS/에셋 수준)
  2. main에서 hotfix/* 브랜치 분기 → 수정
  3. main + develop 양쪽에 머지
  4. CodePush 태그 → Prod 키로 CodePush 릴리즈 → 점진적 롤아웃

마치며

이 전략의 핵심은 태그 컨벤션 하나로 앱, 빌드 타입, 버전을 결정한다는 점입니다. 모노레포에서 여러 앱을 운영하면 배포 복잡도가 급격히 올라가는데, 태그 기반 배포는 이를 단순하고 예측 가능한 구조로 만들어줍니다.

정리하면:

  • 서버 환경은 3개 (Dev / Stage / Prod), Stage와 Prod는 별도 빌드
  • 브랜치는 2개 (develop + main), 배포 시점은 태그로 제어
  • 태그 패턴 하나로 앱별 독립 배포 (앱/빌드타입/버전)
  • CodePush는 Prod 핫픽스 전용, JS/에셋 변경만 가능할 때 사용

이 구조로 전환한 뒤 "어떤 앱이 어떤 상태인지" 파악하는 것이 훨씬 명확해졌고, 배포 실수도 줄어들었습니다. 비슷한 고민을 하고 계신 팀에 도움이 되었으면 합니다.