통과했다는 신호가 거짓일 때

검증이 통과했다는 말은 늘 반쪽짜리 문장이다. 정확히는 검증 장치가 읽은 재료 기준으로 통과했다는 뜻이다. 그 재료가 실제 제품·문서·정책과 어긋나 있으면 초록불은 안심이 아니라 착시가 된다.
이번에 이 착시를 두 곳에서 봤다. 하나는 macOS 클립보드 앱 stash의 TASK-098, 다른 하나는 내 지식 리포의 주간 자동 점검이다. 무대는 다르지만 병은 같았다. 검증 재료가 코드와 함께 늙지 않았다.
초록불이 거짓말하는 구조
문제는 “테스트를 더 많이 돌리자”가 아니었다. 빌드 PASS와 테스트 556건 통과가 이미 떠 있었다. 그런데도 실기 검수로 들어가면 검증이 보지 못한 어긋남이 계속 나왔다.
다섯 사례는 각각 다른 얼굴을 하고 있었지만, 구조는 한 줄로 묶인다.
| 사례 | 통과 신호 | 실제 어긋남 | 바로 물어야 할 질문 |
|---|---|---|---|
| mock | 자리 회귀 테스트 통과 | mock이 프로덕션보다 더 파괴적이었다 | mock은 프로덕션보다 관대한가, 파괴적인가? |
| 병렬 테스트 | 단독 실행 66건 통과 | 스위트 10개가 공유 전역 상태를 경쟁했다 | 병렬 스위트가 같은 전역 상태를 쓰는가? |
| 주석 | 구현 변경 완료 | 주석은 예전 정책을 설명했다 | 구조 변경 커밋이 주석까지 고쳤는가? |
| 정답지 | 독립 검증 준비 완료 | 합격 기준 4건과 표기 2건이 낡았다 | 검증 세션이 읽는 정답지는 최신인가? |
| 린터 | “모순” 점검 이상 없음 | 실제론 [CONFLICT] grep 한 줄이었다 | 린터는 이름 말고 구현상 무엇을 검사하는가? |
즉, 실패한 것은 실행 횟수가 아니라 검사 대상의 정합성이었다.
mock이 프로덕션보다 파괴적일 때
핀 항목에 “자리” 개념을 넣으면서 스키마가 바뀌었다. 프로덕션 구현은 본문을 수정할 때 단일 컬럼만 업데이트했다. 그래서 자리 값은 그대로 남았다.
반대로 테스트용 인메모리 mock은 객체를 통째로 다시 만들었다. 그 과정에서 새 필드 하나가 빠졌고, 본문만 고쳐도 자리가 사라지는 상태를 만들었다.
- 프로덕션: 필요한 컬럼만 바꾼다.
- mock: 객체를 재구성한다.
- 결과: 테스트가 실제보다 더 파괴적인 세계를 정상으로 학습한다.
mock은 보통 “너무 관대해서” 문제를 숨긴다고 생각하기 쉽다. 그런데 반대도 위험하다. mock이 실제보다 더 거칠면, 테스트는 잘못된 상태를 기준으로 통과한다. 프로덕션과 mock의 구현 형태가 다르면 그 차이 자체를 검증해야 한다.
산발 실패는 새 코드의 죄처럼 보인다
새 테스트를 붙인 뒤 전체 실행 3회 중 1회가 실패했다. 더 헷갈리는 점은 매번 다른 항목이 떨어졌다는 것이다. 신규 테스트가 원인처럼 보였지만, 격리해 보니 구조가 달랐다.
clipsPerPage를 공유UserDefaults에 쓰는 테스트 파일이 10개였다.- Swift Testing은 스위트를 병렬로 실행한다.
.serialized는 스위트 내부만 보호한다.- 단독 실행하면 66건 전부 통과했다.
여기서 “내 변경이 테스트를 깼다”고 판단하면 멀쩡한 변경을 되돌리게 된다. 실제로는 새 테스트가 타이밍을 바꿔 기존 경쟁 상태를 드러낸 것이다.
코드보다 오래 사는 주석과 정답지
자리 고정 정책을 넣으면서 정렬 로직은 바뀌었다. 그런데 바로 아래 주석은 예전 세계를 설명하고 있었다.
“한계 — 해제 시 뒤 항목이 당겨진다, 자리 고정 미도입”
문제는 주석 하나의 낡음이 아니다. 같은 취지의 낡은 주석이 3곳 더 있었다. 다음 사람이 코드보다 주석을 믿으면 방금 고친 결함을 되돌리는 방향으로 “수정”할 수 있다.
정답지도 마찬가지였다. 이 프로젝트는 독립 세션이 태스크 문서의 합격 기준을 읽고 검증한다. 그런데 정책을 바꾸자 그 정답지의 일부가 무효가 됐다.
| 낡은 재료 | 실측값 | 위험 |
|---|---|---|
| 자리 고정이 뒤집은 합격 기준 | 4건 | 멀쩡한 구현을 불합격 처리한다 |
| 단축키 표기 오류 | 2건 | 실제 화면과 다른 기준으로 UI를 판정한다 |
| 추가 낡은 주석 | 3곳 | 다음 수정자가 예전 정책으로 되돌릴 수 있다 |
정책 변경이라 테스트를 뒤집는 것은 정상이다. 하지만 그때는 테스트 이름·기대값·주석·정답지까지 같이 뒤집어야 한다. 코드만 바꾸면 완료가 아니라 거짓 신호를 심어둔 상태다.
이름이 아니라 구현을 읽어야 한다
다른 무대에서도 같은 병이 나왔다. 내 지식 리포에는 주간 자동 점검이 돌고, 그 항목 중 하나가 “모순”이었다. 그래서 일부러 낡은 문장 하나를 남겨두고 잡히는지 봤다. 자동화 알림 채널이 바뀌었는데도 예전 채널로 알림이 간다고 적힌 문장이었다.
린터는 못 잡았다. 검사 구현이 이것이었기 때문이다.
grep -rn '\[CONFLICT\]' wiki/ profile.md이 검사는 모순을 발견하는 장치가 아니다. 이미 누군가 [CONFLICT] 태그를 박아둔 항목이 방치됐는지만 확인한다. 태그 없는 의미론적 낡음은 원리상 검출 대상이 아니다.
항목 이름이 “모순”이라서 모순을 찾는 줄 알았다. 하지만 검사기는 이름대로 움직이지 않는다. 구현대로 움직인다.
⇒ 검증 도구의 신뢰 범위는 라벨이 아니라 입력·검색식·비교 기준이 결정한다.
초록불을 다시 읽는 법
이번 일에서 내가 얻은 규율은 단순하다. 초록불을 보면 “통과했다”에서 멈추지 않고, 바로 그 앞에 빠진 목적어를 붙인다.
- 무엇을 기준으로 통과했나?
- 그 기준은 언제 갱신됐나?
- 실물과 다른 대역(mock)이 끼어 있나?
- 병렬 실행에서 공유 전역 상태를 건드리나?
- 문서·주석·정답지가 코드 변경을 따라왔나?
- 린터의 이름과 실제 구현이 같은 범위를 말하나?
테스트와 린터는 필요하다. 다만 그것들은 세계를 직접 보는 눈이 아니라, 사람이 건네준 재료를 읽는 장치다. 재료가 낡으면 장치는 성실하게 낡은 세계를 통과시킨다.
즉, 초록불은 “검사기가 맞다”는 전제 위에서만 신호다. 확인 범위를 모르는 통과는 통과가 아니라 유예다.
참고 자료
- [1] TASK-098 stash pin slot and explicit save UX session
- [2] lint compile cron collision and conflict check limit session
- [3] verification artifact drift playbook