총 완료가 언제나 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;        -- 6287

DB에 9,126건이 있는데 API는 1,000건만 돌려주고 있었다.

PostgREST의 기본 max-rows가 1000이다. Supabase도 이 값을 그대로 쓴다. 상한을 넘으면 그냥 잘라서 준다 — .range()도, 페이지네이션도, "잘렸습니다"라는 신호도 없다. 200 OK에 1,000행이 담겨 올 뿐이다.

화면은 3년째 거짓말을 하고 있었다

이 페이지는 완료 기록을 전부 받아 브라우저에서 집계한다. 받은 배열이 잘려 있으면 집계도 전부 틀린다.

화면 요소표시값실제
총 완료1,0006,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
stable

security invoker가 중요하다. 기본값이긴 하지만 명시해뒀다. security definer로 바꾸면 함수 소유자 권한으로 실행돼 RLS를 우회하고 남의 기록이 내 통계에 섞인다. 집계 함수에서 특히 위험한 실수다.

함정: 타임존

completed_attimestamptz다. 날짜별로 세려면 어딘가에서 날짜로 잘라야 하는데, 그냥 자르면 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 폴백이 없다. 그래서 순서가 정해져 있다.

  1. 마이그레이션을 원격에 먼저 적용 (db:push)
  2. 그다음 앱 배포

거꾸로 하면 함수를 못 찾아 기록 페이지가 통째로 에러가 난다. 롤백은 역순 — 코드를 되돌린 뒤 함수를 드롭한다.

교훈

  • 지표 조합으로 원인을 추론하지 말고 감사를 뽑는다. "LCP·CLS는 그대로인데 TBT만 올랐으니 JS 문제"는 성립하지 않는 규칙이었다. 레이아웃 비용도 정확히 같은 흔적을 남긴다. 각 지표가 무엇을 안 보는지까지 알지 못하면, 지표 조합 읽기는 추론이 아니라 점이다.
  • 성능 지표를 비교할 땐 양쪽의 데이터 조건이 같은지 먼저 확인한다. 원장은 델타를 계산해줄 뿐 "비교해도 되는 두 값인지"는 판단해주지 않는다. 조건을 기록하지 않는 측정 도구는 언젠가 조용히 거짓말을 한다.
  • 응답이 잘렸는데 아무도 알려주지 않는 상황을 의심하라. PostgREST의 1000행 상한처럼, 기본값으로 켜진 안전장치가 데이터가 적을 땐 보이지 않다가 어느 순간부터 조용히 틀린 답을 준다. 목록 API를 쓸 때 "전부 받는다"는 가정은 대개 언젠가 깨진다.
  • 집계를 클라이언트에서 하면 받은 만큼만 정확하다. 총계·비율·기간별 카운트는 전체 데이터를 볼 수 있는 쪽에서 계산해야 한다. 브라우저는 전체를 볼 수 없다.