CI/CD 파이프라인 · 트렁크 기반 개발과 브랜치 보호 · 이론
초록불이 병합 뒤에도 초록불인가
한 줄 요약
브랜치에서 초록불이었는데 병합하고 나니 빨간불이 되는 일은 드물지 않다. 브랜치의 검사는 내 변경 더하기 그때의 트렁크를 본 것이지, 병합된 뒤의 트렁크를 본 것이 아니기 때문이다. 이 간극을 줄이는 것이 짧은 수명 브랜치와 서버 쪽 규칙이 하는 일이다.
왜 이게 필요했나
트렁크 기반 개발은 "개발자들이 트렁크라 불리는 하나의 브랜치에서 협업하고, 다른 장수 개발 브랜치를 만들려는 압력에 저항하는 소스 관리 모델" 로 정의된다. 짧은 수명 브랜치는 코드 리뷰와 빌드 검사를 위해 쓰되 곧 사라지는 것을 전제로 한다. 릴리스 브랜치도 필요할 때 트렁크에서 잘라 쓰고 릴리스 뒤 얼마 지나 지운다. 릴리스에 붙이는 버전 이름에도 같은 성질이 요구된다. 시맨틱 버전 규약은 한 번 배포된 버전의 내용은 수정되어서는 안 되며 고칠 것이 있으면 새 버전으로 내야 한다고 못 박는다. 이미 나간 것을 손대지 않는 쪽이 되돌릴 대상을 남긴다.
왜 장수 브랜치를 피하는가. 브랜치가 길어질수록 트렁크와의 거리가 벌어지고, 벌어진 거리는 병합할 때 한꺼번에 청구된다. 그리고 그 청구서에서 무서운 것은 텍스트 충돌이 아니다. 텍스트 충돌은 도구가 알려 준다. 무서운 것은 의미 충돌 이다. 내가 브랜치에서 쓰고 있던 함수의 동작을 다른 사람이 트렁크에서 바꿨다면, 두 변경은 서로 다른 줄에 있어서 깨끗하게 병합되고 결과만 틀린다. 버전 관리 도구는 이것을 감지하지 못한다.
되먹임 관점에서도 답은 같다. 통합이 미뤄질수록 문제를 늦게 알고, 늦게 알수록 원인 후보가 많아진다. "어떤 코드도 두어 시간 넘게 통합되지 않은 채로 있지 않는다" 는 켄트 벡의 문장이 목표선을 가장 짧게 적어 준다.
브랜치 보호는 서버 쪽 규칙이어야 한다
규칙을 사람의 약속으로 두면 지켜지지 않는다. 바쁜 날, 장애 대응 중, 새로 온 사람의 첫 주에 깨진다. 그래서 규칙은 밀어 넣는 쪽이 아니라 받는 쪽 에 둔다.
git 자체가 이 구분을 갖고 있다. 훅은 기본적으로 $GIT_DIR/hooks 에 있고, 복제로 따라오지 않는다. git init 이 템플릿을 복사할 수는 있지만, 내가 만든 pre-commit 훅이 동료의 사본에 저절로 생기지는 않는다. 즉 로컬 훅은 편의 장치이지 강제 수단이 아니다. 강제는 서버 쪽 훅이 한다.
pre-receive는 받는 작업 전체에 한 번 돈다. 0이 아닌 값으로 끝나면 어떤 레퍼런스도 갱신되지 않는다. 푸시가 통째로 거부된다.update는 갱신할 레퍼런스마다 한 번씩 돈다. 0이 아니면 그 레퍼런스만 갱신되지 않는다.post-receive는 갱신이 끝난 뒤에 돈다. 알림이나 후속 작업용이다.
호스팅 서비스의 브랜치 보호도 같은 자리의 기능이다. GitHub 의 보호 규칙에는 병합 전 풀 리퀘스트 요구, 필수 상태 검사, 대화 해결 요구, 서명된 커밋 요구, 선형 이력 요구, 병합 대기열 요구, 배포 성공 요구, 브랜치 잠금, 밀어 넣을 수 있는 사람 제한, 강제 푸시 허용, 삭제 허용이 있다.
여기서 반드시 알아야 할 기본값이 하나 있다. 보호 규칙은 기본적으로 관리자에게 적용되지 않는다. 문서는 저장소 관리자 권한이나 보호 우회 권한을 가진 사람에게는 제약이 적용되지 않는다고 적고, "위 설정의 우회를 허용하지 않음" 을 켜야 관리자에게도 적용된다고 안내한다. 규칙을 켜 놓고 안심하고 있었는데 정작 가장 위험한 변경이 그 규칙을 지나쳐 가는 일이 여기서 생긴다.
초록불이 병합 뒤에 빨간불이 되는 문제
필수 상태 검사를 켜도 간극은 남는다. 브랜치 A 와 브랜치 B 가 각각 트렁크 기준으로 초록이었는데, A 를 먼저 병합하고 나면 B 의 검사 결과는 더 이상 현재 트렁크에 대한 것이 아니다. 그대로 병합하면 트렁크가 깨진다.
해결의 방향은 두 가지다.
- 병합 직전에 최신 트렁크를 반영해 다시 검사한다. "브랜치를 최신 상태로 유지" 요구가 이것이다. 단순하지만, 병합이 잦으면 다시 검사하는 동안 또 다른 병합이 일어나 계속 뒤처진다.
- 병합 대기열을 쓴다. 병합할 것들을 줄 세워 병합된 상태를 미리 만들어 검사하고, 통과한 것만 트렁크에 넣는다. 여러 건을 묶어 한 번에 검사하고 실패하면 범인만 빼고 다시 시도하는 식이라, 줄이 길어도 검사 횟수가 폭발하지 않는다. GitLab 의 병합 트레인도 같은 문제를 푸는 기능이다.
긴 브랜치 대신 기능 플래그
"이 기능은 아직 완성이 아니라서 병합할 수 없다" 가 장수 브랜치의 흔한 이유다. 답은 완성되지 않은 것을 트렁크에 넣되 켜지 않는 것 이다. 기능 플래그와 추상화에 의한 분기(branch by abstraction)가 그 방법이다. 미완성 코드가 트렁크에 있되 사용자에게는 보이지 않으므로, 통합은 매일 하면서 공개 시점은 따로 정할 수 있다.
대신 플래그에는 비용이 있다. 플래그가 늘면 실제로 도는 조합이 늘고, 시험이 커버하지 못하는 경로가 생긴다. 플래그를 만들 때 지우는 시점을 같이 정해 두는 것 이 유일하게 통하는 관리법이다.
현장에서 만나는 모습
- "급해서 직접 밀었다" 는 사건의 절반은 관리자 계정에서 나온다. 우회 허용을 끄지 않았기 때문이다.
- 필수 상태 검사 이름을 바꿨는데 보호 규칙의 이름은 그대로라, 아무 검사도 요구하지 않는 상태로 몇 주가 지난다. 규칙이 실제로 막는지 직접 시험해 보는 것 말고 확인할 방법이 없다.
- 선형 이력을 요구하면 되돌리기와 이분 탐색이 쉬워진다. 병합 커밋이 얽힌 이력에서 범인 커밋을 좁히는 일은 눈에 띄게 더 성가시다.
- 브랜치 수명을 재기 시작하면 대개 며칠짜리 브랜치가 문제의 대부분을 만들고 있다는 것이 드러난다.
참고
- 트렁크 기반 개발: https://trunkbaseddevelopment.com/
- 브랜치 보호: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches
- 병합 대기열: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue
- GitLab 병합 트레인: https://docs.gitlab.com/ci/pipelines/merge_trains/
- githooks: https://git-scm.com/docs/githooks
- 지속적 통합: https://martinfowler.com/articles/continuousIntegration.html
- 추상화에 의한 분기: https://martinfowler.com/bliki/BranchByAbstraction.html
- 시맨틱 버저닝: https://semver.org/
다음 실습에서 할 것
로컬에 베어 저장소를 만들어 받는 쪽 규칙을 직접 세운다. pre-receive 와 update 훅을 써서 보호 브랜치로의 직접 푸시를 막고, 특정 레퍼런스만 거부하는 것과 푸시 전체를 거부하는 것의 차이를 종료 코드로 확인한다. 그다음 로컬 훅을 만들어 두고 복제한 사본에 그 훅이 따라오지 않는 것을 눈으로 본다. 필수 검사 흉내는 커밋에 붙인 검사 결과를 훅이 확인하는 방식으로 만들고, 의미 충돌은 두 브랜치가 각각 통과한 뒤 병합했을 때 깨지는 예제로 재현한다. 마지막에는 병합 전에 트렁크를 반영해 다시 검사하는 대기열 흉내를 스크립트로 만들어, 줄이 길어질 때 검사 횟수가 어떻게 달라지는지 센다.