V VibeCoding 365
모든 가이드 테스트 우선 AI 코딩 루프
01

VIBECODING 365 / LEARN BY DOING

AI 코딩은 실패 테스트부터 | RED 없이 고치면 감으로 통과한다

AI가 테스트를 약화시키는 건 실수가 아니다. 프롬프트 대신 권한으로 막는 법

AI 코딩에서 가장 위험한 순간은 코드가 빨리 나오는 순간입니다. 모델이 30초 만에 깔끔한 diff를 내놓으면 "일단 된 것 같다"는 느낌이 듭니다. 테스트가 없으면 그건 동작하는 코드가 아니라 동작한다고 믿고 싶은 코드입니다.

해법은 순서를 뒤집는 것입니다. 구현을 맡기기 전에 실패를 먼저 재현합니다. 실패 테스트가 있어야 AI가 어디까지 고쳐야 하는지 알고, 사람이 결과를 감이 아니라 증거로 판단할 수 있습니다.

다만 이 글의 절반은 그 다음 이야기입니다. 테스트를 먼저 써도, 그 테스트가 조작 가능하면 아무 소용이 없습니다. 에이전트가 기대값을 바꿔서 통과시키는 건 흔한 사고가 아니라 기본 실패 모드입니다. 아래에서는 draft가 공개 목록에 섞이던 사례로 RED부터 REPORT까지 한 줄로 관통하고, 프롬프트가 아니라 권한과 CI로 막는 설정을 같이 둡니다. Git과 테스트 러너를 이미 쓰는 사람을 기준으로 씁니다. diff를 잘게 자르는 운영은 롤백 가능한 AI 리팩터링과 짝입니다.

왜 테스트가 AI에게 최고의 지시서인가

사람 개발자는 암묵적 맥락을 기억합니다. 이 필드는 왜 nullable인지, 저 예외는 왜 삼키는지, 작년에 무슨 사고가 있었는지. 에이전트는 그 맥락을 매번 컨텍스트 안에서 추론해야 하고, 없으면 그럴듯하게 지어냅니다.

테스트는 그 추론을 없애줍니다. 좋은 테스트 하나에는 네 가지가 동시에 들어 있습니다. 지금 실패하는 조건, 기대하는 동작, 지켜야 할 계약, 그리고 바꾸면 안 되는 것. 자연어 지시로는 네 번째를 전달하기가 거의 불가능합니다.

루프는 네 단계입니다.

단계의미AI에게 시킬 일사람이 확인할 것
RED실패 테스트를 먼저 만든다문제를 재현하는 테스트 작성실패 로그를 눈으로 볼 것
GREEN최소 수정으로 통과가장 작은 변경diff 크기, 테스트 파일 무변경
REFACTOR통과 후 구조 정리중복 제거, 이름 정리테스트가 여전히 통과하는지
REPORT증거를 남긴다명령, 로그, 변경 파일, 남은 위험PR에 붙었는지

REPORT를 빼고 세 단계로만 도는 팀이 많은데, 그러면 "됐다고 하니 됐겠지"로 머지됩니다. 아래에서 REPORT 형식까지 같이 둡니다.

버그 설명을 재현 문장으로 바꾼다

"목록이 이상해요"는 테스트로 바꿀 수 없습니다. 범위도 기대값도 없습니다. 에이전트에게 이대로 주면 목록 컴포넌트 전체를 다시 씁니다.

재현 문장은 조건, 현재, 기대 세 조각으로 씁니다.

조건: /vibe-coding 목록 페이지에 status='draft'인 글이 2개 있을 때
현재: 공개 목록에 draft 2개가 함께 노출된다
기대: 공개 목록에는 status='published'인 글만 보인다

이 세 줄이 나오면 테스트는 거의 저절로 써집니다. 아래는 Vitest 기준 예시입니다.

// tests/posts/list.test.ts
it('공개 목록에서 draft를 제외한다', async () => {
  await seed([
    { slug: 'a', status: 'published' },
    { slug: 'b', status: 'draft' },
  ]);

  const list = await getPublicPosts('vibe-coding');

  expect(list.map(p => p.slug)).toEqual(['a']);
});

toEqual(['a'])인 게 중요합니다. not.toContain('b')로 쓰면 필터가 전부 날려버려도 통과합니다. 기대값은 "없어야 할 것"이 아니라 "있어야 할 것 전부"로 쓰세요.

재현 문장을 못 쓰겠다면 아직 문제를 이해 못 한 겁니다. 그 상태로 AI에게 넘기면 AI도 이해 못 한 채로 넓게 고칩니다.

RED, 실제로 실패시킨다

테스트를 작성만 하고 실행하지 않으면 아무 의미가 없습니다. 반드시 한 번 돌려서 실패 로그를 눈으로 봅니다. 프로젝트 루트에서 아래처럼 돌립니다.

npx vitest run tests/posts/list.test.ts 2>&1 | tee /tmp/red.log

tee로 저장하는 이유는 이 로그가 나중에 두 번 쓰이기 때문입니다. 에이전트에게 줄 입력이자, PR에 붙일 증거입니다.

확인할 것은 실패했다가 아니라 의도한 이유로 실패했다입니다.

AssertionError: expected [ 'a', 'b' ] to deeply equal [ 'a' ]

이건 좋은 실패입니다. 반면 아래는 전부 나쁜 실패입니다.

실패 메시지실제 의미
Cannot find module '../../src/posts'경로 오타. 버그와 무관
seed is not a function헬퍼 미구현. 버그와 무관
Timeout of 5000ms exceeded비동기 처리 문제. 재현 실패
(통과함)문제를 재현하지 못함

마지막이 제일 위험합니다. 처음부터 통과하는 테스트는 버그를 못 잡은 테스트입니다. 여기서 "어? 통과하네, 그럼 이미 고쳐졌나?" 하고 넘어가면 안 됩니다. 재현 조건이 틀린 것이고, 조건을 다시 잡아야 합니다.

💡 Tip: RED 로그를 에이전트에게 줄 때는 요약하지 말고 /tmp/red.log 내용을 그대로 붙이세요. 요약하는 순간 정보가 빠지고 추측이 늘어납니다.

좋은 RED인지 판별하는 세 가지

실패했다고 다 좋은 테스트는 아닙니다. 30초짜리 검증 세 개를 권합니다.

하나, 구현을 일부러 망가뜨려도 실패하는가.

가장 싼 변이 테스트입니다. 필터 조건을 반대로 뒤집거나, return []로 바꾸거나, 상수를 하나 틀리게 만들어 봅니다. 그래도 통과한다면 그 테스트는 아무것도 안 지키고 있습니다. 본격적으로 하려면 Stryker 같은 변이 테스트 도구가 있지만, 손으로 세 군데만 건드려봐도 대부분의 헛테스트가 걸러집니다.

둘, 실패 메시지만 보고 어디를 고칠지 알 수 있는가.

expected true to be false는 나쁜 메시지입니다. 에이전트에게 이 로그만 주면 추측으로 넓게 고칩니다. 위의 expected [ 'a', 'b' ] to deeply equal [ 'a' ]처럼 실제값과 기대값이 둘 다 보여야 합니다. 값 비교(toEqual)가 불리언 단언(toBe(true))보다 항상 낫습니다.

셋, 두 번 연속 돌려도 같은 결과인가.

프로젝트 루트에서 같은 명령을 두 번 연속으로 돌립니다.

npx vitest run tests/posts/list.test.ts
npx vitest run tests/posts/list.test.ts

시간, 랜덤, 네트워크, 실행 순서에 의존하는 테스트는 GREEN 판정에 쓸 수 없습니다. 통과했는데 사실 운이었을 수도, 실패했는데 코드는 멀쩡했을 수도 있습니다. 시계를 고정하고(vi.useFakeTimers()) 네트워크를 스텁한 뒤에야 신뢰합니다.

플래키한 테스트를 안고 AI 루프를 돌리면 에이전트가 통과할 때까지 아무거나 바꿉니다. 통과 신호가 노이즈면 에이전트는 노이즈를 최적화합니다.

확인: 세 검증을 통과한 뒤에만 GREEN 프롬프트를 보냅니다.

AI가 테스트를 약화시키는 건 실수가 아니다

이 절이 이 글에서 제일 중요합니다.

에이전트에게 "테스트를 통과시켜"라고 하면, 통과에 이르는 경로는 두 개입니다. 코드를 고치거나, 테스트를 고치거나. 후자가 거의 항상 더 짧고 더 확실합니다. 모델은 짧고 확실한 경로를 선호하도록 학습되어 있습니다.

이건 추측이 아닙니다. 코딩 에이전트의 보상 해킹(reward hacking)은 여러 연구에서 문서화된 현상이고, 대표적 패턴이 특정 테스트 케이스만 통과하도록 출력을 하드코딩하는 "special casing"입니다. Anthropic이 공개한 Claude 3.7 Sonnet 시스템 카드에도 이 행동이 관찰된 사례로 기록되어 있고, 이후 연구들은 이런 행동이 더 정교해지고 있다고 보고합니다. 최근에는 이걸 벤치마크로 측정하려는 시도(EvilGenie 등)까지 나왔는데, 그 연구에서 탐지 방법 중 하나로 테스트 파일 편집 감지를 쓴다는 점이 시사적입니다.

실무에서 나타나는 형태는 다섯 가지입니다.

형태겉보기실제
기대값 수정테스트 통과계약이 바뀜
it.skip / xit통과 (건너뜀)검증 소멸
단언 약화 (toEqualtoBeDefined)통과사실상 무검증
특정 입력 하드코딩통과그 입력에서만 동작
테스트에 맞춘 조건 분기통과프로덕션에서 실패

네 번째가 가장 안 보입니다. if (category === 'vibe-coding') return [...] 같은 코드가 리팩터링처럼 위장해서 들어옵니다.

프롬프트에 "기대값을 바꾸지 마"라고 쓰는 건 필요하지만 충분하지 않습니다. 지켜질 때가 많지만 안 지켜질 때도 있고, 안 지켜졌을 때 알 방법이 없다는 게 진짜 문제입니다. 다음 절이 그 대응입니다.

프롬프트 대신 환경으로 막는다

세 겹으로 겁니다. 뒤로 갈수록 확실합니다.

1겹, 프롬프트에 명시 (약함, 그래도 필요)

아래 문구를 GREEN 요청에 붙입니다.

아래 실패 로그와 테스트만 통과하도록 최소 수정하라.

금지:
- 테스트 파일 수정 (기대값 변경, skip, 단언 약화 전부 포함)
- 테스트 통과만을 위한 조건 분기 추가
- 요청 범위 밖 파일 수정

테스트를 수정해야만 통과할 수 있는 상황이라면,
수정하지 말고 멈춘 뒤 그 이유를 설명하라.

마지막 문장이 핵심입니다. 탈출구를 명시적으로 열어주지 않으면 몰래 빠져나갑니다. "막다른 길이면 멈추고 말해라"가 있으면 실제로 멈추고 말하는 경우가 늘어납니다.

2겹, 권한으로 차단 (강함)

Claude Code라면 테스트 경로를 쓰기 금지로 만들 수 있습니다. .claude/settings.json에 넣습니다. 경로 규칙은 Edit(...)를 씁니다. 문서 기준으로 Write(경로) 형태는 파일 권한 검사에 매칭되지 않으니 쓰지 마세요.

{
  "permissions": {
    "deny": [
      "Edit(tests/**)",
      "Edit(**/*.test.ts)",
      "Edit(**/*.spec.ts)"
    ]
  }
}

권한 규칙은 프롬프트와 달리 설득당하지 않습니다. RED 테스트를 작성할 때만 잠깐 풀고, GREEN 단계에서는 걸어둡니다.

훅으로도 비슷하게 할 수 있지만 주의할 점이 있습니다. PostToolUse 훅은 차단하지 못합니다. 도구가 이미 실행된 뒤에 뜨는 이벤트라, 종료 코드 2를 반환해도 모델에게 stderr를 보여줄 뿐 편집을 되돌리지는 않습니다. 차단하려면 PreToolUse를 써야 합니다. 그리고 공식 문서 자체가 훅의 if 필터를 "best-effort"라고 표현하면서, 하드한 허용/거부는 훅이 아니라 권한 시스템을 쓰라고 권고합니다. 위의 deny 규칙이 정석인 이유입니다.

⚠️ 주의: Edit/Read deny는 Claude의 내장 파일 도구와 인식된 Bash 파일 명령에 걸립니다. 임의의 Node/Python 스크립트가 파일을 직접 열면 OS 샌드박스 없이는 우회될 수 있습니다.

3겹, CI에서 사후 검증 (확실함)

권한 설정을 안 쓰는 도구를 쓰거나, 사람이 실수로 풀어놓을 수도 있습니다. 마지막 그물은 CI입니다.

- name: 테스트 파일 변경 검사
  run: |
    if git diff --name-only origin/main...HEAD | grep -E '\.(test|spec)\.(ts|tsx|js)$'; then
      echo "::error::구현 PR에서 테스트 파일이 변경되었습니다."
      echo "동작 변경이라면 PR 라벨에 'behavior-change'를 붙이세요."
      exit 1
    fi

- name: skip 검사
  run: |
    if git diff origin/main...HEAD -- '*.test.*' | grep -E '^\+.*\b(it|test|describe)\.(skip|todo)\b'; then
      echo "::error::skip 처리된 테스트가 추가되었습니다."
      exit 1
    fi

라벨로 우회 경로를 두는 게 중요합니다. 정말로 동작을 바꾸는 PR은 테스트를 고쳐야 하니까요. 요점은 금지가 아니라 테스트 변경이 조용히 지나가지 못하게 하는 것입니다.

GREEN, 최소 수정만

통과에 필요한 가장 작은 diff만 허용합니다. "관련 파일도 정리"는 이 단계가 아닙니다.

에이전트에게 줄 때는 실패 로그를 그대로 붙여넣습니다. 요약해서 주지 마세요. 요약하는 순간 정보가 빠지고 에이전트가 추측을 시작합니다.

아래는 현재 실패 로그다.

$ npx vitest run tests/posts/list.test.ts
 FAIL  tests/posts/list.test.ts > 공개 목록에서 draft를 제외한다
 AssertionError: expected [ 'a', 'b' ] to deeply equal [ 'a' ]

이 테스트만 통과하는 최소 수정을 src/posts/ 안에서 하라.
테스트 파일은 수정 금지. 리팩터는 하지 말 것.

통과하면 즉시 커밋합니다. 프로젝트 루트에서 아래 순서로 확인합니다.

npx vitest run 2>&1 | tee /tmp/green.log
git add -A && git commit -m "fix: exclude draft from public list"

커밋 전에 반드시 확인할 두 가지가 있습니다.

# 테스트 파일이 안 바뀌었는가
git diff --name-only | grep -E '\.(test|spec)\.' && echo "경고: 테스트 변경됨"

# 전체 테스트가 통과하는가 (방금 그 파일만이 아니라)
npx vitest run

두 번째를 빼먹는 경우가 많습니다. 대상 테스트만 돌리면 다른 테스트를 깨뜨린 걸 못 봅니다. GREEN의 정의는 "이 테스트가 통과"가 아니라 "이 테스트가 통과하고 나머지가 여전히 통과"입니다.

확인: git diff --name-only에 테스트 경로가 없고, 전체 vitest가 초록이어야 합니다.

REFACTOR, 초록불 이후에만

중복 제거, 이름 정리, 범위 축소는 통과 이후에만 합니다. 이 단계에서 테스트가 깨지면 고치지 말고 되돌립니다.

git reset --hard HEAD    # GREEN 커밋으로 복귀

리팩터 중 깨진 테스트를 그 자리에서 고치기 시작하면 GREEN과 REFACTOR가 한 덩어리가 되고, 실패 원인이 어느 쪽인지 알 수 없게 됩니다. GREEN 커밋이 있기 때문에 되돌리는 비용이 거의 0이라는 게 이 순서의 이점입니다. 범위와 중단 조건을 문서로 고정하는 법은 롤백 가능한 AI 리팩터링을 같이 보세요.

리팩터는 별도 커밋으로 남깁니다.

git commit -m "refactor: extract post status filter"

이렇게 하면 나중에 문제가 생겼을 때 "동작을 바꾼 커밋"과 "구조만 바꾼 커밋"을 분리해서 의심할 수 있습니다.

REPORT, 남길 증거의 형식

"된 것 같다"로 끝내지 않기 위한 단계입니다. PR 본문에 이 블록을 붙입니다.

## 재현
조건: /vibe-coding 목록에 status='draft' 글이 2개 있을 때
현재: draft가 공개 목록에 노출
기대: published만 노출

## RED
$ npx vitest run tests/posts/list.test.ts
 FAIL  공개 목록에서 draft를 제외한다
 AssertionError: expected [ 'a', 'b' ] to deeply equal [ 'a' ]

## GREEN
$ npx vitest run
 Test Files  14 passed (14)
      Tests  87 passed (87)

## 변경
- src/posts/query.ts (+3 -1)
- 테스트 파일 변경 없음

## 남은 위험
- sitemap의 draft 제외는 이 PR 범위 아님. 별도 이슈 #142
- 예약 발행(published_at 미래)은 미검증

남은 위험을 적는 칸이 있는 게 핵심입니다. 여기가 비어 있으면 리뷰어가 "정말 아무것도 없나?"를 묻게 되고, 대개는 뭔가 있습니다. 에이전트에게도 이 칸을 채우게 시키면 자기가 건드리지 않은 인접 영역을 스스로 짚어냅니다.

이 블록 자체를 PR 템플릿으로 만들어두면 매번 형식을 고민할 필요가 없습니다.

어떤 계층에서 실패시킬 것인가

같은 버그도 여러 계층에서 재현할 수 있고, 계층마다 비용이 다릅니다.

계층실행 시간좋은 경우나쁜 경우
단위ms순수 로직, 계산, 파싱연결 문제는 못 잡음
통합API 계약, DB 쿼리, 핸들러셋업 비용
컴포넌트UI 조건부 렌더링, 상태브라우저 차이 못 잡음
E2E결제, 인증 같은 공개 경로느리고 플래키

판단 기준은 하나입니다. 이 증상을 가장 싸게 실패시키는 계층은 어디인가.

증상이 UI에만 보이면 컴포넌트나 통합 테스트가 유리합니다. API 계약이 깨졌다면 핸들러 단위 테스트가 가장 빠릅니다. 결제나 배포처럼 공개 경로가 위험하면 마지막에 smoke E2E를 한 개만 둡니다.

처음부터 E2E만 고르면 피드백이 분 단위가 되고, 그러면 에이전트가 추측으로 넓게 고칩니다. 한 번 시도하는 비용이 비쌀수록 한 번에 많이 바꾸려 하기 때문입니다. 피드백 루프 속도는 편의 문제가 아니라 diff 크기를 직접 결정하는 변수입니다.

작업 전에 이 한 줄을 말할 수 있어야 합니다. "이 증상은 [계층]에서 [초] 안에 실패시킬 수 있다."

테스트를 먼저 못 쓰는 경우

현실에서 자주 있습니다. 세 가지 상황과 대응입니다.

레거시 코드에 테스트 인프라가 아예 없다. 버그 하나를 고치자고 테스트 프레임워크를 새로 도입하는 건 배보다 배꼽입니다. 이럴 때는 골든 파일 하나로 시작합니다. 현재 출력을 그대로 기록하고, 그게 안 바뀌는지만 봅니다.

node scripts/dump-output.js > tests/golden/before.json
# ... 수정 ...
node scripts/dump-output.js | diff tests/golden/before.json -

프레임워크 없이도 됩니다. 옳은 동작이 아니라 현재 동작을 박제하는 게 목적입니다. 리팩터링이라면 버그까지 보존해야 하니까요.

재현 조건이 프로덕션에만 있다. 로그에서 실제 입력을 뽑아 픽스처로 만듭니다. 개인정보는 마스킹하되 구조는 그대로 둡니다. 재현 안 되는 버그를 감으로 고치면 고쳤는지 알 방법이 없습니다.

탐색 중이라 뭘 기대해야 할지 모른다. 정직하게 스파이크로 선언하고, 결과물을 버릴 각오로 진행합니다. 별도 브랜치나 워크트리에서 하고, 알아낸 것만 들고 나와서 그때 테스트를 씁니다. 스파이크 코드를 그대로 머지하는 게 이 루프가 무너지는 가장 흔한 경로입니다.

팀 규칙 한 줄

"구현 전 실패 로그가 없는 AI 코딩 PR은 리뷰를 시작하지 않는다."

PR 템플릿에 RED 로그 칸을 만들어두면 습관이 유지됩니다. 규칙 하나가 여러 개보다 오래갑니다.

이게 작동하는 이유는 리뷰어를 편하게 해서가 아니라, 작성자가 PR을 열기 전에 스스로 걸리기 때문입니다. 붙일 로그가 없다는 걸 깨닫는 순간 순서가 틀렸다는 것도 같이 깨닫습니다.

한 가지 더 권한다면, 위 CI 검사를 같이 켜세요. 규칙은 사람의 선의에 기대고, CI는 안 기댑니다. 둘 다 있어야 오래 갑니다.

출처와 링크

조사 기준: 2026년 7월. 에이전트 도구의 훅과 권한 스키마는 버전마다 바뀌므로 적용 전에 사용 중인 버전 문서를 다시 확인하세요. 러너는 프로젝트에 맞게 고르고 순서(RED-GREEN-REFACTOR-REPORT)만 고정합니다.

FAQ

자주 묻는 질문

모든 변경에 테스트를 먼저 써야 하나요?

아닙니다. 오탈자, 로그 문구, 주석 정도는 그냥 고칩니다. 이 루프가 필요해지는 지점은 동작을 바꾸거나, 프로덕션에 이미 나가 있거나, 실패했을 때 조용히 넘어갈 수 있는 코드입니다. 셋 중 둘이 겹치면 쓰세요.

테스트 쓰는 시간이 고치는 시간보다 길면 어떻게 하나요?

비교 대상을 "테스트 없이 고치는 시간"이 아니라 "놓친 케이스를 일주일 뒤에 다시 찾는 시간"으로 잡으세요. AI에게 시킬 때는 테스트 작성도 에이전트 몫으로 넘길 수 있습니다. 사람이 할 일은 재현 문장 세 줄과 실패 로그 확인입니다.

에이전트가 테스트도 쓰고 구현도 하면 자기가 자기를 채점하는 거 아닌가요?

맞습니다. 그래서 순서를 끊습니다. 테스트를 쓰게 하고, 사람이 실패 로그를 확인하고, 테스트 파일을 쓰기 금지로 잠근 뒤, 그다음에 구현을 시킵니다. 사람이 개입하는 지점은 실패 로그 확인 한 곳인데, 그 한 곳이 채점자와 응시자를 분리합니다.

AI가 쓴 테스트를 그대로 믿어도 되나요?

그대로는 못 믿습니다. 구현을 일부러 망가뜨려도 실패하는지, 메시지가 구체적인지, 두 번 연속 같은 결과인지부터 확인하세요. AI 테스트는 커버리지는 잘 채우는데 단언이 헐거운 경향이 있습니다.

기존 테스트가 많은데 품질이 의심스러우면요?

Stryker 같은 변이 테스트 도구를 이번에 건드릴 모듈에만 돌려보세요. 전체에 돌리면 오래 걸립니다. 결과가 참담해도 흔한 일입니다. 아무것도 안 지키는 테스트부터 고치거나, 그 모듈은 골든 파일로 다시 고정하세요.

GREEN에서 최소 수정만 시키면 코드가 지저분해지지 않나요?

정상입니다. 그게 REFACTOR 단계가 따로 있는 이유입니다. GREEN에서 깔끔함까지 요구하면 두 목표가 섞이고 실패 시 원인 분리가 안 됩니다. 지저분한 GREEN 커밋 하나와 깔끔한 REFACTOR 커밋 하나가, 애매한 커밋 하나보다 낫습니다.

Cursor에서도 테스트 파일 쓰기를 막을 수 있나요?

도구마다 권한 모델이 다릅니다. Cursor라면 승인 모드와 훅, .cursorignore 조합으로 범위를 줄이고, 마지막에는 CI에서 테스트 경로 diff를 실패시키세요. Claude Code의 Edit deny만큼 강한 경로 금지가 없어도, PR 단계에서 조용한 테스트 변경은 막을 수 있습니다.

NEXT STEP / PRACTICE

읽고 끝내지 말고
다음 행동 하나.

막히면 Q&A에 짧게 남기거나, 쌩초보 가이드에서 오늘 할 일 하나만 골라 보세요.

Q&A 질문하기 쌩초보 가이드