이 글에서 다룰 이야기
AI한테 프론트엔드 작업을 시켜보면 신기하게도 "틀렸다"는 말을 거의 안 들어요. 그런데 결과물을 열어보면 요구사항이랑 미묘하게 다른 경우가 많죠.
왜 그럴까요? 답은 좀 허무해요. AI한테 "무엇이 정답인지" 알려준 적이 없거든요.
이 글은 제가 쓰는 frontend-oracle-design이라는 작업 방식의 철학을 정리한 거예요. 핵심은 딱 세 가지예요.
- 정답을 테스트 코드로 최대한 많이 재현하기 — AI가 정답지를 보면서 답을 찾아가게 만들어요
- 하네스 엔지니어링과 베스트 프랙티스로 구현하기 — "테스트가 흔들려서 그랬어요"를 없애요
- 셀프 피드백과 독립 리뷰어 붙이기 — 자기가 짠 걸 자기가 보면 안 보이거든요
용어가 좀 나오는데, 처음 보시는 것들은 나올 때마다 풀어드릴게요.
먼저: AI 코딩은 왜 조용히 틀릴까요
배경지식
Ground truth(그라운드 트루스) 는 "진짜 정답"이라는 뜻이에요. 머신러닝에서 온 말인데, 모델이 뭐라고 하든 상관없이 실제로 맞는 값을 가리켜요. 이게 없으면 채점 자체가 불가능해요.
Oracle(오라클) 은 그 정답지 역할을 하는 기준이에요. 시험지 답안(코드)을 아무리 노려봐도 정답인지 알 수 없잖아요. 따로 답안지가 있어야 채점이 되죠.
어떤 상황이 벌어지냐면
AI는 우리 저장소를 다 읽을 수 있어요. 기존 코드도, 기존 테스트도, 브라우저에서 어떻게 동작하는지도요. 그러다 보니 이런 추론을 아주 자연스럽게 해요.
"지금 이렇게 동작하네? 그럼 이게 요구사항이겠구나."
여기서 사고가 나요. 버그가 사양으로 승격되는 순간이거든요.
두 번째 사고는 테스트 쪽에서 나요. AI한테 "테스트 좀 짜줘"라고 하면 이런 걸 짜와요.
// 구현을 한 번 돌려보고 나온 값을 그대로 붙여넣은 테스트
expect(formatDiscount(1000, 0.1)).toBe("900원");
이 테스트는 100% 통과해요. 그리고 아무것도 검증하지 않아요. 반올림 규칙이 틀렸든 통화 표기가 틀렸든, 구현이 그렇게 뱉으니까 기대값도 그렇게 적힌 것뿐이거든요.
결과적으로 "나중에 망가지면 알려주는" 기능은 하는데, 처음부터 틀린 건 절대 못 잡는 테스트가 돼요.
그래서 필요한 규칙
정답을 정할 수 있는 곳을 딱 두 군데로 제한해요.
| 정책 출처 (정답을 정할 수 있음) | 조사 자료 (정답을 정할 수 없음) |
|---|---|
| 사용자의 명시적인 답변 | 지금 돌아가는 제품 코드 |
| 승인된 명세·기획서·수용 기준 | 기존에 있던 테스트 |
| 브라우저에서 관찰한 동작 | |
| 리뷰어의 개인 취향 |
오른쪽 것들은 "지금 무슨 일이 일어나는가" 를 알려주지 "무엇이 맞는가" 를 알려주지 않아요. 그래서 구현이랑 요구사항이 어긋나면 구현에 맞추지 말고 멈춰서 물어봐야 해요.
이 규칙 하나가 앞의 사고 대부분을 막아줘요.
철학 1 — 정답을 테스트 코드로 최대한 많이 재현하기
왜 굳이 테스트로 옮기나요
말로 합의한 요구사항은 긴 작업에서 반드시 흐려져요. 처음엔 "빈 목록이면 안내 문구"였는데, 구현하다 보면 어느새 스피너가 계속 도는 걸로 끝나 있어요. 아무도 거짓말 안 했는데 그렇게 돼요.
그래서 요구사항을 행 단위 표로 먼저 적어요. 저는 이걸 Oracle Card라고 불러요.
| # | 이런 상황에서 (Given) | 이렇게 하면 (When) | 이래야 하고 (Then) | 절대 이러면 안 돼요 (Never) | 출처 |
| --- | --------------------- | ------------------ | ------------------ | --------------------------- | ------------ |
| 1 | 목록이 비어 있음 | 페이지 진입 | 안내 문구 노출 | 스피너가 계속 도는 상태 | 기획서 3.2절 |
| 2 | 요청이 5초 초과 | 자동 취소 | 재시도 버튼 노출 | 무한 대기 | 사용자 답변 |
| 3 | 같은 항목 연속 클릭 | 두 번째부터 무시 | 요청 1회만 발생 | 중복 요청 | 사용자 답변 |
출처 칸이 진짜 중요해요. 출처를 못 적는 행은 빈칸으로 두고 물어봐요. 여기서 "보통 이렇게 하니까"로 채우는 순간, 그 행은 검증이 아니라 추측이 되거든요.
이걸 그대로 테스트로 옮겨요
표의 각 행이 테스트 하나가 돼요. 행 번호를 테스트 이름에 붙여두면 나중에 추적이 쉬워요.
// O3: 같은 항목을 연속으로 눌러도 요청은 한 번만
it("O3 — 중복 클릭은 요청을 한 번만 보낸다", async () => {
const calls: string[] = [];
server.use(
http.post("/api/favorite", async ({ request }) => {
calls.push(await request.text());
return HttpResponse.json({ ok: true });
}),
);
render(<FavoriteButton itemId="a1" />);
const button = screen.getByRole("button", { name: "찜하기" });
await userEvent.click(button);
await userEvent.click(button); // 두 번째 클릭
await waitFor(() => expect(calls).toHaveLength(1));
});
여기서 Never 행이 특히 값져요. "이러면 안 된다"는 조건은 사람이 잘 안 적는데, 실제 사고는 거의 다 거기서 나거든요. 중복 요청, 무한 대기, 두 번 결제 같은 것들이요.
AI가 정답지를 보면서 답을 찾아가게 만들어요
이게 이 방식의 핵심이에요. 순서를 이렇게 고정해요.
1. 정답 표를 잠근다 ← 여기서 바뀌면 안 됨
2. 테스트를 먼저 쓴다
3. 실행해서 "의도한 이유로" 실패하는 걸 확인한다 ← VALID_RED
4. 그제서야 최소한의 구현을 한다
5. 통과시킨다 ← GREEN
3번이 생각보다 중요해요. VALID_RED는 "새로 쓴 테스트가 우리가 의도한 그 이유로 실패하는 상태"예요.
왜 굳이 확인하냐면요, 오타나 설정 문제로 빨간 걸 진짜 실패로 착각하면 엉뚱한 데를 고치고 나서 "고쳤다"고 오해하거든요. 체온계가 고장 나서 39도가 나온 건지 진짜 열이 나는 건지부터 구분하는 셈이에요.
그리고 3번 전에는 제품 코드를 한 줄도 안 써요. 이 순서만 지켜도 "구현을 먼저 만들고 거기서 기대값을 역산하는" 사고가 구조적으로 불가능해져요.
합의는 텍스트로 잠그고 해시로 확인해요
작업이 길어지면 표 자체가 슬금슬금 바뀌기도 해요. 그래서 파일로 저장하고 내용 해시를 적어둬요.
shasum -a 256 docs/acceptance/favorite.md
# 3f2a... docs/acceptance/favorite.md
각 단계 시작 전에 해시를 다시 확인해요. 안 맞으면?
다시 잠가서 통과시키면 안 돼요. 그때까지 모은 증거를 버리고 사람한테 물어보는 상태로 돌아가요. 검증을 통과시키려고 검증 기준을 바꾸면, 그건 이미 검증이 아니잖아요.
철학 2 — 하네스 엔지니어링과 베스트 프랙티스
하네스가 뭔가요
하네스(harness) 는 테스트를 실제로 굴리는 장치들을 통틀어 부르는 말이에요. 테스트 러너, 가짜 서버, 화면 요소를 찾는 방법, 기다리는 방법 같은 것들이요.
정답 표가 "무엇이 맞는가"라면, 하네스는 "그 판정을 믿을 수 있는가" 를 담당해요. 둘 다 필요해요. 판정 기준이 아무리 정확해도 테스트가 랜덤하게 흔들리면 결과를 못 믿거든요.
가짜 초록불 세 가지를 금지해요
"통과했습니다"를 만드는 편법은 사실 몇 개 안 돼요.
| 편법 | 왜 문제인가요 |
|---|---|
| assertion을 느슨하게 바꾸기 | toBe(3) → toBeGreaterThan(0) 으로 바꾸면 통과는 하는데 검증이 사라져요 |
실패하는 테스트를 skip으로 돌리기 | 초록불인데 실제로는 아무도 그 경로를 안 봐요 |
임의의 sleep 넣기 | 타이밍 문제를 덮을 뿐이라 CI에서 다시 터져요 |
"대충 하지 마세요"는 잘 안 지켜지는데, 이름 붙여서 금지 목록에 적어두면 꽤 잘 지켜져요.
기다림은 시간이 아니라 조건으로
// ❌ 왜 300인지 아무도 모르고, 느린 CI에서 터져요
await new Promise((r) => setTimeout(r, 300));
expect(screen.getByText("저장됨")).toBeInTheDocument();
// ✅ "이 상태가 될 때까지" 기다려요
await screen.findByText("저장됨");
sleep은 "300ms면 되겠지"라는 추측이에요. 추측을 테스트에 넣으면 그 테스트는 언젠가 반드시 배신해요.
네트워크는 MSW로 막아요
MSW(Mock Service Worker) 는 실제 네트워크 요청을 가로채서 가짜 응답을 주는 도구예요.
// ✅ 진짜 요청이 나가고, 그걸 중간에서 받아요
server.use(http.get("/api/items", () => HttpResponse.json({ items: [] })));
// ❌ fetch 자체를 갈아끼우면, 우리 앱이 네트워크를 어떻게 쓰는지는 검증 못 해요
globalThis.fetch = vi.fn().mockResolvedValue(/* ... */);
차이를 비유하자면요, MSW는 진짜 우편함은 그대로 두고 배달부만 배우로 바꾸는 것이고, fetch 바꿔치기는 우편함 자체를 없애고 "편지 받았다고 치자" 하는 거예요. 앞쪽이 훨씬 실제에 가깝죠.
테스트는 소유한 코드 옆에 둬요
루트에 e2e/, mocks/ 폴더 하나 만들어서 다 모으고 싶은 유혹이 있어요. 편하거든요.
근데 그렇게 하면 기능을 지울 때 테스트가 안 지워져요. 아무도 안 보는 죽은 테스트가 쌓이고, 나중엔 지워도 되는지조차 판단이 안 돼요.
✅ src/features/favorite/
├── FavoriteButton.tsx
├── __test__/FavoriteButton.test.tsx
└── __test__/handlers.ts ← 이 경계의 MSW handler
❌ e2e/ ← 전 기능이 뒤섞임
mocks/handlers.ts ← 누가 쓰는지 알 수 없음
같이 태어나고 같이 죽게 두는 게 관리 비용이 훨씬 싸요.
베스트 프랙티스는 "정답"이 아니에요
여기서 헷갈리기 쉬운 부분이 하나 있어요. 외부 가이드나 블로그의 베스트 프랙티스는 구현 방법을 고를 때만 써요. 요구사항을 정하는 데 쓰면 안 돼요.
우리 저장소 규칙과 실제로 설치된 버전을 먼저 확인하고, 거기에 안 부딪히는 선에서만 참고하는 거예요. "요즘은 다 이렇게 한대요"는 정책 출처가 아니거든요.
예산을 정해둬요
무한 재시도만큼 시간을 태우는 것도 없어요. 그래서 상한을 정해요.
| 활동 | 한도 |
|---|---|
| 정책 질문 | 2라운드 |
| 테스트 기계적 보정 (locator 등) | 2회 |
| 구현 개선 | 3라운드 |
| 브라우저 검증·자가개선 | 2라운드 |
서로 빌려 쓸 수 없어요. 다 쓰면 마지막 진짜 실패와 함께 실패로 보고하고 멈춰요. "될 때까지 해볼게요"보다 훨씬 정직하고, 결과적으로 빨라요.
철학 3 — 셀프 피드백과 독립 리뷰어
브라우저에서 실제로 눌러봐요
유닛 테스트가 초록이어도 화면이 멀쩡하다는 보장은 없어요. 그래서 브라우저로 열 수 있는 건 직접 조작한 증거를 남겨요.
정답 표의 각 행이 어떤 증거로 확인됐는지 매핑해요.
O1 → unit: "빈 목록이면 안내 문구를 보여준다"
O2 → browser: 네트워크 5초 지연 후 재시도 버튼 클릭 → 요청 재발생 확인
O3 → unit: "중복 클릭은 요청을 한 번만 보낸다"
D1 → browser: 320px에서 버튼이 잘리지 않음 (스크린샷)
빈칸이 남으면 그건 아직 안 끝난 거예요. "테스트 통과했으니 됐죠"로 넘어가지 않는 게 포인트예요.
관찰 하나에 원인 하나를 붙여요
브라우저나 리뷰에서 뭔가 이상한 걸 발견했을 때, 반사적으로 코드를 고치면 안 돼요. 먼저 분류부터 해요.
| 분류 | 무슨 뜻이냐면 | 허용되는 행동 |
|---|---|---|
POLICY_GAP | 정답이 아직 안 정해짐 | 표 현재본이랑 질문 내밀고 멈추기 |
EVIDENCE_GAP | 정답은 있는데 확인을 안 함 | 잠긴 범위 안에서 매핑만 추가 |
HARNESS_DEFECT | 테스트 도구 문제 | locator·fixture만 보정 (예산 2회) |
PRODUCT_DEFECT | 진짜 버그 | 실패 확인 후 구현 수정 (예산 3회) |
ENVIRONMENT_DEFECT | 환경 문제 | 코드 안 건드리고 실패 보고 |
NON_ORACLE_OPINION | 근거 없는 취향 | 기록만 하고 완료를 막지 않음 |
이렇게 나눠두면 "테스트가 빨개서 일단 코드를 고쳤다" 는 반사적인 대응이 사라져요. 빨간 이유가 도구 문제였는데 제품 코드를 고치는 사고, 생각보다 흔하거든요.
마지막 NON_ORACLE_OPINION도 은근 중요해요. 근거 없는 취향으로 완료를 막으면 리뷰가 끝나지 않거든요. 대신 출처가 있는 미적 요구는 정책이에요. 승인된 디자인이랑 다르면 그건 취향 문제가 아니라 명백한 불일치니까요.
독립 리뷰어를 따로 붙여요
자기가 짠 코드를 자기가 보면 같은 착각을 반복해요. 자기 글의 오타는 남이 더 잘 찾잖아요. 똑같아요.
그래서 메인 작업이랑 분리된 세션에 리뷰를 맡겨요. 리뷰어한테는 정답 표와 결과물만 주고, 중간 과정이나 "이렇게 하느라 고생했다"는 맥락은 안 줘요. 맥락을 주면 리뷰어도 같이 설득당하거든요.
리뷰어의 역할도 명확해요. 증거와 비평은 주지만 정책을 새로 정하지는 않아요. 리뷰어가 "이건 이렇게 하는 게 맞는 것 같은데요"라고 해도, 그게 표에 없으면 정책이 되는 게 아니라 질문이 되는 거예요.
완료의 정의를 뒤로 옮겨요
이게 세 번째 축의 핵심이에요.
IMPLEMENTED_GREEN → 테스트가 실제로 통과함
BROWSER_VERIFIED → 거기에 브라우저 증거까지 붙음
REVIEW_VERIFIED → 독립 리뷰 지적까지 반영하고 재검증 통과 ← 이게 완료
왜 이렇게 하냐면요, 테스트 통과가 완료면 통과시키는 게 목표가 돼요. 그 순간부터 assertion이 야금야금 약해져요. 사람이 나빠서가 아니라 목표가 그쪽을 가리키고 있어서 그래요.
완료를 리뷰 뒤로 옮기면 느슨하게 만들 이득이 사라져요. 어차피 뒤에서 걸리니까요.
세 축이 어떻게 맞물리나요
정답 표 잠금 ──► 테스트로 재현 ──► 최소 구현 ──► 브라우저 증거 ──► 독립 리뷰
(철학 1) (철학 1) (철학 2) (철학 3) (철학 3)
│ │ │ │ │
└───────── 하네스가 이 전 구간의 신뢰도를 담당 (철학 2) ──────────────┘
│
어긋나면 ─► 분류 ─► 정해진 경로로만 되돌아감
한 문장으로 줄이면 이래요.
정답은 사람이 정하고, 테스트가 그걸 대신 기억하고, 하네스가 그 기억을 믿을 수 있게 만들고, 리뷰어가 마지막에 한 번 더 의심해요.
언제 이 방식이 과할까요
솔직히 항상 좋은 건 아니에요.
- 뭘 만들지 자체가 미정인 프로토타입. 표를 채우려는 시도 자체가 낭비예요. 일단 만들어보고 버리는 게 맞아요.
- 정답이 원래 여러 개인 영역. 레이아웃 미세 조정, 카피 문구, 추천 순서 같은 건 단일 기대값으로 못 박으면 정상 변경마다 테스트가 깨져요. 이런 건 불변식(겹침 없음, 개수 보존)이나 시각 회귀로 다뤄요.
- 한 줄짜리 수정. 오타 고치는 데 표를 만들 필요는 없죠.
위험도에 따라 강도를 조절하는 게 현실적이에요. 결제·권한·데이터 마이그레이션처럼 틀리면 피 보는 곳에 몰아 쓰고, 나머지는 가볍게 가요.
정리
AI를 통제하는 핵심은 프롬프트를 잘 쓰는 게 아니었어요. "정답을 정할 권한"을 어디에 두느냐였어요.
| 축 | 한 줄 요약 |
|---|---|
| 1. 정답을 테스트로 재현 | 말로 한 합의는 흐려져요. 표로 적고 테스트로 옮기면 기계가 기억해줘요 |
| 2. 하네스 엔지니어링 | 판정을 믿을 수 있게 만들어요. sleep·skip·느슨한 assertion은 금지 |
| 3. 셀프 피드백 + 독립 리뷰 | 완료를 리뷰 뒤로 옮기면, 대충 통과시킬 이유가 사라져요 |
그리고 세 축을 관통하는 원칙이 하나 있어요.
확신이 부족하면, 그럴듯한 답을 고르지 말고 모른다고 표시하고 멈춰요.
재밌게도 이건 전혀 다른 데서도 똑같이 나타나요. 웹 문서에서 하이라이트를 복원할 때, 후보 구간들의 점수를 매긴 다음 1등과 2등 점수 차가 작으면 후보가 있어도 안 붙이는 규칙이 있거든요. 엉뚱한 데 붙는 게 안 붙는 것보다 나쁘니까요.
한쪽은 사람한테 질문을 돌리고 다른 쪽은 하이라이트를 포기하지만, 목적은 같아요. 조용히 틀린 결과를 만들지 않는 거예요.