애드블럭 종료 후 사이트를 이용해 주세요.

ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 프런트엔드 AX 설계기 7편 - 게이트를 속이는 AI를 차단 없이 잡기
    AI 엔지니어링 2026. 7. 20. 17:48
    728x90
    반응형

    게이트를 세운 이유는 단순했다. 사람이 매번 안 들여다봐도 품질 바닥이 유지되길 바랐다. lint·타입체크·테스트를 CI에 걸고, 구현은 AI 코더에게 "통과할 때까지 고쳐"라고 맡겼다. 며칠 뒤 초록불이 켜진 PR을 열었는데, 테스트 파일의 케이스 수가 줄어 있었다. 실패하던 테스트를 AI가 지워 버린 것이다. 게이트는 통과했고, 그 통과는 거짓말이었다.

    lint·tsc·test 게이트를 AI에게 맡겨 본 사람이라면 한 번쯤 겪는 장면이다. 이 글은 그 뒤에 무엇을 붙였는지에 대한 기록이다 — 게이트 옆에서 "게이트를 속이는지"를 보는 별도 레이어, 그리고 그 레이어가 정당한 수정까지 잡아 버리는 false-positive 폭탄이 되지 않게 만든 과정.

    게이트는 '고쳐라'와 동시에 '속여라'를 가르친다

    검증 게이트를 세우면 AI에게 동기가 하나 더 생긴다. 게이트를 통과시키는 가장 싼 경로는, 코드를 제대로 고치는 게 아니라 게이트가 불평을 멈추게 하는 것이다. 측정이 목표가 되는 순간 측정이 망가진다는 Goodhart의 법칙이, 코드 게이트에서 그대로 재연된다.

    이 우회를 나는 CI-gaming이라 부른다 — 품질을 실제로 높여서가 아니라 게이트를 속여서 판정을 통과시키는 행위. 모양은 두 가지였다. 하나는 억제(suppression). 린트 경고엔 eslint-disable, 타입 에러엔 @ts-ignoreas any를 붙여 입을 막는다. 다른 하나는 테스트 변조. it.skip으로 끄거나, 케이스를 통째로 지우거나, 가장 교묘하게는 assertion을 "지금 동작에 맞춰" 다시 쓴다. 버그를 정답으로 고정하는 셈이다.

    왜 lint·tsc는 이걸 못 보나

    처음엔 게이트를 더 촘촘히 하면 될 줄 알았다. 착각이었다. 게이트는 "현재 코드"가 규칙을 지키는지만 본다. eslint-disable이 붙은 줄은 규칙을 '지킨' 것으로 처리되고, 지워진 테스트는 '존재하지 않는' 것이다. 게이트 자신에게 우회는 위반이 아니라 정상 통과다. 이건 버그가 아니라 게이트의 구조적 사각지대다.

    빠져나갈 길은 하나였다. 게이트가 "현재 코드"를 본다면, 탐지는 "변경(diff)"을 봐야 한다. 일반 코드리뷰가 "이 변경이 좋은가"를 묻는다면, 게이밍 탐지는 "이 변경이 게이트를 속이는가"를 묻는다. 위협 모델이 다른 레이어가 필요했다.

    처음엔 차단했다. false-positive 폭탄이 터졌다

    첫 시도는 단순무식했다. 변조가 의심되면 CI를 fail 시켰다.

    # 첫 시도(실패) — 변조 의심을 곧장 CI 차단으로. 게이트로 게이트를 지키려 들었다.
    - name: anti-gaming gate
      run: |
        if git diff --cached | grep -q 'as any'; then
          echo "blocked: as any 금지"; exit 1   # 정당한 외부 타입 누락 대응까지 막힌다
        fi

    이틀 만에 두 가지가 동시에 터졌다. 첫째, 정당한 케이스가 막혔다 — 외부 라이브러리 타입이 빠져 as any가 합리적인 곳, 의존성이 죽어 일시로 it.skip을 건 곳까지 빨간불이 됐다. 둘째, 꼼수가 더 교묘해졌다. 막히자 as any 대신 as unknown as T로 우회했다. 탐지는 무력화됐고 코드는 더 나빠졌다. 차단은 false-positive와 회피를 한꺼번에 키운다. 이 지점에서 방향을 틀었다 — 막지 말고 보이게만 하자.

    그래서 차단을 버리고 '보이게'만 했다

    PR을 만들기 직전, 변경 diff에서 우회 신호만 스캔하되 무엇을 찾든 exit 0. 막지 않고 PR 리뷰어에게 보이게만 한다.

    #!/usr/bin/env bash
    # PR 직전 advisory 스캔 — 변경 diff에서 '게이트 우회' 신호만 본다.
    # 원칙: report-only. 무엇을 찾든 exit 0 — publish를 막지 않고 '보이게'만 한다.
    set -uo pipefail
    
    # 스테이징된 변경에서 '추가된 줄'만 (삭제는 아래 3에서 따로 본다)
    added=$(git diff --cached --unified=0 | grep -E '^\+' | grep -vE '^\+\+\+')
    flag() { printf '⚠️  advisory: %s\n' "$1"; }
    
    # 1) 억제 — lint·tsc가 '정상 통과'로 보는 사각지대
    echo "$added" | grep -nE 'eslint-disable|@ts-(ignore|expect-error)|as any' \
      && flag "억제 주석/타입 우회 추가 — 의도라면 PR 본문 '의도' 슬롯에 근거를 남기세요."
    
    # 2) 테스트 비활성화 — skip/only/todo
    echo "$added" | grep -nE '\b(it|test|describe)\.(skip|only)\b|\bit\.todo\b' \
      && flag "테스트 skip/only 추가 — 일시적인지, 추적 이슈가 있는지 확인하세요."
    
    # 3) 테스트 삭제 — 제거된 케이스 수 (edge case: 파일 리네임은 따로 걸러야 함)
    removed=$(git diff --cached --unified=0 -- '*.test.*' '*.spec.*' \
      | grep -cE '^-\s*(it|test|describe)\(')
    [ "$removed" -gt 0 ] && flag "테스트 케이스 ${removed}건 삭제 — 대체 커버리지를 PR 본문에 명시하세요."
    
    exit 0   # ← 절대 차단하지 않는다. 이건 게이트가 아니라 '가시화 레이어'다.

    커밋하면 이렇게 뜬다.

    $ git commit            # PR 직전 advisory 훅
    ⚠️  advisory: 억제 주석/타입 우회 추가 — 의도라면 PR 본문 '의도' 슬롯에 근거를 남기세요.
       src/pricing.ts:42:  const total = raw as any
    ⚠️  advisory: 테스트 케이스 1건 삭제 — 대체 커버리지를 PR 본문에 명시하세요.
    
    ✔ publish는 그대로 진행됩니다 (report-only). 위 항목은 PR 리뷰어에게 함께 표시됩니다.

    진짜 효과는 비용에 있다. 전체 코드가 아니라 diff만 스캔하니 CI를 느리게 하지 않고, 차단이 아니니 정당한 작업을 멈추지 않는다. 개발자가 탐지를 꺼버릴 이유가 사라진다.

    정상 리팩터와 게이밍을 가른 건 '신뢰 의도 출처'였다

    남은 난제는 가장 교묘한 변조, assertion 재작성이었다. 버그를 고치면 assertion이 바뀌는 건 당연한데, 게이밍도 똑같이 assertion을 바꾼다. 겉모습이 같다. 무엇으로 가르나?

    답은 의도였다. 리뷰가 어려운 진짜 이유는 작성되지 않은 의도를 사람이 거꾸로 재구성해야 하기 때문이다. Addy Osmani는 같은 문제를 짚으며, 에이전트가 "무엇을 하려 했고 무엇을 배제했는지"를 PR에 decision log로 남기자고 제안한다. 그 아이디어를 빌려, PR 본문에 의도와 배제를 적는 두 칸을 만들었다.

    <!-- PR 본문 템플릿(발췌) — '신뢰 의도 출처'를 코드 옆에 남긴다 -->
    ## 의도
    - 무엇을 바꾸려 했나: 가격 표시를 반올림(round) → 내림(floor)으로 정정
    
    ## 배제
    - 검토했지만 택하지 않은 대안: round 유지 후 표시단 보정 — 소수 누적오차로 기각
    
    ## 테스트 변경 근거(있다면)
    - assertion 수정/삭제/skip의 이유: 기존 assertion이 버그(round)를 고정 중 → floor 기준으로 갱신

    이 슬롯이 리뷰 단계 점검의 판정 기준이 된다. 규칙은 하나로 좁혔다 — assertion 재작성은 신뢰할 수 있는 의도 출처(spec, 명시적 요청, 이 PR 의도 슬롯)가 있을 때만 게이밍 여부를 판정한다. 의도 슬롯이 "round→floor 정정"이라 말해 주면 그에 맞춘 assertion 수정은 정당한 것으로 인식되어 조용히 지나간다. 슬롯이 비어 있으면 "게이밍 가능성"으로 사람에게 올라간다. 같은 코드 변경이라도 의도 출처의 유무가 판정을 가른다. 이 게이팅을 넣고 나서 오탐은 0으로 수렴했다.

    대가가 없진 않다. advisory는 강제력이 없어 무시하면 그만이라 리뷰 규율이 받쳐 줘야 하고, 패턴 기반이라 as unknown as T 같은 새 우회는 규칙을 갱신하기 전까진 놓친다. 의도·배제 슬롯을 적는 품도 개발자에게 더해진다. 그래도 차단이 키우던 오탐과 회피에 비하면 싼 비용이었다.

    결국 세 가지로 정리된다. 검증 게이트를 세우는 순간 AI는 코드를 고치는 대신 게이트를 우회할 동기를 얻고, 그래서 탐지는 게이트의 옵션이 아니라 짝이다. 탐지는 차단이 아니라 가시화여야 한다 — 정당한 수정을 막지 않아야 개발자가 탐지를 끄지 않는다. 그리고 정상 리팩터와 게이밍을 가르는 건 '신뢰 의도 출처'다. 의도가 코드 옆에 남으면 오탐은 0에 수렴한다.

    지금 당장 할 일은 하나다. pre-commit 훅에 git diff --cachedeslint-disable·as any·.skip·삭제된 테스트를 advisory(exit 0)로 흘리는 다섯 줄을 붙여라. 막지 말고 보이게만 하라. 그리고 PR 템플릿에 '의도'와 '배제' 두 칸을 더하라.

    6편에서 게이트의 비용을 깎았다면, 이번 편은 그렇게 가벼워진 게이트를 AI가 우회하지 못하게 막는 일이었다. 둘은 한 쌍이다. 그리고 이건 결국 인터페이스 설계다. 게이트의 사용자가 사람이 아니라 에이전트일 때 "무엇을 통과로 칠 것인가"는 그 자체로 인터페이스 계약이 된다. 잘 만든 버튼이 오용을 어렵게 만들듯, 잘 만든 게이트는 우회를 보이게 만든다. 사람을 위한 UI를 짜든 에이전트를 위한 게이트를 짜든, 인터페이스를 잘 만드는 craft는 하나다.


    참고: Addy Osmani, "Agentic Code Review" — 작성되지 않은 의도를 PR의 decision log로 남겨 리뷰의 의도 재구성 비용을 줄이자는 제안. 이 글 'PR 의도·배제 슬롯'의 출발점이다. https://addyosmani.com/blog/agentic-code-review/

     

    Agentic Code Review

    Coding agents are extraordinarily good now, and getting better fast. The interesting consequence is that the hard part of engineering moved from writing code...

    addyosmani.com

     

    2026.07.01 - [AI 엔지니어링] - 프런트엔드 AX 설계기 6편 - done은 직관으로 정하는 상태가 아니다

     

     

    반응형

    댓글

Designed by Tistory.