총 완료가 언제나 1,000이었다 — 성능을 재다 만난 정확성 버그
경험을 토대로 AI를 활용하여 작성한 글 입니다.
취업 준비 플래너 앱의 Lighthouse 원장에 회귀가 세 건 찍혀 있었다. 그중 /jobs가 제일 이상했다.
| Page | Perf | LCP | TBT | CLS |
| /jobs | 82 🔴-16 | 2.37s 🔴+0.13s | 619ms 🔴+542ms | 0.000 (—) |
LCP와 CLS는 그대로인데 TBT만 8배다. 이 조합은 "그리는 게 느림"이 아니라 "메인스레드에서 JS를 오래 실행함"을 가리킨다. 그래서 무거운 import나 동기 루프가 들어갔겠거니 하고 코드를 열었는데, 페이지가 너무 가벼웠다. 619ms를 설명할 만한 게 없었다.
추측으로 고치면 안 되는 상황이었다. 원인을 모르는 채 최적화를 넣으면 효과 없는 변경으로 코드만 복잡해지고 진짜 원인은 남는다. 그래서 도구부터 만들었다.
요약 지표로는 아무것도 알 수 없다
측정 스크립트는 Perf/LCP/TBT/CLS/FCP/SI 여섯 개만 원장에 남긴다. 회귀가 보여도 왜인지 물어볼 수단이 없었다. Lighthouse는 훨씬 많은 걸 알고 있는데 내가 버리고 있었다.
상세 audit을 뽑는 스크립트를 붙이고, 건강한 페이지와 나란히 놓았다. 대조군은 /goal(Perf 98).
── mainthread-work-breakdown ──
/jobs /goal
styleLayout 1178ms 188ms ← 6.3배
scriptEvaluation 856ms 607ms ← 1.4배
가설이 틀렸다. 지배적인 건 JS 실행이 아니라 스타일 재계산·레이아웃이었다. 그리고 DOM 요소 수를 보고 답이 나왔다.
/jobs : 5,995 개
/goal : 201 개
30배. "Most children" 노드는 채용공고 목록 <ul>이었다. 공고를 200건 저장해두고 전부 렌더하고 있었으니 스타일 계산이 폭발하는 게 당연했다.
틀린 건 결론이 아니라 추론 규칙이었다
여기서 그냥 넘어갈 뻔했는데, 곱씹어보니 문제는 "코드가 가벼워 보였다"가 아니었다. "LCP·CLS는 그대로인데 TBT만 폭증했으니 JS 문제"라는 규칙 자체가 성립하지 않는다.
- LCP는 "가장 큰 요소가 언제 그려졌나"다. 첫 화면 위쪽만 본다. 화면 밖에 있는 나머지 180장의 레이아웃 비용은 LCP가 확정된 뒤에 치러지므로 이 지표를 안 건드린다.
- CLS는 "이미 그린 게 나중에 움직였나"다. 200장이 처음부터 제자리에 그려지면, 그리는 데 아무리 오래 걸려도 시프트는 0이다.
즉 레이아웃 비용은 LCP와 CLS 어느 쪽에도 안 나타나면서 TBT만 올린다. 내가 "JS 문제"의 증거로 삼은 지표 조합은, 사실 "DOM이 너무 크다"의 증거이기도 했다. 두 원인이 같은 흔적을 남기니 흔적만 보고는 구분할 수 없었던 것이다.
지표 조합으로 원인을 좁히는 건 그럴듯하지만, 각 지표가 무엇을 안 보는지까지 알아야 성립한다. 그걸 모르면 추론이 아니라 점(占)이다. 그리고 감사를 뽑는 비용은 추론을 검증하는 비용보다 언제나 싸다.
회귀가 아니었다
여기서 멈추고 데이터를 세어봤다. 언제부터 200건이었지?
job-postings 200건 — 전부 2026-07-27 생성, 제목 "[perf] 채용 공고 N"
completed 1000건 — 전부 2026-07-27 생성, 제목 "[perf] ..."
baseline은 7월 21일, 두 번째 측정은 7월 28일. 그 사이인 27일에 성능 테스트용 데이터가 대량으로 시딩돼 있었다. [perf] 접두사를 보면 의도적인 작업이다.
즉 두 측정은 데이터가 다른 계정을 잰 것이고, 원장의 🔴 델타는 코드가 만든 게 아니었다. 다시 보니 패턴이 명확했다.
| 페이지 | 델타 | 데이터에 의존하나 |
|---|---|---|
/jobs 98→82 | 공고 200건 | ✅ |
/history 89→73 | 완료 기록 | ✅ |
/monthly 89→77 | 같은 훅 | ✅ |
/daily·/goal·/quiz·/timer | 유지·개선 | ❌ |
회귀한 페이지가 정확히 데이터 의존 페이지였다. 우연일 수 없다.
계획대로 커밋을 이분 탐색했다면 아무것도 못 찾았을 것이다. 원인 커밋이 없으니까. 진단을 먼저 한 게 유일하게 잘한 선택이었다.
원장이 이걸 경고해주지 못한 게 진짜 문제였다. 두 측정을 나란히 놓고 델타를 계산해주지만 데이터 볼륨은 기록하지 않는다. 비교 조건이 다른데 비교표를 그려준 것이다. 도구가 조용히 거짓말을 했고, 그 도구는 내가 만들었다.
데이터를 세다가 더 큰 걸 봤다
여기까지는 "테스트 데이터였네" 하고 끝날 이야기였다. 그런데 API가 돌려주는 수와 DB의 수가 안 맞았다.
GET /api/tasks?completed=true → 1000건
GET /api/tasks → 1000건 (그중 완료 731건)
두 응답이 서로 모순이었다. 완료가 1000건인데 전체를 받으니 완료가 731건? 그래서 DB에 직접 물었다.
select count(*) from tasks; -- 9126
select count(*) from tasks where is_completed; -- 6287DB에 9,126건이 있는데 API는 1,000건만 돌려주고 있었다.
PostgREST의 기본 max-rows가 1000이다. Supabase도 이 값을 그대로 쓴다. 상한을 넘으면 그냥 잘라서 준다 — .range()도, 페이지네이션도, "잘렸습니다"라는 신호도 없다. 200 OK에 1,000행이 담겨 올 뿐이다.
화면은 3년째 거짓말을 하고 있었다
이 페이지는 완료 기록을 전부 받아 브라우저에서 집계한다. 받은 배열이 잘려 있으면 집계도 전부 틀린다.
| 화면 요소 | 표시값 | 실제 |
|---|---|---|
| 총 완료 | 1,000 | 6,287 |
| 완료율 | 분모가 1,000 | 분모는 6,287 |
| 2026-01 이전 월 필터 | 0건 | 5,287건 존재 |
| 히트맵(최근 12주) | 486건 | 486건 |
API가 보는 범위는 2026-02-01 ~ 2026-07-26이었다. 실제 데이터는 2023-07-28부터 있는데, 그 이전은 앱 입장에서 존재하지 않았다. 기간 필터를 2025년으로 옮기면 데이터가 154건 있는데도 "완료된 태스크 (0건)"이 뜬다.
재미있는 건 히트맵만 맞았다는 점이다. 최근 1,000건이 12주보다 넓은 기간을 덮고 있어서 우연히 전부 포함됐다. 우연히 맞은 것이고, 기록이 더 쌓이면 히트맵도 조용히 틀려진다.
성능을 재려고 시작한 일에서 정확성 버그가 나왔다. 그리고 이건 데이터가 적을 땐 절대 보이지 않는 종류다. 1,000건에 닿기 전까지는 모든 게 정상으로 보인다.
해결: 다 받아서 거르지 말고, 필요한 것만 묻기
구조를 바꿨다.
이전 — 서버는 행을 던져주고 판단은 전부 브라우저가 한다.
GET /api/tasks?completed=true → 완료 태스크 배열 (1000건에서 잘림)
→ 통계 4종 계산 (배열 2회 순회)
→ 히트맵 일별 카운트 (배열 1회 순회)
→ 월·카테고리 필터 (배열 필터링)
→ slice(0, n) 클라이언트 페이지네이션
이후 — 집계는 DB가 전체 행에 대해 하고, 목록은 화면에 보이는 만큼만 받는다.
create or replace function public.completed_history(
p_tz text default 'UTC',
p_month text default null,
p_category text default 'all',
p_limit int default 40,
p_offset int default 0,
p_grid_start date default null,
p_grid_end date default null
)
returns jsonb
language plpgsql
security invoker
stablesecurity invoker가 중요하다. 기본값이긴 하지만 명시해뒀다. security definer로 바꾸면 함수 소유자 권한으로 실행돼 RLS를 우회하고 남의 기록이 내 통계에 섞인다. 집계 함수에서 특히 위험한 실수다.
함정: 타임존
completed_at은 timestamptz다. 날짜별로 세려면 어딘가에서 날짜로 잘라야 하는데, 그냥 자르면 UTC 기준이 된다.
completed_at::date -- UTC 기준
(completed_at at time zone 'Asia/Seoul')::date -- KST 기준한국에서 새벽 2시에 완료한 태스크는 UTC로 전날 17시다. UTC로 자르면 전날 완료로 집계된다. 기존 클라이언트 코드는 date-fns로 로컬 시각 기준으로 세고 있었으니, 그대로 옮겼다면 모든 날짜가 미묘하게 어긋났을 것이다. 화면에서는 눈치채기 어렵고, 히트맵 셀 하나가 옆으로 밀리는 정도로 나타난다.
그래서 클라이언트가 IANA 타임존을 넘기고 모든 절단을 그 기준으로 맞췄다.
const timeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'주 시작 요일도 맞춰야 했다. 클라이언트는 startOfWeek(now, { weekStartsOn: 1 })로 월요일 시작이었고, Postgres date_trunc('week', ...)도 월요일 시작이라 다행히 일치했다. 일요일 시작이었다면 "이번 주 완료" 수가 조용히 달라졌을 것이다.
일별 카운트 범위를 파라미터로
히트맵은 최근 12주, 월간 페이지의 미니달력은 특정 월이 필요하다. 범위를 인자로 받게 해서 한 함수를 둘이 공유한다. 월간 페이지도 같은 버그를 갖고 있었으니 — 같은 훅을 쓰고 있었다 — 함께 고쳐야 두 화면의 숫자가 일치한다.
검증
숫자부터 맞는지 봤다.
총 완료 : 1,000 → 6,287 (DB 실제와 일치)
2025-06 필터 : 0 → 154
2024-03 필터 : 0 → 183
2023-08 필터 : 0 → 181
응답 크기 : 396KB → 17KB (-95.6%)
396KB는 심지어 잘린 상태의 크기였다.
Lighthouse도 다시 쟀다.
LCP 6.07s → 4.22s (-1.85s)
Perf 75 → 81
LCP가 1.85초 줄었다. 다만 4.22초가 남았다.
이 잔여분이 전송량이 아니라 원격 Supabase 왕복 지연이라는 건 며칠 뒤 /jobs를 고치면서
확인됐다. /jobs는 목록 렌더를 20건으로 끊어 TBT를 611 → 171ms로 떨어뜨렸는데, LCP는
2.6초에서 꿈쩍도 하지 않았다. 두 페이지가 전혀 다른 수정을 하고도 같은 바닥에 닿은 것이다.
응답 크기를 396KB에서 17KB로 줄이고도 LCP가 4초대에 남았다는 게 애초에 단서였다. 23배를 줄여서 안 움직였다면 남은 건 크기가 아니라 거리다. 앱 레이어에서 더 짜낼 게 별로 없다는 뜻이고, 남은 선택지는 DB 지역을 옮기거나 첫 데이터를 서버에서 렌더하는 쪽이다. 다음 숙제다.
e2e는 48개 중 1개가 깨졌다. 확인해보니 앱 버그가 아니라 테스트가 낡은 것이었다.
// 기록 로드 실패 시 재시도 버튼이 뜨는지 검증하는 테스트
if (req.method() === 'GET' && req.url().includes('completed=true')) {
await route.fulfill({ status: 500, ... })
}엔드포인트를 바꾸면서 completed=true 파라미터가 사라졌으니 가로채기가 안 걸렸고, 요청이 성공해서 에러 UI가 안 떴다. 테스트를 새 엔드포인트에 맞추니 48/48 통과했다.
마지막으로 /api/tasks에 남아 있던 completed=true 모드를 지웠다. 이제 아무도 안 쓰지만 절단 버그를 그대로 품은 경로라, 남겨두면 다음 사람이 다시 밟는다.
배포 순서
RPC를 쓰는 코드는 함수가 없는 DB에서 그냥 죽는다. JS 폴백이 없다. 그래서 순서가 정해져 있다.
- 마이그레이션을 원격에 먼저 적용 (
db:push) - 그다음 앱 배포
거꾸로 하면 함수를 못 찾아 기록 페이지가 통째로 에러가 난다. 롤백은 역순 — 코드를 되돌린 뒤 함수를 드롭한다.
교훈
- 지표 조합으로 원인을 추론하지 말고 감사를 뽑는다. "LCP·CLS는 그대로인데 TBT만 올랐으니 JS 문제"는 성립하지 않는 규칙이었다. 레이아웃 비용도 정확히 같은 흔적을 남긴다. 각 지표가 무엇을 안 보는지까지 알지 못하면, 지표 조합 읽기는 추론이 아니라 점이다.
- 성능 지표를 비교할 땐 양쪽의 데이터 조건이 같은지 먼저 확인한다. 원장은 델타를 계산해줄 뿐 "비교해도 되는 두 값인지"는 판단해주지 않는다. 조건을 기록하지 않는 측정 도구는 언젠가 조용히 거짓말을 한다.
- 응답이 잘렸는데 아무도 알려주지 않는 상황을 의심하라. PostgREST의 1000행 상한처럼, 기본값으로 켜진 안전장치가 데이터가 적을 땐 보이지 않다가 어느 순간부터 조용히 틀린 답을 준다. 목록 API를 쓸 때 "전부 받는다"는 가정은 대개 언젠가 깨진다.
- 집계를 클라이언트에서 하면 받은 만큼만 정확하다. 총계·비율·기간별 카운트는 전체 데이터를 볼 수 있는 쪽에서 계산해야 한다. 브라우저는 전체를 볼 수 없다.