전체 코드베이스에 단일 mutation score를 강제하는 것은 팀이 테스트를 싫어하게 만드는 최고의 방법입니다.

일반적인 저장소에 PIT이나 Stryker를 실행하면 같은 패턴이 보입니다: 인증 모듈은 40%를 기록하고, 문자열 유틸리티는 95%를 찍으며, ORM 계층은 60%대 어딘가에 머물러 있습니다. 본능적인 반응은 전역 게이트를 예를 들어 70%로 설정하고, 이 아래로 떨어지는 모든 PR을 차단하는 것입니다. 두 스프린트 후, 누군가 CI에서 해당 검사를 비활성화하고 “flaky mutators” 탓으로 돌립니다.

진짜 문제는 도구가 아닙니다. mutant가 살아남았을 때 모든 코드가 같은 blast radius를 가진다고 가장하는 것입니다.

mutation testing이 실제로 측정하는 것

Code coverage는 어떤 라인이 실행되었는지 알려줍니다. mutation testing은 그 라인들이 변경되면 테스트가 이를 감지할 수 있는지 알려줍니다.

mutation framework는 소스에 작은 결함(mutants)을 삽입합니다. ><로 뒤집거나, 메서드 호출을 제거하거나, 반환값을 변경할 수 있습니다. 테스트 스위트가 변경 사항을 잡아낸다면 mutant는 killed됩니다. 아무 문제 없이 통과한다면 mutant는 survives합니다. mutation score는 killed된 mutant의 비율입니다.

password hash comparison에서 살아남은 mutant는 프로덕션을 기다리는 보안 버그입니다. capitalizeFirstLetter 헬퍼에서 살아남은 mutant는 최악의 경우 약간 이상한 UI 라벨일 뿐입니다.

둘을 똑같이 다루는 것이 팀이 실수하는 지점입니다.

인증(auth)이 90%+ 게이트를 받을 자격이 있는 이유

authentication과 authorization 코드는 공격적인 mutation testing에 이상적인 두 가지 특성을 가지고 있습니다.

첫째, 로직은 보통 이산적이고 상태 머신과 같습니다. 토큰이 만료되었는가? 역할이 허용된 집합에 있는가? 서명이 검증되었는가? 각 분기에는 명확한 보안적 의미가 있으며, 각각 테스트되어야 합니다.

둘째, 살아남은 mutant의 비용은 참혹합니다. role check에서 단 하나의 boolean이 뒤집히면 admin endpoint가 노출될 수 있습니다. token validation routine에서 not 하나가 빠지면 위조된 JWT를 수락할 수 있습니다. 이것은 이론이 아닙니다. CVE 데이터베이스에는 mutation testing이 잡아냈을 논리 오류로 인한 auth bypass가 가득합니다.

Sentry에서는 authn/authz/ 모듈의 모든 것에 90% mutation score를 강제합니다. 그 이하이면 CI가 실패합니다. 재정의도 없고, “다음 스프린트에 고치겠다”도 없습니다. 모듈이 충분히 작아서 프로덕션 코드 한 줄당 테스트 40줄을 작성하지 않고도 달성할 수 있습니다.

실제로는 이렇게 보입니다. 이것은 단순화된 JWT validation routine입니다:

import time
from typing import Optional

def verify_token(token: dict, expected_aud: str, leeway: int = 30) -> bool:
    now = time.time()

    if token.get("aud") != expected_aud:
        return False

    exp = token.get("exp")
    if exp is not None and now > exp + leeway:
        return False

    return True

mutation framework는 만료 검사에서 >>=로 뒤집을 수 있습니다. 정확히 now + leeway에 만료되는 토큰을 사용하는 테스트가 없으면 그 mutant는 살아남습니다. 이는 테스트가 실제로 경계를 검증하지 않는다는 의미입니다. 90% mutation coverage에서는 그 테스트가 존재합니다.

유틸리티 코드는 60%로 충분하다

StringUtils, DateHelpers, MathExtensions는 스펙트럼의 반대편 끝입니다.

이런 모듈은 순수하고, 많이 재사용되며, 이해하기 쉬운 경향이 있습니다. truncate(str, maxLen)에서 >>=로 변경하는 살아남은 mutant는 문자 하나를 추가로 잘라낼 뿐입니다. 이는 UI의 quirks일 뿐, 보안 사고가 아닙니다.

risk-reward 계산이 달라집니다. 이런 모듈에는 종종 수십 개의 작은 함수가 있습니다. 90% mutation coverage를 쫓는다는 것은 padLeft의 모든 off-by-one variant에 대한 테스트를 작성한다는 의미입니다. 테스트가 보호하는 코드보다 길어지고, 유지보수 부담이 가치를 앞서기 시작합니다.

유틸리티 모듈에는 60% 하한을 설정합니다. 이는 명백한 공백(누락된 null check, 잘못된 반환값)을 잡아내지만, 팀이 모든 문자열 슬라이싱 조합을 철저히 테스트하도록 강요하지는 않습니다.

핵심은 60%가 의미하는 바에 대해 정직하는 것입니다. 이는 “흔한 경우와 명백한 실패를 테스트했다”는 의미입니다. “이 코드는 중요하지 않다”는 의미가 아닙니다. 유틸리티 함수가 보안에 민감한 경로에서 사용된다면, 사용하는 쪽의 더 높은 임계값을 상속받습니다.

중간 지대: business logic

대부분의 코드는 이 두 극단 사이에 있습니다. payment processing, data validation, workflow orchestration. 이런 모듈은 정확성과 사용자 신뢰에 영향을 주지만, 단 하나의 살아남은 mutant가 공격자에게 데이터베이스를 넘겨주지는 않습니다.

우리는 계층화된 시스템을 사용합니다:

Module typeMutation thresholdRationale
AuthN / AuthZ90%High blast radius, discrete logic
Business logic75%Correctness-critical, moderate complexity
Utilities / helpers60%Low blast radius, high reuse, simple functions
Generated / boilerplateExcludedDon’t test code you didn’t write

이는 엄격한 규칙이 아닙니다. payment calculation 모듈은 85%로 올릴 수 있습니다. auth code에서 사용되는 널리 사용되는 JSON helper는 75%로 승격될 수 있습니다. 계층은 출발점이지 감옥이 아닙니다.

계층화된 mutation gate 구현 방법

Stryker와 PIT은 둘 다 모듈별 설정을 지원합니다. 다음은 mutmut를 사용하여 Python 프로젝트에 커스텀 설정으로 연결하는 방법입니다:

# mutation_config.py
THRESHOLDS = {
    "src/authn/": 90,
    "src/authz/": 90,
    "src/billing/": 85,
    "src/workflows/": 75,
    "src/utils/": 60,
}

EXCLUDE_PATHS = [
    "src/generated/",
    "src/migrations/",
]

CI에서 작은 스크립트가 이 설정을 읽고 모듈별로 mutation tester를 실행합니다:

#!/usr/bin/env bash
# ci/check-mutation.sh
set -e

python -m mutmut run --paths-to-mutate=src/authn/
python -m mutmut results || true
python -m mutmut run --paths-to-mutate=src/utils/
python -m mutmut results || true

python ci/verify_thresholds.py

검증 스크립트는 각 모듈의 점수를 임계값과 비교합니다. src/authn/이 87%를 기록하면 빌드는 명확한 메시지와 함께 실패합니다: authn/ scored 87%, threshold is 90%.

Stryker(JavaScript/TypeScript)의 경우, mutator groups와 함께 stryker.conf.js를 사용합니다:

// stryker.conf.js
module.exports = {
  thresholds: {
    high: 90,
    low: 75,
    break: null, // we handle this per-module
  },
  mutate: [
    "src/auth/**/*.ts",
    "src/billing/**/*.ts",
    "src/utils/**/*.ts",
  ],
  ignorePatterns: ["src/generated/**"],
};

우리는 Stryker를 스크립트로 감싸서 다른 경로 globs로 세 번 실행하고, 각 실행 후 디렉터리별 임계값을 강제합니다. 조금 어색하지만, 작동합니다.

100% 추구의 함정

일부 팀은 mutation testing을 이겨야 할 게임으로 봅니다. 그들은 동작을 검증하기 위한 것이 아니라 mutant를 죽이기 위해서만 존재하는 테스트를 작성합니다.

최악의 예는 특정 예외 메시지가 부분 문자열을 포함하는지 테스트하는 것입니다. 단지 메시지 텍스트를 변경하는 mutant를 죽이기 위해서입니다. 그 테스트는 아무런 가치를 추가하지 않습니다. 예외가 적절한 시점에 던져지는지, 올바른 타입이 발생하는지 검증하지 않습니다. 문자열만 검증할 뿐입니다.

순전히 비율을 올리기 위해 테스트를 작성하고 있다면, 목표를 뒤집은 것입니다. mutation testing은 진단 도구이지 리더보드가 아닙니다. 점수는 어디를 봐야 하는지 알려줍니다. 언제 끝났는지는 알려주지 않습니다.

힘들게 배운 교훈

우리는 전역 80% 게이트로 시작했습니다. 한 달 안에 세 팀이 기능 브랜치에서 이를 “일시적으로” 비활성화했습니다. 그중 두 개의 일시적 비활성화는 영구가 되었습니다.

문제는 숫자가 아니었습니다. auth code에는 80%가 너무 낮았고(스테이징까지 간 role-check 버그를 놓쳤습니다), 4,000줄짜리 유틸리티 모듈에는 너무 높았습니다(팀이 isValidEmail variant에 대한 테스트를 작성하는 데 두 주를 보냈습니다).

계층으로 나눈 후, 도입이 유지되었습니다. auth 팀은 범위가 제한되어 있었기 때문에 90% 기준을 받아들였습니다. 플랫폼 팀은 미친 짓 없이 달성 가능했기 때문에 유틸리티에 60%를 받아들였습니다. 계층화된 접근 방식은 mutation testing을 처벌에서 리스크에 대한 대화로 바꾸었습니다.

시작은 어디서

기존 코드베이스에 mutation testing을 도입한다면, 첫 주에는 어떤 게이트도 설정하지 마세요. 도구를 실행하고, 점수를 확인하고, 질문하세요: 살아남은 mutant가 어디서 가장 큰 피해를 줄까요?

auth부터 시작하세요. 거기에 90%를 설정하고, green으로 만들고, 가치를 증명하세요. 팀이 신호를 신뢰하면 business logic으로 확장하세요. 습관이 들 때까지 유틸리티는 낮은 기준을 유지하거나 완전히 제외하세요.

기억하세요: 정직한 테스트로 얻은 60% 점수가 mutator를 속이기 위해 작성된 테스트로 얻은 95% 점수를 이깁니다. 목표는 실제 버그를 잡는 것이지, 메트릭스 대시보드를 감동시키는 것이 아닙니다.

직접 시도해 보고 싶다면, Python용 mutmut과 JavaScript용 Stryker는 위에서 설명한 디렉터리별 패턴을 모두 지원합니다. 작게 시작하세요. 하나의 auth module. 일주일. 무엇이 살아남는지 보세요.