이 글에서 다룰 이야기
지난 글에서 "AI에게 일을 시키기 전에 정답 기준(오라클 카드)부터 잠그자"는 이야기를 했어요. 그 방식으로 실제 페이지 구현을 하나 끝까지 해봤는데요. 좋은 소식과 나쁜 소식이 하나씩 있었어요.
좋은 소식: 카드에 적은 건 정말 끝까지 지켜져요. 21개 계약 전부 테스트로 잠기고, 전부 GREEN이 됐어요.
나쁜 소식: 카드에 없는 곳에서 버그가 4개 나왔어요. 전부 구현이 끝난 뒤에, 제가 화면을 보다가 "어 이거 왜 이래?"라고 물어서야 발견됐고요.
이 글은 그 4개의 버그를 뜯어보고, "시키지 않은 것"을 잡기 위해 스킬을 어떻게 뜯어고쳤는지 정리한 기록이에요. 결론부터 말하면 원칙은 한 줄이에요.
경우의 수를 나열하는 건 기계가 하고, AI는 판정만 한다.
왜 이 한 줄이 나왔는지, 차근차근 풀어볼게요.
사건: 카드 밖에서 터진 버그 4개
카드 목록에 가상 스크롤(화면에 보이는 줄만 그리는 기법)을 붙이는 작업이었어요. 카드에는 "몇 열로 보여라", "끝까지 내리면 더 불러와라" 같은 요구사항이 잘 잠겨 있었죠. 그런데 이런 버그들이 터졌어요.
| 버그 | 원인 |
|---|---|
| 로딩 스켈레톤이 영원히 안 사라짐 | React StrictMode가 effect를 두 번 실행하는데, 타이머를 이벤트 핸들러가 쥐고 있어서 정리가 어긋남 |
| 필터를 바꾸면 화면이 맨 위로 튐 | 필터 변경 → 목록 리마운트 → 새 가상화 라이브러리가 초기화하면서 스크롤을 0으로 밀어버림 |
| 필터를 바꿀 때마다 화면이 꿀렁임 | 첫 렌더의 기본 열 수(4열)가 실측값으로 바뀌는 순간이 "리사이즈"로 잡혀서 앵커 로직이 발동 |
| 목록이 사라졌다 나타남 | 필터 변경 → 쿼리 키 변경 → Suspense 폴백으로 추락 |
공통점이 보이시나요? 전부 조건 두세 개가 겹치는 지점이에요. "StrictMode 그리고 핸들러 소유 타이머", "기존 필터 정책 그리고 새 라이브러리의 초기화 동작". 하나씩 떼어놓으면 전부 멀쩡한데, 겹치는 순간 사고가 나요.
그리고 더 아픈 사실이 하나 있어요. 이 교차점들은 카드에 물어볼 기회조차 없었어요. 요구사항 어디에도 "StrictMode에서 타이머는요?"라는 항목이 없었으니까요.
배경지식: 이런 버그는 원래 이렇게 생겨요
NIST(미국 표준기술연구소)가 실제 소프트웨어 결함들을 수십 년 분석했는데요. 결론이 재밌어요. 결함 대부분은 파라미터 12개의 상호작용에서 터지고, 조사한 도메인들에서는 46개 이하의 조합 안에서 사실상 전부 관측됐어요. 2개 조합(pairwise)만 체크했을 때의 결함 검출률은 연구 대상마다 대략 50~90% 수준으로 보고되고요.
그래서 나오는 전략이 조합 테스트(combinatorial testing)예요. 전체 조합을 다 도는 대신, 낮은 차수의 조합 커버리지를 먼저 채우는 게 실용적이라는 거죠. 물론 pairwise가 항상 충분하다는 뜻은 아니에요 — NIST도 높은 보증이 필요한 시스템엔 더 높은 차수를 권하고, 뒤에 나올 제 도구도 위험이 높으면 3-way로 올려요.
제 버그 4개가 정확히 이 분석 그대로였던 거죠. 단일 조건은 다 검증했는데, 조합을 아무도 안 봤던 거예요.
진단: 실패에는 두 가지 모드가 있어요
버그를 앞에 두고 곰곰이 생각해보니, 실패가 두 종류로 갈라지더라고요.
모드 A — 안 적힌 것: 검증 대상에서 아예 빠져요. 카드에 없는 케이스는 확률적으로 새는 게 아니에요. 애초에 아무도 안 봐요. 나중에 잡힌다면 그건 검증이 아니라 우연이고요. 더 무서운 건 흔적이 안 남는다는 거예요. "물어봤는데 답이 없었다"가 아니라 "물어볼 생각 자체를 못 했다"는 기록이 어디에도 없어요.
모드 B — 적힌 것: 검사가 확률적이에요. 카드에 있어도 안심은 일러요. AI가 그 행을 테스트로 옮길 때 느슨하게 옮길 수도 있고, 화면을 보고 판정할 때 실행마다 결론이 흔들릴 수도 있거든요.
비유하면 이래요. 공장 검수 체크리스트가 있는데,
- 체크리스트에 없는 항목은 아무도 안 봐요 (모드 A)
- 체크리스트에 있는 항목도 검사원이 사람이라 그날그날 달라요 (모드 B)
둘은 처방이 달라요. 모드 B는 검사를 기계로 바꾸거나 검사원을 두 명 붙이면 돼요. 진짜 문제는 모드 A예요. "생각 안 함"은 어떻게 잡죠?
원칙: 문제는 기계가 내고, AI는 답만 채워요
여기서 제일 중요한 발견이 있었어요. 최근 연구 두 갈래가 방향을 보여주는데요.
한쪽은 한계 측정이에요. TestEval이 LLM 16개에게 테스트 생성을 시켜봤더니, 전반적인 커버리지는 곧잘 뽑는데 "이 라인/브랜치/경로를 지나는 테스트"처럼 특정 지점을 겨냥한 생성은 여전히 어려웠어요. 자연어 요구사항을 대상으로 한 비교연구(ISEC 2025)도 복잡하고 서로 얽힌 요구사항에서 커버리지 기준 미달을 보고했고요.
다른 쪽은 개선 방향이에요. LLMCFG-TGen은 요구사항을 먼저 흐름도(CFG)로 바꾸고, 기계가 경로를 전부 뽑은 다음 LLM이 경로마다 테스트를 채우게 했어요. 직접 나열시킨 기준선보다 경로 커버리지가 크게 올라가고, 중복과 누락도 줄었대요.
그러니까 AI의 약점은 "판정"이 아니라 "빠짐없이 나열하기" 예요. 사람도 똑같잖아요. "떠오르는 위험 다 말해봐"는 늘 빠뜨리는데, 빈칸 있는 표를 주면 다 채워요.
그래서 스킬의 구조를 이렇게 바꿨어요.
| 역할 | 예전 | 지금 |
|---|---|---|
| 경우의 수 나열 | AI가 떠올림 (확률적) | 기계가 생성 (결정적) |
| 각 경우의 판정 | AI | AI (여기만 남김) |
| 빠짐 감지 | 불가능 — 흔적이 없음 | 빈칸 = lint 에러 = 잠금 불가 |
"생각했는가?"는 검사할 수 없지만, "셀 (i, j)에 값이 있는가?"는 기계가 검사할 수 있어요. 침묵을 빈칸으로 바꾸는 것, 이게 전부예요.
층 1 — 교차 스윕: 새 가구를 들일 땐 기존 가구와 전부 맞대봐요
첫 번째 장치는 단순해요. 새로 정한 정책들과 기존에 있던 정책들의 모든 짝을 표로 만들어요. 표의 행과 열은 제가 고르는 게 아니라 카드에 이미 적힌 정책 ID에서 기계적으로 나와요.
| 짝 | 판정 |
| --------------------------- | --------------------------------------------- |
| 가상화 × 기존 필터 리마운트 | needs-decision: 리마운트 때 스크롤 유지? 리셋? |
| 가상화 × 목록 시맨틱 | covered(O20) — 이미 계약 행이 있음 |
| 열 수 × 스크롤 소유 | impossible: 둘 다 읽기만, 서로 안 건드림 |
판정은 딱 세 가지만 허용해요. covered(이미 어떤 행이 책임짐), impossible(만날 수 없음 — 이유 필수), needs-decision(정해진 게 없음 — 질문으로 승격). 빈칸이나 빠진 정책은 lint가 잡아서 카드 잠금 자체가 안 돼요.
포인트는 이거예요. 위 표의 첫 줄이 바로 "필터 바꾸면 화면 튀는" 버그예요. 구현하고, 터지고, 조사하고, 고치는 왕복 대신 카드 단계의 질문 한 줄로 바뀐 거죠.
층 2 — 편차 스윕: 항공안전에서 빌려온 질문 4개
두 번째 장치는 항공·원전 안전공학의 STPA라는 기법에서 빌려왔어요. STPA는 시스템의 제어 행동이 위험해지는 방식을 4가지로 분류하는데요. 저는 이 분류를 원래 용도 그대로가 아니라, 카드의 정책마다 붙는 질문 생성기로 옮겨 썼어요.
- 안 지켜지면? (not provided)
- 지키면 안 되는 상황에서 지켜지면? (unsafe provided)
- 너무 이르게, 늦게, 순서가 꼬이면? (wrong timing/order)
- 너무 일찍 멈추거나, 끝났는데 계속되면? (stopped early / applied long)
모든 정책 × 4 = 판정 공간이 카드에서 자동으로 나오고, 역시 전부 채워야 잠겨요. "타이머가 너무 일찍 정리되면?"이라는 4번 질문이 바로 StrictMode 스켈레톤 버그고요. 정적인 정책은 impossible: 지속 시간 없음 한 줄이면 끝나서 부담도 크지 않아요.
층 3 — 지뢰 스윕: 라이브러리 옵션은 남이 밟은 지뢰의 화석이에요
이번 세션에서 제일 마음에 드는 발견이에요.
가상화 라이브러리에 initialOffset이라는 옵션이 있어요. "초기 스크롤 위치를 지정한다"는 평범한 옵션처럼 보이죠. 그런데 뒤집어 생각하면 — 이런 옵션의 존재는 누군가 "마운트할 때 스크롤이 리셋되는" 문제를 이미 겪었다는 흔적일 가능성이 높아요. 약 설명서의 부작용 항목이 "누군가 이미 겪은 부작용"의 목록인 것처럼요.
물론 모든 API 옵션이 실제 버그에서 태어났다고 증명할 수는 없어요. 그래서 이건 법칙이 아니라 심문 순서를 정하는 휴리스틱이에요 — "회피용으로 보이는 옵션"을 먼저 의심하되, 진짜 지뢰인지는 근거로 확인하는 거죠.
그 확인이 규칙에 들어 있어요. 카드에 라이브러리가 등록되면, 잠그기 전에 그 라이브러리의 주의사항 문서, "문제 회피용" 옵션 목록, 이슈 트래커를 훑어서 지뢰 표를 만드는데, 항목마다 docs·issue·changelog 출처 링크가 필수예요. 출처를 못 대는 "그럴 것 같은데요"는 lint가 기각해요 — 근거 제약 없는 AI 판정은 오탐이 절반을 넘는다는 연구가 있거든요. 그리고 역시 전부 판정해야 하고요.
실제로 소급해보면, "화면 튀는" 버그는 이 표 한 줄로 구현 전에 잡혔어요. 해결책(initialOffset)이 라이브러리 API에 이미 있었으니까요.
층 4 — 케이스 공간: 시험 범위표는 기계가 채점해요
여기가 이번 개편의 본체예요. 위의 스윕들이 "정책끼리"를 봤다면, 이건 입력·상태·순서·진입 경로·환경 전체를 봐요.
카드에 8개 계열의 범위표를 선언해요. 계열 목록은 제가 발명한 게 아니라 소프트웨어 테스팅 쪽에서 수십 년 검증된 카탈로그(SFDIPOT 등)를 수입한 거예요.
## Case space
| 계열 | 차원 | 값들 |
| ----------- | --------- | ------------------------------------ |
| Data | rows | 0, 1, pageSize, pageSize+1, max |
| Async | 요청 | success, http-5xx [error] |
| Order | 필터/페이지 | 순차, 역전 |
| Entry | 진입 | 신규, 새로고침, 뒤로가기 |
| Environment | viewport | 320, desktop |
| Platform | — | excluded: 단일 브라우저 범위 (S1) |
| ... | | |
값들은 지난 글에서 소개한 경계값 분석(BVA)에서 그대로 나와요. 그리고 두 가지 강제가 걸려요.
- 계열을 빼먹을 수 없어요. 8개 계열 전부, 차원을 선언하거나
excluded: 사유를 적어야 해요. "브라우저 호환은 생각 못 했어요"는 불가능하고, "단일 브라우저로 한정한다고 적었어요"만 가능해요. 전자는 감사가 안 되지만 후자는 나중에 따질 수 있죠. - 선언이 끝나면 스크립트가 조합을 대신 뽑아요. 위 공간의 전체 조합은 120개인데, "모든 2개 조합을 커버하는 집합"을 계산하면 십수 개로 줄어요. 아까 본 NIST 분석대로 결함 대부분이 낮은 차수의 조합에서 나오니까, 십수 개 판정으로도 밀도가 꽤 나와요. 위험이 높은 카드는 3-way로 올리고요.
상태 다이어그램(State Model)이 있으면 모든 경로와 모든 빈칸도 같이 뽑혀요. "로딩 중에 또 페이지를 누르면?"은 빈칸으로 튀어나와서 판정을 요구해요. 판정 안 된 프레임이 하나라도 있으면? 예상하셨겠지만, 잠금 불가예요.
뽑힌 경로들은 그대로 테스트 목록이 돼요. 경로 하나 = 테스트 하나. 순서 차원이 있으면 fast-check라는 도구로 "임의 순서로 마구 섞어보는" 테스트도 필수로 붙고요.
여기서 한 발 물러나 보면, 층 1부터 4까지는 사실 하나의 규약이에요. 판정 공간은 기계가 카드에서 파생하고, 판정 값은 covered/impossible/needs-decision 셋뿐이고, 빈칸은 전부 같은 lint가 잠금을 막아요. STPA든 조합 테스트든 지뢰 표든, 기법마다 다른 건 "공간을 만드는 방법"뿐이에요. 그래서 다음에 좋은 기법을 발견하면 표 하나를 더 꽂으면 돼요 — 기법 모음이 아니라 소켓이 있는 시스템인 거죠.
층 5 — 그물: 몰랐던 것도 걸리게
여기까지가 "미리 나열하는" 층이라면, 마지막은 나열에 실패해도 걸리는 그물이에요.
- 불변식(Invariants): 시나리오와 무관하게 항상 참이어야 하는 것들이에요. "콘솔 에러 0건", "화면에 표시된 데이터는 항상 현재 URL과 일치". 어떤 경로로 도달했든 이게 깨지면 잡혀요.
- 탐색 QA: 요청받은 검증이 끝나면, 시간을 정해놓고(30번의 상호작용) 새로고침·연타·로딩 중 조작 같은 심술을 부려요. 판정 기준은 불변식뿐이고, 발견은 "버그 확정"이 아니라 "질문 후보"로만 올라와요. QA가 멋대로 정책을 정하면 안 되니까요.
- WebKit 매트릭스: 탐색을 chromium과 WebKit 두 엔진에서 돌려요. 카드에 Safari 축을 아무도 안 적었어도, WebKit 엔진에서만 나타나는 종류의 문제를 한 겹 더 관찰할 수 있어요. 실제 macOS/iOS Safari와 완전히 같은 환경은 아니라서 "Safari 버그를 잡는다"가 아니라 "관찰 범위가 넓어진다"가 정확한 표현이고요. 포인트는 축을 선언하지 않아도 작동하는 유일한 층이라는 거예요.
- 가정 감시: 라이브러리 버전을 잠금 파일에 고정해두고, 나중에 버전이 바뀌면 "이 카드의 지뢰 스윕은 낡았어요, 다시 하세요"라는 신호를 줘요. 카드는 옳았는데 세상이 바뀌어서 새는 버그를 사용자 제보 전에 잡는 거예요.
정직하게: 그래도 다 못 잡아요
여기까지 읽으면 "이제 모든 버그를 예지하는 건가?" 싶으실 텐데요. 아니에요. 그리고 이 한계를 정확히 적는 게 중요해요.
기계가 보장하는 완전성은 "선언한 공간 안에서"만이에요. 범위표에 Platform 계열이 아예 없었다면 Safari 버그는 영원히 프레임으로 생성되지 않아요. 축을 발명하는 일 자체는 어떤 기법으로도 자동화가 안 돼요.
대신 할 수 있는 건 이거예요.
- 축 목록을 내 머리가 아니라 업계가 이미 수업료를 낸 카탈로그에서 시작하기
- 축을 빼려면 "제외한다"고 적게 강제하기 — 생각 안 함을 기록된 결정으로 바꾸기
- 유도 가능한 축(브라우저 목록 등)은 설정 파일에서 기계로 뽑기
- 몰라도 걸리는 그물(불변식 × 두 엔진) 깔기
- 그래도 새면 — 한 번 새어나간 버그는 반드시 축 목록에 추가하기. 다음 카드부터는 모든 프로젝트가 그 질문을 받아요. 수업료는 한 번만 내는 거죠.
지난 글의 문장을 빌리면, 정답지를 잠그는 것만으로는 부족했어요. 정답지에 실을 문제를 뽑는 일까지 기계에게 넘겨야 했던 거예요.
마무리: 숫자로 정리하면
- 실제 버그 4개를 새 체계에 소급해보면 — 교차 스윕 1개, 편차 스윕 1개, 지뢰 스윕 1개, 나머지 1개는 스윕이 승격시키는 질문으로 — 4개 전부 카드 단계에서 질문이 됐어요.
- 전체 조합 120개 → 판정할 프레임 십수 개 (2-way 커버링 우선, 고위험은 3-way 승격)
- AI가 틀릴 수 있는 지점: "케이스를 빠짐없이 떠올리기" 전체 → "빈칸 하나의 판정" 으로 축소
- 사용자 체감: 구현 전에 질문 몇 개가 늘고, 구현 후에 "어 이거 왜 이래?"가 줄어요
체크리스트를 늘리는 이야기가 아니에요. 체크리스트를 누가 만드느냐를 바꾸는 이야기예요. 사람도 AI도 나열에는 약하고, 기계는 나열만큼은 절대 안 빼먹거든요.
참고 자료
본문에서 예시로 든 통계와 기법들의 출처예요.
"조건 2개 조합이면 결함 50~90%" (NIST 통계)
- NIST SP 800-142 — Practical Combinatorial Testing (Kuhn·Kacker·Lei) — 조합 테스트의 표준 문서예요
- NIST CSRC — Why do Combinatorial Testing? — 실제 결함이 몇 개 조건의 교차에서 터지는지 분석한 페이지예요
"AI 자유 나열의 한계, 구조를 주면 개선" (LLM 테스트 생성 연구)
- TESTEVAL: Benchmarking Large Language Models for Test Case Generation (arXiv 2406.04531, NAACL 2025 Findings) — LLM 16개 벤치마크. 전반 커버리지는 높지만 특정 라인/브랜치/경로를 겨냥한 생성은 여전히 어렵다는 측정이에요
- Test Case Generation for Requirements in Natural Language — An LLM Comparison Study (ISEC 2025, ACM) — 자연어 요구사항에서 뽑은 테스트가 커버리지 기준을 못 채우고, 복잡·상호의존 요구사항에서 특히 실패한다는 측정이에요
- LLMCFG-TGen: Using LLM-Generated Control Flow Graphs to Automatically Create Test Cases from Use Cases (arXiv 2512.06401) — 요구사항을 CFG로 바꿔 경로를 기계가 뽑고 LLM이 채우게 하면 직접 나열 대비 경로 커버리지·중복·누락이 개선된다는 연구예요
- Evaluating LLMs for Unit Test Generation with Equivalence Partitions and Boundary Values (arXiv 2505.09830) — 동치류·경계값 기준으로 LLM 생성 테스트를 평가한 연구예요
"경우의 수를 기계가 뽑는다" (40년 된 계보)
- The Category-Partition Method for Specifying and Generating Functional Tests (Ostrand & Balcer, CACM 1988) — 차원(category)×값(choice)을 선언하면 생성기가 테스트 프레임을 전부 뽑아주는 방식의 원조예요.
[error]단독 프레임 주석도 이 논문 거예요 - Classification Tree Method — 같은 아이디어의 시각화 버전이고, ISO/IEC/IEEE 29119-4 표준에도 들어가 있어요
"약속이 무너지는 방식은 4가지" (편차 스윕)
- STPA 튜토리얼 (John Thomas, MIT) — unsafe control action 4유형의 출처예요. 항공·원전에서 쓰는 위험 분석 기법이에요
- A comprehensive safety engineering approach for software-intensive systems based on STPA (arXiv) — STPA를 소프트웨어와 조합 테스트에 연결한 논문이에요
"8개 계열 범위표는 수입품" (케이스 공간)
- Heuristic Test Strategy Model — SFDIPOT (James Bach, Satisfice) — Structure·Function·Data·Interfaces·Platform·Operations·Time. Platform 계열이 여기서 왔어요
- Test Heuristics Cheat Sheet (Elisabeth Hendrickson) — 입력 변형 아이디어 카탈로그예요
"출처 없는 AI 회상은 오탐이 절반" (지뢰 스윕의 인용 강제)
- Exploring Automatic Cryptographic API Misuse Detection in the Era of LLMs (arXiv) — 제약 없는 LLM 판정은 오탐이 절반을 넘고, 범위 제약과 self-correction을 걸면 90% 근처까지 올라간다는 측정이에요
본문에 나온 도구들
initialOffset— TanStack Virtual 문서 — "옵션은 지뢰의 화석" 예시로 든 실제 옵션이에요- fast-check — model-based testing — 순서를 임의로 섞어보는 테스트(
fc.commands,fc.scheduler) 문서예요 - Does Bug Prediction Support Human Developers? — Google 사례연구 — 본문에는 짧게만 나왔지만, "어느 파일에서 버그가 잘 나는지" 점수를 매기는 TWR 알고리즘의 출처예요