제 8 장

시험 및 검증 결과

이 장의 수치는 2026-09-30에 직접 돌린 결과다. 게이트 명령을 그대로 실행하고 출력을 옮겼다.

1) 시험 환경 및 항목

단위·통합 시험

pytest + pytest-asyncio를 쓴다. 시험 파일 74개에 1,182건이고, 앱별로는 아래와 같다.

앱 시험 파일
kayfabe 943 53
ontology 111 9
lion_king 65 5
auth 63 7

전체가 16초에 끝난다. DB도 LLM도 붙이지 않기 때문이다 — 리포지토리 자리에 가짜 세션을 끼우고, 도메인 서비스는 고정 픽스처만으로 부른다. 그래서 커밋 직전에 전체를 돌릴 수 있고, 실제로 CI가 그렇게 한다.

시험 경로는 앱 안의 레이어 구조를 그대로 따라간다(tests/app/use_cases/ · tests/app/services/ · tests/domain/). 앱별 tests/conftest.py가 apps/를 sys.path에 넣어 kayfabe.* 임포트를 활성화하며, 새 앱은 그 conftest를 복사한다.

가짜 세션이 값을 치르는 자리가 있다. 결과 처리를 건너뛰고 파이썬 객체를 그대로 돌려주므로, SQLAlchemy의 타입 처리기가 도는 경로가 시험에 없다. 실제로 그 틈으로 운영 장애 하나가 통과했다(3절 ②).

게이트 세 층

층 무엇을 돌리나 언제
손으로 돌리는 하네스 스택별 명령(ruff · lint-imports · ESLint · tsc) 코드를 고친 직후
pre-commit 훅 10종 위 전부 + 시험 두 파일 커밋할 때
GitHub Actions gates 위 전부 + apps 전체 ho · main · messi 푸시, 모든 PR

훅과 CI가 다른 점은 시험 범위 하나다. 훅은 커밋 흐름을 느리게 하지 않으려고 파일 둘만 돌린다 — 자격 판정 골든 세트(89건 · 0.09초)와 계보 사슬 E2E(10건 · 2.40초). 앞쪽이 규칙의 의미를 붙들고 뒤쪽이 마디 사이의 매핑을 붙든다. 조각별 시험은 전부 초록인데 사슬만 끊어지는 고장이 뒤쪽에서 잡힌다. 나머지 검사(ruff · import-linter · ESLint · tsc · prettier · flutter analyze · dart format)는 훅과 같은 명령이므로, 명령을 바꿀 때는 두 파일을 함께 본다.

CI를 따로 둔 이유는 훅이 머신에 있기 때문이다. 훅은 pre-commit install을 돌린 컴퓨터에만 있고, 클론을 새로 하거나 --no-verify로 건너뛰면 사라진다. 실제로 설정 파일에만 있고 .git/hooks/pre-commit이 없어서 골든 세트를 아무도 강제하지 않던 기간이 있었다(3절 ⑥).

2) 시험 결과

전 게이트를 순서대로 돌린 결과다.

게이트 명령 결과
단위·통합 시험 pytest apps -q 1,182 passed · 16.35초
린트 (Python) ruff check fastapi/ All checks passed
포맷 (Python) ruff format --check 744 files already formatted
아키텍처 계약 lint-imports 4 kept · 0 broken (512 파일 · 699 의존)
린트 (프론트) eslint . 0 errors · 3 warnings
타입 (프론트) tsc --noEmit 오류 없음
포맷 (프론트) prettier --check . All matched files use Prettier code style
정적 분석 (Flutter) flutter analyze No issues found (3.6초)
포맷 (Dart) dart format --set-exit-if-changed 18 files · 0 changed
골든 세트 (훅) pytest …test_eligibility_golden_set.py 89 passed · 0.09초
계보 사슬 E2E (훅) pytest …test_provenance_chain_e2e.py 10 passed · 2.40초

ESLint의 경고 3건은 설정 파일 자신(eslint.config.mjs)의 import/no-anonymous-default-export이고 제품 코드가 아니다. 오류는 0건이며, 오류는 그 자체로 커밋을 막는다 — no-console과 no-explicit-any가 경고가 아니라 에러로 걸려 있어서, any는 코드베이스에 0건이다.

가장 값이 나가는 게이트는 lint-imports 다. 512개 파일의 699개 의존을 훑어 계약 넷을 판정한다.

계약 결과
스포크끼리 직접 import 금지 KEPT
스포크는 허브만, 허브는 스포크를 못 본다 KEPT
앱 안의 레이어 순서 KEPT
auth는 누구도 import 불가 KEPT

이 넷은 문서로 지킬 수 있는 규칙이 아니다. 스포크 하나가 다른 스포크를 한 줄 import하는 것은 그 순간에는 가장 쉬운 해법이라, 사람의 주의로 막으면 언젠가 뚫린다. 다만 새 앱을 계약 목록에 넣지 않으면 그 앱은 검사되지 않은 채 초록으로 통과한다 — 게이트가 조용히 거짓말을 하는 유일한 경로다.

3) 발견 이슈 및 조치

게이트가 잡은 것과 게이트가 놓쳐서 운영에서 드러난 것을 함께 적는다. 뒤쪽이 더 값진 기록이다.

① 읽어 온 float이 저장값과 달랐다 — 두 번에 걸쳐 고쳤다

재현(제 7 장)이 저장된 confidence와 다시 계산한 값을 오차 없이 견주는데, 1/3·2/3처럼 15자리로 떨어지지 않는 값을 가진 멀쩡한 예측이 diverged로 떴다. 원인은 연결의 extra_float_digits가 0이어서 PostgreSQL이 double precision을 유효숫자 15자리 문자열로 내보내고, 그것을 다시 파싱하면 원래와 다른 double이 되는 것이었다.

첫 수정은 효과가 없었다. psycopg가 SET extra_float_digits = 1으로 트랜잭션을 열고, 세션이 반납될 때 도는 롤백이 그 SET을 함께 되돌린다.

놓친 이유가 검증 방법에 있었다. SET 직후 같은 트랜잭션에서 읽으면 1로 보인다. 스크립트로 확인했을 때는 고쳐진 것처럼 보였고, 연결이 반납된 뒤에만 0으로 돌아가므로 앱에서만 안 고쳐졌다.

두 번째 수정에서 검증을 요청 경로로 옮겼다 — TestClient로 임시 라우트를 붙여 연속 두 요청에서 값이 유지되는 것을 확인했다. 연결 파라미터로 넣는 방법(options=-c ...)은 쓰지 않았다: 풀러가 조용히 버려서 연결은 성공하고 에러도 없는데 값이 0에 머문다. 고친 줄 알고 아무것도 안 바뀌는 쪽이 더 위험하다.

② 거리 컬럼을 벡터 파서가 물었다 — 가짜 세션이 못 잡던 자리

검색에 거리 값을 함께 실으면서 예측 생성이 통째로 깨졌다(TypeError: 'float' object is not subscriptable). op("<=>")가 결과 타입을 왼쪽 피연산자에서 추론해 VECTOR로 두는데, 옛 코드는 그 식을 ORDER BY에만 써서 타입이 쓰이는 자리가 없었다. SELECT에 얹는 순간 pgvector의 파서가 돌아온 float를 슬라이스하려다 죽는다.

시험이 못 잡은 이유는 1절에 적은 그대로다 — 가짜 세션에는 타입 처리기가 도는 경로가 없다. 조치는 cosine_distance()로 바꾸고(같은 <=>를 내면서 Float을 단다), 식의 타입을 직접 보는 검사를 더한 것이다. DB도 임베딩 모델도 없이 돌면서 이 회귀를 정확히 잡는다.

③ 잠복해 있던 순환 import

import kayfabe.app.dtos.data_center_dto 한 줄만으로 ImportError가 났다. DTO가 인바운드 스키마를 참조하고, 그것이 라우터 패키지를 깨우고, 라우터가 아직 실행 중인 DTO를 다시 부르는 고리다. 시험에서는 다른 모듈이 먼저 그 패키지를 로드해 줘서 드러나지 않았다 — 잠복해 있었을 뿐 구조적으로는 이미 깨져 있었고, 원인은 app 레이어가 adapter를 향한 역방향 의존이었다.

to_schema() 13개를 DTO에서 라우터로 옮겨 DTO가 Pydantic을 모르게 했다. API 계약이 바뀌지 않았음을 추측이 아니라 비교로 확인했다 — 운영에서 다섯 엔드포인트 응답을 먼저 받아 두고, 리팩터한 코드를 같은 DB에 붙여 같은 질의를 보내 JSON을 대조했다.

④ 누수 판정이 깊은 주소로 빠져나가던 구멍

대회 문서를 걸러내는 규칙이 URL의 마지막 조각만 봐서, /shows/moneyinthebank는 걸리는데 /shows/moneyinthebank/2026은 “2026”만 보고 통과했다. 같은 대회 문서인데 한 단 더 깊은 주소면 빠져나가는 구멍이고, 그 길로 들어온 문서는 ex-ante인 척하는 표본을 만든다.

경로 조각 전부를 보게 고쳤다. 조각 안에서는 여전히 시작으로만 판정한다 — 포함으로 바꾸면 “Champions”가 “Night of Champions”에 걸린다. 호스트는 일부러 뺀다: 도메인이 대회 이름과 겹치면 그 사이트의 모든 문서가 대회 문서가 돼 버린다.

소급 영향이 0건임을 세어서 확인했다. 운영 코퍼스 URL 74개 × 대회 19개 = 1,406쌍을 옛 판정과 새 판정으로 나란히 돌려 바뀌는 쌍이 없음을 봤다. 이 수정은 과거를 다시 재는 것이 아니라 다음 수집을 막는다.

⑤ 트리오스 카드에서 이름이 한 건도 안 뽑혔다

참가자 이름을 &로만 나눠서 팀 — A, B, C 표기가 통째로 한 이름이 됐다. 그 이름은 위키 확인에서 버려지므로 그 경기에는 근거가 한 건도 안 잡힌다. worlds-collide는 일곱 경기 중 넷이 그 모양이었다. 구분자를 셋으로 늘렸다 — —가 팀 이름과 명단을 가르고 &·,가 명단 안을 가른다.

⑥ 훅이 설치조차 안 돼 있었다

설정 파일에 훅 10종이 적혀 있는데 .git/hooks/pre-commit이 없었다. 게이트가 있다고 적혀 있고 실제로는 아무것도 돌지 않던 상태다. 조치는 GitHub Actions를 신설해 같은 검사를 서버에서 한 번 더 돌리는 것이었다(2026-09-29 · 세 브랜치 green).

⑦ CI가 곧바로 잡은 것 둘

그 CI가 첫 실행에서 두 가지를 드러냈다.

  • auth 시험 10건이 로컬 .env의 운영 서명키로 통과하고 있었다. 그 시험은 실제로 토큰에 서명하므로 개인키가 필요한데, 러너에는 당연히 없고 넣어서도 안 되는 값이다. 실행마다 버리는 RSA 키페어를 만들어 주는 쪽으로 풀었다 — 시험이 필요한 것은 그 키가 아니라 어떤 키다.
  • astral-sh/setup-uv는 v7 이후 이동 major 태그를 발행하지 않는다. 다른 액션처럼 @v10으로 적으면 tags/v10이 404다. 정확한 버전으로 핀했다.

CI에 .env를 만들지 않는 결정도 같은 실행에서 정해졌다. DB URL이 빈 값이면 엔진이 None이 되어 초기화가 즉시 반환하는 “더미 모드”이고, 파일이 아예 없는 CI도 같은 상태다. 템플릿(.env.example)을 복사하면 오히려 깨진다 — 그 파일의 DB URL은 러너에 없는 호스트를 가리키는, 비어 있지 않은 값이기 때문이다.