useMemo가 한 번도 적중하지 않고 있었다, 그런데 고쳐도 점수는 그대로였다
경험을 토대로 AI를 활용하여 작성한 글 입니다.
취업 준비 플래너 앱에 로컬 Lighthouse 측정 스크립트를 붙여두고 일주일이 지났다. pnpm perf 한 번이면 프로덕션 빌드를 띄우고 페이지마다 5회 측정해서 중앙값을 문서에 델타로 쌓아주는 물건이다. 첫 측정을 baseline으로 남겨두고 일주일치 작업을 한 뒤 두 번째로 돌렸다.
| Page | Perf | LCP | CLS |
| /history | 73 🔴-16 | 6.26s 🔴+2.66s | 0.094 🔴+0.050 |
| /jobs | 82 🔴-16 | 2.37s 🔴+0.13s | 0.000 (—) |
/history가 89에서 73으로 떨어져 있었다.
먼저: 이게 노이즈인가
Lighthouse 랩 지표는 실행마다 튄다. 그래서 회귀라고 부르기 전에 5회 run의 점수 산포부터 봤다.
/history run 1/5: 73 run 2/5: 72 run 3/5: 73 run 4/5: 73 run 5/5: 73
/monthly run 1/5: 77 run 2/5: 85 run 3/5: 87 run 4/5: 77 run 5/5: 76
/history는 7273으로 촘촘하다. 노이즈라면 이 폭으로 모이지 않는다. 반면 87로 11점이나 벌어져 있어서, 같이 떨어졌지만 폭을 믿을 수 없다고 보고 이번 범위에서 뺐다. 회귀처럼 보이는 것과 회귀인 것을 구분하는 게 첫 단추였다./monthly는 76
코드를 열어보니 메모가 죽어 있었다
/history는 완료 기록 페이지다. 상단에 통계 카드 4개, 그 아래 12주짜리 히트맵, 필터, 목록이 있다. 통계 컴포넌트를 열었다.
export function HistoryStats({ tasks }: { tasks: Task[] }) {
const now = new Date()
const weekStart = startOfWeek(now, { weekStartsOn: 1 })
const weekEnd = endOfWeek(now, { weekStartsOn: 1 })
const monthStart = startOfMonth(now)
const monthEnd = endOfMonth(now)
const stats = useMemo(() => {
// tasks 전체를 두 번 필터링
}, [tasks, weekStart, weekEnd, monthStart, monthEnd])처음엔 "now가 매 렌더 갱신되니까 메모가 깨지는구나" 하고 넘어갈 뻔했다. 그런데 다시 보니 그 설명은 정확하지 않았다.
weekStart는 startOfWeek로 주 시작 시각으로 잘린 값이다. 같은 주 안에서라면 오늘 몇 시에 렌더하든 값이 완전히 동일하다. 밀리초까지 같다. monthStart도 마찬가지다. 그런데도 메모는 깨진다.
useMemo는 deps를 Object.is로 비교하는데, startOfWeek는 호출할 때마다 새 Date 객체를 반환하기 때문이다.
const a = startOfWeek(new Date(), { weekStartsOn: 1 })
const b = startOfWeek(new Date(), { weekStartsOn: 1 })
a.getTime() === b.getTime() // true — 값은 같다
Object.is(a, b) // false — 그런데 메모는 깨진다즉 now가 밀리초 단위로 달라지는 것과 무관하게, Date 객체를 deps에 넣는 순간 이미 끝난 것이었다. 값을 아무리 안정적으로 만들어도 참조가 새것이면 Object.is는 항상 false를 돌려준다. 이 컴포넌트의 useMemo는 작성된 이래 한 번도 적중한 적이 없었다.
히트맵 컴포넌트도 같은 구조였다. today → gridStart로 이어지고 그 gridStart가 columns 메모의 deps에 들어가 있어서, 렌더할 때마다 12주 × 7일 = 84개 날짜 문자열을 format()으로 다시 만들고 있었다.
재미있는 건 같은 파일들 안에 정답도 같이 있었다는 점이다.
const countByDay = useMemo(() => { /* ... */ }, [tasks]) // 정상
const filtered = useMemo(() => { /* ... */ }, [tasks, month, category]) // 정상이쪽 deps는 배열 참조와 문자열·원시값이라 제대로 메모된다. 정답과 오답이 나란히 있었는데 원시값만 쓰는 쪽이 우연히 맞은 것에 가까웠다.
이게 실제로 뭘 망가뜨리고 있었나
이 컴포넌트들은 React.memo로 감싸여 있지 않다. 그래서 페이지 훅의 상태가 바뀌면 그대로 리렌더된다. 이 페이지에는 상태가 셋 있다 — 월 필터, 카테고리 필터, 그리고 목록을 몇 개까지 보여줄지.
정리하면 이렇다.
- 월 필터 변경 → 완료 태스크 전체를 2회 순회 + 히트맵 84칸 재생성
- 카테고리 변경 → 동일
- "더 보기" 클릭 → 동일
목록만 40개 늘리면 되는데 히트맵을 통째로 다시 만들고 있었다. tasks는 그대로인데도.
고치기
deps에서 객체를 없애는 방향으로 갔다. 날짜 경계 계산을 메모 안으로 넣고, 밖에는 문자열 키만 남긴다.
// 날짜 경계는 useMemo 안에서 만든다. Date 객체를 deps 에 넣으면 값이 같아도
// 매 렌더 새 참조라 Object.is 비교가 항상 실패해 메모가 무력화된다.
const todayKey = format(new Date(), 'yyyy-MM-dd')
const stats = useMemo(() => {
const now = parseISO(todayKey)
const weekStart = startOfWeek(now, { weekStartsOn: 1 })
// ... 경계 계산과 집계를 여기서
}, [tasks, todayKey])todayKey는 문자열이라 값으로 비교된다. 같은 날 안에서는 재계산이 일어나지 않고, 날짜가 바뀌면 자연스럽게 갱신된다.
한 가지 유혹이 있었다. useMemo(() => new Date(), [])로 now를 마운트 시점에 고정하면 한 줄로 끝난다. 그런데 그렇게 하면 자정을 넘겨 탭을 열어두면 날짜가 어제로 박힌다. 이 앱은 예전에 일간 페이지에서 정확히 이 문제를 겪고 고친 적이 있다. 이미 고친 버그를 다른 파일에서 되살릴 이유는 없었다.
CLS는 로딩 화면이 범인이었다
CLS도 0.044에서 0.094로 두 배가 됐다. 이건 원인이 더 단순했다.
{isLoading ? (
<p className="text-sm text-zinc-500">불러오는 중…</p>
) : (
<>
<HistoryStats … />
<HistoryCalendar … />
…
</>
)}로딩 중에는 텍스트 한 줄만 있다가, 데이터가 도착하면 통계 카드·히트맵·필터·목록이 한꺼번에 삽입된다. 아래 있던 게 전부 밀린다.
스켈레톤을 만들었는데, 만들면서 규칙을 하나 정했다. 높이를 매직넘버로 맞추지 말 것. h-[420px] 같은 걸 박아두면 실제 컴포넌트가 바뀌는 순간 조용히 어긋나고, 아무도 눈치채지 못한다. 그래서 실제 컴포넌트와 같은 래퍼 클래스를 그대로 복제하고 내용만 placeholder로 채웠다.
{/* HistoryCalendar: 12주 × 7일 히트맵 */}
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white p-4 shadow-sm">
<div className="flex gap-1">
{Array.from({ length: WEEKS }, (_, w) => (
<div key={w} className="flex flex-col gap-1">
{Array.from({ length: DAYS }, (_, d) => (
<Skeleton key={d} className="size-3 rounded-sm sm:size-3.5" />
))}
</div>
))}
</div>
</div>패딩도, 셀 크기도, 격자 개수도 실제와 같으니 높이가 저절로 따라온다.
검증: 하나는 성공, 하나는 아무 일도 일어나지 않았다
고치고 다시 측정했다.
| Page | Perf | LCP | TBT | CLS | SI |
| /history | 75 🟢+2 | 6.07s 🟢-0.20s | 190ms (—) | 0.000 🟢-0.094 | 1.34s 🔴+0.20s |
CLS는 0.094 → 0.000. 레이아웃 시프트가 완전히 사라졌고 baseline(0.044)보다도 좋아졌다.
그런데 TBT가 189ms에서 190ms로, 아무 변화가 없었다.
메모를 고쳤는데 왜 안 움직이지? 한참 들여다보다 깨달았다. 문제는 수정이 아니라 내가 세운 성공 기준이었다.
Lighthouse의 TBT(Total Blocking Time)는 페이지 로드 구간만 측정한다. FCP부터 TTI까지, 즉 "화면이 처음 뜨는 동안" 메인스레드가 얼마나 막혔는지를 잰다. 그런데 내가 고친 건 필터를 바꾸거나 "더 보기"를 누를 때 일어나는 재계산이다. 그건 로드가 끝난 한참 뒤의 일이고, Lighthouse는 거기까지 따라가지 않는다.
초기 로드 중에는 이 컴포넌트들이 한두 번밖에 렌더되지 않는다. 메모가 깨져 있든 말든 그 구간의 비용 차이는 사실상 없다. 190ms는 수정이 실패했다는 뜻이 아니라, 내가 고친 영역을 이 도구가 재지 않는다는 뜻이었다.
수정을 되돌리지는 않았다. 매 렌더 전체 재순회는 실재하는 버그고, 완료 기록이 쌓일수록 비용이 선형으로 늘어난다. 다만 정직하게 말하면 이번 측정으로는 그 효과를 증명하지 못했다. 증명하려면 Lighthouse가 아니라 React Profiler나 상호작용 후 INP를 봐야 한다.
정직하게 남긴 것들
SI(Speed Index)가 1.14s에서 1.34s로 올랐다. 스켈레톤이 회색 블록으로 화면을 채웠다가 실제 콘텐츠로 교체되는 구조라, "시각적으로 완성됐다"는 판정이 늦어졌을 수 있다. 다만 1회 측정이라 노이즈와 구분이 안 된다. CLS 0.094 → 0.000의 대가로는 남는 거래라고 보고, 다음 측정에서 다시 관찰하기로 했다.
그리고 LCP는 6.26s에서 6.07s로 거의 그대로다. 원인은 짐작하고 있었다. 이 페이지는 완료 기록을 한 번에 다 받아온 다음 클라이언트에서 월별로 거른다. 월 범위로 좁히면 되지만, 히트맵은 12주를 쓰고 통계는 전 기간 총계를 쓰기 때문에 화면의 의미가 바뀐다. 성능 수정이 아니라 설계 변경이라 이번 브랜치에 섞지 않았다.
이 "다 받아온다"는 전제가 틀렸다는 건 며칠 뒤에 알게 된다. 아래 덧붙임을 참고.
지금 사용자는 스켈레톤을 6초간 보고 있다. 스켈레톤이 그 6초를 덜 거슬리게 만들었을 뿐, 6초 자체는 그대로다. 그건 다음 작업이다.
곁가지: e2e 로그에서 튀어나온 것
병합 전에 e2e를 돌리다 서버 로그에서 이런 걸 봤다.
+ className="mx-auto max-w-3xl space-y-6"
- className="mx-auto max-w-3xl"
+ <div className="flex flex-col gap-2">
- <p className="text-sm text-zinc-500">
at <unknown> (https://react.dev/link/hydration-mismatch)
at DailyPlanner (src/app/(dashboard)/daily/page.tsx:109:7)
일간 페이지에서 하이드레이션 불일치가 나고 있었다. 서버는 로딩 상태를, 클라이언트는 로드 완료 상태를 렌더해서 트리가 어긋난다. 내 브랜치 탓인가 싶어 main에서도 돌려봤더니 실행당 6건씩 똑같이 나왔다. 원래 있던 문제였다.
이게 성능 관점에서 중요한 이유가 있다. 하이드레이션이 실패하면 React는 서버가 보낸 HTML을 버리고 해당 서브트리를 클라이언트에서 통째로 다시 렌더한다. SSR로 얻은 이득이 사라지고 LCP가 나빠진다. 여러 페이지가 동시에 LCP 6초대로 나오는 이유를 찾고 있었는데, 유력한 후보가 엉뚱한 곳에서 나온 셈이다. 이번 범위는 아니라 이슈로만 남겼다.
교훈
두 줄로 남긴다.
useMemodeps에 객체를 넣으면 값이 아무리 안정적이어도 메모는 깨진다.Object.is는 참조를 본다. deps는 원시값으로 좁히고, 객체 생성은 메모 안으로 밀어 넣는 게 안전하다. "값이 안 바뀌니까 괜찮겠지"는 통하지 않는다.- 측정 도구가 무엇을 재는지 모르면 성공 기준을 틀리게 잡는다. 나는 상호작용 비용을 고쳐놓고 로드 구간 지표로 검증하려 했다. 지표가 안 움직인 게 다행이었다 — 움직였다면 엉뚱한 이유를 성공으로 기록했을 것이다. 고치기 전에 "이 수정이 개선하는 구간을 이 도구가 보고 있는가"를 먼저 물어야 했다.
덧붙임 — 이 글의 전제 두 개가 틀렸다
며칠 뒤 /jobs의 TBT 회귀를 진단하다가, 이 글을 쓰게 만든 전제부터 잘못됐다는 걸 알았다. 고쳐 쓰는 대신 여기 남긴다. 틀린 과정도 기록의 일부다.
① 애초에 코드 회귀가 아니었다.
"일주일치 작업을 한 뒤 측정했더니 89 → 73으로 떨어졌다"고 썼다. 그런데 그 사이 누군가 테스트 계정에 성능용 데이터를 대량으로 시딩했다. 태스크 9,006건, 채용공고 200건. 즉 두 측정은 데이터가 다른 계정을 잰 것이었고, 델타는 코드가 만든 게 아니었다.
회귀한 페이지가 정확히 데이터 의존 페이지(/history, /monthly, /jobs)였고, 데이터와 무관한 페이지(/daily, /goal, /quiz, /timer)는 유지되거나 오히려 좋아졌다. 다 찍혀 있던 단서였는데 못 읽었다.
원인 커밋을 찾겠다고 이분 탐색을 돌렸다면 영원히 아무것도 못 찾았을 것이다. 원인 커밋이 없으니까.
문제는 원장이 이걸 경고해주지 않았다는 데 있다. 두 측정을 나란히 놓고 델타를 🔴로 찍어줬지만, 데이터 볼륨을 기록하지 않았다. 도구가 조용히 거짓말을 한 셈이고, 그건 도구를 만든 내 책임이다. 지금은 측정마다 행 수를 같이 남긴다.
② "전 기간 무제한으로 받아온다"가 사실이 아니었다.
무제한이 아니었다. PostgREST 기본 max-rows가 1000이라 응답이 1,000건에서 조용히 잘리고 있었다. DB에 완료 기록이 6,287건인데 API는 1,000건만 돌려주고, 잘렸다는 신호는 어디에도 없었다.
그래서 이건 성능 문제이기 전에 정확성 버그였다. 화면의 "총 완료"는 3년 내내 1,000이었을 것이고, 2026년 1월 이전 달을 필터로 고르면 데이터가 5,287건 있는데도 "0건"이 나왔다. LCP가 6초에서 안 줄어든 것도 데이터가 많아서가 아니라 매번 1,000행을 받고 있었기 때문이다.
자세한 이야기는 총 완료가 언제나 1,000이었다에 따로 썼다.
③ 덤 — TBT "회귀"도 노이즈였다.
집계를 서버로 옮긴 뒤 TBT가 190 → 257ms로 올라 회귀를 의심했다. 그런데 같은 조건에서 다시 재보니 225ms였고, run 산포도 7285에서 8085로 좁아졌다. 257ms는 그 측정의 노이즈였다.
이 글의 두 번째 교훈("측정 도구가 무엇을 재는지 모르면 성공 기준을 틀리게 잡는다")에 한 줄 더 붙일 수 있겠다 — 한 번 잰 값은 값이 아니다. 산포를 먼저 보고, 애매하면 다시 재야 한다. 이 글에서 /monthly를 "산포가 커서 못 믿겠다"며 뺐던 그 판단을, 정작 내 수정 결과에는 적용하지 않았다.