성능·보존 기준선 — 기준 SHA 에서 잰 숫자 (이슈 #1284, G04)
한 문장 — v0.18.0 구조 개선 전의 숫자다. 같은 fixture·같은 PC·같은 절차로 R04 가 다시 재서 전후를 비교한다. 여기 숫자는 목표가 아니라 출발점이고, 릴리스 상한은 릴리스 예산에 따로 있다. 미측정은 미측정으로 적었다(0 이 아니다).
- 이슈: #1284 (계획 ID G04). 규격: 진단 규격. 도구:
scripts/performance/{workload,measure,probe}/*.mjs(재실행 절차 §5). - 기준 SHA: 앱·서버
16b8e996(main, 2026-09-07 — D03 #1312 뒤; 이 트랙의 코드는 측정 대상이 아니다). Production 프로브 시점의 배포도 같은 상태(v0.17.1 릴리스 전, main = staging). - 증거 파일:
scripts/performance/evidence/{workloads/<프로필>/(workload|load-result|measure-db|measure-browser).json, production/<sha>.json}— 저장소 밖(.gitignore). 표는node scripts/performance/measure/summarize.mjs가 낸 것을 그대로 붙였다.
1. 읽는 법 — 유저 A 의 하루로
A 가 폰에서 운동을 저장한다. 이 문서는 그 한 건이 지나는 자리마다 "지금 얼마나 걸리는가" 를 적는다: ① 저장 문이 서버에서 끝나기까지(쓰기 p95) ② 통계 잡이 큐에서 기다렸다가 처리되기까지(큐 대기·소진) ③ 홈 숫자가 새 세대를 보기까지(요청→반영) ④ 다음날 앱을 열 때 화면이 뜨기까지(브라우저 초기 비용) ⑤ 홈·달력·리포트 RPC 가 서버에서 답하기까지(읽기 p95). 그리고 그 사이 서버가 얼마나 일했는가(WAL·행 갱신). Production 은 실제 5명의 기록으로, 샌드박스는 1·4·10년 fixture 로 잰다.
2. 조건 (이 숫자가 유효한 범위)
| 조건 | 샌드박스 | Production |
|---|---|---|
| Postgres | 17.6.1.127 (Supabase 로컬 이미지, package.json supabaseToolchain) | 17.6 (aarch64) |
| 인스턴스 | Docker Desktop VM 24 vCPU · 31GB(호스트 AMD Ryzen 9 5900X 12코어 24스레드 · 69GB · Windows 11) — 측정 중 다른 세션의 Supabase 컨테이너 130여 개가 함께 떠 있었다 | max_connections 60 · shared_buffers 256MB · effective_cache_size 768MB(약 1GB 인스턴스 — compute 등급은 대시보드 확인 대기) · 도쿄 리전 |
| DB 설정 | 기본(track_functions none) | track_functions none · track_io_timing off · pg_stat_statements.track top · statement_timeout 2min · wal_level logical |
| worker | cron 없음 → 러너가 process_user_exercise_stats_refresh_jobs(25) 를 직접 호출(배치 25 = Production cron 과 같은 값) | lift-guild-stats-refresh 매분 · pr-overview-snapshot-rollover 매분 |
| 시간 측정 | 서버 시계(clock_timestamp) — PostgREST·네트워크·TLS 비용 없음 | 서버 통계 표·cron 이력·잡 행의 시각 차 |
| 브라우저 | Chromium(Playwright) · CPU 4배 느리게 · 미리보기 빌드(vite preview) · 로컬 스택 — Vercel 엣지·실기기 아님 | 실기기 숫자 없음(perf_tab_switch 이벤트만) |
| 캐시 상태 | 읽기 20회 반복(첫 회 포함 분위수) — DB 캐시 적중률 99.9% 이상 = warm | 실사용 |
3. Production 실측 (읽기 전용 프로브)
| 항목 | 값 (2026-09-07T01:17:42.558Z, release e41fe1ea) |
|---|---|
| Postgres / 연결 / shared_buffers / effective_cache | 17.6 / 60 / 256MB / 768MB |
| track_functions / track_io_timing / pg_stat_statements.track / statement_timeout | none / off / top / 2min |
| DB 크기 | 405 MB |
| 사용자 / 기록 있는 사용자 | 5 / 5 |
| 완료 세션 (앱 / 전체) · 계획 | 64 / 2018 · 7 |
| 세부 종목 / 세트 / 세부 세트 / 영수증 | 4199 / 15878 / 16704 / 1863 |
| 기록 기간 | 2021-11-11 ~ 2026-09-06 |
| 사용자당 세션 p50 / p95 / 최대 · 최대 기간(일) | 331 / 1023 / 1023 · 1760 |
| 7일 실제 세션 / 영수증 / 쓰기 사용자 · 30일 영수증 | 12 / 340 / 4 · 1350 |
| 잡 상태 · 열린 잡 · 최대 시도 | {"failed":3,"completed":1624} · 0 · 3 |
| 잡 큐 대기 p50 / p95 / max (ms, 30일) | 59931 / 60022 / 202886 |
| 잡 처리 시간 p95 (ms) · 0 으로 기록된 수 / 전체 | 0 · 1362 / 1362 |
| 잡 실패 30일 | |
| 세대: stale owner / 최대 lag / 요청→반영 p50 / p95 / max (ms) | 0 / 0 / 9226 / 59909 / 59909 |
| WAL 누계(통계 리셋 2026-08-18 14:39:17.139507+00 이후) | 53 GB · records 390363522 |
| 클라이언트 오류 7일 / 30일 · 30일 release 종류 | 96 / 297 · 58 |
cron barbelic-client-error-purge 24h (succeeded) | 1회 · p50 61 / p99 61 / max 61 ms · 5초 초과 0 |
cron barbelic-exercise-usage-stats-refresh 24h (succeeded) | 24회 · p50 50 / p99 114 / max 114 ms · 5초 초과 0 |
cron barbelic-stats-backfill-scan 24h (succeeded) | 24회 · p50 321 / p99 803 / max 803 ms · 5초 초과 0 |
cron barbelic-wodup-failed-staging-purge 24h (succeeded) | 1회 · p50 72 / p99 72 / max 72 ms · 5초 초과 0 |
cron lift-guild-pr-overview-snapshot-rollover 24h (succeeded) | 1440회 · p50 25 / p99 67 / max 36341 ms · 5초 초과 3 |
cron lift-guild-stats-refresh 24h (succeeded) | 1440회 · p50 18 / p99 23606 / max 31895 ms · 5초 초과 24 |
churn user_exercise_period_stats | live 10728 · ins 6721440 · upd 14672724 · del 6692894 · autovacuum 445 |
churn user_exercise_strength_observations | live 15374 · ins 5520937 · upd 13938924 · del 5484439 · autovacuum 445 |
churn user_exercise_session_rollups | live 3968 · ins 1357881 · upd 7517105 · del 1347061 · autovacuum 456 |
churn user_exercise_records | live 3968 · ins 1357881 · upd 1920302 · del 1347061 · autovacuum 436 |
churn user_exercise_max_rep_observations | live 3602 · ins 1362629 · upd 1723776 · del 1358859 · autovacuum 427 |
| WAL 상위 문장 | select public.run_user_exercise_stats_refresh_cron($1) · 27988회 · 평균 302.8ms · WAL 46074MB |
| WAL 상위 문장 | WITH pgrst_source AS (SELECT pgrst_call.pgrst_scalar FROM (SELECT $1 AS json_dat · 165회 · 평균 2508.9ms · WAL 1458MB |
| WAL 상위 문장 | WITH pgrst_source AS (SELECT pgrst_call.pgrst_scalar FROM (SELECT $1 AS json_dat · 116회 · 평균 1066.5ms · WAL 357MB |
무슨 뜻인가
- 통계 잡의 큐 대기 p50 59.9초·p95 60.0초 — 잡은 거의 즉시 생기지만 다음 분 cron 이 집을 때까지 기다린다. freshness 바닥이 cron 주기(60초)다. 요청→반영 p50 9.2초는 잡 처리 자체가 아니라 요청 시각과 cron 시각의 차다.
- 잡 처리 시간이 전부 0 — 뷰가
processed_at − processing_started_at을 빼는데 두 값이 같은 트랜잭션의now()라 항상 0 이다(측정 결함, D08). Production 도 샌드박스도 같다. - 매분 cron
run_user_exercise_stats_refresh_cron이 20일 동안 WAL 46GB(전체의 85%)를 썼고 투영 테이블은 살아 있는 행 1만 개에 갱신 1,467만·삭제 669만 회 — 재계산이 투영을 통째로 지우고 다시 쓴다(D08/D11 입력). cron 은 대부분 20ms 안에 끝나지만 하루 24회는 5초를 넘고 최대 32초다. - 5명·2,018세션(앱 작성 64) 규모라 요청률은 사실상 smoke·e2e 가 만든다(30일 영수증 1,350건 중 실제 세션 12건/7일). 부하 조건은 §4 의 fixture 가 대신한다.
4. 샌드박스 workload 실측
| 프로필 | 사용자 | 기록 | 세션 | 세트 | 쓰기 p50/p95/max (ms) | 적재 결과 |
|---|---|---|---|---|---|---|
history-10y | 1×기기 1 | 10년 | 2609 | 31308 | 19.6 / 27.8 / 107.3 | ok · 거부 0 · 재생 0 · 충돌 0/0 |
history-1y | 1×기기 1 | 1년 | 260 | 3120 | 20.1 / 26.4 / 47 | ok · 거부 0 · 재생 0 · 충돌 0/0 |
history-4y | 1×기기 1 | 4년 | 1043 | 12516 | 20.5 / 28.7 / 86 | ok · 거부 0 · 재생 0 · 충돌 0/0 |
mixed-read-write-import | 5×기기 1 | 4년 | 5715 | 68580 | 18.8 / 30.9 / 26465.1 | ok · 거부 0 · 재생 0 · 충돌 0/0 |
multi-owner-10 | 10×기기 1 | 1년 | 2600 | 31200 | 18.8 / 26 / 86.7 | ok · 거부 0 · 재생 0 · 충돌 0/0 |
same-owner-two-devices | 1×기기 2 | 1년 | 260 | 3120 | 19.4 / 27.4 / 63.3 | ok · 거부 0 · 재생 60 · 충돌 26/26 |
| 읽기 RPC p95 (ms) | history-10y | history-1y | history-4y | mixed-read-write-import | multi-owner-10 | same-owner-two-devices |
|---|---|---|---|---|---|---|
get_home_dashboard | 18.5 (29KB) | 10.9 (29KB) | 90 (29KB) | 12.1 (29KB) | 12 (29KB) | 41.5 (29KB) |
get_calendar_month_summary | 17.5 (12KB) | 7.3 (12KB) | 35.4 (12KB) | 7.8 (12KB) | 7.3 (12KB) | 8.1 (12KB) |
get_calendar_day_summary | 8 (1KB) | 7 (1KB) | 10.5 (1KB) | 7.9 (1KB) | 7.9 (1KB) | 9.6 (1KB) |
get_session_detail | 15.1 (21KB) | 12 (23KB) | 13 (19KB) | 10.9 (19KB) | 20.5 (23KB) | 22.5 (23KB) |
get_pr_overview | 9.5 (63KB) | 9.1 (63KB) | 9.4 (63KB) | 7.3 (63KB) | 7.8 (63KB) | 7.2 (63KB) |
get_volume_overview | 3104 (1557KB) | 164.2 (618KB) | 1218.8 (1180KB) | 366.5 (1182KB) | 170.7 (618KB) | 267.5 (618KB) |
get_profile_feed | 43.9 (82KB) | 53.9 (82KB) | 35.1 (82KB) | 36.8 (82KB) | 53.2 (82KB) | 46.5 (82KB) |
get_session_search | 229.9 (480KB) | 276.9 (479KB) | 804.4 (480KB) | 255.9 (480KB) | 210.5 (479KB) | 217.6 (480KB) |
get_user_manual_records_v1 | 0.5 (0KB) | 0.6 (0KB) | 0.6 (0KB) | 0.5 (0KB) | 0.6 (0KB) | 0.7 (0KB) |
| 잡·세대·DB | history-10y | history-1y | history-4y | mixed-read-write-import | multi-owner-10 | same-owner-two-devices |
|---|---|---|---|---|---|---|
| 잡 수(적재 뒤) | 1 | 1 | 1 | 7 | 10 | 1 |
| 큐 대기 p95 (ms) | 58378 | 6513 | 24749 | 67069 | 44990 | 9634 |
| 잡 처리 시간 p95 (ms, 뷰) | 0 | 0 | 0 | 0 | 0 | 0 |
| 잡 처리 시간 0 으로 기록된 수 | 1 | 1 | 1 | 7 | 10 | 1 |
| backlog 소진 wall (ms) | 127625 | 4495 | 28521 | 75169 | 43524 | 4823 |
| worker 라운드 최대 (ms) | 127617 | 4491 | 28516 | 미측정 | 43505 | 4820 |
| 요청→반영 p50 (ms) | 847 | 762 | 803 | 22749 | 12313 | 843 |
| stale owner | 0 | 0 | 0 | 0 | 0 | 0 |
| 소진 WAL (MB) | 394.7 | 35.5 | 140.9 | 705 | 306.5 | 35.3 |
| 소진 행 갱신(upd) | 363593 | 36585 | 145560 | 626505 | 293196 | 36585 |
| 읽기 20회 blks_hit | 5338620 | 1357932 | 19510621 | 2312601 | 813904 | 1661930 |
| 캐시 적중률 (%) | 99.9 | 100 | 100 | 99.8 | 99.8 | 99.9 |
| lock wait 관측(대기 backend 최대) | 0 | 0 | 0 | 0 | 0 | 0 |
| 브라우저 초기 비용 | cold | warm |
|---|---|---|
history-1y 홈 그려짐 (ms) | 965 | 995 |
history-1y load 이벤트 (ms) | 304 | 230.1 |
history-1y domInteractive (ms) | 54.7 | 33.4 |
history-1y 전송 바이트(문서) | 2568 | 300 |
history-1y script 수 / KB | 40 / 534 | 40 / 12 |
history-1y 가장 느린 script (ms) | 24.2 | 10.2 |
history-1y DOM 노드 | 267 | 267 |
history-1y RPC 호출 수 | 9 | 8 |
history-1y RPC p50 (ms, 브라우저 왕복) | get_profile_feed 43.8, get_pr_overview 51, get_user_exercise_favorites 45.5, get_home_dashboard 204.1, get_exercise_search_signals_v1 20.1, get_calendar_month_summary 17.7, get_calendar_day_summary 25.6, get_volume_overview 25.7, get_exercise_catalog 631.3 | get_pr_overview 455, get_profile_feed 460.3, get_home_dashboard 464.7, get_user_exercise_favorites 461.7, get_exercise_search_signals_v1 17.7, get_calendar_month_summary 18.4, get_calendar_day_summary 20.1, get_volume_overview 39.4 |
무슨 뜻인가
- 쓰기(저장 문 서버 시간)는 기록량과 무관하게 p95 22~28ms — 저장 자체는 싸다. 비용은 그 뒤 통계 재계산에 있다.
- 읽기 가운데
get_volume_overview가 기록량에 비례해 자란다: 1년 170ms → 4년 1,081ms → 10년 3,002ms(응답 1.5MB). CI 시간 예산(1,000ms)은 4년부터 넘는다 — CI 는 in-process mock 으로 재므로 이 사실을 모른다.get_session_search도 4년에서 661ms, 혼합 부하에서 1,041ms. 나머지 화면 RPC 는 20ms 안팎. - 통계 backlog 소진은 세션 수에 비례한다: 1년 4.8초(1잡) → 4년 27초 → 10년 128초(worker 한 라운드 127초 — Production
statement_timeout2분과 같은 크기). WAL 은 10년 소진에 353MB, 행 갱신 36만 회. 잡 하나가 사용자 전체 이력을 다시 계산한다. - 같은 owner 두 기기: 재생 60건 전부
replayed=true, 낡은 개정번호 수정 26건 전부 40001/LG409 — 멱등·충돌 규칙은 이 부하에서 그대로 성립. - owner 10명이 같은 분에 쓰면 잡 10개가 각각 생기고 owner 별로 처리된다. lock wait 는 이 방법(100ms 샘플링)으로는 관측되지 않았다 — 정확한 잠금 대기 시간은
log_lock_waits가 없어 미측정. - 인입 근사(저장 문 500회 연속)는 정식 인입 경로(staging → canonical)가 아니다. 인입의 파일 쪽 비용(브라우저 사전검사·SHA-256·worker 정규화)은 I01(#1415, 2026-09-10)이
npm run perf:import로 쟀다 — 10,000세션·24.3MB 파일: 사전검사 0.29~0.33초(이벤트 루프 최대 정지 47ms·힙 최고 55MB), 정규화 0.92~0.94초(힙 최고 226MB = Edge 256MB 의 88%); 2,000세션·4.9MB: 사전검사 66ms·정규화 0.22초·힙 52MB. 값은 자원 예산 §3-5. staging → canonical 의 DB 비용은 여전히 미측정.
5. 다시 재는 법 (R04 가 같은 절차로)
bash
# 1) 샌드박스(같은 Postgres 이미지) — 다른 세션의 스택을 쓰지 않는다
npm run ci:local -- --full --only db --sandbox <폴더>
export BARBELIC_DB_SANDBOX=<폴더>
# 2) workload 생성(결정적) → 적재 → DB 측정, 프로필마다
node scripts/performance/workload/generate.mjs --all
for p in history-1y history-4y history-10y same-owner-two-devices multi-owner-10 mixed-read-write-import; do
node scripts/performance/workload/load.mjs --workload scripts/performance/evidence/workloads/$p
node scripts/performance/measure/db.mjs --workload scripts/performance/evidence/workloads/$p --iterations 20
done
# 3) 브라우저 초기 비용 — 미리보기 빌드 + e2e 환경변수(supabase status -o env)
npm run build && node node_modules/vite/bin/vite.js preview --port 4173 &
E2E_SUPABASE_URL=… E2E_SUPABASE_ANON_KEY=… E2E_SUPABASE_SERVICE_ROLE_KEY=… E2E_APP_URL=http://127.0.0.1:4173 \
node --import tsx scripts/performance/measure/browser.mjs --workload scripts/performance/evidence/workloads/history-1y --seed-sessions 120
# 4) Production 읽기 전용 프로브(supabase CLI 로그인 토큰)
node scripts/performance/probe/production.mjs
# 5) 표
node scripts/performance/measure/summarize.mjs비교 규칙: 같은 프로필·같은 PC·같은 반복 수로 두 번 이상 재고 분위수(p50·p95·max)와 표본 수 를 같이 적는다. 다른 PC 의 절대값을 비교하지 않는다 — 같은 PC 에서 전/후 비율(릴리스 예산 §2)로 판정한다.
6. 미측정과 결함 (숫자로 쓰지 않은 것)
| 항목 | 상태 | 담당 |
|---|---|---|
잡 처리 시간(duration_ms) | 결함으로 전부 0 — processing_started_at·processed_at 이 같은 트랜잭션 now(). clock_timestamp() 로 바꿔야 잰다 | D08 |
| RPC 별 서버 시간(Production) | pg_stat_statements 가 PostgREST 래핑(pgrst_source … $1)만 보여 이름을 못 붙인다. track_functions = pl 뒤에 pg_stat_user_functions 로 | D08/HQ(DB 설정) |
| lock wait(ms) | pg_locks 샘플링으로 대기 backend 수만. 시간은 log_lock_waits 필요 | D08 |
| 인입(정식 경로) | 파일 쪽(사전검사·해시·정규화)은 I01 이 Node 로 실측(npm run perf:import, 자원 예산 §3-5). staging → canonical 의 DB 비용과 브라우저 실기기 정지는 미측정 | I01 → R04 |
| 브라우저 실기기·Vercel 엣지 | 미리보기 빌드·로컬 스택 값만 | U05/B02 |
| 복구 RPO/RTO | 사본은 매일 1회(암묵 RPO 24시간)·복원 리허설 0회 → RTO 미측정. 평가 방법은 릴리스 예산 §5 | S11/R03 |
| Production compute 등급 | shared_buffers 256MB 로 약 1GB 추정 — 대시보드 확인 대기(오너 손) | 오너 |