김지효

Lighthouse 100점을 향한 삽질기 — Cache Components와 정적 렌더링으로 블로그 성능 뜯어고치기

BlogWeb

89점이라는 현실

260213-blog-lighthouse-89.png

Lighthouse Performance 89점.

나쁘지 않다. 솔직히 말하면 꽤 괜찮은 점수다. 근데 문제는 이게 내 블로그라는 거다. 남의 사이트였으면 "오 괜찮네~" 하고 넘어갔을 텐데, 내가 직접 만든 블로그가 89점이라고 하면 이상하게 11점이 거슬린다.

범인은 금방 찾았다. LCP가 2.1초. 텍스트 요소 하나가 화면에 뜨는 데 2.1초가 걸리고 있었다.

이유는 명확했다. 이번에 PPR(지금은 Cache Components라고 부른다)을 도입하면서, 정적 페이지 위에 동적 RSC를 얹는 구조로 바꿨는데, 이 동적 RSC를 다운로드받는 시간이 고스란히 LCP에 반영된 것이다.

여기에 Vercel 서버리스 환경의 콜드 스타트까지 겹치면? 오랜만에 누군가 내 블로그에 들어왔을 때 LCP가 더 늘어날 수도 있는 상황이었다. 반가운 손님한테 현관문을 늦게 열어주는 꼴이랄까.

CLS도 0.01 정도 잡혔다. 미미한 수치지만 이것도 0이 아니면 찝찝하다.

이 점수를 정적 렌더링과 Cache Components 기능을 활용하여 개선하고자 한다.

Cache Components

본론에 들어가기 전에, 이번 글의 핵심인 Cache Components에 대해 짚고 넘어가자.

Next.js 16 이전에는 Partial Pre-Rendering(PPR)이라는 이름의 실험적 기능이었는데, 이제 정식 버전에 포함됐다. (여전히 opt-in이긴 하다.)

이걸 이해하려면 Next.js의 두 가지 렌더링 전략을 먼저 알아야 한다.

Static Rendering

빌드 시점에 라우트를 렌더링한다. HTML과 RSC가 만들어져서 Full Route Cache라는 서버 캐시에 저장된다. 데이터 재검증 요청이 오면 백그라운드에서 다시 렌더링하고 캐시를 갱신하는 식이다. 빠르고, 예측 가능하고, CDN 친화적이다.

Dynamic Rendering

요청 시점에 라우트를 렌더링한다. cookies, headers, searchParams 같은 요청마다 달라지는 정보를 쓰는 순간 해당 라우트는 동적으로 처리된다. 당연히 Full Route Cache에 캐시되지 않는다.

문제는 이게 이분법이라는 점이다. 페이지 하나에 동적인 부분이 조금이라도 있으면 전체가 동적 렌더링으로 빠진다. 99%가 정적이어도 1%의 동적 요소 때문에 전부 요청 시점에 렌더링해야 하는 거다. 좀 억울하지 않나?

Cache Components가 해결하는 것

Cache Components는 이 억울함을 해소해준다. 정적인 부분은 빌드 시점에 미리 만들어두고, 동적인 부분만 요청 시점에 채워넣을 수 있게 해준다.

방법은 두 가지다.

React의 <Suspense>로 컴포넌트를 감싸면 해당 영역은 빌드 때 fallback UI만 남겨두고, 사용자 요청이 오면 RSC 스트리밍으로 실제 데이터를 채운다. use cache 지시어를 쓰면 결과를 캐싱해서 정적 셸에 포함시킬 수 있다.

그리고 라우트를 이동할 때 React 19의 <Activity /> 컴포넌트를 활용해서, 이전 라우트를 언마운트하지 않고 hidden 모드로 전환한다. 컴포넌트는 리액트 트리에 남아있고, state는 유지되고, effect만 재실행된다. 뒤로 가기 했을 때 페이지가 처음부터 다시 로딩되는 게 아니라 "어, 아까 그 상태 그대로네?" 하는 경험을 줄 수 있는 것이다.

최근 댓글 영역에 use cache 적용하기

이전 글에서 포스트 페이지(정적)에 댓글 컴포넌트(동적)를 PPR로 얹는 이야기를 했었다.

이번에는 홈페이지의 "최근 댓글" 영역에 use cache를 적용해서 5분 주기 캐시로 정적 셸에 포함시키기로 했다.

내 블로그 특성상 댓글이 분 단위로 쏟아지는 곳이 아니다. (그랬으면 좋겠지만.) 그래서 5분 캐시는 충분히 합리적인 선택이었다.

이걸 적용하면서 해결되는 문제가 꽤 많았다.

첫째, 불필요한 스켈레톤 로딩이 사라진다. 댓글 데이터를 Neon DB 무료 플랜에 저장하고 있는데, 이 녀석이 콜드 스타트 되면 꽤 느리다. 그때마다 사용자는 스켈레톤 UI를 한참 쳐다보게 된다. 캐시된 결과를 바로 보여주면 이 대기 시간이 사라진다.

둘째, CLS를 0으로 만들 수 있다. 스켈레톤 UI가 실제 콘텐츠로 교체될 때 아무리 잘 만들어도 레이아웃이 미세하게 밀린다. 스켈레톤의 높이를 정확히 맞추는 건 사실상 불가능하니까. 하지만 캐시된 결과를 처음부터 보여주면? 교체 자체가 없으니 CLS도 0이다.

셋째, 시각적 flashing이 없어진다. 이게 은근히 거슬리는 부분인데, 로딩이 아무리 빨라도 스켈레톤 → 실제 콘텐츠로 전환되는 그 찰나의 "반짝임"이 있다. 눈이 예민한 사람은 이게 은근 신경 쓰인다. 캐시를 쓰면 이 반짝임 자체가 사라진다.

260212-blog-comment-list-flashing.gif

개선 전, 오른쪽의 최신 댓글 목록이 로딩되면서 깜빡 거리는 현상

홈페이지를 완전히 정적으로 만들기

자, 최근 댓글은 use cache로 해결했다. 근데 더 근본적인 문제가 있었다.

홈페이지에서 포스트 목록을 페이지네이션으로 보여주고 있었다. ?page=2 이런 식으로. 태그 필터링도 있었다. ?tag=pnpm 이런 식으로.

위에서 설명했듯이, Next.js에서 searchParams를 쓰는 순간 해당 라우트는 동적이 된다. 페이지네이션이랑 태그 필터링, 이 두 기능 때문에 홈페이지 전체가 동적 렌더링으로 빠져있었던 거다.

그러니까 사용자가 내 블로그 홈에 접속하면, 서버가 RSC를 실시간으로 만들어서 내려주고, 그동안 사용자는 로딩 화면을 본다. 여기에 Vercel 콜드 스타트까지 걸리면?

260212-blog-home-dynamic-rendering.gif

누군가 나에 대해 궁금해서 블로그에 들어왔는데, 포스팅 목록 하나 보겠다고 몇 초간 빈 화면을 쳐다보고 있어야 한다. 생각만 해도 끔찍하다. 첫인상이 로딩 스피너라니.

질문 던지기

잠깐. 페이지네이션이 정말 필요한가?

글이 지금 16개쯤 된다. 페이지네이션이 필요한 양이 아니다. 솔직히 인정하자. 다만 한 페이지에 모든 글을 다 늘어놓으면 리스트가 너무 길어질까 봐 넣은 거였다.

태그 필터링은? 편의 기능이긴 한데, 이것 때문에 홈페이지 전체가 동적이 되는 건 배보다 배꼽이 크다.

해결 전략

결국 이런 방향으로 정리했다.

홈페이지에서는 최신 글 몇 개만 정적으로 보여준다. 페이지네이션 자리에 "모든 게시글 보기" 버튼을 둔다. 이 버튼을 누르면 별도의 페이지가 열리고, 거기서 페이지네이션과 태그 필터링을 마음껏 쓸 수 있다.

태그 목록도 홈페이지에 그대로 둔다. 다만 클릭하면 홈에서 필터링되는 게 아니라, "모든 글 보기" 페이지가 열리면서 해당 태그로 필터링된 결과를 보여준다.

이렇게 하면 홈페이지는 완전히 정적이 된다. CDN에 캐시되고, 콜드 스타트 걱정 없이, 전 세계 어디서 접속하든 빠르게 응답한다.

그리고 별도 페이지의 페이지네이션? 어차피 페이지가 2~3개밖에 안 되니까 모두 빌드 시점에 정적 렌더링 시켜놨다. 태그별 필터링 결과도 마찬가지다. 빌드 사이즈가 좀 커지겠지만, 이 정도 글 수로는 큰 부담이 아닐 거라 판단했다.

그렇게 Claude Code에게 바이브코딩을 시켰다.

개선 결과

260212-blog-static-rendering.gif

LCP 문제도, CLS 문제도, 콜드 스타트로 인한 로딩 지연도 전부 해결됐다.

260212-blog-lighthouse-100.png

Lighthouse Performance 100점.

대신 빌드 사이즈는 커졌다

얼마나 커졌는지 궁금해서 프로젝트의 .next 디렉토리에서 정적 렌더링 결과물의 크기를 직접 재봤다.

echo "RSC total:"
find .next -type f -name "*.rsc" -print0 \
  | xargs -0 du -ch 2>/dev/null \
  | grep total
 
echo "HTML total:"
find .next -type f -name "*.html" -print0 \
  | xargs -0 du -ch 2>/dev/null \
  | grep total
# 개선 전
RSC total: 1.8M
HTML total: 636K
 
# 개선 후
RSC total: 3.7M
HTML total: 2.0M

빌드 사이즈가 2배 넘게 늘었다. 모든 페이지네이션 조합과 태그 필터링 결과를 정적으로 만들어놨으니 당연한 결과다.

마무리하며

항상 최적화에는 trade-off가 따른다.

이번에는 빌드 사이즈를 내주고 사용자 경험을 가져왔다. 페이지네이션의 편의성을 홈페이지에서 빼는 대신, 압도적인 초기 로딩 속도를 얻었다.

89점에서 100점. 숫자로 보면 11점 차이지만, 사용자가 체감하는 건 "로딩이 있는 블로그"와 "로딩이 없는 블로그"의 차이다. 이 11점이 꽤 비싼 11점이었다.