01 THINK어떻게 생각하는가

릴리스 브랜치를 늦게 만들 필요가 없었다

"QA에서는 됐는데 배포하니 깨졌다"가 반복되면 사고가 아니다. 검증한 조합과 배포하는 조합이 다르기 때문이다.

"QA에서는 됐는데 배포하니 깨졌다."

이 말이 반복되는 팀에서는 대개 사고를 찾는다. 누가 뭘 빠뜨렸는지, 테스트가 왜 못 잡았는지. 그런데 브랜치 배치에 따라서는 이게 설계상 예정된 결과다.

검증하는 것ABCA+B+C 를테스트배포하는 것ABA+B 를재조립이 조합은 배포되지 않는다

A+B+C 를 검증했지만 나가는 것은 A+B 다

통합 브랜치에서 A·B·C를 함께 검증한다. 배포는 릴리스 브랜치에서 A와 B만 골라 재조립한다. A+B+C는 검증됐지만 배포되지 않고, A+B는 배포되지만 검증된 적이 없다.

결함의 상당수는 개별 기능이 아니라 조합에서 나온다. 조합이 바뀌면 검증 결과도 같이 무효가 된다. 그래서 "QA에서는 됐는데"는 사람 문제가 아니다.

왜 그렇게 배치하는가

여기서 "그럼 릴리스 브랜치를 없애자"로 가면 틀린다. 그 배치에는 이유가 있다.

릴리스 브랜치를 최대한 늦게 만들면, 만드는 시점에 무엇이 실제로 나갈 수 있는지 이미 확정돼 있다. 나갈 것만 골라 담으면 된다. 기능 하나가 빠져도 안 넣으면 그만이라 git을 건드릴 일이 없다.

정기 배포 조직에서 이건 합리적인 설계다. 포기할 이유가 없는 능력이고, 실제로 그 능력 때문에 이 배치를 쓴다.

그러니 질문은 "이 구조가 틀렸나"가 아니다. 늦은 확정을 유지하면서 검증한 커밋을 그대로 배포할 수는 없나다.

유연함은 자르는 시점에서 오지 않았다

늦게 자르니까 늦게 고를 수 있다고 생각했다. 두 개를 한 덩어리로 보고 있었다.

늦은 확정(late binding)은 "브랜치를 늦게 자르는 것"이 아니라 "준비된 것만 머지하는 것"에서 온다.

아니다. 고를 수 있는 이유는 아직 머지하지 않았기 때문이다. 자른 시점은 상관이 없다.

그래서 릴리스 브랜치를 스프린트 시작에 만들어 두고, feature를 QA 통과 시점에 하나씩 머지해도 된다. 고르는 시점은 여전히 늦다. 달라지는 건 하나다 — QA를 실제로 배포될 그 브랜치에서 한다. 검증한 덩어리와 배포하는 덩어리가 같아진다.

능력을 잃지 않고 구조만 고치는 길이다.

기능이 빠질 때

언제 만드느냐와 무엇을 넣느냐는 별개 결정이다. 그래서 아래 넷은 릴리스 브랜치를 먼저 만들어 놓고도 전부 쓸 수 있다.

방법비용언제
늦게 채우기 — 안 넣는다git 0 · 인프라 0기본값. 아직 머지 전이면 항상
플래그 — 넣되 끈다플래그 인프라여러 기능이 공통 코드를 공유할 때
revert — 넣었다 되돌린다얽히면 충돌이미 머지했는데 배포 직전 취소
다음 차대기일정이 밀린 게 확실할 때

위험한 조합이 둘 있다. 공통 리팩터링을 공유하는 기능은 ③을 쓰면 다른 기능까지 되돌아간다. 반대로 DB 스키마 변경은 ②로 못 숨긴다 — 스키마는 플래그 뒤에 있지 않다. 안 넣거나, 스키마만 호환되게 먼저 내보내는 수밖에 없다.

revert 는 생각한 대로 동작하지 않는다

③을 쓰기 전에 확인할 게 있어서 임시 저장소에서 직접 돌려봤다. 결과가 예상과 달랐다.

featC 머지 → revert → featC 에 새 커밋(다른 파일) → 다시 머지

core.txt   없음
ui.txt     없음
memo.txt   있음   ← revert 이후에 만든 새 커밋만

revert한 머지를 그냥 다시 머지하면 코드가 돌아오지 않는다. git이 이미 머지한 커밋은 건너뛴다. 돌아오는 건 revert 이후에 새로 만든 것뿐이고, 새 커밋이 하나도 없으면 Already up to date. 가 뜬다.

가장 위험한 건 충돌이 안 난다는 점이다. 조용히 성공한다. 담당자는 "머지했는데?" 라고 생각하고, QA는 없는 기능을 테스트한다.

여기서 한 가지가 더 나왔다. 새 커밋이 같은 파일을 통째로 덮어쓰면 우연히 돌아오기도 한다. 결과가 예측 불가능하다는 뜻이고, 이건 "절대 안 돌아온다"보다 나쁘다. 한 번 우연히 성공하면 규칙을 잘못 배운다.

해법은 되돌리기를 되돌리는 것이다. git revert <revert 커밋>. 실험에서 전부 정확히 복구됐다.

정할 규칙 둘. 연기용 revert는 커밋 해시를 남긴다 — 사람이 기억할 수 있는 정보가 아니다. 그리고 취소가 잦으면 revert를 쌓지 말고 릴리스를 다시 조립한다. 되돌리기가 2~3개를 넘으면 이력이 안 읽힌다.

외부 연동이면 위 처방은 절반만 듣는다

결정권이 우리에게 없기 때문이다.

일정이 남의 손에 있다. 상대사 개발·검수·계약·오픈일이 우리 스프린트와 무관하게 움직인다. 예고도 없다. QA가 끝나고 배포 직전에 통보가 온다. 그리고 폐기가 아니라 연기라 코드를 버릴 수도 없다.

릴리스 주기를 줄여도 이 셋 중 무엇도 바뀌지 않는다.

바꿀 수 있는 건 배포(deploy)와 출시(launch)를 다른 날로 두는 것이다. 대기 상태가 "브랜치에 묶인 미완성"에서 "프로덕션에 있는데 꺼진 것"으로 바뀐다. 연기 통보가 와도 되돌릴 게 없다. 켜는 날짜만 미루면 된다.

그리고 외부 연동만은 플래그 인프라가 없어도 거의 공짜다. 외부 연동은 어차피 엔드포인트·API 키·파트너 식별자 같은 설정이 필요하다. 설정이 없으면 연동 경로가 시작되지 않게 짜면 그 자체가 스위치다.

  • 설정 없음 = 에러가 아니라 비활성. 단 "연동 비활성" 로그는 남긴다. 안 그러면 안 켜진 걸 아무도 모른다
  • 화면 노출도 같은 스위치에 묶는다. 호출만 막고 버튼이 보이면 사용자에겐 고장으로 보인다
  • 끄면 연동 전 동작으로 폴백. 상대사 장애 때도 같은 스위치를 쓴다

안 하면 이렇게 된다. 연동 코드가 브랜치에서 두 스프린트 대기하는 동안 main은 수백 커밋 앞서 간다. 막상 상대가 준비돼 머지하면 연동 로직이 아니라 그동안 쌓인 충돌부터 풀게 되고, 그 코드는 처음 QA했던 코드가 아니라 QA도 다시 해야 한다. 가장 오래 기다린 기능이 가장 위험한 상태로 배포된다.

표준 네 가지는 무엇을 합의했나

여기까지가 한 배치를 뜯어본 결과다. 표준들은 이 문제를 각자 다르게 처리한다.

전략상시1회용가지 수명배포 시점
Git Flow (2010)main, developrelease/* hotfix/* feature/*수일~수주release 안정화 완료 시
GitHub Flowmainfeature/*수시간~2일main 머지 즉시
GitLab Flowmain, 환경 브랜치feature/* release/*수시간~수일하위로 흘려보낼 때
Trunk-Basedmain짧은 feature1일 미만상시(노출은 플래그로)

넷의 공통 불변식은 하나다 — 테스트한 커밋을 그대로 배포한다. 서로 다른 건 가지를 얼마나 짧게 자르느냐, 상시 브랜치를 몇 개 두느냐뿐이다.

Git Flow 저자는 2020년에 자기 글에 주석을 달았다. 계속 배포하는 팀은 GitHub Flow 같은 더 단순한 워크플로를 쓰라고. 명시적 버전 관리가 필요하거나 여러 버전을 동시에 지원할 때만 여전히 유효하다고 본다. 15년 된 표준의 저자가 스스로 적용 범위를 좁힌 경우다.

GitLab 공식 문서는 "GitLab Flow"라는 이름을 더 이상 내세우지 않는다. 필요한 수준에 맞추라고만 안내한다. 그 전략의 핵심은 아래로만 흐른다는 것 — 환경 브랜치는 되돌아 올라오지 않는다.

Trunk-Based는 기능 플래그가 전제다. 플래그 인프라 없이 하면 미완성이 그대로 나간다.

상시와 1회용을 섞지 않는다

넷이 공유하는 전제가 하나 더 있다. Git Flow 원문도 master·develop만 main branches라 부르고, release/hotfix/feature"supporting branches — 결국 삭제될, 수명이 한정된 브랜치" 로 명시해 구분한다.

상시 브랜치1회용 브랜치
main, developfeature/*, release/*, hotfix/*
수명저장소가 존재하는 한머지되는 순간 존재 이유 끝
규칙역할이 하나씩만머지되면 지운다

이 구분이 흐려지면 브랜치 목록이 신호를 잃는다. 지금 살아있는 게 뭔지 눈으로 판별할 수 없게 되고, 죽은 브랜치에 실수로 커밋한다.

구조와 절차를 갈라 봐야 한다

해결 비용이 완전히 다르다.

리스크판별법
구조검증 지점과 배포 지점이 다르면 QA 결과가 이전되지 않는다브랜치 배치를 바꿔야 사라진다
구조한쪽으로만 흐르는 상시 브랜치는 신뢰의 종착점이 못 된다이 브랜치에서 나가는 화살표가 있는가
절차태그 없는 롤백 절차는 절차가 아니다직전 배포를 태그 하나로 가리킬 수 있는가
절차오래된 base에서 가지를 자르면 충돌이 누적된다충돌 전용 규칙이 생겼다 = 충돌이 일상이 됐다는 신호
절차1회용 브랜치를 안 지우면 목록이 신호를 잃는다머지 후 자동 삭제
절차문서에 적힌 브랜치가 실제로 없으면 규칙 전체가 불신된다상시 브랜치 목록은 문서 한 곳에만

두 번째 줄의 판별법이 특히 쓸모 있었다. 나가는 화살표가 없는 브랜치는 본류가 아니라 웅덩이다. 들어오는 화살표만 있는 상시 브랜치는 시간이 갈수록 실제 배포되는 것과 멀어지고, 배포와 무관해진 브랜치의 검증은 배포의 근거가 못 된다.

절차부터 한다

절차 쪽은 코드를 안 건드리고 오늘 할 수 있다.

  • 배포마다 태그를 자동으로 남긴다. 패키지 태그와 구분되는 접두사로 (deploy/<날짜>-<회차>)
  • 롤백을 평상시에 한 번 해 본다. 문서에만 있는 절차는 장애 때 처음 실행하게 된다
  • 머지된 브랜치 자동 삭제를 켠다
  • 상시 브랜치 목록을 문서 한 곳에만 적는다

구조는 그 다음이고, 가장 작은 변화는 릴리스 브랜치를 먼저 만들고 거기서 QA하는 것이다. 준비된 것만 머지하면 늦은 확정은 유지되고, 테스트한 커밋이 배포한 커밋이 된다.

어느 쪽을 택하든 가지 수명 목표를 숫자로 적어야 한다. "짧게"는 목표가 아니다.


표준 4종은 공식 문서에서 확인했다. 자주 인용되는 DORA 배포 빈도 수치는 2차 인용이라 원본 리포트를 확인하지 못해 넣지 않았다. revert 동작만은 임시 저장소에서 직접 재현한 것이다.

다이어그램은 인라인 SVG다. mermaid는 d3와 dagre를 포함해 gzip 500KB가 넘고, 경량 대안도 30KB 수준이라 라우트당 예산에 들어가지 않는다. 손으로 쓴 SVG는 수백 바이트이고, currentColorvar(--accent) 를 쓰므로 이 사이트의 명암 반전을 자동으로 따라간다.

gitbranchingreleaseverification

기록

2026.08.27에 처음 커밋됐고, 이후 손대지 않았습니다.

  • 8be82d4Content: 릴리스 브랜치를 늦게 만들 필요가 없었다

전체 빌드 기록 →