성능 최적화로 사용자 경험 개선하기
말은 거창하지만, 성능 최적화라는 말은 늘 조심스럽다.
프론트엔드에서 신경 써야 할 부분인 건 맞지만, 자칫하면 오히려 욕심이 될 수도 있다.
"이걸 지금 꼭 해야 하나?" 싶은 생각이 드는 순간도 많고 “굳이, 지금?”이라는 생각이 들기도 한다.
왜냐면 다른 중요한 일들이 더 많기 때문이다. 특히 기능 구현 같은 (늘 끝나지 않는…)
성능 최적화, 꼭 지금 해야 하는 걸까?
토스 모닥불 영상에서 성능 최적화에 관한 얘기를 한 적이 있다. 건영님의 말을 빌리자면
"성능 최적화, 하면 좋을 것 같은데? 싶을 땐 하지 마라.
그래도 해야 될 것 같으면 한 번만 더 생각해라.
진짜 진짜 진짜 해야겠다 싶으면, 그때 프로파일링부터 하고 시작하자."
이 얘기에 완전히 공감했다. 성능 최적화는 물론 중요하다.
근데... 다른 기능이 안 되어 있다거나, 유저가 진짜로 불편해하는 부분이 따로 있는데도 "왠지 해야 할 것 같은 기분"에 휘둘리는 건 오히려 우선순위를 흐릴 수 있다고 본다.
정말 필요한 순간, 그때 하는 게 맞다고 생각한다.
진짜 해야겠다고 느낀 이유
그렇다면 나는 이 기준을 가지고 성능 최적화를 하게 됐는데, 왜 하게 됐을까?
진짜 진짜 진짜 해야겠다고 생각했기 때문이다.
Jung Archive 웹사이트를 만들면서, 유저 입장에서 딱 걸리는 부분이 있었다.
특정 페이지들이 불러올 때 너무 느리다는 거였다. 개발하는 나조차도 그게 진짜 체감될 정도였고.
주변 사람들 5명 정도한테 직접 QA를 받아봤는데, 다들 공통적으로 불편함을 느낄 정도로. “전체적으로 사이트가 느리다”라는 피드백을 받았다.
사용자가 불편함을 느낀다면(더 많은 지표가 있으면 좋겠다만 적은 인원으로도 체감될만큼) 이건 진짜로 성능 최적화를 고려해봐야 하는 상황 아닌가?
성능 병목이 느껴졌던 부분은 여러 가지가 있지만, 이번에는 페이지 렌더링 성능에 대해서만 얘기하려고 한다. (아직 부족한 게 많은 프로젝트고, 계속 개선중입니다)
대표적으로 이런 문제들이 있었다
- 블로그 페이지 초기 진입 시부터 사용자 입장에선 첫 화면이 보이기까지 꽤 기다려야 하는 느낌이었다.
- 블로그 페이지에서 카테고리 탭을 클릭했을 때, 약 2초 정도 로딩 후에야 콘텐츠가 나타났고
- 갤러리 페이지에서도 탭 전환 시 이미지 로딩이 지연되어 사용자 입장에서 버벅인다는 인상을 줄 수밖에 없었다.
결국 문제는 이거였다. 화면에 보여줄 데이터를 제때 준비하지 못해, 사용자가 체감하는 지연이 발생하고 있었다는 점이다.
이거 왜 느리지?의 진짜 이유
왜 처음부터 정적으로 만들지 않았느냐? 라는 의문이 생길수도 있다.
블로그 페이지는 자주 바뀌는 콘텐츠가 아니라서, 처음에는 당연히 정적 생성(SSG) 으로 만들어두는 게 맞았다. 그리고 실제로도 그렇게 구성했었다. 그런데 어느 순간 문제가 생기기 시작했다.
다국어 설정을 추가하면서, 모든 경로에 ko, en 같은 언어 접두사(prefix) 가 붙었고, 이게 곧 동적 라우트(Dynamic Route) 로 처리되면서 정적으로 캐시되지 않는 상태가 되어버렸다.
Next.js는 기본적으로 정적 라우트의 결과물(HTML과 React Server Components Payload)을 Full Route Cache에 저장해서, 이후 방문 시 빠르게 서빙한다.

하지만 동적 경로는 이 캐시에 포함되지 않게 되기 때문에, 결국 매번 요청 시마다 서버에서 페이지를 새로 렌더링해야 했다. 즉, 사실상 SSR처럼 작동하게 된 셈이다. 그래서 페이지 요청 시마다 서버에서 HTML을 다시 만들어야 했고, 그 결과 TTFB(Time To First Byte) 가 늘어나면서 사용자 입장에서 느리다는 체감을 하게 된 것이다.
사실 이런 문제는 자연스럽게 발생했다. 다국어 적용을 하느라(현재 세팅만 되어있습니다) 의도치 않게 기존의 정적 페이지들이 동적으로 바뀌었고, 덕분에 Full Route Cache를 활용할 수 없게 된 것이다
게다가 또 하나의 문제는 쿼리 파라미터 기반 라우팅이었다.
예를 들어 /blog?cat=dev, /gallery?tab=collections 같은 URL 구조는 Next.js 입장에서 보면 searchParams를 사용하는 동적 API이기 때문에, 마찬가지로 정적 캐시에서 제외된다. 이런 구조는 결국 SSR과 유사하게 요청 시마다 새로 렌더링이 일어나게 만들고, Full Route Cache는 또다시 무용지물이 되어버린다. 결과적으로 렌더링 성능에도 좋을 리가 없었다.
사실 동적 렌더링(Dynamic Rendering) 이 항상 나쁜 건 아니다. 요구사항에 따라, 페이지 요청 시점에 서버에서 HTML을 만들어야만 하는 경우도 분명히 있다. 예를 들어, 사용자마다 다른 데이터를 보여주면서도 SEO까지 챙겨야 하는 페이지라면, 정적 렌더링만으로는 한계가 있다.
그런데 내 블로그나 갤러리, Spot 페이지 같은 경우에는 얘기가 좀 다르다. 사용자와 상호작용하는 기능이라고 해봐야 좋아요나 댓글 정도고, 이런 건 클라이언트에서 비동기 처리하면 된다. 즉, 초기 페이지 요청 자체를 서버가 굳이 처리할 필요가 없다는 얘기다. 그럴 이유도 없고, 오히려 동적으로 만들어서 캐시도 못 쓰고 느려지기만 했다.
눈으로 마주하게 된 성능 지표: Lighthouse 분석
직접 눈으로 확인해보자. 그동안 애써 외면해왔던, 바로 내 웹사이트의 성적표를 받아보기로 했다.
Lighthouse를 돌려보니, 내가 얼마나 나쁜 사용자 경험을 제공하고 있었는지 고스란히 드러났다.
(모든 Lighthouse 측정은 배포된 페이지를 시크릿 탭 + 데스크탑 환경에서 진행함.
네트워크 상태나 디바이스 성능에 따라 점수는 약간씩 달라질 수 있다.
/blog 페이지 Lighthouse 측정

숫자만 보면 ‘그렇게까지 나쁘진 않네?’ 싶을 수도 있지만, 이건 Web Vitals에서 정의한 기준으로는 꽤나 느린 편에 해당한다.
특히 FCP랑 LCP는 사용자 경험의 체감 속도를 결정짓는 핵심 지표인데 각각 1.8초 / 2.5초를 넘기면 느려요라는 딱지가 붙는 셈이다.

지금 내 페이지는 둘 다 그 기준을 넘겼고, 사용자 입장에서 보면 페이지 들어왔는데 뭔가 안 뜨네…? 하고 찜찜함을 느끼기에 충분한 상황이었다.

내 Lighthouse 점수와 Web Vitals를 비교해보면 데스크탑 기준으로 엄청 느린걸로 파악된다.

1.8초(FCP), 2.5초(LCP) 이 기준들을 모두 넘어버리는 수치이기에, 결국은 Poor에 가까운 값들이었다.
사용자 경험을 얼마나 해치고 있었을지, 이제서야 실감이 났다.
FCP, LCP, Speed Index에 대해 더 자세히 알고 싶다면, 아래 글들을 참고하면 도움이 많이 된다.
https://web.dev/articles/fcp?hl=ko
https://web.dev/articles/lcp?hl=ko
https://developer.chrome.com/docs/lighthouse/performance/speed-index?hl=ko
/blog?cat=UK Lighthouse 측정

마찬가지로 성능측면에서 아주 좋지않은 지표를 보인다.
/blog?cat=life&q=노팅힐 검색결과가 포함된 동적페이지 측정

/blog?cat=life&q=노팅힐 처럼 카테고리와 검색어가 모두 포함된 동적 페이지는 렌더링 시점에 더 많은 연산과 데이터를 요구하기 때문에, 일반적인 정적 페이지나 단일 필터 페이지에 비해 성능 지표가 더 나쁘게 나올 수 있다.
/gallery 페이지도 마찬가지이다.

문제를 파악했으니, 이제 개선할 차례
문제는 파악했고, 정의까지 했으니까. 이제 진짜 중요한 개선단계다. 일단은 방향은 정해져 있었다.
Next.js의 캐싱 기능을 제대로 활용하자.
가능한 페이지는 정적으로 만들어서 미리 제공하고,
그 외 필요한 데이터는 클라이언트에서 비동기로 가져오자.
이걸 기준 삼아서, 페이지들을 이렇게 나눠보기로 했다:
- 어떤 페이지는 정적 프리렌더링(SSG) 으로 처리할 수 있을까?
- 어떤 데이터는 굳이 매번 요청해야 하니까, CSR로 남겨야 할까?
- 그리고, 원래 동적이던 페이지들을 어떻게 정적으로 바꿀 수 있을까?
그렇게 고민 하던 와중에, 우연히 카카오 테크 블로그에서 나랑 비슷한 문제를 겪고 해결한 사례를 발견했다.
실제로 대기업에서 적용한 패턴이었고, 직접 성능 지표를 측정해 결과를 수치로 보여주니 신뢰도도 높았다.
특히나 이렇게 사용자 지표가 부족한 사이드 프로젝트에서는 대기업 테크 블로그에서 공유한 실험 결과들이 꽤 유용한 참고가 된다. (결국 직접 수치로 증명된 사례만큼 믿을 만한 건 없으니까)
그래서 나는 다음과 같은 방향으로 개선 작업을 시작했다:
- 다국어 경로를 모두 사전에 생성해서, 블로그 페이지를 다시 정적 페이지(SSG) 로 바꿨다.
- 블로그 카테고리 페이지도 기존에는
blog?cat=dev처럼 쿼리 파라미터 기반이었지만, 이를blog/cat/dev형태의 정적 경로로 전환했다./spot페이지도 같은 방식으로 처리. - 갤러리 탭 전환도 마찬가지.
gallery?tab=collections같은 구조를gallery/collection같은 정적 경로로 변경해서 모든 탭을 미리 프리렌더링해두는 방식으로 바꿨다.
참고로 이 방식은 Instagram도 쓰고 있는 구조다. 탭 간 전환 시 URL이 아래처럼 명확하게 나뉘어 있다
(닉네임)/reels , (닉네임)/saved, (닉네임)/tagged

이렇게 경로를 정적으로 구성하면 Next.js의 Full Route Cache를 제대로 활용할 수 있게 된다.
왜냐하면 모든 페이지가 빌드 타임에 미리 생성되기 때문에, 사용자가 페이지를 요청했을 때 별다른 처리 없이 즉시 HTML을 전달할 수 있기 때문이다. 이건 곧 LCP, FCP 같은 성능 지표 개선으로 이어진다.
실제로 정적 페이지로 전환한 이후, 해당 지표들이 눈에 띄게 좋아졌다. 특히 첫 화면에서 보여지는 콘텐츠가 빠르게 렌더링되니, 사용자 입장에서는 체감 속도가 확 달라졌다.
다국어 경로도 마찬가지다.
다음과 같이 generateStaticParams()를 활용해서 ko, en 언어 경로를 사전에 생성해두었다
export async function generateStaticParams() {
return SUPPORTED_LANGS.map((lang) => ({ lang }));
}현재는 en, ko 두 가지 언어만 제공 중이다. 이 구조를 통해, 정적으로 생성 가능한 모든 페이지를 빌드 타임에 미리 준비해두고 페이지 요청 시 서버의 렌더링 비용 없이 바로 캐시된 HTML을 응답할 수 있게 되었다.
그리고 기존에 blog?cat=[카테고리이름]으로 searchParams를 활용하여 제공되던 구조
blog/categoreis/[categoryName]으로 정적구조로 변경한다.

마찬가지로 generateStaticParams를 활용한다.
수치로 확인한 변화

빌드 결과를 보면, 각 언어별 경로(/en, /ko)와 각 페이지들이 정적 경로로 잘 생성되어 있는 걸 확인할 수 있다. blog, gallery, spots 같은 페이지들도 언어별로 프리렌더링되어 있고, 특히 gallery/collections/[id], spots/[id] 같은 상세 페이지도 정적 페이지로 포함된 걸 볼 수 있다.
이렇게 빌드 타임에 미리 생성해두면, 페이지 요청 시 서버가 렌더링을 수행할 필요가 없고, 그 결과로 Full Route Cache를 제대로 활용할 수 있게 된다.
이렇게 적용하고나서 /blog 페이지를 다시 측정해보았다.

/blog/categories/frontend 블로그 카테고리 페이지 측정

여전히 레이아웃 시프트가 있긴하지만 FCP, LCP 가 엄청나게 개선된 것을 확인할 수가 있다.
/gallery 페이지도 마찬가지다.

/gallery/photo/[id] 상세 페이지도 마찬가지다

전체적으로 FCP, LCP 지표가 확실히 개선됐고, Speed Index도 눈에 띄게 낮아졌다.
덕분에 사용자 입장에서는 페이지가 더 빠르게, 부드럽게 렌더링되는 걸 체감할 수 있게 됐다.
실제로 주변에서의(유저들?) 반응도 사용자경험이 엄청 개선되었다는 평을 받았다.
Static 하게 만들면 다 해결일까?
여기서 한 가지 의문이 생긴다.
“그럼 그냥 모든 페이지를 정적으로 만들어서 빌드 타임에 미리 다 뽑아놓으면 끝 아닌가?”
그리고 Next.js의 Full Route Cache까지 활용하면 완벽한 거 아니냐는 생각을 할수가 있다.
하지만 현실은 그렇게 단순하지 않다. 장단점이 분명하다.
변경이 거의 없거나, 사용자와의 인터랙션이 거의 없는 페이지라면 SSG(Static Site Generation) 으로 미리 만들어두는 게 당연히 좋다. CDN에서 즉시 서빙되니까, 속도도 빠르고 비용도 적다.
하지만 반대로 데이터가 자주 바뀌거나, 사용자에 따라 달라지는 페이지라면 정적으로 만드는 게 오히려 독이 될 수도 있다.
게다가 Next.js가 App Router로 바뀌면서, 예전처럼 getStaticProps로 페이지 단에서 렌더링 방식을 결정하던 구조에서, 이제는 React Server Component 기반으로 컴포넌트 단에서 설정을 분기할 수 있게 되었다.
https://vercel.com/blog/understanding-react-server-components


그래서 나 같은 경우에는, 블로그, 갤러리, 스팟처럼 콘텐츠가 거의 바뀌지 않는 정적 페이지는 정적 생성(SSG) 으로 처리하고 있다.
반면, 페이지 내부에서 사용자 인터랙션이 필요한 부분
예를 들어 좋아요나 댓글 기능 같은 건 클라이언트에서 비동기로 페칭하는 방식을 사용한다.
그리고 콘텐츠는 고정되어 있지만, 일정 주기로 업데이트가 필요한 페이지라면 ISR(Incremental Static Regeneration) 전략을 적용하면 좋다.
예를 들어, 게스트북 페이지처럼
- 실시간성이 있고
- 사용자별로 달라질 수 있으며
- SEO까지 고려해야 하는 경우라면, SSR(Server-Side Rendering) 이 적합할 수 있다.
데이터가 많아지면, 정적으로만 만드는 건 한계가 있다
지금은 갤러리 상세페이지, 블로그 상세페이지 전부를 사전에 정적으로 생성해서 제공하고 있다.
근데 만약 블로그나 갤러리 데이터가 진짜 1000개, 2000개씩 쌓이게 된다면?
그리고 다국어도 지원하고 있어서 ko, en 이렇게 언어별로도 페이지를 만들어야 한다면?
단순 계산만 해봐도 2,000개 * 2 언어 = 4,000개 페이지가 된다.
빌드 타임에 이 모든 걸 생성하려면 시간이 엄청 오래 걸릴 수밖에 없다.
그건 우리가 원하는 방향은 아니다.
지금은 아직 데이터가 적지만, 앞으로 글을 더 많이 쓰고, 더 많은 콘텐츠를 작성하게 된다면, 빌드 타임 이슈는 분명히 문제가 될 수 있다. 그래서 그땐, 좋아요 수, 조회수 같은 지표를 기준으로 인기 있는 콘텐츠만 사전에 정적으로 생성하고, 나머지는 ISR이나 SSR을 적절히 섞는 전략이 필요할 수 있다.
“이 페이지는 언제, 어떤 데이터로, 어떻게 보여줘야 하는가?” 이걸 기준으로 SSR, SSG, ISR, CSR 중에서 그때그때 맞는 걸 골라서 최적화를 할 수 있을 것 같다. 렌더링 전략은 정답이 정해져 있는 게 아니다. 본인의 프로젝트 요구사항에 맞게 적용하는 게 중요하다.
이번 작업을 하며 느낀 것들
이번 성능 최적화 작업은 정말 "이제는 해야겠다" 싶은 시점에 하게 된 거라, 그만큼 사용자 경험을 개선할 수 있었던 게 꽤 뿌듯했다. 사실 원리 자체는 복잡하거나 어려운 건 아니었지만, 좋은 지표와 자료를 근거 삼아 진짜 필요한 타이밍에, 제대로 방향 잡고 최적화를 한 게 핵심이었다.
그리고 성능 최적화만 한 줄 알았는데, 되려 이번 작업 덕분에 렌더링 전략에 대해 더 깊이 고민해보는 계기가 됐다. “어떤 페이지는 어떤 방식으로 보여줘야 하는가?” 그 기준을 세우는 게 생각보다 중요하다는 걸 다시 느꼈다.
성능 최적화라는 칼을 뽑았으면 끝까지 간다. 아직 성능 점수는 100점이 아니고, CLS나 렌더링 리소스 차단 같은 문제도 남아있다. 그건 또 다음 글에서 다뤄보려고 한다.
다음 편도 기대해주시길 🙇
