v0.18.0 G04 — "저장이 몇 초 걸리고 지난 릴리스보다 느려졌는가" 를 아무도 답할 수 없던 것에서 진단 규격·조건이 적힌 workload·기준 SHA 실측·릴리스 예산까지 (2026-09-07)
- 기간: 2026-09-07 ~ 2026-09-07 (세션 1개
08b11132, 오너 지시 "1284 진행해줘" → 분석·계획 게시 → "맞음 ㄱ") - 랜딩: PR #1315(Phase 1~5 한 PR, squash) — 마이그레이션·엣지 함수·화면 변경 없음. 앱 번들에 실리는 변경은
appErrorReporter.ts의 릴리스 식별 한 함수(주입값 없으면 종전 동작)와 새 모듈performanceEvidenceReport.ts(import 없음 → 트리셰이킹)·appPerformanceBudgets.ts의 상수 추가뿐. 선행 G02b0e5076a·G03e94f0d34·스텝 1-3 D0316b8e996머지 확인 뒤 착수 - 설계서: 없음 — 분석·Phase 계획은 이슈 #1284 댓글("예상 효과·개선사항" 절 포함)
- 정본:
docs/gates/performance-diagnostics.md(열쇠·채널 대응표·금지 필드·증거 schema v1) ·docs/gates/performance-baseline.md(기준 SHA16b8e996실측·조건·미측정 표·재실행 절차) ·docs/gates/performance-budgets.md(상한 18개·추세·회복·관찰 기간·RPO/RTO 평가법·변경 절차) · 코드src/react/services/appPerformanceBudgets.tsRELEASE_PERFORMANCE_BUDGETS·src/react/services/performanceEvidenceReport.ts·src/react/services/appErrorReporter.tsresolveReleaseIdentity· 총괄 문서2026-09-07-v0-18-0-architecture-roadmap.md§12 G04 행 - 도구:
scripts/performance/workload/{profiles,generate,load}.mjs(프로필 6종·결정적 생성·샌드박스 적재) ·scripts/performance/measure/{db,browser,summarize}.mjs(읽기/쓰기 p95·큐 age·freshness·lock wait·WAL·backlog 소진 / cold·warm 초기 비용 / 표) ·scripts/performance/probe/production.mjs(Management API 읽기 전용) ·scripts/performance/lib/{stats,hygiene}.mjs·scripts/performance/evidence/schema.json(생성물은.gitignore) - 게이트: 새 행동 테스트
tests/react/performance{EvidenceReport,Workload,Probes,BudgetsRelease}.test.mjs22건(전부npm test자동 편입). 로컬npm run check전체 통과(정적 게이트 14·테스트 2,742). 마이그레이션·pgTAP·e2e 미접촉(측정용 샌드박스는ci:local --only db로 만들어 별도 사용) - 버그리포트: 없음(수리 건 아님 — 발견한 결함 3건은 §4 "작업 중 드러난 것" 과 HQ 갱신안으로)
- 계약: 새 계약 문서 3(위). 기존
APP_SCREEN_PERFORMANCE_BUDGETS(CI mock 예산)·관측 로깅 문서·ADR 무변경.package.json·vite.config.mjs·workflow 무변경(SHA 주입 한 줄은 HQ 갱신안)
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 조사(클라 계측·서버 잡·백업·CI 예산 실태)·Production 읽기 전용 프로브 4회·계획 게시 | ✅ 이슈 댓글 |
| Phase 1 | 진단 규격(열쇠 7·채널 11·금지 필드·증거 schema v1)·릴리스 SHA resolver·증거 조립/위생 모듈 | ✅ c96f4582 |
| Phase 2 | workload 프로필 6종·결정적 생성기·샌드박스 적재기·조건 manifest | ✅ e41fe1ea |
| Phase 3 | 측정 러너 3·Production 프로브·증거 스키마·6프로필 실측·브라우저 초기 비용·기준선 문서 | ✅ 0cb57677·e1244386 |
| Phase 4 | 릴리스 예산 문서·RELEASE_PERFORMANCE_BUDGETS·대조 테스트 | ✅ 7e7b16d1 |
| Phase 5 | 장부 재생성·이 기록·등록 2곳·전체 검사·PR·인계·HQ 갱신안 | ✅ PR #1315 |
1. 배경
v0.18.0 은 통계 단일 작성자(D01~D11)·저장 codec(S 계열)·앱 분리(A 계열)·UI 토큰(U 계열)을 동시에 진행한다. "사용자가 많아도 안정적" 이라는 목표를 나중에 증명하려면 바꾸기 전의 숫자와 허용 비용이 먼저 있어야 한다(총괄 F23·F29·C08). 기존 예산(appPerformanceBudgets.ts)은 바이트·행 상한이고 시간 예산 5개는 in-process mock 으로 재는 CPU 시간이라 "실제로 몇 초 걸리는가" 를 말하지 않았다(이슈 보존 규칙: 기존 RPC budget 을 계측 없는 성능 보장으로 인용하지 않는다).
2. 문제 제기
시간 측정이 세 곳에 따로 있고 잇는 열쇠가 없었다
브라우저 RPC 디버그(sessionStorage 120건, operationId)·read-model 메트릭(메모리 200건)·서버 잡 행·cron 이력이 각자 있고 서버로 올라가는 것은 오류 이벤트뿐. release 는 청크 파일명(30일 58종)이라 커밋과 잇지 못했다.
잡 처리 시간이 항상 0 이었다
관측 뷰가 processed_at − processing_started_at 을 빼는데 두 값이 같은 트랜잭션의 now(). Production 30일 1,362건 전부 0, 샌드박스도 같다.
부하 조건을 적어 둔 곳이 없었다
10년 fixture 는 메모리 전용, 여러 owner·같은 owner 여러 기기·인입 혼합 없음, DB 사양·요청률·worker 한도 기록 없음.
매분 cron 이 20일 동안 WAL 46GB 를 쓰고 있었다(발견)
run_user_exercise_stats_refresh_cron 27,966회·평균 302ms·최대 66초, 전체 WAL 53GB 의 85%. 투영 테이블은 살아 있는 행 1만 개에 갱신 1,467만·삭제 669만 회. 사용자 5명 규모에서.
3. 해결 방안
원칙 (오너 결정 없음 — 전제로 진행, 2026-09-07)
- 전제 1: 이 이슈는 기준선과 측정 수단만 만든다. 발견 사항은 담당(D08/D11/HQ)에 넘긴다. 숫자를 보고 상한을 넓히지 않는다.
- 전제 2: 새 SQL 객체·마이그레이션 없음. 서버 집계는 스크립트가 기존 표를 읽는다. Production 은 읽기 전용.
- 전제 3: SHA 주입·새 이벤트 kind·DB 설정은 HQ 갱신안으로. 이 PR 은 resolver 만.
- 전제 4: fixture 는 G02 도구 위(결정적 uuid·고정 종료일·payload v5). 조건은
workload.json에 같이. - 전제 5: 기준 측정은 전용 로컬 샌드박스(같은 Postgres 이미지)·브라우저는 미리보기 빌드. 미측정은 미측정으로.
- 전제 6: 상한 = 측정값 × 배율, R04 전에 고정. RPO/RTO 는 평가 방법만.
- 전제 7: CI 1회.
접근
| 안 | 내용 | 채택 |
|---|---|---|
| A. 규격 + 결정적 fixture + 러너 + 예산 표 | 열쇠·금지 필드를 규격으로, 조건이 적힌 workload 를 생성기로, 측정을 러너로, 상한을 기준선 × 배율로 | 채택 — 같은 절차를 R04 가 그대로 돌려 전후 비교 |
| B. 새 계측을 켜고(APM·OpenTelemetry) 서버로 올린다 | 정상 경로 시간을 전부 수집 | 불채택(보류) — 측정 경로 비용·새 의존성·Free 플랜 밖 서비스. 기존 오류 채널에 열쇠만 맞추고 정상 경로 증거는 러너가 파일로 |
| C. Production 에 부하를 걸어 잰다 | 실제 인프라 숫자 | 불채택 — 이슈가 production 변경 승인을 포함하지 않고, 5명 규모라 요청률이 smoke 가 만든다. 읽기 전용 프로브 + 샌드박스 fixture |
D. CI mock 예산(maxMs)을 실측으로 바꾼다 | 한 표로 통일 | 불채택 — CI 는 네트워크·DB 없이 돌아 실측과 다른 물건. 별도 RELEASE_PERFORMANCE_BUDGETS 로 두고 문서가 차이를 설명 |
4. 적용한 내용
Phase 1 — 진단 규격 (c96f4582)
docs/gates/performance-diagnostics.md(열쇠 표 7·릴리스 식별·금지 필드 두 층·채널 대응표 11·증거 schema·측정 경로 비용). resolveReleaseIdentity(주입 SHA 7~40 hex 우선). performanceEvidenceReport.ts(RPC 디버그·read-model·Navigation/Resource Timing → allowlist 조립, assertEvidenceHygiene). 테스트 5건.
Phase 2 — workload (e41fe1ea)
프로필 6종(history-1y/4y/10y·same-owner-two-devices(재생 20%·충돌 10%)·multi-owner-10·mixed-read-write-import(인입 근사 500)), 생성기(seed 결정적, owner id 는 프로필·owner 순번 접두), 적재기(저장 문 서버 시간·재생/충돌 기대 검사), manifest(사용자·기기·요청률·동시 인입·기록량·환경 칸). 테스트 8건.
Phase 3 — 측정·기준선 (0cb57677·e1244386)
measure/db.mjs(owner 로서 RPC 9종 20회·backlog 소진(열린 잡 대기 재시도)·큐/세대 통계·lock wait 샘플링·WAL/행 델타) · measure/browser.mjs(일회용 사용자 + 온보딩 완료 + 세션 120 시드, 모바일 뷰포트·CPU 4배·cold/warm) · probe/production.mjs(읽기 전용 15쿼리) · summarize.mjs. 샌드박스를 리셋한 뒤 6프로필 전부 재측정. 결과 = docs/gates/performance-baseline.md.
Phase 4 — 릴리스 예산 (7e7b16d1)
docs/gates/performance-budgets.md + RELEASE_PERFORMANCE_BUDGETS(reads 9·writes 1·stats 4·browser 4·trend 3) + 대조 테스트 4건.
Phase 5 — 장부·기록·PR
coverage-inventory.json 규칙 5줄(성능 스크립트·테스트·증거 모듈·G03 의 경계 테스트 누락분) + --render, 이 기록, 사이드바·README 등록.
주요 결정과 그 근거
- 기준선 = 이력 프로필 중 가장 큰 p95(프로필 이름 명시): "어느 조건의 상한인가" 를 잃지 않으려고. 4년이 10년보다 큰 항목(홈·검색)이 있어 캐시 순서 효과가 있다 — 재측정 두 번 규칙을 예산 문서에.
- 서버 시계로 잰다(세 문장: 시작 시각 set_config → 호출 → 차): 한 문장 lateral 은 STABLE 함수를 planner 가 먼저 평가해 0 이 나왔다. 네트워크·PostgREST 는 브라우저 러너가 따로.
- 인입은 근사(저장 문 연속 호출)로 표시: 저장 문은
source를 받지 않고 정식 인입 경로는 I01. - CI mock 예산은 그대로: 다른 물건이라 통일하면 둘 다 거짓이 된다.
- RPO/RTO 는 숫자를 쓰지 않았다: 리허설 0회. 사본 존재를 달성으로 쓰지 않는다(이슈 규칙).
작업 중 드러난 것
- 잡
duration_ms결함(전부 0) — Production·샌드박스 동일.clock_timestamp()로 바꿔야 한다(D08). - Production
pg_stat_statements는 PostgREST 래핑 때문에 RPC 이름을 못 붙인다(track_functions none). 함수별 시간은track_functions = pl뒤에만(D08/HQ DB 설정). - 매분 cron WAL 46GB/20일·투영 통째 재작성(D08/D11).
cron.job_run_details127k행 정리 없음. get_volume_overview는 4년부터 CI 예산 1,000ms 를 넘고 10년 3,104ms(1.5MB).get_session_search4년 804ms. 통계 backlog 소진은 세션 수 비례(10년 128초 = worker 한 라운드가 Production statement_timeout 120초 초과).- 혼합 프로필의 저장 max 26,465ms 1건(인입 근사 트랜잭션 중) — 원인 미상, D08 조사 대상.
- 기본 체크아웃
node_modules에서@babel/core가 사라져 lint 가 죽음(재발) →npm i --no-save @babel/core. - Docker 호스트에 다른 세션의 Supabase 컨테이너 130여 개가 떠 있어 측정 잡음 요인 — 조건 표에 적음.
- 히어독으로 JSON 규칙을 끼워 넣으면
\\.이 망가진다 →.cjs파일로.
5. 적용 결과
| 항목 | 전 → 후 |
|---|---|
| 잇는 열쇠 규격 | 없음 → 7종 + 채널 대응표 11행 |
| 릴리스 식별 | 청크 파일명 → resolver(주입 시 커밋 SHA; 주입은 HQ 갱신안) |
| 부하 조건 기록 | 없음 → workload.json(사용자·기기·요청률·인입·기록량·환경) 6프로필 |
| 서버를 지나는 fixture | 메모리 mock → 샌드박스 적재 6프로필(최대 5,715세션·68,580세트) |
| 기준 SHA 실측 | 없음 → 읽기 9 RPC × 6프로필 p95·쓰기 p95·큐 age·freshness·WAL·소진·브라우저 cold/warm·Production 표 |
| 릴리스 상한 | maxMs 5개(mock) → 실측 기반 18항목 + 추세 3 + 회복·관찰·RPO/RTO 평가법 |
| telemetry 위생 검사 | 수동 → 이름 기반 검사(단어·정확 일치 두 층) + 테스트 |
| 테스트 | — → 22건, npm run check 통과 |
| 앱 동작 변화 | 0(주입 전 release 동작 동일) |
| 미검증 | Vercel 엣지·실기기 초기 비용, lock wait 시간(ms), 정식 인입 경로, RTO, Production compute 등급(오너 확인 대기), 함수별 Production 시간 |
6. 이번 개선으로 향상된 것
"전보다 좋아졌다" 를 증명할 출발점이 생겼다
R04 가 같은 fixture·같은 절차로 다시 재면 전후 비율이 나온다. 숫자 없이 "최적화했다" 고 말할 수 없게 됐다.
구조 문제가 숫자로 드러났다
볼륨 리포트의 연차 비례 성장, 통계 재계산의 전체 이력 재작성(WAL 46GB), 잡 시간 측정 결함 — 전부 D08/D11/U05 의 입력으로 넘어간다.
구조적으로 남는 것
진단 규격·증거 스키마·workload 생성기/적재기·측정 러너·Production 프로브·예산 상수와 대조 테스트·변경 절차(넓히려면 오너 결정).
남은 것
- HQ:
vite.config.mjsdefine 한 줄(SHA 주입), 총괄 §12 G04 행, DB 설정 3종(track_functions·track_io_timing·pg_stat_statements.track), 장부 도구 실패(타 트랙 미분류 13). - D08:
duration_ms결함, cron WAL,job_run_details정리, 저장 max 26초 1건. D11: 볼륨 리포트·소진 세션 비례. U05/B02: 실기기·엣지 초기 비용. I01: 정식 인입 fixture. S11/R03: 복원 리허설(RTO). R04: 전후 비교. - 오너 손: Production compute 등급 확인(급하지 않음).
- 릴리스 v0.18.0 뒤
[v0.18.0 반영완료]+ 닫기.