로딩 화면을 한 프레임 더 보여줬더니 LCP가 빨라졌다
경험을 토대로 AI를 활용하여 작성한 글 입니다.
취업 준비 플래너 앱의 이슈 목록에 priority: high 딱지가 붙은 채 일주일 묵어 있던 항목이 있었다. "/daily 하이드레이션 불일치 — 서버는 로딩, 클라이언트는 완료 상태를 렌더". 화면은 멀쩡히 동작하고 e2e도 전부 통과하는데 콘솔에만 경고가 찍히는 종류의 문제라 계속 밀렸다.
이슈에는 착수 조건이 붙어 있었다. "현재도 재현되는지 먼저 확인. 관측 기록이 열흘 전이라 이후 변경으로 달라졌을 수 있음." 그래서 고치기 전에 세는 것부터 했다.
세어보니 늘어 있었다
e2e를 돌리고 로그에서 경고를 추렸다.
pnpm test:e2e 2>&1 | tee /tmp/e2e.log
grep -c "Hydration failed" /tmp/e2e.log
# 19
grep -oE "at [A-Za-z]+ \(src/[^)]+\)" /tmp/e2e.log | sort | uniq -c | sort -rn
# 18 at DailyPlannerPage (src/app/(dashboard)
# 18 at DailyPlanner (src/app/(dashboard)
# 1 at Header (src/components/layout/Header.tsx:103:13)이슈에 적힌 건 6건이었는데 18건이었다. 두 번째 명령이 중요한데, 총 건수만 세면 원인이 하나인 줄 알게 된다. 컴포넌트별로 쪼개보니 /daily 18건과 헤더 1건은 애초에 다른 문제였다.
불일치 지점도 로그에 그대로 찍혀 있었다.
- <p className="text-sm text-zinc-500"> (서버가 보낸 것)
+ <div className="flex flex-col gap-2"> (클라이언트가 그린 것)
서버는 로딩 문구를, 클라이언트는 플래너 본문을 그렸다.
값이 아니라 타이밍이 갈렸다
하이드레이션 불일치라고 하면 보통 localStorage나 Date.now() 같은 "서버가 알 수 없는 값"을 떠올린다. 그런데 이건 그 유형이 아니었다.
대시보드 레이아웃은 프로필을 서버에서 미리 조회해 클라이언트 캐시에 심는다. 일간 페이지의 "유효 오늘"(하루 시작 시각을 반영한 오늘) 게이트가 클라이언트 fetch를 기다리지 않게 하려는 것이다. 그런데 await을 하지 않는다.
// await 하지 않는다 — pending 쿼리째로 dehydrate 되어 스트리밍으로 전달되므로
// 셸 렌더링을 막지 않는다
void queryClient.prefetchQuery({ queryKey: profileKeys.all, queryFn: … })pending 상태의 쿼리를 스트리밍으로 넘기려고 dehydrate 조건도 열어뒀다.
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',여기까지는 의도한 설계다. 문제는 그 다음이다.
서버 렌더 시점에 이 쿼리는 항상 pending이다. await을 안 했으니 당연하다. 그래서 ready가 false고, 페이지는 이 분기를 탄다.
const { ready, … } = useEffectiveToday()
if (!ready) return <p>불러오는 중…</p>서버는 무조건 로딩을 그린다. 반면 클라이언트에서는 스트리밍된 프로필이 하이드레이션이 끝나기 전에 도착할 수 있다. 그러면 첫 렌더에서 이미 ready === true라 본문을 그린다. 서버가 빠르고 JS 번들 파싱이 느릴수록 잘 걸린다.
값은 서버도 알고 클라이언트도 안다. 갈린 건 도착 시점이었다.
왜 성능 문제이기도 한가
하이드레이션이 실패하면 React는 서버 HTML을 버리고 그 서브트리를 클라이언트에서 통째로 다시 그린다. SSR로 얻은 게 그대로 사라진다. /daily는 이 앱에서 가장 자주 여는 페이지고 LCP가 이미 좋지 않은 편이었으니, 여기가 먼저 볼 자리였다.
고치는 방향이 둘 있었다
하나. 레이아웃에서 프로필을 await한다. 서버도 데이터를 갖고 렌더하니 불일치가 사라진다. 다만 셸 렌더가 프로필 응답만큼 지연된다.
둘. 첫 렌더를 서버와 같게 고정하고, 본문 전환을 마운트 이후로 미룬다.
await을 뺀 이유가 정확히 그 지연이었다는 게 코드 주석에 남아 있었다. 없앤 이유가 적혀 있는 걸 되돌리려면 그때보다 나은 근거가 있어야 하는데, 여기서는 없었다. 불일치는 첫 렌더 한 번의 문제지 데이터 흐름의 문제가 아니다. 두 번째를 택했다.
const hydrated = useHydrated()
const { ready, … } = useEffectiveToday()
if (!hydrated || !ready) return <p>불러오는 중…</p>useHydrated는 이렇게 만들었다.
const subscribe = () => () => {}
export function useHydrated() {
return useSyncExternalStore(
subscribe,
() => true, // 클라이언트 스냅샷
() => false, // 서버 스냅샷
)
}useState(false) + useEffect(() => setHydrated(true), [])로도 결과는 같다. 실제로 이 앱의 타이머 페이지가 그 방식을 쓰고 있고, 거기엔 react-hooks/set-state-in-effect 예외 주석이 붙어 있다. useSyncExternalStore는 애초에 서버 스냅샷과 클라이언트 스냅샷을 구분해서 넘기라고 만들어진 API라, 예외 주석 없이 의도가 그대로 드러난다.
검증에서 걸린 것: SI를 믿을 수 있나
수정 전후를 각각 5회 median으로 쟀다. 같은 데이터 볼륨(태스크 9,151건)이다.
| 하이드레이션 실패 | Perf | LCP | TBT | SI | |
|---|---|---|---|---|---|
| 수정 전 | 18건 | 92 | 2.69s | 114ms | 3.66s |
| 수정 후 | 0건 | 95 | 2.39s | 89ms | 1.52s |
로딩 화면을 한 프레임 더 보여주는 변경인데 LCP가 0.3초 빨라졌다. 처음엔 잘못 잰 줄 알았는데, 생각해보면 당연하다. 서브트리를 통째로 다시 그리는 비용이 로딩 프레임 하나보다 훨씬 크다.
다만 SI가 3.66s에서 1.52s로 절반 넘게 줄어든 건 그대로 믿지 않았다. 원장을 열어보니 수정 전 측정 자체가 직전 기록 대비 SI만 🔴+1.39s로 튀어 있었다. 이 지표는 이 페이지에서 run마다 산포가 크다는 뜻이다. 실제로 run별 점수도 수정 전이 92 91 93 84 92로 한 번 크게 빠졌다.
그래서 근거로 쓸 수 있는 건 LCP와 TBT까지다. 둘은 방향과 크기가 메커니즘 설명과 맞아떨어진다. SI는 "같이 좋아졌다" 정도로만 적고 원장에 남겼다. 한 번 잰 값은 값이 아니라 산포 중 하나라는 걸 이 프로젝트에서 이미 한 번 크게 데었다.
남은 1건은 다른 문제였다
처음에 컴포넌트별로 쪼개둔 게 여기서 값을 했다. 헤더 1건은 이렇게 갈렸다.
- 기본
+ 에메랄드
at Header (src/components/layout/Header.tsx:103)
테마 라벨이다. 원인을 보니 이쪽은 전형적인 유형이었다.
const [themeId, setThemeId] = useState<ThemeId>(() => {
if (typeof window === 'undefined') return DEFAULT_THEME_ID
return localStorage.getItem('theme') ?? DEFAULT_THEME_ID
})서버는 기본 테마, 클라이언트는 저장된 테마. 첫 렌더부터 값이 다르다. 게다가 CSS 변수를 useEffect에서 주입하고 있어서 첫 페인트는 항상 기본 테마 색으로 그려진다. 라벨 불일치와 색 깜빡임이 같은 뿌리다.
공교롭게도 같은 파일에 이미 올바른 처리가 있었다. 헤더의 현재 시각은 useState<Date | null>(null)로 시작해서 마운트 후 채우는 식으로 불일치를 피하고 있다. 시각은 막았는데 테마는 안 막혀 있던 것이다.
이건 useHydrated로 라벨만 가릴 수도 있지만, 그러면 색 깜빡임은 그대로 남는다. 제대로 없애려면 테마를 쿠키로 옮겨 서버가 값을 알게 해야 한다. 범위가 다르고 트레이드오프(요청마다 전송, SSR 캐시 분기)도 따로 따져야 해서 별도 이슈로 뺐다. 한 PR에서 원인이 다른 두 문제를 같이 고치면 어느 쪽이 무엇을 개선했는지 측정이 섞인다.
정리하며
이번에 배운 걸 문서로 남기면서 이 저장소에 있던 하이드레이션 불일치를 네 유형으로 나눠봤다.
| 상황 | 방법 |
|---|---|
| 비동기 데이터의 도착 타이밍이 갈린다 | 첫 렌더를 서버와 같게 고정 |
| 클라이언트 전용 값이고 "없음"을 렌더할 수 있다 | null로 시작하고 마운트 후 채움 |
| 서버도 값을 알아야 한다 (색 깜빡임 동반) | 쿠키로 옮겨 서버 렌더에 반영 |
| 무작위지만 양쪽이 같아야 한다 | 시드 고정 |
suppressHydrationWarning은 목록에 넣지 않았다. 경고만 지울 뿐 재렌더 비용은 그대로 남는다. 이번 건이 정확히 그 비용이 지표로 드러난 사례였다.
돌아보면 제일 도움이 된 건 고치는 기술이 아니라 세는 것부터 한 것이었다. 건수를 세지 않았다면 이슈에 적힌 6건을 그대로 믿었을 테고, 컴포넌트별로 쪼개지 않았다면 헤더 문제를 같은 원인으로 묶어 한 번에 고치려다 둘 다 어중간해졌을 것이다. 고치기 전에 재는 습관은 회귀를 감시하기 위한 것이라고만 생각했는데, 이번엔 문제의 경계를 긋는 데 먼저 쓰였다.