← posts/b.log()

blog92@web:~$ cat posts/devops-patterns-03-code-flow.md

DEVOPS10 min read

DevOps 패턴과 안티패턴 3강 — 코드 흐름: 병합 지옥에서 벗어나기

Integration Hell과 장수 브랜치가 만드는 병합 이자, 그리고 Continuous Integration·Trunk-Based Development·Branch by Abstraction·Stop the Line으로 변경 단위를 줄이는 방법.

이전 강의: 2강. 인프라: 손으로 만든 서버의 최후 이번 강의 키워드: Integration Hell, Merge Hell, 장수 브랜치, Continuous Integration, Git Flow, Trunk-Based Development, Branch by Abstraction, Stop the Line

들어가며

두 달 동안 기능 브랜치에서 개발한 결제 모듈이 드디어 완성됐습니다. 이제 main에 병합만 하면 됩니다. 그런데 그 두 달 사이 main에는 다른 팀원들의 커밋이 300개 넘게 쌓였습니다. 충돌 파일이 40개가 나오고, 충돌을 다 해결해 빌드가 통과했는데도 테스트 서버에서는 이상한 버그가 쏟아집니다. 병합에만 일주일이 걸립니다.

이 일주일은 기능 개발에 쓴 시간이 아닙니다. 순전히 따로 떨어져 있던 시간에 대한 이자입니다. 2강에서 서버가 따로 놀면서 드리프트가 생겼듯, 코드도 따로 놀면 드리프트가 생깁니다. 이번 강의는 이 이자를 어떻게 줄이는지, 즉 1강의 두 번째 질문인 "변경 단위가 작은가"를 코드 흐름에서 구현하는 방법을 다룹니다.

Integration Hell: 오래된 문제

이 문제는 Git보다 훨씬 오래됐습니다. 폭포수 개발 시절에는 각 팀이 모듈을 몇 달씩 따로 개발한 뒤 프로젝트 막바지에 한꺼번에 합치는 "통합 단계"가 있었습니다. 이 단계는 거의 항상 일정을 초과했고, 업계는 이를 Integration Hell(통합 지옥)이라고 불렀습니다. 각 모듈은 단독으로는 잘 동작했지만, 서로에 대한 가정이 몇 달 동안 검증되지 않은 채 어긋나 있었기 때문입니다.

분산 버전 관리와 브랜치가 보편화되면서 같은 문제가 Merge Hell(병합 지옥)이라는 이름으로 돌아왔습니다. 도구는 바뀌었지만 구조는 같습니다. 따로 떨어져 있는 기간이 길수록 합칠 때의 고통이 커집니다.

왜 병합 비용은 기간에 비례하지 않고 더 빨리 커지나

장수 브랜치의 비용이 단순히 "기간에 비례해서" 커진다면 참을 만했을 겁니다. 실제로는 그보다 빠르게 커집니다. 이유는 두 가지입니다.

첫째, 충돌이 서로 얽힙니다. 브랜치가 하루 떨어져 있으면 main의 변경 몇 개와만 부딪힙니다. 두 달 떨어져 있으면 main의 변경들이 서로의 위에 쌓인 상태와 부딪힙니다. 어떤 충돌을 해결하려면 그 사이 main에서 일어난 리팩터링 세 개를 먼저 이해해야 하는 식이죠.

둘째, 보이지 않는 충돌이 있습니다. Git이 알려주는 것은 같은 줄을 동시에 고친 텍스트 충돌뿐입니다. 더 위험한 것은 의미 충돌(semantic conflict)입니다. 내 브랜치에서는 getUser()가 null을 반환하지 않는다고 가정하고 코드를 짰는데, main에서 누군가 그 함수가 null을 반환할 수 있도록 바꿨다고 해봅시다. 두 변경은 서로 다른 파일에 있으니 Git은 아무 충돌도 보고하지 않고, 빌드도 통과합니다. 버그는 운영에서야 드러납니다.

의미 충돌은 도구로 막을 수 없습니다. 유일한 방어책은 서로의 변경을 빨리, 자주 만나게 하는 것입니다. 그래야 가정이 어긋났을 때 그 범위가 작고, 누가 무엇을 바꿨는지 기억이 생생할 때 발견할 수 있습니다.

Continuous Integration: 원래 뜻

이 통찰에 붙은 이름이 Continuous Integration(지속적 통합, CI)입니다. Grady Booch가 1991년 처음 이 표현을 썼고, Kent Beck이 1990년대 후반 익스트림 프로그래밍(XP)에서 핵심 관행으로 정착시켰습니다. Martin Fowler가 2000년 쓴 글(이후 2006년, 2024년 개정)이 사실상의 표준 정의가 됐고, 2001년 ThoughtWorks가 CruiseControl이라는 초기 CI 서버를 내놓았습니다.

여기서 짚어야 할 점이 있습니다. CI의 원래 정의는 "모든 개발자가 적어도 하루에 한 번 메인라인에 코드를 통합한다"는 관행입니다. 매 통합마다 자동 빌드와 테스트로 검증하는 것은 그 관행을 안전하게 만드는 수단입니다. CI 서버는 도구일 뿐이죠.

그래서 흔한 안티패턴이 생깁니다. 이른바 CI 극장(CI Theater)입니다. Jenkins나 GitHub Actions가 기능 브랜치의 모든 푸시마다 빌드를 돌리고 초록 불을 띄우지만, 그 브랜치는 한 달 동안 main과 만나지 않습니다. 빌드는 자동화됐지만 통합은 여전히 한 달에 한 번입니다. 1강에서 본 "도구 우선 DevOps"의 코드 흐름 버전입니다.

브랜칭 전략의 역사: Git Flow와 그 이후

2010년 Vincent Driessen이 발표한 Git Flow는 한동안 Git 브랜칭의 교과서처럼 쓰였습니다. main, develop, feature/*, release/*, hotfix/*로 역할을 나눈 이 모델은 체계적이었고, 버전 단위로 패키지를 출시하는 소프트웨어에 잘 맞았습니다.

문제는 이 모델이 웹 서비스에도 그대로 복사됐다는 점입니다. 하루에도 여러 번 배포할 수 있는 웹 서비스에 develop과 release 브랜치 단계를 두면, 변경이 main에 닿기까지 여러 번의 병합을 거치고 기능 브랜치는 길어지기 쉽습니다. Driessen 본인도 2020년 원문에 메모를 추가해, 지속적으로 배포하는 웹 애플리케이션이라면 GitHub Flow 같은 더 단순한 흐름이 나을 수 있다고 밝혔습니다.

GitHub Flow(2011년 Scott Chacon이 정리)는 main 하나와 짧은 기능 브랜치만 둡니다. 여기서 한 걸음 더 나간 것이 Trunk-Based Development(TBD)입니다.

Trunk-Based Development

TBD의 규칙은 간단합니다. 모든 개발자가 trunk(main) 하나를 기준으로 작업하고, 브랜치를 만들더라도 하루나 이틀 안에 병합합니다. 소규모 팀에서는 브랜치 없이 trunk에 직접 커밋하기도 합니다. DORA 연구에서도 활성 브랜치를 소수로 유지하고 적어도 하루 한 번 trunk에 병합하는 팀이 높은 소프트웨어 전달 성과를 보였습니다.

처음 듣는 사람이 가장 먼저 하는 반론은 이것입니다. "기능 하나 만드는 데 2주가 걸리는데, 어떻게 하루 만에 병합합니까? 미완성 코드가 main에 들어가면 운영에 나가잖아요."

TBD의 답은 "병합(merge)과 공개(release)를 분리하라"입니다. 미완성 코드도 main에 들어갈 수 있습니다. 다만 사용자에게 보이지 않게 들어가야 합니다. 이를 위한 기법이 몇 가지 있습니다.

Feature Flag. 새 기능을 조건문 뒤에 숨깁니다. 코드는 배포되지만 플래그가 꺼져 있으면 실행되지 않습니다. 5강에서 자세히 다룹니다.

Branch by Abstraction. Paul Hammant가 이름 붙인 기법으로, 큰 구조 변경을 브랜치가 아니라 코드 안의 추상화 계층으로 격리합니다. 예를 들어 결제 모듈을 교체한다면 이렇게 진행합니다.

text
1. 기존 결제 코드 앞에 PaymentGateway 인터페이스를 만든다   → 병합
2. 호출부를 모두 인터페이스를 통하도록 바꾼다               → 병합
3. 새 구현체를 인터페이스 뒤에 조금씩 만든다                 → 매일 병합
4. 설정이나 플래그로 새 구현체로 전환한다                    → 병합
5. 기존 구현체를 삭제한다                                    → 병합

모든 단계에서 main은 빌드되고 배포 가능한 상태를 유지합니다. 두 달짜리 브랜치 하나가 수십 개의 작은 병합으로 바뀌는 것이죠.

작은 단위로 쪼개기. 가장 기본적이지만 가장 어려운 기법입니다. DB 스키마 추가, API 엔드포인트 추가, UI 연결을 각각 별도로 병합합니다. 사용되지 않는 API가 잠시 main에 있어도 괜찮습니다.

TBD에도 릴리스 브랜치는 있을 수 있습니다. 다만 규칙이 있습니다. 릴리스 브랜치는 짧게 유지하고, 버그 수정은 trunk에 먼저 넣은 뒤 릴리스 브랜치로 가져옵니다(cherry-pick). 반대로 하면 수정 사항이 trunk에 반영되는 걸 잊어, 다음 릴리스에서 같은 버그가 되살아납니다.

빨간 빌드는 모두의 문제다

모두가 하루에 여러 번 main에 병합한다면, main이 깨졌을 때의 영향도 모두에게 퍼집니다. 그래서 CI에는 짝을 이루는 규율이 있습니다. main 빌드가 실패하면 다른 모든 일을 멈추고 먼저 고친다는 것입니다.

이 발상은 도요타 생산 방식의 안돈(andon) 줄에서 왔습니다. 생산 라인의 누구든 결함을 발견하면 줄을 당겨 라인 전체를 멈출 수 있습니다. 결함이 있는 부품이 다음 공정으로 넘어가면 나중에 고치는 비용이 훨씬 커지기 때문입니다. 소프트웨어에서도 깨진 main 위에 다른 사람들이 계속 커밋을 쌓으면, 어느 변경이 문제인지 가려내기가 급격히 어려워집니다.

이 규율이 무너질 때 나타나는 안티패턴이 두 가지 있습니다.

빨간 빌드 방치. "그 테스트는 원래 가끔 깨져요"라는 말이 나오기 시작하면 위험 신호입니다. 빌드가 늘 빨간색이면 새로 생긴 진짜 실패도 구별되지 않습니다.

Flaky Test(불안정한 테스트). 코드 변경 없이도 통과했다 실패했다 하는 테스트입니다. 사람들은 실패하면 일단 재실행 버튼부터 누르게 됩니다. 사회학자 Diane Vaughan은 챌린저호 사고를 분석하며 일탈의 정상화(normalization of deviance)라는 개념을 제시했습니다. 비정상 신호가 반복되는데 큰 사고가 나지 않으면, 그 신호를 정상으로 받아들이게 된다는 것이죠. Flaky test는 팀에게 "실패 신호는 무시해도 된다"는 습관을 가르칩니다. 불안정한 테스트는 즉시 격리하고 고치거나 삭제해야 합니다. 신뢰할 수 없는 테스트는 없는 테스트보다 해롭습니다.

피드백 속도: 느린 빌드는 큰 배치를 부른다

하루에 여러 번 통합하려면 빌드와 테스트가 빨라야 합니다. XP는 전통적으로 10분 빌드를 목표로 삼았습니다. 빌드가 한 시간 걸리면 개발자는 결과를 기다리지 않고 다음 작업을 시작하고, 실패 알림이 올 때쯤엔 이미 맥락을 잃습니다. 결국 사람들은 "빌드가 오래 걸리니 모아서 한 번에 올리자"고 생각하게 되고, 변경 단위가 다시 커집니다. 1강의 순환이 여기서도 나타나는 것이죠.

빠른 피드백을 위한 대표적인 구조가 Mike Cohn이 정리한 테스트 피라미드입니다. 빠르고 많은 단위 테스트를 바닥에, 적당한 수의 통합 테스트를 중간에, 느리고 비싼 E2E 테스트를 꼭대기에 소수만 둡니다. 이 모양이 뒤집혀 E2E 테스트가 대부분인 상태를 아이스크림 콘 안티패턴이라고 부릅니다. 테스트 스위트가 느리고 불안정해지기 쉽습니다.

코드 리뷰라는 병목

TBD를 도입하려는 팀이 자주 부딪히는 벽이 코드 리뷰입니다. 리뷰가 하루씩 걸리면 하루 한 번 병합은 불가능합니다.

여기서 흔한 안티패턴이 거대한 PR입니다. 변경 파일이 50개인 PR을 받은 리뷰어는 꼼꼼히 볼 엄두가 나지 않아 대충 훑고 승인합니다. 반면 파일 3개짜리 PR에는 날카로운 지적이 달립니다. 역설적으로 PR이 클수록 리뷰의 질은 떨어집니다. 작은 PR은 리뷰를 빠르게 할 뿐 아니라 제대로 하게 만듭니다.

리뷰 대기 시간 자체를 줄이는 방법도 있습니다. 리뷰를 다른 작업보다 우선하는 팀 규칙, 페어 프로그래밍이나 몹 프로그래밍으로 작성과 동시에 리뷰하는 방식이 대표적입니다.

안티패턴 한눈에 보기

안티패턴증상대응 패턴
장수 기능 브랜치병합에 며칠씩 걸림, 의미 충돌Trunk-Based Development, 작은 병합
CI 극장CI 서버는 초록인데 통합은 드묾하루 한 번 이상 trunk 병합
환경별 브랜치dev, staging, prod 브랜치 간 병합단일 trunk + 아티팩트 승격 (4강)
빨간 빌드 방치"원래 가끔 깨져요"Stop the Line
Flaky Test재실행이 습관이 됨즉시 격리·수정·삭제
아이스크림 콘빌드가 느리고 불안정테스트 피라미드
거대한 PR형식적인 승인작은 PR, 리뷰 우선 규칙

표의 세 번째 줄인 환경별 브랜치는 특히 흔합니다. 개발 환경용 브랜치에서 스테이징 브랜치로, 다시 운영 브랜치로 병합하며 배포하는 방식이죠. 이 방식에서는 각 환경에 배포되는 것이 같은 코드라는 보장이 없습니다. 병합 과정에서 누락이나 충돌 해결 실수가 끼어들 수 있기 때문입니다. 이 문제의 해법은 다음 강의의 주제입니다.

정리

Merge Hell은 코드가 따로 떨어져 있는 시간에 대한 이자이고, 그 이자는 텍스트 충돌과 의미 충돌이 얽히며 기간보다 빠르게 불어납니다. CI의 본질은 CI 서버가 아니라 하루 한 번 이상 통합하는 관행이며, Trunk-Based Development는 이 관행을 브랜칭 전략으로 구현한 것입니다. 미완성 코드는 브랜치가 아니라 Feature Flag와 Branch by Abstraction으로 격리하고, 빨간 빌드를 즉시 고치는 규율과 빠른 테스트가 이 흐름을 지탱합니다.

1강의 세 질문으로 보면, 이번 강의는 작은 변경 단위와 빠른 피드백을 코드 흐름 안에서 구현하는 방법이었습니다.

다음 4강에서는 trunk에 병합된 코드가 운영 서버까지 가는 길, 즉 배포 파이프라인을 다룹니다. 왜 환경마다 다시 빌드하면 안 되는지, latest 태그가 왜 위험한지, 그리고 GitOps가 2강의 수렴 모델을 어떻게 다시 불러오는지 살펴봅니다.

더 깊이

COMMENTS (…)

댓글을 불러오는 중이에요.

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..