- name
- a-check-that-cannot-flip-is-not-measuring-anything
- description
- 테스트가 아닌 검사 — 부재 가드 · 완료 감지기 · 문서 전체를 훑는 단언 — 은 RED 를 먼저 보는 습관이 없어서, **구조상 한쪽 판정만 낼 수 있는 채로** 배포된다. 초록이면 "결함 없음"이 아니라 "빨개질 수 없음"이고, 타임아웃이면 "전파 안 됨"이 아니라 "성공을 감지할 수 없음"이다. 검사마다 **반대 판정을 한 번 강제로 보라.** 트리거 - `until`/폴링 대기 루프, 삭제 PR 의 부재 가드, dict/응답을 문자열로 합쳐 매칭하는 단언, `$(cmd || echo 0)`, "왜 계속 타임아웃이지", 고치기 전인데 통과하는 테스트.
- version
- 1.0.0
- task_types
- ["testing","devops","review"]
- triggers
- [{"pattern":"폴링 루프/대기 스크립트를 써서 배포·CI·잡 완료를 감지할 때"},{"pattern":"$( ) 안에서 || echo / || true 로 실패를 기본값으로 접을 때"},{"pattern":"dict·JSON 응답을 한 덩어리 문자열로 합쳐 in 으로 단언할 때"},{"pattern":"삭제/금지 가드를 새로 쓸 때"},{"pattern":"대기 루프가 계속 타임아웃인데 대상은 멀쩡해 보일 때"}]
- category
- trap
# 뒤집히지 못하는 검사는 아무것도 재고 있지 않다
## Problem
BSVibe 2026-09-09 한 세션에서 **검사 셋**을 썼다. 셋 다 그럴듯했고, 셋 다
**시스템이 아니라 자기 구조 때문에** 판정이 미리 정해져 있었다.
| 검사 | 낸 판정 | 실제 의미 |
|---|---|---|
| 배포 완료 감지기 | 30분 **타임아웃** | 성공 조건이 **영원히 거짓** — 전파는 3분 만에 끝나 있었다 |
| 삭제 부재 가드 | **초록** | 이름 축 하나만 물어 4개 파일이 그대로 통과 |
| Art.30 응답 단언 | **초록** (고치기 전인데) | 옆 항목이 조건을 만족시켰다 |
셋 다 **다른 것이 잡아줬다** — 스위트의 수집 에러, 손으로 친 대조 쿼리, 그리고
"고치기 전인데 왜 초록이지?" 라는 위화감. 검사 자신은 끝까지 아무 말도 안 했다.
> 테스트에는 RED 를 먼저 보는 습관이 있다. **가드 · 감지기 · 단언에는 없다.**
> 그것들은 산출물이 아니라 "인프라"로 취급돼서, 한 번도 반대 판정을 강제당하지 않는다.
### 사례 1 — `$( )` 안의 `|| fallback` 은 **덮어쓰지 않고 덧붙인다**
```bash
# ❌ 성공을 영원히 감지 못 한다
old=$(docker exec c sh -c 'grep -c "needle" /app/f.py' 2>/dev/null || echo 0)
if [ "$new" = "1" ] && [ "$old" = "0" ]; then echo "전파됨"; fi
```
`grep -c` 는 **매치가 0건이면 종료코드 1** 을 낸다(개수는 정상 출력하면서). 그래서
성공 케이스(=0건)에서 `grep` 이 `0` 을 찍고 **그 직후 `echo 0` 도 발화**한다.
명령 치환은 둘 다 담는다:
```
old = "0\n0" → [ "$old" = "0" ] 는 영원히 거짓
```
폴백은 **명령이 아무것도 안 찍었을 때만** 대체가 된다. 뭔가 찍고 실패하는 명령
(`grep -c`, `grep -q` 아닌 것들, `wc` 파이프, 일부 CLI)에서는 **덧붙는다.**
그리고 로그에는 이렇게 남아 사람 눈에도 잘 안 띈다:
```
=== 타임아웃: 30분 내 전파 안 됨 (신규=1 옛=0
0) ===
```
**고침** — 실패를 기본값으로 접지 말고, 값을 못 얻은 것과 값이 0 인 것을 갈라라:
```bash
# ✅ 폴백 없이. 못 얻으면 빈 문자열이 되고, 그건 "0" 과 다르다
n=$(docker exec c sh -c 'psql ... -tAc "SELECT count(*) ..."' 2>/dev/null | tr -d '[:space:]')
[ "$n" = "0" ] && { echo "적용됨"; exit 0; }
```
가능하면 **개수를 세지 말고 존재를 물어라** — `grep -c` 대신 `grep -q` 는 판정이
곧 종료코드라 이 함정이 없다.
### 사례 2 — 가드는 **자기가 열거한 축**에서만 빨개진다
지운 것에 이름이 여럿이면(테이블 리터럴 · ORM 심볼 · 인덱스 접두사),
한 축만 스캔한 가드는 나머지 축에서 **구조적으로 빨개질 수 없다.**
자세한 건 [[absence-guard-listing-spellings-proves-only-imagination]] §축.
### 사례 3 — 문서를 통째로 합쳐 매칭하면 **옆 항목이 대신 만족시킨다**
```python
retention = resp.json()["retention"]
blob = " ".join(f"{k} {v}" for k, v in retention.items())
assert "30" in blob # ❌ 고치기 전인데 통과했다
```
`retention` 의 **다른 항목**이 이미 *"soft-deleted rows are retained 30 days"* 를
담고 있었다. 내가 재려던 항목은 손도 안 댄 상태였는데 단언은 초록이었다.
**고침 둘을 같이** — 범위를 항목으로 좁히고, **옆 항목과 충돌할 수 없는 값**을 쓴다:
```python
_PATCHED_DAYS = 17 # 30 은 옆 문장이 이미 담고 있다
audit_sentence = resp.json()["retention"]["audit_outbox"] # 문서 아닌 항목
assert str(_PATCHED_DAYS) in audit_sentence
```
픽스처 값을 아무거나 고르면 **그 값이 우연히 옆에 있는지**는 아무도 안 본다.
[[ascii-fixture-cannot-catch-byte-vs-char-confusion]] 과 같은 병이다.
## Solution
### 검사마다 반대 판정을 한 번 강제하고, **본 것을 적어라**
| 검사 | 강제할 것 | 안 하면 |
|---|---|---|
| 테스트 | 고치기 **전** RED (개수까지) | 거짓 초록 |
| 부재 가드 | 가드가 **모르는 철자**로 인스턴스 하나 심기 | 거짓 초록 |
| 존재 가드 | 그 코드를 실제로 지우기(컴파일 되게) | 거짓 초록 |
| **완료 감지기 · 대기 루프** | **조건을 손으로 참으로 만들기** | 거짓 **타임아웃** |
| 문서/컬렉션 단언 | 대상 항목만 비우고 나머지 유지 | 옆 항목이 대신 만족 |
대기 루프는 특히 **반대 방향**으로 실패한다는 점이 함정이다 — 다른 검사는 거짓
초록을 내지만 대기 루프는 **거짓 빨강(타임아웃)** 을 낸다. 그래서 "아직 안 됐나
보다" 로 읽히고 조사되지 않는다.
### 대기 루프는 파싱한 값을 **매 회 찍어라**
```bash
for i in $(seq 1 60); do
n=$(... | tr -d '[:space:]')
echo "[poll $i] n=<$n>" # ← 이 한 줄이 "0\n2" 를 즉시 드러낸다
[ "$n" = "0" ] && exit 0
sleep 30
done
```
`<$n>` 처럼 **경계를 감싸서** 찍어라. 개행·공백이 낀 값은 그냥 찍으면 정상으로 보인다.
### 실패 메시지에 **비교한 값 둘을 다 담아라**
```bash
echo "타임아웃 (기대=0 실제=<$n>)"
```
이번 건은 실패 메시지가 값을 담고 있었기 때문에 사후에 원인을 알 수 있었다.
담지 않았으면 "배포가 느리네" 로 끝났을 것이다.
## Key Insights
- **초록과 타임아웃은 둘 다 "아무 일 없음"처럼 읽힌다.** 그래서 뒤집히지 못하는
검사는 소리 없이 오래 산다. 소리를 내는 건 오직 반대 판정을 강제해 볼 때다.
- **`|| fallback` 은 대체가 아니라 덧붙임일 수 있다.** 명령이 실패하면서도 출력을
낸다면 그렇다. `grep -c` 가 대표적이고, 이건 "실패를 빈 값으로 접는" 익숙한
결함([[a-failed-read-degraded-to-empty-becomes-a-measurement]])의 **사촌**이다 —
거기서는 값이 사라지고, 여기서는 값이 **오염된다.**
- **인프라 코드에는 RED 습관이 없다.** 테스트는 TDD 가 강제하지만 가드·감지기·
대기 루프는 "돌아가면 된 것"으로 취급된다. 그 셋이 정확히 스스로를 검증하지
않는 코드다.
- **다른 것이 잡아줬다면, 그건 이 검사가 안 잡은 것이다.** 스위트가 잡았고 손으로
친 쿼리가 잡았다면 그 검사는 이번에 **점수가 0점**이다 — 결과가 맞았어도.
## Red Flags
- 대기 루프가 **매번** 타임아웃까지 간다 (한 번도 조기 종료를 본 적 없다)
- `$(cmd || echo <기본값>)` — 특히 `cmd` 가 개수를 세는 것일 때
- 실패 로그에 값이 **여러 줄로** 찍힌다 (`옛=0` 다음 줄에 `0`)
- 고치기 **전에** 새 테스트가 통과한다
- dict/JSON 을 `" ".join(...)` · `str(...)` · `json.dumps(...)` 로 합쳐 `in` 으로 단언
- 픽스처 값이 그 자료구조의 **다른 곳에도** 나타나는 흔한 수(30, 1, 0, "test")
## Related
- [[absence-guard-listing-spellings-proves-only-imagination]] — 부재 가드가 축을 놓치는 쪽
- [[completion-detector-fires-on-an-earlier-success-string]] — 감지기가 **너무 일찍** 발화하는 쪽. 이 스킬은 **영원히 발화 못 하는** 쪽
- [[a-failed-read-degraded-to-empty-becomes-a-measurement]] — 실패를 기본값으로 접는 것의 원형
- [[a-cut-that-does-not-compile-is-not-a-wire-cut]] — 반대 판정을 강제할 때 그 절단 자체가 유효해야 한다
- [[piped-gate-masks-exit-code]] — 파이프 뒤 `$?` 가 다른 명령 것인 사촌 함정
Auf GitHub ansehen