production/staging 환경 분리 — 손으로 밀던 배포에서 릴리스 PR 한 건으로 (2026-09-01)
- 기간: 2026-08-21 설계·Phase 0~1 랜딩 ~ 2026-09-01 전환 창 집행(세션 3개, 마지막 세션
50f8b252-fd3b-4708-ac42-d5354a86a2eb). 오너 지시: "#667 뭐남았는지 정리좀" → 정리 후 "응 진행해줘" - 랜딩: PR #546(Phase 1,
2a1ca620) · #552(Phase 0 런북,6be5db49) · #553(Phase 2~3,71803de7) · #1080(a60521a1) · #1081(15352e80) 게이트 forward-fix 2건 · #1082(첫 릴리스 PR,a5c618b7) · #1083(버전 자동 태그,b176efca). 마이그레이션 신규 0건 — staging에 밀려 있던 84건 일괄 적용, Production은 이미 최신이라 적용 0건. Vercel 배포 = staging·Production 각 1회(워크플로 경유) - 설계서: 이슈 #667 본문이 정본(§1~§18, "예상 효과·개선사항" 포함). 08-21 artifact
c0b783a6-d4f4-40d7-acca-61251fc2cc48(비공개) - 정본:
.github/workflows/deploy.yml(유일한 배포 경로) ·deployment-pipeline.md(배포·버전 규칙) ·environment-separation-runbook.md(콘솔 체크리스트) - 도구: 없음(콘솔 작업은 오너 수행, 검증은 세션이 번들·네트워크 실측)
- 게이트: deploy.yml의 잡 의존(database → functions → frontend → smoke) · project-ref 프리플라이트 · staging-applied 게이트 · dry-run 목록 = release diff 대조 · ruleset
production-protection - 버그리포트: 없음(배포 체계 전환 — 사용자 영향 사고 아님)
- 계약:
deployment-pipeline.md에 "환경 분리 이후의 배포" + 릴리스 버전(SemVer)·merge commit 규칙 신설
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 콘솔 준비(Vercel·Supabase·GitHub·Kakao) + 런북 | ✅ PR #552 (6be5db49), 0-1~0-6 전부 09-01 종료 |
| Phase 1 | 런타임 설정 외부화(app-config.js → 환경변수) | ✅ PR #546 (2a1ca620) |
| Phase 2 | staging 자동 반영(main 머지 → staging) | ✅ PR #553 (71803de7) + forward-fix #1080·#1081 |
| Phase 3 | release 파이프라인(main → production PR) | ✅ 첫 릴리스 PR #1082 (a5c618b7) 완주, D5 발효 |
| Phase 3-1 | 릴리스 버전 체계(SemVer) + 배포 성공 시 자동 태그 | ✅ PR #1083 (b176efca), v0.9.0 발행 |
| Phase 4 | 파생 정리(하드코딩 잔재·네이티브 staging 빌드 스위치) | ⬜ 미착수 — "남은 것" 참조 |
1. 배경
이 트랙 전까지 Barbelic의 배포는 이랬다.
main에 머지하면 그 순간 Vercel이 실서비스 화면을 바꿨다. 릴리스라는 단위가 없었고, 어디까지가 이번에 나간 것인지 경계가 없었다.- DB 변경(마이그레이션)과 서버 함수 배포는 사람이 자기 PC에서 손으로 실행했다. "DB 먼저, 그다음 서버 함수, 마지막에 화면"이라는 순서는 문서에만 있었고, 지키는 주체는 사람의 주의력이었다.
- PR 미리보기 화면조차 실서비스 DB를 그대로 봤다. 디자인 확인을 하다 실수로 데이터를 건드릴 수 있는 구조였다.
- 개발과 실서비스 사이에 공유되는 중간 환경이 없었다. CI가 띄우는 일회용 DB와 실서비스, 두 단계뿐이었다.
08-21에 설계와 Phase 0~1을 마쳤지만, 그 뒤로 트랙이 오너 콘솔 작업 앞에서 멈춰 열흘 넘게 정지해 있었다. 그 사이 main에는 마이그레이션이 계속 쌓여 staging과의 간격이 40건 → 124건으로 벌어졌다.
2. 문제 제기
배포 순서를 기계가 아니라 사람이 지키고 있었다
DB보다 화면이 먼저 나가면 저장 기능이 즉시 깨진다(구 RPC 대체 경로를 두지 않는 정책). 그런데 이 순서를 강제하는 장치가 없어, 사고를 막는 것은 문서와 습관뿐이었다.
릴리스 단위가 없어 "지금 나간 것"을 특정할 수 없었다
버그가 보고돼도 어느 변경 묶음에서 들어왔는지 되짚을 기준점이 없었다.
미리보기가 실서비스 DB를 봤다
유저 A(오너)가 디자인 확인용 미리보기 링크를 열면, 그 화면은 실서비스 데이터를 읽고 쓰는 화면이었다.
트랙이 멈춘 지점이 오너 콘솔 작업이었다
세션은 Vercel·Supabase 콘솔에 접근할 수 없다. 남은 일이 전부 "오너가 클릭해야 하는 것"이라 트랙이 자연스럽게 정지했고, 정지한 만큼 staging 간격이 벌어져 재개 비용이 커졌다.
3. 해결 방안
원칙 (오너 결정 D1~D7, 2026-08-21 확정 / D1 대체안 09-01)
- D1 릴리스 =
main → productionPR 머지, 머지는 오너만. 09-01 실측 결과 GitHub Environment의 "Required reviewers"가 이 요금제에 없어 → ruleset + 운영 규칙으로 대체(설계 §16-①이 예고한 경로). - D2 마이그레이션 자동 적용, 단 "staging에서 확인된 뒤" → staging-applied 게이트가 기계로 확인.
- D5 사람이 Production에 직접
db push·functions deploy하지 않는다 → 09-01 첫 Production 배포 완주로 발효. - D6 미리보기 = staging 데이터. 오너 본인 기록이 미리보기에 안 보이는 것을 감수.
- D7 staging 데이터는 폐기 가능(재베이스라인 전제).
- 오너 결정 추가(09-01): 릴리스 버전은 SemVer. 날짜 기반(CalVer)을 제안했으나 오너가 "semver가 더 좋은 거 같은데"로 선택. 오늘 첫 릴리스를
v0.9.0으로 확정(정식 출시에v1.0.0).
접근
| 갈래 | 채택 | 기각한 대안과 이유 |
|---|---|---|
| DB 격리 | 별도 Supabase 프로젝트(staging) | Supabase Branching = 브랜치별 과금 + 이 레포의 순서 규율·이름 충돌 게이트와 맞물리기 어려움 / 한 프로젝트 스키마 분리 = auth·storage 공유라 실질 격리 아님 |
| 프론트 환경 | Vercel Custom Environment staging | 별도 Vercel 프로젝트 = 모든 푸시가 3프로젝트를 빌드(08-20 빌드 한도 소진 사고 재현) |
| 릴리스 트리거 | production 브랜치 PR | 태그 = diff 리뷰 면 없음 / 수동 실행 = 기록 없음 |
| 실행 주체 | Action이 순서 집행, Vercel은 CLI 배포 | git 통합 유지 = 화면이 DB보다 먼저 활성화될 수 있음 |
| 버전 체계 | SemVer + 배포 성공 시 자동 태그 | CalVer(날짜) = 판단 불필요하나 오너가 의미 전달을 우선 |
4. 적용한 내용
Phase 0 마무리 — 콘솔 작업 6종 (09-01)
- GitHub secrets 15개 완비(마지막
PROD_SUPABASE_DB_PASSWORD는 값 분실로 Production DB 비밀번호 재설정 후 등록 — 설계가 예고한 전환 창 절차 그대로). - ruleset
production-protection(id 22006830, Active) 생성: 대상production, PR 필수·강제 푸시 차단·삭제 차단·status checkverify. - Vercel:
staging환경 확인, staging 변수 4종을 Preview 환경에도 적용, Deployment Protection 해제, Production Branch =production/ staging Branch Tracking =main. - 0-6 검증 실측: 검증용
claude/ui-*브랜치로 미리보기를 빌드해 번들에 staging URL 주입 확인 + 화면에서 카카오 로그인 시작 시redirect_uri가 staging 콜백으로 나가는 것까지 확인(동작 기준). 검증 후 브랜치 삭제.
Phase 2~3 — deploy.yml 랜딩 (#553)
08-21 작성분을 main 최신으로 병합(충돌 0, 429커밋 뒤였음). 런북 secret 표에 PROD 4종 보강(§18 잔여 정리). 머지 즉시 staging 레인이 시작되어 밀린 마이그레이션 84건을 일괄 적용.
forward-fix 2건 — remote-schema 게이트 (#1080 · #1081)
첫 staging 실행이 마지막 검증 단계에서 죽었다. 원인은 두 겹이었다.
check-supabase-remote-schema.mjs가 CLI stdout에서 첫{부터 통째로JSON.parse→ CI에서 결과 앞에 다른 JSON 문서가 섞여 나와 파싱 사망(#1080: 문서 단위로 파싱, 실패 시 원문 덤프).- 원문 덤프가 나오자 실원인이 드러남 — CI(linux)의
supabase db query --output json은{rows:[...]}포장 없이 행 배열을 그대로 출력한다. 로컬 Windows는 포장 형식이라 로컬 실측으로는 절대 안 잡히는 차이였다(#1081: 두 형식 모두 수용).
Phase 3-1 — 릴리스 버전 자동 태그 (#1083)
release-tag 잡 신설: Production 레인이 smoke까지 초록일 때만, 릴리스 머지 커밋 메시지의 vX.Y.Z로 GitHub Release(자동 노트) 생성. 버전이 없으면 notice 후 건너뛰고 배포는 정상 진행.
주요 결정과 그 근거
- 릴리스 PR은 merge commit으로만 머지(squash/rebase 금지). squash하면
production역사가main과 어긋나 다음 릴리스의 diff 계산이 꼬인다. 문서에 명시. - 버전 판단만 사람, 나머지는 자동. 세션이 릴리스 PR 제목에 버전을 제안하고 오너가 머지 전 수정 가능 — 머지가 곧 버전 확정.
- 실패한 배포에는 버전이 붙지 않는다 → 태그 목록 = 실제로 나간 릴리스 목록.
작업 중 드러난 것
- ruleset이 브랜치 생성까지 막는다.
production브랜치를 만들려면 ruleset을 잠시 끄고 만든 뒤 다시 켜야 한다(이번엔 그렇게 처리). 다음에 같은 상황이면 브랜치를 먼저 만들고 ruleset을 나중에 만드는 순서가 낫다. - Vercel은 없는 브랜치를 Branch Tracking에 못 넣는다. "Failed to save branch tracking" = 브랜치 부재. 순서는 브랜치 생성 → Vercel 설정.
VITE_접두사 변수는 Vercel에서 Secret 유형으로 만들 수 없다("public framework prefix cannot use visibility: secret"). Config 유형으로 삭제 후 재생성해야 하고, 이때 환경 체크박스가 기본 "Production, Preview"로 잡히므로 Production 체크 해제를 반드시 확인해야 한다(안 하면 실서비스가 staging DB를 보게 된다).- Vercel의 Production Branch 설정은 Settings → Git이 아니라 Settings → Environments 안에 있다(UI 변경).
barbelic-git-main-...별칭은 낡은 실서비스 빌드로 굳어 있다. main의 git 통합 빌드를 끈 뒤로 갱신되지 않으며 Production DB를 본다 — staging 확인용으로 열면 안 된다.- 로컬 Windows와 CI linux의 CLI 출력 형식이 다르다(위 forward-fix). 배포 워크플로처럼 CI에서만 도는 코드는 로컬 초록이 보증이 되지 않는다.
5. 적용 결과
| 항목 | 전 → 후 |
|---|---|
| 배포 순서 보장 주체 | 사람의 8단계 수동 실행 → 워크플로 잡 의존(DB·함수 실패 시 화면 미배포) |
| 릴리스 단위 | 없음(개별 push 누적) → main → production PR 1건의 diff |
| Production DB 반영 경로 | 각 세션이 로컬에서 db push --linked → 워크플로만(D5 발효, 09-01 12:43 첫 실행 완주) |
| 중간 환경 | 없음(일회용 CI DB ↔ 실서비스) → staging 상시 운영(마이그레이션 40건 → 124건, main과 동기) |
| 미리보기가 보는 DB | 실서비스(kobxeylancdimqhfkbnl) → staging(ajqddlglezvxzsawquku) — 번들·로그인 요청 2중 실측 |
| 릴리스 버전 | 없음 → SemVer 자동 태그, v0.9.0 발행 |
| Production 배포 후 확인 | canary 1종 → staging·Production 양쪽 smoke(CRUD roundtrip) |
| 브랜치 보호 | ruleset 0개 → production-protection(PR 필수·강제 푸시/삭제 차단·verify 필수) |
검증 방식: 전부 자동 증거 — deploy.yml 실행 결과(staging run 33507952720 / Production run 33509274780 모두 전 잡 success), 번들 정적 검사, 미리보기 화면의 로그인 요청 호스트 실측, Production 읽기 전용 스키마 점검(EXIT 0, release_contract_failures []).
미검증으로 남긴 것:
- 서버리스(
api/*)용 비-VITE 변수 2종(SUPABASE_URL·SUPABASE_PUBLISHABLE_KEY)의 Preview 적용은 화면 실측 범위 밖 — 미리보기에서 관리자 API를 쓸 때 드러난다. release-tag잡은 다음 릴리스가 첫 실검증(오늘v0.9.0은 수동 발행).- staging 화면의 시각·감각적 품질은 사람 눈의 몫(자동 증거 없음).
6. 이번 개선으로 향상된 것
화면이 DB보다 먼저 나가는 사고가 구조적으로 불가능해졌다
잡 의존이 순서를 강제하므로, DB나 서버 함수가 빨간불이면 화면은 배포되지 않는다. 오늘 실제로 이 방어가 작동했다 — 게이트 실패 2회 모두 화면 배포 전에 멈췄다.
오너가 "무엇이 나가는지" 보고 결정한다
릴리스 PR의 diff가 곧 이번에 나가는 것 전부다. 머지 버튼 하나가 승인이고, 안 누르면 실서비스는 그대로다.
디자인 미리보기가 실서비스 데이터에서 분리됐다
오너가 미리보기 링크를 열어 무엇을 눌러도 실서비스에는 닿지 않는다. 대신 오너 본인 기록이 안 보인다(D6에서 감수한 대가).
실서비스에 나간 것에 이름이 붙는다
v0.9.0처럼 버전으로 부를 수 있고, 릴리스 노트에 포함된 작업 목록이 자동으로 쌓인다. 버그 보고 시 "어느 버전에서"가 성립한다.
구조적으로 남는 것
deploy.yml하나가 두 환경의 유일한 배포 경로 — 배포 계약이 문서가 아니라 코드로 존재한다.- project-ref 프리플라이트 + 장부 꼬리 단언 — 엉뚱한 DB에 닿는 것을 기계가 막는다.
- staging-applied 게이트 — staging을 거치지 않은 마이그레이션은 Production에 못 들어간다.
- ruleset
production-protection— 실서비스 브랜치에 사람이 직접 손댈 수 없다. - SemVer + 자동 태그 — 릴리스 이력이 저절로 남는다.
남은 것
- Phase 4 파생 정리(미착수): 하드코딩된 Production 주소가 남은 소스 4곳(
app-config.js폴백은 의도된 것 — 유지) · 네이티브 3곳(android/app/build.gradle·ios/App/App/MainViewController.swift·ios/Debug.xcconfig·Release.xcconfig) · 문서 8곳 · 테스트 5곳. iOS/Android--mode staging빌드 스위치 포함. - 아이폰 앱으로 staging 확인 불가: 설치된 네이티브 앱은 실서비스만 본다. 사파리로 staging 주소를 열면 확인 가능(홈 화면 추가 시 앱처럼 사용). 네이티브 staging 빌드는 Phase 4 + TestFlight 배포가 필요하다.
- staging 고정 주소 없음: 배포마다 URL이 바뀐다. Vercel → Environments → staging → Domains에 도메인을 하나 붙이면 해결(오너 작업, 미착수).
- 낡은 별칭 정리:
barbelic-git-main-...이 옛 실서비스 빌드로 굳어 있다. 혼동 방지를 위해 정리 검토. prod-smoke.yml: CLI 배포는deployment_status를 만들지 않아 자동 트리거가 멈췄다(smoke는 deploy.yml이 직접 돈다). 수동 실행 경로만 남은 상태 — 정리 여부 미결정.