순환 복잡도와 카나리 배포 위험
요약
순환 복잡도 점수 자체보다는 diff에서 변경된 복잡도(complexity delta)가 배포 위험을 결정합니다. 테스트 갯수와의 비율을 cross-check해서 카나리 진행 속도를 gate하고, runbook에 기록하는 것이 20% 에러 버짓 소비를 피하는 가장 간단한 방법입니다.
순환 복잡도는 함수의 독립적인 실행 경로 갯수를 센다. NIST 권고는 함수당 10 이하, 대부분의 팀은 CI에서 비슷한 수준으로 강제한다. 문제는 그 수치가 카나리로 5% 트래픽에 어떤 경로가 실제로 실행될지는 말해주지 않는다는 점이다. 그 간극이 실제 배포 incident이 시작되는 지점이다. 순환 복잡도를 lint 규칙이 아니라 배포 위험 입력값으로 대하면 이 숫자가 의미를 가지기 시작한다.
순환 복잡도가 측정하는 것
McCabe 지표는 그래프 이론: 노드, 간선, 연결 요소. 모든 if, for, while, case, 그리고 boolean 연산자가 경로를 추가한다. 복잡도 25인 함수는 최소 25개의 선형 독립 경로를 가지고 있다. 이건 의견이 아니라 basis path testing 수학이고, 함수를 end-to-end로 한 번 커버하려면 최소 몇 개의 테스트 케이스가 필요한지를 정의한다. Sourcegraph의 분석에는 전체 임계값이 있다: 1-10은 low risk, 11-20은 moderate, 21-50은 high, 50 이상은 "우리가 대화해야 할" 수준이다.
대부분의 팀은 그 최소값에 못 미친다. coverage 도구는 line coverage를 보고하지 path coverage를 보고하지 않으니, 함수가 90% coverage를 보여도 branch의 절반은 CI에서 한 번도 실행되지 않았을 수 있다. 그게 PR 템플릿에 나가지 않는 부분이고, "tests pass" = "이건 safe하다"가 두 가지 다른 주장인데 하나로 처리되는 이유다.
우리가 본 post-mortem 충분히 많은데 대부분은 error budget burn 라인, time to detect 라인이 있다. 거의 없는 게 changed function의 complexity 모양이다. 그건 도구의 문제가 아니라 보고 프로세스의 간극이다.

단순 숫자가 진짜 blast radius를 숨기는 이유
여기가 모든 complexity dashboard가 틀리는 부분이다: 두 함수가 같은 cyclomatic complexity 점수를 가지고 있어도 위험도는 완전히 다를 수 있다. 20개 branch를 가진 switch statement(well-tested handler로 dispatching)는 80줄에 흩어진 4개의 nested conditional pocket을 가진 함수와는 다른 동물이다. CodeScene은 두 번째 형태를 "bumpy road"라고 부르고, 그 이름이 정확하다. 작동 기억력을 소비하는 건 raw branch count가 아니라 nesting depth이고, 그 부분이 edge case를 놓친다. CodeScene의 writeup에는 비교가 자세히 나와 있고, 배포 데이터에서 우리가 보는 것과 깔끔하게 맞아떨어진다.
이게 우리한테 중요한 이유는 카나리가 평균 complexity에서 실패하지 않기 때문이다. 실패하는 건 이번 diff에서 touched된 하나의 bumpy function이고, 2am에, load-test 안 된 트래픽 아래에서다. 우리가 platform team과 리뷰한 post-mortem에서 패턴이 반복된다: root-cause function은 거의 절대로 repo에서 complexity 점수가 가장 높지 않다. 그 점수가 이번 배포 diff에서 가장 높은 complexity delta를 가진다. 그게 다른 신호고, 거의 아무도 instrument하지 않는다.
Dashboard가 codebase의 complexity가 trending down이라고 말하는 것과 dashboard가 지금 이 rollout이 touched한 function이 3개의 nested branch를 새로 pick up했다고 말하는 건 다르다. 첫 번째는 분기마다 한 번 읽는 health report다. 두 번째는 deploy pipeline에 있어야 하는 gate인데, SLO check 옆에 있어야지, 일하는 중 아무도 안 여는 static-analysis tool에 파묻혀 있지 않아야 한다.
Ring-based와 percentage-based rollout은 이미 일부 diff가 다른 것보다 더 risky하다고 가정한다. 그게 카나리의 전체 전제다. 대부분의 pipeline이 하지 않는 것은 diff 자체의 complexity 모양이 카나리가 얼마나 빨리 widen되어야 하는지를 inform하게 하는 것이다. 그건 code-review concern으로 대해지고, PR merge 순간 잊혀진다.
모두가 설정하는 CI gate, 10으로 설정하고, 왜 변화가 없을까
표준 advice는 cyclomatic complexity를 10으로 cap하고 그 이상은 build fail하는 것이고, 거의 모든 엔지니어링 블로그가 추천하고 거의 아무도 실제 incident 데이터로 검증하지 않는다. 틀린 건 아니다. 단순하게 불완전하다. 팀이 위험을 처리했다고 믿게 하는데, 대부분은 단지 이동했을 뿐이다.
두 가지 공통 failure mode:
게임하기. Extract Method는 textbook fix이고, 작동한다: 함수당 cyclomatic complexity가 drops. 하지만 extraction이 실제 decision count를 줄이지 않고, 단지 하나 함수에서 3개 함수로 relocation하면, 시스템 complexity는 unchanged다. Gate는 green으로 간다. Blast radius는 shrink하지 않는다. Single diff view에서 보기만 harder해진다.
모양 무시하기. Flat threshold는 12-branch dispatcher와 12-path bumpy road를 동등하게 risky하게 취급한다. 다르다. Dispatcher는 아마 fine하다. 그건 mechanical routing이다. Bumpy road가 다음 rollback이 사는 곳이다. 왜냐하면 reviewer가 3단계 nested 안의 상태를 tracking하기를 멈췄거든.
AI coding agent가 이것을 먼저 worse하게 만든 다음 better하게 만든다. Devin과 비슷한 autonomous agent는 PR을 빠르게 ship한다. "Fast"는 이미 있는 것을 refactor하는 대신 branch를 추가하는 것을 의미하는 경우가 많다. 그게 passing test suite를 위해 optimize하는 model의 least resistance path다. Reviewer의 cognitive load는 아니다. 팀이 AI-authored diff를 volume으로 merge하고 있으면, 그 전에 복잡도 delta per PR이 dashboard에 있어야 하는 metric이고, incident review finding으로가 아니라.

Complexity delta가 실제로 말하는 것은 testing burden
절대값은 forget하자. Incident risk를 predict하는 숫자는 single diff로 도입된 complexity의 change다. 그것을 test delta와 cross-reference하는 것이다.
Complexity가 8에서 19로 한 PR에서 가는 함수는, 위의 basis-path-testing 수학에 의해, 대략적으로 필요한 test count를 두 배로 했다. PR이 2개의 test를 추가했으면, coverage gap이 passing CI run으로 disguise된 것을 ship한 것이다. 그 gap은 카나리가 5% 트래픽 slice을 hit할 때까지 show up하지 않는다. 그것이 untested branch를 exercise할 때다. 그때는 incident고, code review comment가 아니다.
Upstreamapi의 AI Pilot이 닫는 게 이 instrumentation gap이다. Deploy side에서: complexity delta와 shipped diff를 gate하고, test delta와 cross-check하고, downstream error-rate SLO에만 의존하지 않는다. Error-rate SLO는 뭔가 이미 broken이라고 말한다. Complexity-delta gate는 diff가 live traffic을 serve하기 전에 뭔가를 break할 가능성이 더 높다고 말한다. 그게 그 정보가 still actionable한 유일한 지점이다.
// Simplified canary gate: widen slowly on low-risk diffs, hold on high-risk ones
function canaryStep(diff: DiffMetrics): CanaryDecision {
const complexityRisk = diff.complexityDelta / Math.max(diff.testDelta, 1);
if (complexityRisk > 3 && diff.slo.errorBudgetBurn > 0.1) {
return { action: "hold", trafficPct: diff.currentTrafficPct };
}
if (complexityRisk > 3) {
return { action: "extend_bake_time", bakeMinutes: 45 };
}
return { action: "advance", trafficPct: diff.currentTrafficPct + 10 };
}Research가 정말 이 주제에서 합의하는가
아니고, 그건 plainly 말할 가치가 있다. 일부 practitioner research는 cyclomatic complexity를 defect predictor로 완전히 pushback한다. 더 낮은 점수로 optimize하는 team이 단순히 덜 visible한 곳으로 복잡도를 relocate한다고 argue한다. GetDX의 critique는 이 case를 만들고 developer-experience metric 대신 team을 point한다. 우리는 그 argument가 metric을 kill한다고 생각하지 않는다. 우리는 단독으로 사용된 metric을 kill한다고 생각한다. Static repo-wide score로, disconnected from the diff and the rollout it's attached to.
Complexity를 실제로 카나리에 gate하는 방법
Three things, 얼마나 많은 friction을 PR에 add하는가에 따라 순서가 있다:
Compute complexity delta per diff, not per repo. Repo-wide average는 이번 주에 의미 있는 하나의 function을 hide한다. Delta per PR은 대부분의 static analysis tool에서 compute하기 cheap이고, 그게 following 48시간에서 actually break와 correlate하는 숫자다.
Cross-reference against test delta, not test count. 40개 test를 가지고 있고 complexity jump가 8에서 19로 가고 zero new test인 함수는 bigger risk보다 complexity 15와 matching coverage from day one인 fresh function다.
Feed the ratio into rollout pacing, not just merge approval. High complexity-delta diff가 review에서 block될 필요는 없다. 많은 legitimately complex domain logic (state machine, protocol parser)은 항상 높은 점수를 post할 것이다. 그것이 필요한 건 slower canary와 shorter error-budget leash고, 그건 rollout decision이지, code review decision이 아니다.
Incident가 발생하기 전에 runbook entry를 쓰자. 3am에 incident channel을 open하는 on-call engineer가 reverse-engineer해야 한다면 why a "clean" PR이 error budget을 burn했는지, documentation은 code보다 먼저 fail했다. Deploy log에 한 줄 note ("complexity delta 11, test delta 1, held at 20%")는 writing cost nothing이고 following인 모든 incident review의 첫 15분을 save한다.

추적할 가치가 있을까, 아니면 단순한 또 하나의 dashboard 숫자인가
어디에 attach하는지에 완전히 달려있다. Repo-health trend line으로서의 cyclomatic complexity는 대부분 decoration이다: quarterly slide로는 nice하고, 2am에는 useless다. Per-diff delta로서의 cyclomatic complexity, canary pacing에 fed, test growth와 cross-check되면, "이 rollout은 누군가를 page할 예정"의 cheaper leading indicator 중 하나다.
Post-mortem은 SLO budget이 rollback 전에 뭘 보였는지 물을 것이다. 점점 더 우리 것은 또한 complexity delta를 물어본다. 그 질문을 retro에서 forced되기 전에, incident 전에 자신의 runbook에 add하는 것이 worth한다. 그때는 shrug와 "다음 time better tests add하자"는 promise다.