2026-08-11 (화) 개발일지
오늘은 하네스 벤치마크의 채점기 판별력 결함 5건을 고쳤다. 이 채점기는 riff·GSD·gstack 세 하네스가 만든 앱을 같은 자로 재는 공식 계측기인데, 파고들수록 “못 만든 앱도 통과시키는” 구조적 느슨함이 드러났다. 파일럿 완료를 전제로 태스크를 정리하던 중이라, 비교의 타당성을 해치는 구멍부터 막기로 했다.
채점기 판별력 — has_amount 부분문자열 매칭이 부가세 단언을 통과시키던 문제
fixture 작업 중 발견된 결함은 보고만 되고 수정은 안 된 상태였다. 그중 가장 골치 아팠던 건 has_amount였다. 초기 증상은 단순했다. “부가세를 한 번도 안 보여준 앱이 vat-total 금액 단언을 통과한다”는 것. 처음엔 fixture 작성 실수라고 생각했다. 하지만 반례를 직접 만들어 돌려보니 채점기 로직 자체의 문제였다.
'합계 110,000원' 이라는 텍스트가 부가세 "10,000"을 문자열로 포함
→ has_amount(110000)도 has_amount(10000)도 참
원인은 re.search가 숫자 경계를 보지 않고 부분문자열만 봤기 때문이다. 110000 안에 10000이 들어가 있으니, 합계만 보여준 앱이 부가세 금액 단언을 그대로 통과했다. 실판별이 단어 매칭 하나뿐이었으니, 이건 단순 버그가 아니라 채점 기준 자체가 무너진 상황이었다.
해법은 숫자 경계를 강제하는 정규식으로 바꾸는 것이었다. (?<![\d,])와 (?![\d,])로 양쪽을 막아 “독립된 숫자”일 때만 매칭되게 했다. 이후 12/12 반례를 통과했다. '합계 110,000원' 10000 -> False, '합계 110,000원' 110000 -> True.
회귀 게이트를 바꾼 이유 — 판정이 바뀌는 게 정상인 작업
이 작업에서 가장 중요한 결정은 회귀 게이트를 바꾼 것이다. 기존 게이트는 “baseline 15/25·critical 8 재현”이었는데, 이번엔 느슨함을 고치는 작업이라 판정이 바뀌는 게 정상이었다. 게이트를 “바뀐 체크마다 왜 바뀌었는지 근거 제시”로 바꿨다. 정당화되지 않는 변화는 구현 버그로 간주하는 방식이다.
여기서 두 번째 결함이 드러났다. status-change(c-quotes) 체크가 상태 단어의 존재만 보고 전이를 검증하지 않았다. “승인”이라는 단어가 화면에 있으면 통과였으니, 실제로 상태를 바꾸지 않는 앱도 통과했다. 해법은 status_transition() 헬퍼를 추가해 실제로 다른 상태로 전이시킨 뒤, 새로고침해도 렌더 텍스트가 유지되는지 확인하는 것으로 바꿨다.
검증에서 재미있는 케이스가 나왔다. 저장하지 않는 select 앱 합성 반례에서 정확히 실패하는 것을 확인했다. status_ok = status_words and status_changed 조건에서 status_changed가 컨트롤 조작 → page.reload() → 렌더 텍스트 유지를 요구하니, 저장이 없는 앱은 자연스럽게 걸러졌다.
b-reviews persistence — 컨트롤 종류 무관한 공용 헬퍼로 정리
세 번째 결함은 tagName === 'SELECT' 하드코딩이었다. 버튼형 상태 앱은 처음부터 채점 대상에서 빠지는 구조였다. 공용 헬퍼 status_transition(page, pattern)로 바꿔서, select면 다른 value의 옵션을 선택하고 버튼형이면 현재 표시 상태와 다른 라벨의 버튼을 클릭하도록 일반화했다. 이후 reload → 유지 확인.
검증: 버튼형 상태 앱에서 status_transition이 (True, "status changed to '개선 반영' and rendered after reload=True")로 통과했고, b-reviews persistence fixture도 통과했다.
booking-conflict — 정적 문구 결합 제거
네 번째는 booking-conflict였다. a-salon 사본의 <body> 직후에 정적 배너 안내: 시간이 겹치는 중복 예약은 저장되지 않습니다.를 주입했더니 판정에 영향이 있었다. 정적 문구가 판정 기준에 결합되어 있어서였다. before/after 상태를 비교하는 방식으로 바꿔서, 정적 배너가 판정에 영향을 주지 않게 분리했다.
serve() 소켓 누수 — finally에 server_close() 한 줄
마지막은 사소하지만 실제 문제였다. serve()가 소켓을 닫지 않아 테스트가 누수됐다. finally에 server.server_close() 한 줄을 추가했다.
독립 검증 — baseline 판정 변화 0건, 증거물 무수정
수정 후 독립 검증을 돌렸다. 결론부터 말하면 FIX REQUIRED 판정이 나왔다. 5건 중 3.5건이 닫혔고, 1건 미해결 + 1건 신규 false negative가 발견됐다.
- 결함 ②(sentiment-theme)가 닫히지 않았다.
CLASSIFIED_ITEMS_JS(riff_preflight_playwright.py:429-444)의 sentiment-theme 체크가 여전히 텍스트 존재만 보고 행동을 판정하지 않았다. status_transition버튼 경로에서 신규 false negative가 나왔다.:300-303에서 저장하는 앱은 통과하지만, 저장 안 하는 앱이 오탈락하는 케이스가 범례 케이스에서 발생했다.
검증에서 중요한 건 두 가지였다. 첫째, 느슨해진 곳 점검에서 체크 제거 0건 / criticality 강등 0건 — 25개 체크의 id·critical 플래그가 커밋본과 완전히 동일했다(프로그램 비교). 조건 완화는 2곳이며 둘 다 정당했다. 둘째, 증거물(preflight/, grader/fixtures/)이 무수정이었다. git status --porcelain이 빈 결과를 반환했다.
baseline 판정 변화는 0건이었다. 이게 이 작업의 핵심 결과다. 채점 로직을 고쳤는데도 기존 25개 체크의 판정이 하나도 바뀌지 않았다는 건, 고친 것이 “틀린 통과”만 막았지 “맞는 판정”을 건드리지 않았다는 뜻이다.
오늘의 한줄
채점기가 느슨하면 채점 대상이 아니라 채점기가 재판을 받아야 한다 — 판정이 바뀌는 것을 버그로 보지 말고, 바뀐 이유를 증명하라.