
AI가 CI를 초록색으로 만들기 위해 '속임수'를 쓰는 이유 — 타입·테스트·린터의 '통과한 척'을 막는 GitHub Action 삼형제
요약
AI 코딩 에이전트가 CI 통과를 위해 타입 무시나 테스트 스킵 등 '입막음' 식의 코드를 작성하는 문제를 해결하기 위한 GitHub Action 도구를 소개합니다. 'ratchet' 원리를 이용해 에러 허용 수치를 점진적으로 줄여나가는 방식으로 코드 품질을 관리합니다.
핵심 포인트
- AI 에이전트가 CI를 초록색으로 만들기 위해 편법(as any 등)을 쓸 위험성 경고
- ratchet 방식: 현재 허용 수치를 baseline으로 설정하고 개선 시에만 수치를 낮춤
- type-ratchet, test-ratchet, lint-ratchet 등 3종의 GitHub Action 제공
- 레거시 코드가 있는 환경에서도 점진적으로 코드 품질을 높일 수 있는 실용적 접근
CI는 초록색. 하지만 그 초록색, 진짜인가요?
테스트도 타입 체크(Type Check)도 린터(Linter)도 전부 통과해서, PR에는 초록색 체크가 나열되어 있다. 안심하고 머지(Merge)——하기 전에, 딱 한 가지만 의심해 주었으면 합니다. 그 초록색은 '고쳤기 때문에 초록색'인가요, 아니면 '입을 막았기 때문에 초록색'인가요?
AI 코딩 에이전트(Claude Code, Copilot, Cursor 등)와 함께하면, 빨간색 CI를 초록색으로 만드는 속도는 확실히 빨라집니다. 다만 빨간색을 없애는 방법에는 두 가지가 있습니다. 제대로 고치거나, 아니면 입을 막거나. 그리고 보통은 입을 막는 쪽이 더 빠릅니다.
- 타입이 맞지 않을 때 →
as any나# type: ignore를 쓰면 타입 에러는 사라집니다. - 테스트가 실패할 때 →
it.skip이나@pytest.mark.skip으로 제외합니다. 혹은it.only로 하나만 실행하고 나머지는 건너뜁니다. - 린터가 화를 낼 때 →
eslint-disable이나# noqa를 붙이면 경고는 사라집니다.
예를 들어, 이런 한 줄입니다.
const user = res.data as any; // ← 이렇게 하면 tsc는 초록색을 유지함
result = compute() # type: ignore # ← 이렇게 하면 mypy도 초록색을 유지함
모두 CI는 초록색이 됩니다. 까다로운 점은, 인간의 리뷰는 이것을 놓치기 쉽다는 것입니다. 수백 줄의 차분(Diff) 속에 섞인 한 줄의 as any를 매번 정확히 잡아낼 수 있는 사람은 그리 많지 않습니다.
만약을 위해 말씀드리지만, 이것은 'AI에게 악의가 있다'는 이야기가 아닙니다. 'CI를 초록색으로 만든다'는 목적만을 부여받은 존재는——AI든 마감에 쫓기는 인간이든——지름길을 택하기 쉽다는 일반론입니다. 그렇기에 리뷰어의 근성이 아니라 기계로 제동을 걸고 싶은 것이죠.
그렇게 생각하며 만든 것이 ** *-ratchet 삼형제**라는 3개의 GitHub Action입니다. 우리가 내걸고 있는 가치는
No AI, no SaaS, no config입니다. AI 코드 리뷰 SaaS의 정반대 지점에 서 있습니다.
라쳇(ratchet)이라는 사고방식
라쳇(Ratchet)은 '되돌아가기 방지 제동 장치'를 뜻합니다. 라쳇 렌치(Ratchet wrench)의 그 딸깍거리는, 한 방향으로만 돌아가는 장치 말입니다. 세 가지 도구가 공통적으로 가진 동작은 심플합니다.
baseline(현재 허용 수)을 결정한다
-
에스케이프(Escape)의 수가 baseline보다 증가하면 PR을 fail 처리합니다.
-
감소하면
IMPROVED라고 표시하며 baseline을 낮추고 커밋하여 조입니다. -
숫자는 늘어나는 방향으로 움직이지 않습니다. 줄어드는 방향으로만 움직입니다.
포인트는, 제로(0)부터 시작할 필요가 없다는 것입니다. 레거시 코드에는 정당한 이유로 남아 있는 Any나 # noqa가 이미 대부분 존재합니다. 그래서 "지금 있는 분량은 허용한다. 다만 이 이상은 늘리지 못하게 한다"에서 시작하여, 고칠 때마다 제동을 한 단계씩 조여 나갑니다. 이 일방향성이 실제 코드에 효과적입니다.
삼형제, 각각 무엇을 지키는가
1. type-ratchet — 타입의 '입막음'을 막는다 🔒
지키는 것: 타입 에스케이프 해치(Escape hatch). TypeScript는 any (타입 위치) / as any / @ts-ignore / @ts-expect-error, Python은 Any / # type: ignore를 대상으로 합니다.
기존 도구로 부족한 이유: tsc도 mypy --strict도 에스케이프 해치를 세어주지는 않습니다. as any를 쓰면 타입 에러는 말 그대로 사라져 타입 체크는 초록색이 되지만, 타입 체커는 '입이 막혔다'는 사실을 알려주지 않습니다. 그 증가를 막는 PR 게이트(Gate)가 표준에는 없기 때문입니다.
사용법 (1단계만 추가하면 됩니다):
# .github/workflows/type-ratchet.yml
name: Type Ratchet Gate
on:
...
한 줄 팁: TypeScript에서는 테스트 파일(*.test.ts / *.spec.ts)을 카운트 대상에서 제외하고 있습니다. 모크(Mock)에서 as any를 사용하고 싶은 현실적인 타협(방침 A)입니다. 그렇다고 해서 "테스트는 타입 검사를 하지 않는다"는 뜻은 아니며, typecheck-command(예: pnpm exec tsc --noEmit)를 전달하면 카운트와는 별개로 tsc / mypy...
전체에 적용할 수 있습니다. baseline은 baseline-any와 baseline-suppress로 나뉘어 있으며, "Any는 5까지, type: ignore는 2까지"와 같이 각각 별도로 제한됩니다.
탐지는 grep 기반이므로, 주석이나 문자열 중의 "any"를 포착할 가능성이 제로(0)는 아닙니다. 이는 투명성(무엇을 세고 있는지 읽을 수 있음)을 대가로 얻은 거칠기이며, 엄격함을 원하는 사람들을 위해 ESLint의 no-explicit-any로 교차 검증하는 opt-in 방식을 ROADMAP에 올려두었습니다.
2. test-ratchet — 테스트의 '통과한 척'을 막는 도구
보호 대상: 무효화된 테스트. 메커니즘은 두 가지 종류가 있습니다.
- skip / xfail 계열 (ratchet, baseline으로 관리)
- Python:
@pytest.mark.skip/skipif/xfail,pytest.skip(), unittest의@skip/skipIf/skipUnless,.skipTest() - TypeScript:
it/test/describe(및bench)의.skip/.todo/.fails,xit/xdescribe
- Python:
.only(TypeScript 전용, 엄격 금지)it.only가 하나라도 섞이면, 러너(runner)는 해당 블록만 실행하고 나머지는 전부 스킵합니다. 그래도 CI는 초록색(pass)으로 뜹니다. 이것은baseline방식이 아니라 0개를 유지해야 하는 엄격 금지 방식이며, 하나라도 발견되면 빨간색(fail)이 됩니다 (forbid-only: false로 해제 가능).
기존 도구로 부족한 이유: .only 탐지라면 eslint-plugin-no-only-tests가 유명하지만, 그것은 TypeScript + ESLint 설정이 전제되어 있습니다. 언어에 구애받지 않고, ESLint 없이도 작동하는 제로 설정(zero-config)의 PR 게이트는 보이지 않았습니다. Python 측에서도 skip의 순증가를 막는 표준 메커니즘도 없습니다.
사용법:
# .github/workflows/test-ratchet.yml
name: Test Ratchet Gate
on: ...
한 줄 팁: baseline-skip으로 "현재 존재하는 skip"은 허용하면서 증가만 막고, .only는 별도로 항상 0을 유지합니다. 기존 테스트 스위트(suite)를 존중하면서, 몰래 테스트를 빼먹는 행위만 차단하는 이미지입니다.
3. suppress-ratchet — 린터의 '묵인'을 막는 도구
보호 대상: 린터(linter)의 억제(suppression) 주석. TypeScript는 eslint-disable (line / next-line / block) 및 biome-ignore, Python은 # noqa / # ruff: noqa / # pylint: disable입니다.
기존 도구로 부족한 이유: 린터 자체는 disable 주석을 "존중"하여 초록색(pass)이 되도록 설계되어 있습니다. 즉, 억제 주석을 더 많이 쓸수록 결과는 더 조용해집니다. 그 순증가를 막는 게이트가 표준에는 없다는 것이 문제입니다.
사용법:
# .github/workflows/suppress-ratchet.yml
name: Suppress Ratchet Gate
on: ...
한 줄 팁: 타입 억제(@ts-ignore / # type: ignore)는 여기서 의도적으로 제외했습니다. 그것은 type-ratchet의 담당이기 때문입니다. 동일한 주석을 두 개의 게이트에서 이중으로 카운트하지 않도록 역할을 나누었습니다. node_modules / dist / .venv / .next 등의 vendor 및 생성물 트리도 제외되었습니다.
공통 설계 결정
세 가지 도구는 골격을 공유합니다. "어떻게 만들었는가"보다 "왜 그렇게 결정했는가"를 설명합니다.
- 제로 의존성(zero-dependency) bash: 실체는 composite action +
gate.sh이며, 수행하는 작업은 기본적으로grep과wc입니다. 무엇을 세고 있는지는 README와gate.sh에서 확인할 수 있습니다.
를 읽으면 모든 것을 알 수 있습니다. 블랙박스 형태의 AI 리뷰와는 정반대되는 투명성을 확보했습니다. -
제로 설정 (Zero-config): uses: motchalini-llc/xxx@v1 한 줄만 추가하면 되며, 언어는 pyproject.toml / tsconfig.json 등의 존재 여부에 따라 자동으로 감지합니다. -
일방향 라쳇 (One-way Ratchet): 수치가 늘어나면 fail, 줄어들면 IMPROVED. baseline은 입력값 또는 baseline-file로 관리합니다. -
셀프 테스트 (self-test): 각 리포지토리에 clean / dirty 피스처(fixture)를 두고, "clean은 초록색, dirty는 빨간색"임을 CI에서 검증합니다. 게이트 자체의 간과나 오탐지를 방지합니다 (test-ratchet과 suppress-ratchet은 TypeScript와 Python 양쪽 매트릭스에서 실행 중입니다). -
가벼운 버전을 먼저, 무거운 버전은 수요가 확인된 후: v1은 비용이 들지 않는 grep 게이트입니다. autofix (mode: fix)나 AST 기반의 엄격한 탐지는 ROADMAP에 두고, "고쳐줬으면 좋겠다"는 목소리가 나온 뒤에 만들 예정입니다. autofix는 opt-in 방식이며, 사용자 자신의 LLM API 키를 사용하고 비용 상한선을 두는 것으로 결정했습니다. -
글로벌 카운트의 과감한 포기: 판정은 리포지토리 전체의 총수로 수행합니다. 파일별(per-file) / 경로별(per-path) baseline (모노레포용)은 향후 과제이며, 파일을 분할하거나 이동하면 총수가 변하는 거친 면이 솔직히 있습니다. 그럼에도 "제로 설정으로 바로 돌릴 수 있다"는 장점을 우선시한 솔직한 트레이드오프(trade-off)입니다.
요컨대 No AI, no SaaS, no config입니다. 코드는 당신의 CI 밖으로 한 발짝도 나가지 않으며, 탐지에 AI도 사용하지 않습니다 (grep을 사용하니까요).
도그푸딩(Dogfooding)을 통해 알게 된 것
공개 전, 자신의 2개 리포지토리에서 먼저 사용해 보았습니다. 다음 수치는 실제 수치 그대로입니다.
- crypto-trading-system (Python / mypy strict):
type-ratchet의baseline은any=5/suppress=2입니다.test-ratchet은baseline-skip 0(PR #24)이며,suppress-ratchet은baseline-suppress 4(PR #25)입니다. 모두main에 머지(merge)되었습니다. - - meguri (Next.js 15 / React 19 / TypeScript strict): 도입 시점에
any=0이었습니다. 여기서는 "0을 0으로 유지하는 예방 게이트"로 운용 중입니다 (test / suppress 모두 0, PR #7 / #8).
흥미로웠던 점은 suppress-ratchet의 baseline-suppress: 4입니다. 이 '4'의 정체는 테스트 코드에 원래 있던 # noqa — 즉, 사용하지 않는 import에 대한 F401이나 함수 내 import에 대한 PLC0415 등, 의도가 있어 남겨두었던 억제(suppression)들이었습니다. 여기서 "전부 지우고 0으로 만들어"라고 했다면 도입 첫날부터 질려버렸을 것입니다. 그래서 "현재의 4는 허용하되, 5가 되게는 하지 않는다". 이것이 라쳇의 현실적인 진입점이라는 것을 사용해 보며 납득할 수 있었습니다. suppress-ratchet의 README에 있는 Python 예제의 baseline-suppress: '4'에는 이 실제 숫자가 그대로 적혀 있습니다.
반면 meguri처럼 처음부터 깨끗한 코드베이스에서는 동일한 액션이 **"늘어나지 않게 하는 보험"**으로서 작동합니다. 동일한 도구 하나가 레거시(수치를 줄여나가는 용도)와 그린필드(0을 지키는 용도) 모두에 쓰일 수 있는 것입니다.
동작을 보여주기 위해 meguri에 일부러 any / as any를 섞은 데모 PR(#6)도 만들었으며, 게이트가 빨간색이 되고 해당 라인에 인라인 주석이 나타나는 것을 확인한 후 클로즈(close)했습니다. 참고로 — 이것은 "AI가 멋대로 넣은 것"이 아니라, 동작을 보여주기 위해 제가 의도적으로 만든 시연입니다.
어떻게 만들었나 (Solo + Claude Code)
내용은 솔직히 단순합니다. 어려운 것은 코드보다 "무엇을 세고, 무엇을 세지 않을 것인가"의 경계선을 긋는 일이었습니다. 타입 억제를 type-ratchet에 가깝게 가져가고, 테스트의 as any는 카운트하지 않는 식의 판단들이 쌓여 제품의 성격을 결정합니다.
구현은 저와 Claude Code를 사용하여 하나당 2~3일 정도 소요되었습니다. 구현 → 자신의 리포지토리에서 도그푸딩 (Dogfooding) → Marketplace 공개 → X(구 Twitter)로 공지라는 동일한 플레이북을 3번 반복했습니다. 골격(composite action + gate.sh + clean/dirty fixture의 self-test + README + ROADMAP)은 재사용할 수 있으므로, 두 번째와 세 번째 구현은 점점 빨라집니다. 탐지 대상이 다를 뿐 구조는 동일합니다. 그래서 "삼형제"입니다.
요약
CI의 초록색을 "진짜 초록색"으로 유지하기 위한, 의존성 제로(zero-dependency)·설정 제로(zero-config)의 PR 게이트 3종이 모두 갖춰졌습니다.
- 타입을 침묵시키기 (
as any/# type: ignore) → type-ratchet - 테스트를 건너뛰기 (
it.skip/.only/@pytest.mark.skip) → test-ratchet - 린터(Linter)를 침묵시키기 (
eslint-disable/# noqa) → suppress-ratchet
AI와 협업하며 개발 속도가 올라갈수록, "초록색으로 만들기 위한 지름길"도 그만큼 늘어납니다. 그 지름길에 일방향적인 제동을 거는 것이 라쳇(Ratchet)입니다. 도입하는 요령은, 갑자기 0을 목표로 하지 말고 베이스라인(baseline)을 "현재의 수"로 설정하여 도입하는 것입니다. "이 이상은 늘어나지 않는다"는 것을 아는 것만으로도 리뷰하는 마음이 훨씬 편해집니다.
링크 모음입니다 (모두 MIT 라이선스).
- Type Ratchet: https://github.com/marketplace/actions/type-ratchet
- Test Ratchet: https://github.com/marketplace/actions/test-ratchet
- Suppress Ratchet: https://github.com/marketplace/actions/suppress-ratchet
- GitHub org: https://github.com/motchalini-llc
- X: https://x.com/motchalini
사용해 보시고 "이 부분이 부족하다"는 점이 있다면, Issue가 다음에 무엇을 만들지에 대한 가장 큰 단서가 됩니다. 편하게 남겨주세요.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기