Skip to content

v0.18.0 G02 — 날짜와 PC 에 따라 흔들리던 테스트에서 재현 가능한 기준선과 행동 검증 도구까지 (2026-09-07)

  • 기간: 2026-09-07 ~ 2026-09-07 (세션 1개 36dffd3a, 오너 지시 "#1280 진행해줘" → 분석·계획 게시 → "내 결정 묻지 말고, phase 계획 작성후 마지막 phase까지 중단없이 진행")
  • 랜딩: PR #1310(Phase 0~7 한 PR, squash) — 마이그레이션·엣지 함수·앱 화면 변경 없음(테스트·스크립트·.gitattributes·문서). 선행 보수는 PR #1278(초안 보관함 픽스처 고정 시계)
  • 설계서: 없음 — 분석·Phase 계획은 이슈 #1280 댓글("예상 효과·개선사항" 절 포함)
  • 정본: docs/gates/test-audit.md "줄바꿈(LF) 정책과 결정성 도구", docs/gates/test-baseline-inventory.md(장부), 루트 .gitattributes
  • 도구: tests/support/(clock·ids·scheduler·storage·clockShift·faultInjection·statsOracle·ownerSwitch·browserClock), tests/db/support/pgSessions.mjs, scripts/normalize-line-endings.mjs, scripts/check-utf8.mjs(CRLF 게이트)
  • 게이트: 새 단위 테스트 9파일 43건(testSupportDeterminism·dateIndependence·lineEndingPolicy·faultInjectionTools·statsOracleTools·ownerSwitchFixture·browserClockFixture·tests/db/concurrencyBarrier) + check-utf8 의 LF 검사. 마이그레이션·pgTAP·e2e 미접촉(샌드박스 pgTAP 은 도구 실행용으로만 돌렸다: 109파일/1899 assert 통과, 16:55)
  • 버그리포트: 없음(수리 건 아님 — CI 빨간불 자체는 #1278 이 고쳤다)
  • 계약: 새 계약 없음. 줄바꿈 정책은 .gitattributes + check-utf8 게이트가 정본

Phase 현황

Phase내용상태
Phase 0기준선 대조 — CI 34042079441 실패 3건 = #1278 수리 확인, Windows 줄바꿈 정책이 로컬 파일에만 있던 것 발견
Phase 1고정 시계·순번 UUID·수동 스케줄러·격리 저장소·시계 이동 preload, ±400일 날짜 독립성 증명35f0894d 9a105fe1
Phase 2.gitattributes(LF)·check-utf8 CRLF 게이트·정규화 스크립트·색인 CRLF 1개 정규화·정책 문서7b60bc76 8b60479b
Phase 3저장 실패 주입(quota/abort/hang·답장 유실)과 초안 캐시·대기열 경계 기준선5f2c5b22
Phase 4두 DB 연결 barrier(tests/db)·자문 잠금/행 잠금 경합 재현1b6088db
Phase 5통계 oracle 코퍼스 기반·owner 전환 픽스처·브라우저 시각 픽스처437c5cd2
Phase 6기준선 장부(비결정성 분류·소스 모양 검사 전환/유지·검사 자산·주입 지점 인계)4ac4cad1
Phase 7npm run check·ci:local·PR·CI·머지·이 기록✅ PR #1310

1. 배경

v0.18.0 은 저장·통계 구조를 바꾸는 62개 작업이다. 구조를 바꾼 뒤 테스트가 빨갛게 되면 "진짜 회귀" 인지 "오늘 날짜·이 PC 의 줄바꿈·실제 타이머 때문" 인지 가를 수 있어야 하는데, 2026-09-05 부터 main 의 검증 레인이 날짜 때문에 빨갰다(CI run 34042079441: 2,586개 중 3개 실패). 로컬 Windows 에서는 schema.sql 의 "CRLF 없음" 단언이 PC 에 따라 달랐다. 총괄 문서 §9 는 저장 실패·DB 경합·통계 동치·계정 전환을 실제 행동으로 검증하라고 요구하지만, 그런 실험을 일으킬 공용 도구가 없었다.

2. 문제 제기

시계가 테스트의 숨은 입력이었다

초안 보관함 테스트는 픽스처 저장 시각이 2026-08-29 고정인데 서비스는 실제 Date.now() 를 읽어, 달력이 9/5 를 지나자 임시 보관 TTL(7일)이 픽스처를 만료시켰다. PR #1278 이 Date 전용 mock 으로 고쳤지만, 같은 방식을 다른 테스트에 펴 줄 도구와 "오늘이 달라도 같은 결과" 라는 증명은 없었다. 전수 조사(단위 369 파일·e2e 8·오류 재현 38): 고정 시계를 쓰는 테스트 2 파일, Playwright page.clock/timezoneId 0건, 랜딩 잠금 preflight 테스트는 자식 CLI 의 벽시계와 부모 시계가 결합.

줄바꿈 정책이 한 PC 의 로컬 파일에만 있었다

supabase/**/*.sql eol=lf 규칙이 .git/info/attributes(커밋되지 않는 파일)에만 있어서, 저장소에는 .gitattributes 가 없었다. Windows 체크아웃은 core.autocrlf=true 로 파일 1,536개가 CRLF 로 받아졌고(색인은 LF 1,855·CRLF 1·바이너리 77), 새 클론·다른 PC 에서는 schema.sql 단언이 빨갛다. 색인에도 CRLF 로 들어간 파일이 1개(tests/audit/pending-changes.json) 있었다.

실패를 일으킬 공용 도구가 없었다

tests/support/ 는 SQL 도우미 3개뿐. 저장 실패(용량 초과·중단·멈춤·답장 유실)를 내는 가짜 저장소 없음, 두 DB 연결 경합은 pgTAP 1 파일(dblink)뿐, 계정 전환 픽스처는 테스트마다 손으로 조립, 통계 기대값은 전부 앱 계산 결과의 박제. 소스 문자열을 읽어 함수 이름·JSX 모양을 고정하는 테스트가 72 파일 — 코드를 옮기면 회귀와 무관하게 깨진다.

3. 해결 방안

원칙

오너 결정 없음(2026-09-07 지시: 결정을 묻지 않고 계획대로 끝까지). 전제 = src/**·package.json·.github/** 는 만지지 않는다 (소유 경계 tests/**·scripts/**·.gitattributes); 제품 코드의 Clock/scheduler 주입 지점은 담당 이슈에 목록으로 인계한다.

접근

대안판정
픽스처 날짜를 매번 최신으로 / 상대 시각(Date.now() 기준)으로기각 — 경계 검증이 안 되고 실행 날짜 의존이 남는다(test-audit 규칙)
suite 전체 시각 일괄 동결기각 — 실제 타이머·자식 프로세스·DB 와 어긋나고 규칙상 기본값이 아니다
테스트 단위 고정 시계 + 공용 도구 + ±400일 증명(preload)채택
줄바꿈: 테스트 단언 완화 / .git/info/attributes 안내기각 — 정책이 저장소 밖에 남는다
.gitattributes 로 LF 고정 + check-utf8 게이트 + 정규화 스크립트채택
두 DB 연결: pg 드라이버 추가기각 — 의존성 추가는 HQ 통합 슬롯. psql(docker exec) 두 프로세스로 충분
통계 oracle: 앱 함수 출력을 코퍼스에 저장기각 — 스냅샷이지 oracle 이 아니다. 출처(table/hand/production)를 강제

4. 적용한 내용

Phase 1 — 결정성 도구 (35f0894d, 9a105fe1)

tests/support/clock.mjs(Date 전용 mock 래퍼, 오프셋 없는 시각 거부, advance/set, 타이머까지 잡는 useFixedTimers), ids.mjs(v4 모양 순번 UUID), scheduler.mjs(advance/flush 로 실행 순서를 정하는 타이머), storage.mjs(테스트 동안만 붙는 window/localStorage), clockShift.mjs(실제 시계를 옮기는 preload). dateIndependence.test.mjs 가 +400일·−400일 자식 프로세스에서 초안 보관함 테스트 12/12 를 확인. landingPreflightGate 의 만료 픽스처를 "지금 − 24h" 에서 절대 과거(2000년)로.

Phase 2 — 줄바꿈 정책 (7b60bc76, 8b60479b)

.gitattributes(* text=auto eol=lf + 바이너리 목록), scripts/check-utf8.mjs(git 추적 텍스트 파일의 CRLF 를 실패로, 고치는 명령 안내, 검사 함수 export), scripts/normalize-line-endings.mjs(CRLF→LF 만 바꾸고 색인 stat 을 다시 잡는다; 내용이 바뀐 파일은 스테이징하지 않는다), pending-changes.json 색인 정규화, lineEndingPolicy.test.mjs, test-audit.md 정책 절.

Phase 3 — 저장 실패 주입 (5f2c5b22)

tests/support/faultInjection.mjs: withStoreFaults(메서드·호출 번째·횟수로 quota/abort/hang/throw, 호출 기록), withReplyLoss/withAdapterReplyLoss(요청은 서버에 닿고 답장만 잃는다). 기준선 7건: 용량 초과 시 이전 봉투 보존, 멈춤은 IdbTimeoutError, 답장 유실 뒤 행은 대기열에 남고 다음 플러시가 같은 식별자 로 재전송(S06 이 중복 수행을 막을 지점), 수정 어댑터 답장 유실 뒤 같은 요청 재수행.

Phase 4 — 두 DB 연결 barrier (1b6088db)

tests/db/support/pgSessions.mjs: docker exec … psql 또는 호스트 psql 프로세스를 세션으로, 문장 끝 표식으로 완료를 알고 잠금에 막힌 문장은 "막혀 있음" 으로 관측(start/run/expectBlocked/waitDone). 대상은 BARBELIC_DB_SANDBOX(ci:local 규칙) ·BARBELIC_TEST_DB_CONTAINER·BARBELIC_TEST_DB_URL. concurrencyBarrier.test.mjs: 서로 다른 백엔드, 자문 잠금 대기→커밋 뒤 해제, 실제 표(exercise_synonyms) 행 잠금 대기→커밋된 값 위에서 이어감(롤백으로 원상 복구).

Phase 5 — oracle·owner·브라우저 픽스처 (437c5cd2)

statsOracle.mjs + tests/fixtures/stats-oracle/(기대값마다 provenancetable:/hand:/production: 이어야 하고 app: 은 거부; 첫 사례 = 볼륨·세트 수·실측 1RM 손 계산 + 공개 정책 표 (90 %, 4.94회)·(85 %, 7.16회) 선형 보간 111.28 kg 이 정책 함수와 0.05 kg 안). ownerSwitch.mjs(세대 번호 있는 계정 전환, 늦은 응답 관측). browserClock.mjs(page.clock.setFixedTime 로 Date 만 고정, Asia/Seoul test.use, 서울 업무 날짜).

Phase 6 — 장부 (4ac4cad1)

docs/gates/test-baseline-inventory.md: 비결정성 분류 7종·처리, 제품 코드 주입 지점 7곳 인계표(S08·A07·A08·S02·D03), 소스 모양 검사 72 파일의 유지(보안·권한·import 경계 20여 파일)/전환(구현 세부 박제 50여 파일, 담당 이슈 매핑) 목록, native·API·provider· CSS·브라우저·DB 검사 자산표(G05 장부 입력), 도구별 검증 명령.

주요 결정과 그 근거

  • Date 만 고정하고 타이머는 흐르게 — IDB·네트워크 대기는 실제 타이머라 함께 멈추면 멈춘 저장이 관측되지 않는다.
  • ±400일 증명은 preload 자식 프로세스로 — 부모 suite 의 시계를 건드리지 않고, 고정 시계를 안 쓴 테스트가 실행 날짜에 기대는지 드러난다.
  • 줄바꿈 게이트는 추적 파일로 한정.env.local 같은 로컬 파일은 정책 밖.
  • 두 연결은 psql 두 프로세스 — 의존성 추가 없이 CI 의 로컬 스택·ci:local 샌드박스 모두에서 같은 방식.
  • oracle 기대값에 출처 강제 — "같은 답" 이 아니라 "맞는 답" 을 대조하기 위해.

작업 중 드러난 것

  • git 은 파일 크기가 색인과 다르면 내용을 비교하지 않고 "수정됨" 으로 둔다. CRLF→LF 는 크기가 줄어 정규화 뒤에도 1,534개가 git status 에 남았다(diff 는 비어 있음). 정규화 스크립트가 내용 같은 파일만 git add --renormalize 로 stat 을 다시 잡는다.
  • 랜딩 잠금 preflight 테스트는 자식 CLI 의 벽시계와 결합 — 부모만 시계를 옮기면 "방금 만든 기록" 이 400일 전 것이 된다. 만료 픽스처는 절대 과거로, 증명 대상에서는 제외(본성상 결합).
  • Node 테스트 러너 안에서 손자 프로세스를 띄우면 물려받은 NODE_TEST_CONTEXT 때문에 TAP 대신 러너 내부 형식이 나온다 — 지운다. --import 는 Windows 절대 경로를 못 받는다(file URL). node --test <디렉터리>/ 는 안 된다(파일 경로).
  • Bash 히어독으로 JS 를 넘기면 \0·\\ 가 망가진다(기존 메모리 함정 재확인) — 스크립트 파일로.
  • sed 로 U+FFFD 문자를 바꾸다 검사기 소스가 "FFFD" 로 망가져 검사기가 자기 자신을 잡았다 — 리터럴 대신 \uFFFD 이스케이프.

5. 적용 결과

항목전 → 후
main verify 레인 날짜 의존 실패3건(9/5~) → 0 (#1278) · ±400일 자식 프로세스 12/12 (이 PR 이 증명 고정)
실제 시계가 기대값에 닿는 단위 테스트미분류 → 분류 7종, 미해소 0 (preflight 는 본성상 결합으로 문서화)
줄바꿈 정책 위치한 PC 의 .git/info/attributes → 저장소 .gitattributes + check-utf8 게이트(추적 텍스트 1,453파일 검사)
이 PC 작업 폴더 CRLF 파일 / 색인 CRLF 파일1,536 / 1 → 0 / 0
저장 실패 주입 도구0 → 4종 + 답장 유실, 경계 기준선 7건
두 DB 연결 도구0 → 1, 샌드박스(supabase_db_cile13437d6, main 57fd2bb4 마이그레이션 전체 적용) 3/3 통과 — CI 레인 미연결(skip 집계)
통계 기대값 출처앱 출력 박제 → 출처 강제 코퍼스(첫 사례 1)
소스 모양 검사72 파일 미분류 → 유지/전환 목록 + 담당 매핑
로컬 게이트npm run check 2,626 tests · pass 2,623 · fail 0 · skipped 3(tests/db 대상 없음) · ci:local verify-only · verify 통과(5단계) · 1분 5초
CIverify 레인 1회 — main 보호 규칙의 필수 체크(verify·landing-queue)라 머지 자체가 초록 증거다(PR #1310 체크 탭) (변경 파일이 full-ci 경로에 없어 migration-smoke·browser·viewport 는 실행되지 않음 — 성공으로 합산하지 않는다)

미검증: Playwright 시각 픽스처는 가짜 page.clock 으로 계약만 잠갔다(실 브라우저 적용은 e2e/** 담당). tests/db 는 로컬 샌드박스에서만 실행됐다.

6. 이번 개선으로 향상된 것

날짜가 지나도 테스트가 저절로 깨지지 않는다

고정 시계 도구와 ±400일 증명이 있어, 다음에 시계 의존 테스트를 쓰면 같은 방식으로 잠그고 증명 대상에 한 줄 추가하면 된다.

어느 PC 에서 받아도 같은 바이트다

.gitattributes 가 저장소에 있고 게이트가 지킨다. 옛 체크아웃은 명령 하나로 돌아온다.

저장·DB·통계·계정 전환의 "실제 행동" 을 실험할 수 있다

S02/S04/S06(저장 실패), D02/D13(두 연결), D01(oracle), A07/A08(owner) 이 도구를 그대로 가져다 쓴다.

구조적으로 남는 것

줄바꿈 게이트, tests/support 도구 층, tests/db 도구, oracle 코퍼스 형식, 소스 모양 검사 전환 장부.

남은 것

  • tests/db 를 CI 의 로컬 스택 잡에 연결(.github/** = HQ 통합 슬롯) — 지금은 verify 잡에서 skip 으로 집계.
  • 제품 코드 주입 지점(장부 §1 표) — S08(withIdbTimeout·자동저장 타이머), A07(appErrorReporter 스케줄러·ID), S02/D03(randomUUID 공장), A08(매퍼 getFullYear 기본 인자).
  • 소스 모양 검사 전환은 코드를 옮기는 담당 이슈의 PR 에서(장부 §2-2), pending-changes.json 신고 동반.
  • Playwright page.clock·timezoneId 적용(e2e/**), waitForTimeout 32곳의 상태 대기 전환(UI 담당).
  • 총괄 문서 §12 G02 상태 칸·§7 장부 갱신은 HQ(세션 4c81b0f9)가 통합한다.