AI가 짠 코드는 빠르고, 돌아가고, 화면도 예쁩니다. 문제는 그럴듯하게 틀린다는 것이에요.
특히 두 군데가 자주 비어 있습니다. 하나는 접근성 — 화면을 보지 않고 웹을 쓰는 사람에게 필요한 것들이요. 다른 하나는 검색어를 비운 채 눌렀을 때, 서버가 500을 뱉었을 때처럼 평소엔 잘 안 일어나는 순간입니다. 이런 걸 엣지케이스라고 불러요. 둘 다 “안 시키면 안 챙기는” 영역이죠.
이 글은 AI 생성 코드가 반복해서 빠뜨리는 패턴을 모으고, 테스트가 그 구멍을 어떻게 자동으로 고발하는지 실제 Before/After로 보여줍니다. 흐름을 타고 빠르게 만드는 요즘 방식 — 바이브 코딩에 매는 안전벨트인 셈이에요.
시리즈를 따라오셨다면 그 문제의식이 한 편으로 모이는 곳이고, 검색으로 처음 오셨어도 표와 예제만으로 완결됩니다. (여기 나오는 테스트 기법들이 궁금해지면 시리즈 목차로.)
실습 코드: frontend-testing-lab — 본문의 계약 테스트는 직접 넣어보시라고 일부러 담지 않았습니다. 그래서
step-21은 앞 편과 같은 상태예요.
오늘 확인할 것들입니다.
- AI 코드가 반복적으로 놓치는 패턴
- 이 시리즈의 테스트로 그 함정을 잡는 법
- ‘빠른 생성’과 ‘느린 검증’의 균형

AI가 반복해서 빠뜨리는 것들 — 접근성과 엣지케이스#
AI에게 “검색 UI 만들어줘"라고 하면, 높은 확률로 이런 코드가 나옵니다.
// AI가 자주 뽑는 패턴 (그럴듯하지만 구멍이 많다)
<div className="search">
<input placeholder="검색..." onChange={...} /> {/* label 없음 */}
<div className="clear-btn" onClick={clear}>✕</div> {/* div 버튼 */}
</div>
{error && <p className="error">{error}</p>} {/* role="alert" 없음 */}돌아가긴 해요. 화면도 예쁩니다. 하지만 라벨 없는 입력, div 버튼, 알림 없는 에러 — 스크린 리더 사용자에겐 벽이고, 빈 검색어·서버 오류 같은 엣지케이스 처리도 대개 빠져 있죠.
패턴을 모아두면 리뷰가 빨라집니다. 제가 반복해서 만나는 것들이에요.
| 빠뜨린 것 | 화면에서는 | 실제로는 |
|---|---|---|
label 없는 입력 (placeholder로 대체) | 멀쩡함 | 이름이 placeholder에 얹혀 버팀 — 입력을 시작하면 그 글자가 사라져 무슨 칸이었는지 확인할 길이 없음 |
div에 onClick | 버튼처럼 보임 | 탭으로 도달 불가, 엔터·스페이스로 눌리지 않음, 버튼으로 안 읽힘 |
| 알림 없는 에러 메시지 | 빨간 글씨가 보임 | 화면을 보지 않는 사용자에게는 아무 일도 안 일어난 것 |
| 색으로만 구별하는 상태 | 초록/빨강으로 명확 | 색각 이상 사용자에게 구별 불가 (WCAG 1.4.1) |
| 사라지는 요소의 포커스 방치 | 티가 안 남 | 지우기 버튼이 사라지면 포커스가 <body>로 떨어져 위치를 잃음 |
| 빈 결과에 아무 안내 없음 | 그냥 비어 보임 | 검색이 안 된 건지, 결과가 없는 건지, 로딩 중인지 알 수 없음 |
| 로딩 중 중복 제출 가능 | 눌리는 게 자연스러움 | 같은 요청이 여러 번, 서버 상태가 꼬임 |
공통점이 보이시나요. 전부 눈으로 봐서는 알 수 없는 것들입니다. AI는 화면에 그려지는 결과를 최적화하지, 화면을 보지 않는 사용자나 예외적인 순간을 최적화하지 않아요. 그래서 눈으로 하는 검수는 이 목록을 거의 통과시킵니다.
AI가 만든 화면을 눈으로 훑고 “오, 잘했네” 하며 머지 버튼을 누른 적, 저는 있습니다. 화면이 멀쩡하면 통과시키는 검수는 딱 화면만큼만 지켜주더라고요.
테스트로 방어 — 어느 구멍을 어느 테스트가 잡나#
앞 편들에서 만든 무기들이 정확히 이 구멍들을 겨냥합니다. 왼쪽이 방금 본 누락이고, 오른쪽이 그걸 걸러내는 테스트예요. 함수 이름이 낯설어도 지금은 괜찮습니다 — “화면을 보지 않고 확인하는 방법이 이미 다 있다"만 가져가시면 돼요.
| AI가 놓친 것 | 잡아내는 테스트 |
|---|---|
| label 없는 입력 | getByLabelText('검색') 실패 — 철학 편 |
| div 버튼 | getByRole('button', { name: ... }) 실패 — 접근성 쿼리 편 |
| 알림 없는 에러 | findByRole('alert') 실패 — MSW 편 |
| 대비·ARIA 위반 | axe 스캔 실패 — 접근성 E2E 편 |
| 빈 값·서버 오류 | 경계 단위 테스트 실패 — 단위·비동기 편 |
이런 테스트가 쌓여 있으면 — 프로젝트에 모아둔 테스트 전체를 스위트라고 부릅니다 — AI가 짠 코드를 넣어보는 순간 어디가 부족한지 빨간불이 짚어줍니다.
한 쌍 보시죠. 지우기 버튼을 AI가 짜면 —
// Before: AI 초안
<div className="clear-btn" onClick={clearQuery}>✕</div>실제로 데모 앱의 버튼을 이 div로 바꿔서 스위트를 돌려봤습니다.
× 지우기 버튼은 접근 가능한 이름을 가진 버튼이다
TestingLibraryElementError: Unable to find an accessible element
with the role "button" and name "검색어 지우기"role 쿼리가 곧장 고발하죠. 그런데 여기 반전이 하나 있어요 — 같은 상태에서 axe 스캔은 위반 0건으로 통과했습니다.
axe는 페이지의 마크업을 훑어 접근성 규칙 위반을 찾아주는 자동 검사 도구예요. 규칙을 보는 도구다 보니, div에 걸어둔 클릭 핸들러가 “사실은 이게 버튼"이라는 사정까지는 알 길이 없습니다. 접근성 E2E 편의 “자동 검사는 바닥만 지킨다"가 정확히 이 장면이에요. 이 구멍을 잡아낸 건 결국 role 쿼리 하나였습니다 — 안전망을 겹쳐 치는 이유가 여기 있어요.
// After: 빨간불을 따라가며 고친 코드
<button type="button" aria-label="검색어 지우기" onClick={clearQuery}>
✕
</button>
// 테스트 통과, 스크린 리더 "검색어 지우기, 버튼"
고치는 데 걸린 시간은 1분. 테스트가 없었다면 이 문제는 어느 사용자의 막힌 화면으로만 존재했을 거예요.

적어두지 않은 약속은, 테스트도 못 잡습니다#
여기까지만 보면 “테스트만 있으면 되겠네” 싶은데, 한 가지를 더 확인해야 공정합니다. 테스트는 우리가 적어둔 약속만 지킵니다. 적지 않은 약속은 AI가 어겨도 조용해요.
위 표의 여섯 번째, 빈 결과 안내로 실험해봤습니다. 데모 앱에는 검색 결과가 없을 때 “검색 결과가 없습니다.“를 띄우는 분기가 있는데, AI 초안이 흔히 빠뜨리는 자리죠. 그 분기를 통째로 지우고 전체 스위트를 돌렸습니다.
# 빈 결과 안내를 제거한 상태 — 단위·컴포넌트
Test Files 5 passed (5)
Tests 13 passed (13)
# E2E(axe 접근성 스캔 포함)
5 passed (4.0s)18개 전부 초록불입니다. 사용자는 검색해놓고 텅 빈 화면을 마주하는데, 로딩 중인지 결과가 없는 건지 알 수 없는 상태예요. 앞에서 div 버튼을 잡아냈던 그 스위트가, 이번엔 아무 말도 하지 않습니다.
이유는 단순합니다. div 버튼은 “지우기 버튼은 접근 가능한 이름을 가진 버튼이다"라는 약속이 테스트로 적혀 있었고, 빈 결과 안내는 적혀 있지 않았거든요. 이렇게 테스트로 못 박아둔 약속을 계약이라고 부릅니다. 계약을 하나 추가하면 곧바로 잡혀요.
it('결과가 없으면 그 사실을 알린다', async () => {
const user = userEvent.setup()
render(<UserSearch />)
await screen.findByText('Alice Kim')
await user.type(screen.getByLabelText('검색'), 'zzz')
expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument()
})× 결과가 없으면 그 사실을 알린다
TestingLibraryElementError: Unable to find an element
with the text: 검색 결과가 없습니다.그래서 결론이 조금 달라집니다. AI 코드를 막아주는 건 “테스트가 있다"가 아니라 “그 약속이 테스트로 적혀 있다”예요. 테스트 스위트는 자동으로 촘촘해지지 않습니다. 무엇을 약속으로 삼을지는 여전히 사람이 정해요 — 19편에서 요구사항을 문장으로 쪼개 하나씩 짝지어본 게 정확히 이 작업이었습니다.
직접 해보실 분께: 이 테스트는 데모 저장소에 일부러 넣지 않았습니다. 위 코드를
src/components/UserSearch.test.tsx에 추가하고, 컴포넌트에서 빈 결과 분기를 지웠다 되돌려보세요. 빨간불과 초록불이 오가는 걸 직접 보는 게 이 편에서 가장 남는 경험일 겁니다.
바이브 코딩과 검증의 균형 — 병목은 이미 옮겨갔습니다#
AI로 빠르게 만들고, 테스트로 꼼꼼히 검증하는 2단 구성이 현실적인 답이에요. 프롬프트에 “접근성 고려해서"를 넣는 것도 도움이 되지만, 그걸 강제하는 건 결국 테스트와 CI 게이트입니다. 생성 속도만 자랑하는 팀은 결국 프로덕션에서 비용을 치릅니다.
생각해보면 병목이 옮겨간 거예요. 예전엔 코드를 쓰는 게 느렸는데, 지금은 쓰는 건 몇 초고 믿을 수 있는지 확인하는 게 느립니다. 그런데 많은 팀이 아직 예전 배분을 유지하고 있어요 — 생성에 쓰던 시간만 줄이고, 검증에는 그대로 몇 분만 쓰는 거죠. 그러면 리뷰가 “돌아가는지” 확인하는 수준에 머물고, 위 표의 일곱 가지는 전부 통과합니다.
앞에서 말한 바이브 코딩을 나무랄 생각은 없어요. 저도 그 속도감을 좋아합니다. 다만 흐름이 끊기지 않으려면 뒤에서 대신 멈춰 세워주는 장치가 있어야 해요. 테스트와 CI가 그 역할입니다. 안전벨트를 매면 오히려 편하게 밟을 수 있는 것과 같아요 — 벨트가 없으면 결국 속도를 스스로 줄이게 되거든요.
사람이 봐야 할 것도 조금 달라집니다. 문법이나 오타는 이제 볼 필요가 거의 없어요. 대신 “이 코드가 무엇을 약속하는가”를 봐야 합니다. 라벨과 role이 사용자가 부르는 이름과 맞는지, 실패했을 때 무슨 일이 일어나는지, 아무것도 없을 때 무엇이 보이는지 — AI가 알아서 정해준 이 결정들이 사실은 제품 결정이거든요.
한 장 요약#
- AI 코드의 단골 구멍: 라벨 없는 입력 · div 버튼 · 알림 없는 에러 · 빈 값/서버 오류 미처리 — 화면은 멀쩡해 보임
- 테스트 스위트가 있으면 AI 코드를 넣어보는 순간 어느 구멍인지 빨간불이 가리킴 (role 쿼리·alert·axe·경계 테스트)
- 단, div 버튼은 axe도 못 봅니다(클릭 핸들러는 스캔에 안 보임, 실측 위반 0건) — 그 구멍은 role 쿼리 계약의 몫
- 적어두지 않은 약속은 스위트도 못 잡습니다 — 빈 결과 안내를 지웠더니 18개 테스트가 전부 초록불(axe 포함). 계약 테스트 한 줄을 더하자 곧장 빨간불
- 고치는 비용은 분 단위 — 테스트가 없었다면 그 구멍은 어느 사용자의 막힌 화면으로만 존재했을 것
- 프롬프트에 “접근성 고려"를 넣는 건 도움, 강제하는 건 테스트와 CI 게이트
- 현실적 균형 = AI로 빠르게 생성 + 테스트로 꼼꼼히 검증하는 2단 구성
방어선을 점검하며#
긴 여정의 마지막이 다가옵니다. 다음 편에서 시리즈를 회고하고, 다음 단계를 이야기할게요.
AI에게 “접근성 챙겨서 짜줘"라고 하면 정말 챙겨줍니다. 문제는 안 시키면 안 챙긴다는 거죠. 그 ‘안 시켰을 때’를 잡는 게 테스트예요.
레벨업: AI 코드의 전형적인 함정을 테스트로 걸러내는 안전망을 갖췄습니다.
다음 편: 테스트 문화·면접·다음 단계 — 시리즈를 마치며
낯선 용어가 있었다면 — 용어집에 전부 한 줄씩 정리돼 있어요.
