AI 회귀 테스팅
동일한 AI 모델이 코드를 작성하고 리뷰할 때 발생하는 체계적인 사각지대를 자동화된 테스트로만 잡아낼 수 있도록 설계된 테스팅 패턴입니다.
활성화 시점
- AI 에이전트(Claude Code, Cursor, Codex)가 API 라우트 또는 백엔드 로직을 수정한 경우
- 버그를 발견하여 수정한 후 재발을 방지해야 하는 경우
- 프로젝트에 DB 없이 테스트할 수 있는 샌드박스/모의 모드가 있는 경우
- 코드 변경 후
/bug-check또는 유사한 리뷰 명령을 실행하는 경우 - 여러 코드 경로가 존재하는 경우(샌드박스 vs 프로덕션, 기능 플래그 등)
핵심 문제
AI가 코드를 작성하고 자신의 작업을 리뷰할 때, 두 단계 모두에서 동일한 가정을 그대로 가져옵니다. 이는 예측 가능한 실패 패턴을 만듭니다:
AI가 수정 작성 → AI가 수정 리뷰 → AI가 "올바른 것 같다"고 판단 → 버그 여전히 존재
실제 사례(프로덕션에서 관찰됨):
수정 1: API 응답에 notification_settings 추가
→ SELECT 쿼리에 추가하는 것을 누락
→ AI가 리뷰했지만 놓침 (동일한 사각지대)
수정 2: SELECT 쿼리에 추가
→ TypeScript 빌드 오류 (열이 생성된 타입에 없음)
→ AI가 수정 1을 리뷰했지만 SELECT 문제를 잡지 못함
수정 3: SELECT *로 변경
→ 프로덕션 경로는 수정했지만 샌드박스 경로는 누락
→ AI가 리뷰했지만 또 다시 놓침 (4번째 발생)
수정 4: 테스트가 첫 실행에서 즉시 발견하여 통과:
패턴: 샌드박스/프로덕션 경로 불일치는 AI가 도입하는 회귀 중 1순위입니다.
샌드박스 모드 API 테스팅
AI 친화적인 아키텍처를 가진 대부분의 프로젝트에는 샌드박스/모의 모드가 있습니다. 이것이 빠르고 DB 없이 API를 테스트하는 핵심입니다.
설정 (Vitest + Next.js App Router)
// vitest.config.ts
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
environment: "node",
globals: true,
include: ["__tests__/**/*.test.ts"],
setupFiles: ["__tests__/setup.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
});
// __tests__/setup.ts
// 샌드박스 모드 강제 — 데이터베이스 불필요
process.env.SANDBOX_MODE = "true";
process.env.NEXT_PUBLIC_SUPABASE_URL = "";
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = "";
Next.js API 라우트용 테스트 헬퍼
// __tests__/helpers.ts
import { NextRequest } from "next/server";
export function createTestRequest(
url: string,
options?: {
method?: string;
body?: Record<string, unknown>;
headers?: Record<string, string>;
sandboxUserId?: string;
},
): NextRequest {
const { method = "GET", body, headers = {}, sandboxUserId } = options || {};
const fullUrl = url.startsWith("http") ? url : `http://localhost:3000${url}`;
const reqHeaders: Record<string, string> = { ...headers };
if (sandboxUserId) {
reqHeaders["x-sandbox-user-id"] = sandboxUserId;
}
const init: { method: string; headers: Record<string, string>; body?: string } = {
method,
headers: reqHeaders,
};
if (body) {
init.body = JSON.stringify(body);
reqHeaders["content-type"] = "application/json";
}
return new NextRequest(fullUrl, init);
}
export async function parseResponse(response: Response) {
const json = await response.json();
return { status: response.status, json };
}
회귀 테스트 작성
핵심 원칙: 올바르게 작동하는 코드가 아니라 발견된 버그에 대한 테스트를 작성하세요.
// __tests__/api/user/profile.test.ts
import { describe, it, expect } from "vitest";
import { createTestRequest, parseResponse } from "../../helpers";
import { GET, PATCH } from "@/app/api/user/profile/route";
// 계약 정의 — 응답에 반드시 있어야 하는 필드
const REQUIRED_FIELDS = [
"id",
"email",
"full_name",
"phone",
"role",
"created_at",
"avatar_url",
"notification_settings", // ← 버그로 누락된 것이 발견되어 추가됨
];
describe("GET /api/user/profile", () => {
it("모든 필수 필드를 반환합니다", async () => {
const req = createTestRequest("/api/user/profile");
const res = await GET(req);
const { status, json } = await parseResponse(res);
expect(status).toBe(200);
for (const field of REQUIRED_FIELDS) {
expect(json.data).toHaveProperty(field);
}
});
// 회귀 테스트 — AI가 4번 도입한 정확한 버그
it("notification_settings가 undefined가 아닙니다 (BUG-R1 회귀)", async () => {
const req = createTestRequest("/api/user/profile");
const res = await GET(req);
const { json } = await parseResponse(res);
expect("notification_settings" in json.data).toBe(true);
const ns = json.data.notification_settings;
expect(ns === null || typeof ns === "object").toBe(true);
});
});
샌드박스/프로덕션 일치성 테스트
가장 흔한 AI 회귀: 프로덕션 경로는 수정했지만 샌드박스 경로를 잊어버리거나(또는 그 반대).
// 샌드박스 응답이 예상 계약과 일치하는지 테스트
describe("GET /api/user/messages (대화 목록)", () => {
it("샌드박스 모드에서 partner_name을 포함합니다", async () => {
const req = createTestRequest("/api/user/messages", {
sandboxUserId: "user-001",
});
const res = await GET(req);
const { json } = await parseResponse(res);
// 이 테스트는 partner_name이 프로덕션 경로에는 추가되었지만
// 샌드박스 경로에는 추가되지 않은 버그를 잡았습니다.
if (json.data.length > 0) {
for (const conv of json.data) {
expect("partner_name" in conv).toBe(true);
}
}
});
});
버그 검사 워크플로우에 테스트 통합
사용자 정의 명령 정의
<!-- .claude/commands/bug-check.md -->
# 버그 검사
## 1단계: 자동화된 테스트 (필수, 건너뛸 수 없음)
코드 리뷰 전에 먼저 다음 명령을 실행하세요:
npm run test # Vitest 테스트 스위트
npm run build # TypeScript 타입 검사 + 빌드
- 테스트 실패 시 → 최우선 버그로 보고
- 빌드 실패 시 → 타입 오류를 최우선으로 보고
- 두 단계 모두 통과한 경우에만 2단계로 진행
## 2단계: 코드 리뷰 (AI 리뷰)
1. 샌드박스 / 프로덕션 경로 일관성
2. API 응답 형태가 프론트엔드 기대치와 일치하는지
3. SELECT 절 완전성
4. 롤백이 있는 오류 처리
5. 낙관적 업데이트 경쟁 조건
## 3단계: 수정된 각 버그에 대해 회귀 테스트 제안
워크플로우
사용자: "버그 체크해줘" (또는 "/bug-check")
│
├─ 1단계: npm run test
│ ├─ 실패 → 기계적으로 버그 발견 (AI 판단 불필요)
│ └─ 통과 → 계속
│
├─ 2단계: npm run build
│ ├─ 실패 → 기계적으로 타입 오류 발견
│ └─ 통과 → 계속
│
├─ 3단계: AI 코드 리뷰 (알려진 사각지대를 염두에 두고)
│ └─ 발견 사항 보고
│
└─ 4단계: 각 수정에 대해 회귀 테스트 작성
└─ 다음 버그 검사에서 수정이 깨지는지 확인
일반적인 AI 회귀 패턴
패턴 1: 샌드박스/프로덕션 경로 불일치
빈도: 가장 흔함 (회귀 4건 중 3건에서 관찰됨)
// 실패: AI가 프로덕션 경로에만 필드를 추가함
if (isSandboxMode()) {
return { data: { id, email, name } }; // 새 필드 누락
}
// 프로덕션 경로
return { data: { id, email, name, notification_settings } };
// 통과: 두 경로 모두 동일한 형태를 반환해야 함
if (isSandboxMode()) {
return { data: { id, email, name, notification_settings: null } };
}
return { data: { id, email, name, notification_settings } };
잡아내는 테스트:
it("샌드박스와 프로덕션이 동일한 필드를 반환합니다", async () => {
// 테스트 환경에서는 샌드박스 모드가 강제로 켜짐
const res = await GET(createTestRequest("/api/user/profile"));
const { json } = await parseResponse(res);
for (const field of REQUIRED_FIELDS) {
expect(json.data).toHaveProperty(field);
}
});
패턴 2: SELECT 절 누락
빈도: Supabase/Prisma에서 새 열을 추가할 때 흔함
// 실패: 새 열이 응답에 추가되었지만 SELECT에는 없음
const { data } = await supabase
.from("users")
.select("id, email, name") // notification_settings 없음
.single();
return { data: { ...data, notification_settings: data.notification_settings } };
// → notification_settings는 항상 undefined
// 통과: SELECT * 사용 또는 새 열을 명시적으로 포함
const { data } = await supabase
.from("users")
.select("*")
.single();
패턴 3: 오류 상태 누수
빈도: 보통 — 기존 컴포넌트에 오류 처리를 추가할 때 발생
// 실패: 오류 상태는 설정되었지만 이전 데이터가 지워지지 않음
catch (err) {
setError("로드 실패");
// reservations는 여전히 이전 탭의 데이터를 표시!
}
// 통과: 오류 발생 시 관련 상태를 지움
catch (err) {
setReservations([]); // 오래된 데이터 지우기
setError("로드 실패");
}
패턴 4: 적절한 롤백 없는 낙관적 업데이트
// 실패: 실패 시 롤백 없음
const handleRemove = async (id: string) => {
setItems(prev => prev.filter(i => i.id !== id));
await fetch(`/api/items/${id}`, { method: "DELETE" });
// API가 실패하면 UI에서는 항목이 사라졌지만 DB에는 여전히 존재
};
// 통과: 이전 상태를 캡처하고 실패 시 롤백
const handleRemove = async (id: string) => {
const prevItems = [...items];
setItems(prev => prev.filter(i => i.id !== id));
try {
const res = await fetch(`/api/items/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("API 오류");
} catch {
setItems(prevItems); // 롤백
alert("삭제에 실패했습니다");
}
};
전략: 버그가 발견된 곳을 테스트하세요
100% 커버리지를 목표로 하지 마세요. 대신:
/api/user/profile에서 버그 발견 → profile API에 대한 테스트 작성
/api/user/messages에서 버그 발견 → messages API에 대한 테스트 작성
/api/user/favorites에서 버그 발견 → favorites API에 대한 테스트 작성
/api/user/notifications에 버그 없음 → 테스트 작성하지 않음 (아직은)
AI 개발에 이 전략이 효과적인 이유:
- AI는 동일한 유형의 실수를 반복하는 경향이 있음
- 버그는 복잡한 영역(인증, 다중 경로 로직, 상태 관리)에 집중됨
- 일단 테스트되면, 그 정확한 회귀는 다시 발생할 수 없음
- 테스트 수는 버그 수정과 함께 자연스럽게 증가 — 낭비되는 노력 없음
빠른 참조
| AI 회귀 패턴 | 테스트 전략 | 우선순위 |
|---|---|---|
| 샌드박스/프로덕션 불일치 | 샌드박스 모드에서 동일한 응답 형태 단언 | 높음 |
| SELECT 절 누락 | 응답의 모든 필수 필드 단언 | 높음 |
| 오류 상태 누수 | 오류 발생 시 상태 정리 단언 | 중간 |
| 롤백 누락 | API 실패 시 상태 복원 단언 | 중간 |
| 타입 캐스트로 인한 null 마스킹 | 필드가 undefined가 아님을 단언 | 중간 |
DO / DON'T
DO:
- 버그를 찾은 후 즉시 테스트 작성 (가능하면 수정하기 전에)
- 구현이 아닌 API 응답 형태를 테스트
- 모든 버그 검사의 첫 단계로 테스트 실행
- 테스트를 빠르게 유지 (샌드박스 모드에서 총 < 1초)
- 방지하는 버그의 이름을 테스트에 지정 (예: "BUG-R1 회귀")
DON'T:
- 한 번도 버그가 발생하지 않은 코드에 대한 테스트 작성
- AI 자체 리뷰를 자동화된 테스트의 대체 수단으로 신뢰
- "그냥 모의 데이터일 뿐"이라며 샌드박스 경로 테스트 건너뛰기
- 단위 테스트로 충분할 때 통합 테스트 작성
- 커버리지 백분율을 목표로 삼지 말고 회귀 방지를 목표로 삼기