seojuny.dev

공포 영화보다 무서운 웹 성능

26분
  • #Web Performance
  • #Core Web Vitals
  • #React
  • #Next.js

2016년 Ericsson ConsumerLab 연구에서는 스마트폰 사용자 30명이 웹페이지와 영상을 기다리는 동안 나타나는 심박수, 뇌 활동, 시선 움직임의 변화를 측정했다.

실험에서 모바일 지연으로 인한 스트레스 반응은 공포 영화를 보거나 수학 문제를 풀 때와 비슷했고, 매장 계산대에서 기다릴 때보다도 크게 나타났다.

참여자가 30명인 소규모 연구이므로 결과를 모든 상황에 그대로 적용할 수는 없다. 다만 짧은 지연도 사용자에게는 단순한 기다림 이상의 스트레스로 느껴질 수 있음을 보여 준다.

웹 서비스에서 페이지 로딩이 느리면 사용자는 내용을 확인하기도 전에 떠날 수 있다. 버튼의 반응이 늦으면 입력이 전달되지 않았다고 생각해 다시 누르고, 같은 요청이 중복으로 처리될 수 있다. 화면이 갑자기 변하면 엉뚱한 곳을 누르기도 한다.

이런 불편함이 반복되면 사용자는 원하는 일을 끝내기 어려워진다. 결국 이탈률이나 전환율 같은 비즈니스 지표에도 영향을 줄 수 있다.

실제 사례도 있다. Vodafone은 LCP가 31% 개선된 페이지에서 매출이 8% 높았다고 밝혔다. redBus는 INP를 개선한 뒤 판매가 약 7% 증가했다고 발표했다. 모든 서비스에서 같은 결과가 나온다는 뜻은 아니지만, 성능 개선이 사용자 경험을 넘어 실제 비즈니스 성과로 이어질 수 있다는 점은 보여 준다.

그렇다면 프론트엔드 개발자는 성능을 어떻게 측정하고 개선해야 할까? 흔히 사용하는 Lighthouse 점수만 높이면 되는 걸까?

이번 글에서는 일부러 성능이 좋지 않은 상품 페이지를 만들었다. 브라우저에서 직접 측정하고, 느려지는 원인을 찾아 하나씩 개선해 본다.

성능 지표

웹 성능은 여러 측면을 나눠서 봐야 한다. 화면이 빨리 보이는 것과 버튼이 바로 반응하는 것은 다른 문제다. 콘텐츠가 나타난 뒤 화면이 안정적으로 유지되는지도 따로 확인해야 한다. Core Web Vitals는 이러한 경험을 세 가지 지표로 나눈다.

지표묻는 질문좋음
LCP중요한 콘텐츠가 언제 보였나?2.5초 이하
INP상호작용에 얼마나 빨리 반응했나?200ms 이하
CLS화면이 얼마나 안정적이었나?0.1 이하

LCP (Largest Contentful Paint)는 화면 안에서 가장 큰 이미지나 텍스트가 표시되기까지 걸린 시간이다. 상품 페이지에서는 대표 이미지나 상품명이 LCP 대상이 될 수 있다. 사용자는 주요 콘텐츠가 보여야 페이지가 열렸다고 느끼므로 LCP는 첫 화면의 로딩 경험을 판단하는 데 유용하다.

INP (Interaction to Next Paint)는 클릭이나 탭, 키보드 입력이 시작된 뒤 브라우저가 다음 화면을 그릴 수 있을 때까지 걸린 시간을 바탕으로 페이지의 전반적인 반응성을 평가한다. 페이지가 빨리 보여도 JavaScript가 메인 스레드를 오래 차지하면 버튼은 늦게 반응한다. INP는 첫 화면이 나온 뒤에도 페이지가 빠르게 반응하는지 보여 준다.

CLS (Cumulative Layout Shift)는 예상하지 못한 화면 이동을 나타낸다. 이미지가 늦게 나타나 버튼을 밀어내면 사용자는 읽던 위치를 잃거나 엉뚱한 곳을 누를 수 있다. LCP와 INP는 시간을 재지만 CLS는 이동한 영역과 거리를 계산하기 때문에 단위가 없다.

Lighthouse는 정해진 기기와 네트워크 조건에서 페이지를 실행하므로 변경 전후를 비교하기 좋다. 하지만 점수만으로 원인을 바로 파악하기는 어렵다. LCP가 늦다면 Network 패널에서 이미지의 발견 시점과 전송량을 확인해야 한다. CLS는 Performance 패널에서 실제로 움직인 요소를 추적할 수 있다. Lighthouse는 실제 사용자 입력이 없기 때문에 INP를 직접 측정하지 못한다. INP는 DevTools에서 직접 상호작용하거나 운영 환경의 사용자 데이터로 확인해야 한다.

이런 실험은 조건을 통제할 수 있다는 장점이 있지만, 한 번의 측정만으로 실제 사용자의 경험을 모두 설명하지는 못한다. 운영 후에는 다양한 기기와 네트워크에서 수집한 실제 사용자 성능 데이터를 함께 봐야 한다. 그래야 개선 효과가 일부 환경에만 나타난 것인지, 대다수 사용자에게도 이어졌는지 판단할 수 있다.

렌더링 과정

앞에서 살펴본 지표가 느린 경험을 숫자로 보여 준다면, 렌더링 과정은 그 원인을 찾을 단서가 된다. 이를 이해하려면 개발자가 코드를 배포하는 과정과 브라우저가 페이지를 그리는 과정을 나누어 봐야 한다.

개발자가 코드를 배포하는 과정

소스 코드
→ 빌드와 번들링
→ 배포와 캐시
→ 서버 또는 CDN

빌드 도구는 TypeScript와 JSX를 브라우저가 실행할 수 있는 JavaScript와 CSS로 바꾸고, 여러 모듈을 번들로 묶는다. 번들이 크면 다운로드와 실행에 오래 걸린다. 반대로 너무 잘게 나누면 요청 수가 늘 수 있다.

배포 후에는 서버나 CDN이 이 파일들을 전달한다. 캐시가 있으면 재방문 비용을 줄일 수 있지만, 첫 방문에는 저장된 파일이 없다. 그래서 처음 내려받는 파일의 크기와 요청 순서가 중요하다.

사용자가 페이지를 여는 과정

URL 입력
→ 도메인의 서버 주소 확인
→ 서버에 연결하고 HTML 요청
→ HTML 응답
→ HTML을 읽으며 CSS, JavaScript와 에셋 발견
→ 필요한 파일 요청
→ DOM과 CSSOM 생성
→ 레이아웃과 화면 그리기
→ JavaScript 실행과 하이드레이션
→ 사용자 상호작용

브라우저는 HTML을 받는 즉시 읽기 시작한다. HTML로 DOM을 만들고 CSS로 CSSOM을 만든다. 그다음 두 정보를 바탕으로 요소의 크기와 위치를 계산해 화면에 그린다.

렌더링을 막는 CSS와 JavaScript

브라우저는 CSS를 모두 읽기 전에는 어떤 스타일이 최종 결과인지 확신할 수 없다. 뒤에 나온 규칙이 앞의 스타일을 덮어쓸 수 있기 때문이다. 그래서 CSSOM이 준비될 때까지 첫 화면 그리기를 기다린다.

asyncdefer가 없는 <script>는 HTML 파싱도 멈출 수 있다. 스크립트가 아직 읽지 않은 DOM을 바꿀 수 있으므로, 브라우저는 다운로드와 실행이 끝날 때까지 기다린다.

일반 CSS    → HTML 파싱은 계속할 수 있지만 첫 화면 그리기는 기다린다
일반 script → HTML 파싱을 멈추고 다운로드와 실행을 기다린다

defer를 사용하면 HTML을 읽는 동안 스크립트를 내려받고, 파싱이 끝난 뒤 실행한다. async는 다운로드가 끝나는 즉시 실행한다. 성능을 볼 때는 파일 크기뿐 아니라 그 파일 때문에 브라우저가 어떤 작업을 기다리는지도 확인해야 한다.

프리로드 스캐너

브라우저의 프리로드 스캐너는 HTML 파서가 멈춰 있는 동안에도 뒤쪽 마크업을 훑는다. 이 과정에서 img, link, script 같은 리소스를 발견하면 요청을 미리 시작한다.

하지만 HTML에 아직 드러나지 않은 주소는 찾을 수 없다. JavaScript가 나중에 추가하는 이미지나, 아직 내려받지 않은 CSS 안의 @import가 대표적인 예다. 중요한 리소스가 늦게 발견되면 파일 자체가 작아도 LCP는 늦어질 수 있다.

<link rel="preload">는 브라우저가 HTML을 읽는 시점에 곧 사용할 파일을 알려 요청을 앞당긴다. 파일을 작게 만들지는 않으며, 남용하면 덜 중요한 파일이 대역폭을 차지한다. 이처럼 HTML부터 화면이 완성되기까지 브라우저가 거치는 흐름을 Critical Rendering Path라고 한다.

하이드레이션

React가 서버에서 만든 HTML을 보내면 브라우저는 내용을 먼저 보여줄 수 있다. 하지만 이 시점에는 useState나 React의 onClick이 아직 연결되지 않는다.

하이드레이션은 브라우저가 React를 기존 HTML에 연결하고 상태와 이벤트를 붙이는 과정이다. 클라이언트 JavaScript가 많으면 하이드레이션도 늦어진다. 그러면 화면은 보이지만 버튼은 아직 반응하지 않는 구간이 생길 수 있다.

실험 환경

이제 이 흐름을 실제 페이지에서 확인해 볼 차례다. Next.js App Router로 무선 스피커 상품 페이지를 만들고 다음과 같은 성능 문제를 넣었다.

  • 약 13 MB인 대표 PNG 이미지
  • 첫 화면 밖에서도 바로 내려받는 4.5 MB 자동재생 영상
  • CSS @import로 불러오는 Google Fonts 스타일시트
  • JavaScript가 실행된 뒤에야 보이는 상품 정보
  • 상품과 리뷰, 추천 상품을 순서대로 요청하는 데이터 워터폴
  • 늦게 나타나는 콘텐츠를 위한 공간이 없는 레이아웃

각 문제를 해결했는지 확인할 수 있도록 Playwright 테스트도 작성했다. 테스트는 main 브랜치에서 의도적으로 실패하고, 최적화를 마치면 통과한다.

실습 코드는 web-performance-lab 저장소에서 확인할 수 있다.

이미지 최적화

데스크톱 화면에 표시된 무선 스피커 대표 이미지
실험 페이지의 대표 이미지

가장 먼저 이미지 최적화를 살펴보자. 대표 이미지는 화면 너비에 맞춰 작게 보이지만, 실제 브라우저에서는 13 MB짜리 원본 PNG를 그대로 받고 있다. 페이지 하단의 상품 이미지도 표시 크기보다 훨씬 큰 원본 파일을 내려받고 있다.

CSS의 width는 화면에 표시되는 크기만 바꿀 뿐, 브라우저가 다운로드하는 파일의 용량은 줄이지 않는다. 특히 대표 이미지는 LCP 대상이므로 이미지가 늦게 도착하면 LCP도 함께 늦어진다.

대표 상품의 원본 PNG가 약 13 MB 전송된 Network 기록
이미지 최적화 전

이미지는 웹페이지에서 용량이 큰 리소스인 경우가 많다. 이미지 최적화는 화면에 필요한 크기의 이미지를 전달하고, WebP나 AVIF처럼 압축 효율이 높은 형식을 사용하는 것에서 시작한다. 반응형 화면에서는 srcsetsizes로 여러 크기의 후보를 제공한다.

Next.js의 Image 컴포넌트는 이 과정을 자동화한다. 여러 크기의 최적화 URL을 만들고, 선택된 이미지를 필요한 너비와 형식으로 변환한다. 지연 로딩과 이미지 공간 확보도 지원한다. 이번 실험에서는 일반 <img>Image로 바꾸고, 레이아웃과 로딩 시점에 필요한 속성을 직접 지정했다.

<Image
  src="/speaker-hero.png"
  alt="Still One wireless speaker"
  width={2400}
  height={1800}
  sizes="(max-width: 800px) 100vw, 61vw"
  loading="eager"
/>

widthheight에는 원본 이미지의 가로와 세로 크기를 지정했다. 브라우저는 이 값으로 이미지 비율을 미리 계산하고, 이미지가 도착하기 전에 표시될 공간을 확보한다. 덕분에 이미지가 나타나면서 주변 콘텐츠가 밀리는 현상을 줄일 수 있다.

sizes는 화면 너비에 따라 이미지가 차지하는 크기를 브라우저에 알려 준다. 브라우저는 이 값과 기기의 픽셀 밀도를 바탕으로 srcset의 후보를 고른다. Next.js가 모바일과 데스크톱을 판별하는 것이 아니므로 sizes가 실제보다 크면 필요 이상의 이미지를 내려받을 수 있다.

선택된 이미지를 캐시에 없는 상태로 처음 요청하면 Next.js가 해당 너비와 형식의 파일을 만들고 결과를 저장한다. Vercel에 배포하면 최적화된 이미지는 CDN에 캐시된다. 자체 호스팅에서 기본 이미지 최적화 기능을 사용하면 서버의 디스크 캐시에 저장된다. 첫 요청에는 변환 시간이 추가될 수 있으므로 측정할 때는 캐시 전후를 구분해야 한다.

파일의 크기뿐 아니라 요청 시점도 구분했다. LCP 대상인 대표 이미지는 loading="eager"를 사용해 브라우저가 이미지 요소를 발견하면 바로 요청하도록 했다. 페이지 하단의 상품 이미지는 loading="lazy"로 화면에 가까워질 때까지 요청을 늦췄다.

preload는 이미지 주소를 <head>에 넣어 브라우저가 더 일찍 발견하도록 돕는다. 이번 프로젝트에서는 대표 이미지가 초기 HTML에서 바로 발견됐고, 리소스 발견 지연도 없었기 때문에 별도로 사용하지 않았다.

여러 이미지에 eagerpreload를 적용하면 정작 LCP 이미지가 대역폭을 충분히 사용하지 못할 수 있기 때문에 주의해야 한다.

이미지 최적화 후에는 13,002 kB PNG 대신 64.3 kB WebP가 전달됐다. 전송량은 약 99.5% 줄었다.

Next.js 이미지 최적화 후 WebP가 64.3 kB 전송된 Network 기록
이미지 최적화 후

영상 최적화

데스크톱 화면에 표시된 상품 소개 영상 영역
실험 페이지의 영상 영역

다음은 영상이다. 상품 소개 영상은 스크롤해야 보이는 위치에 있지만, 최적화 전에는 페이지를 열자마자 4.5 MB 파일을 받기 시작한다.

첫 화면에서는 보이지 않는 동영상 요청이 이미 시작된 Network 기록
영상 최적화 전

영상 최적화는 크게 두 가지로 나눌 수 있다. 먼저 해상도와 압축률, 코덱을 조정해 파일 자체를 줄인다. CDN은 캐시를 통해 전송을 돕고, 영상 스트리밍 서비스는 네트워크 상태에 맞는 화질을 제공할 수 있다. 자세한 방법은 web.dev의 영상 성능 가이드에서 확인할 수 있다.

다른 하나는 요청 시점을 조절하는 것이다. 외부 서버가 영상을 효율적으로 전송하더라도 브라우저가 언제 요청할지는 별개의 문제다. 첫 화면에 없는 영상을 곧바로 요청하면 당장 필요한 이미지와 폰트의 다운로드가 늦어질 수 있다. 사용자가 영상까지 스크롤하지 않으면 내려받은 데이터를 사용하지도 않는다.

이번 실험은 두 번째 문제에 집중한다. 최적화 전에는 autoplay가 자동재생을 위해 영상 요청을 시작하고, preload="auto"도 재생 전에 영상 데이터를 미리 받을 수 있도록 허용한다. preload는 브라우저에 전달하는 힌트이므로 반드시 그대로 동작하지는 않으며, autoplay가 있으면 자동재생이 우선한다.

글을 작성하는 시점에 <video>loading="lazy"는 비교적 최근에 추가된 실험적 기능이다. Chrome 150 이상에서 지원되지만 Firefox와 Safari에서는 아직 지원하지 않는다. 지원하지 않는 브라우저에서는 속성이 무시될 수 있다.

Next.js에는 Image처럼 영상 요청을 자동으로 최적화하는 내장 컴포넌트는 없다. Next.js 영상 가이드는 기본 <video>와 외부 영상 서비스를 상황에 맞게 사용하도록 안내한다. 이번 실험에서는 여러 브라우저에서 같은 동작을 만들기 위해 직접 Video 컴포넌트를 작성한다.

<Video
  src="/speaker-demo.mp4"
  poster="/speaker-demo-poster.jpg"
  width={1920}
  height={1080}
  loading="lazy"
  autoPlay
  loop
  muted
  playsInline
  preload="auto"
/>

이 코드의 loading prop은 브라우저에 그대로 전달되지 않고 Video 컴포넌트가 처리한다. Intersection Observer로 영상이 화면에 들어오는 시점을 감지한 뒤 src를 연결한다. 그전에는 브라우저가 영상 주소를 모르기 때문에 autoplaypreload="auto"가 있어도 요청할 수 없다. 즉, 이 구현에서 요청 시점을 결정하는 것은 preload가 아니라 src를 연결하는 시점이다. src가 연결되면 다운로드와 자동재생을 시작한다.

자동재생은 브라우저 정책을 고려해 mutedplaysInline을 함께 사용한다. 영상을 불러오기 전에는 poster로 대표 이미지를 보여 주고, widthheight, CSS의 aspect-ratio로 영상이 들어갈 공간을 미리 확보한다. 덕분에 다운로드가 늦어져도 빈 화면이 보이지 않고, 영상이 나타날 때 주변 콘텐츠가 밀리는 현상도 줄일 수 있다. 포스터 역시 실제 표시 크기에 맞게 최적화해야 한다.

상품 소개 영역까지 스크롤한 뒤 동영상 요청이 시작된 Network 기록
영상 최적화 후

변경 후 첫 화면에서 받는 영상 파일은 4,539 kB에서 0 kB로 줄었다. 대신 영상이 준비되기 전에 보여 줄 약 68 kB의 포스터 이미지는 먼저 받았다. 사용자가 영상 영역까지 스크롤하자 영상 다운로드와 자동재생이 시작됐다. 파일 용량은 그대로지만, 첫 화면에 필요하지 않은 4.5 MB 요청을 뒤로 미룬 것이다.

Intersection Observer가 모든 영상 최적화에 필요한 것은 아니다. 자동재생이 없다면 preload="none"과 재생 버튼만으로 더 단순하게 구현할 수 있다. 첫 화면에 있는 영상은 요청을 늦추면 오히려 재생이 늦어질 수도 있다. 이번에 적용한 방식도 JavaScript가 없으면 영상을 불러오지 못하므로, 영상의 위치와 재생 방식, 지원할 브라우저를 기준으로 선택해야 한다.

폰트 최적화

다음으로 폰트가 발견되고 요청되는 순서를 살펴보자. 실험 페이지는 전역 CSS에서 Google Fonts 스타일시트를 @import로 불러온다.

@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap");

브라우저는 HTML에서 앱 CSS의 주소를 발견하면 파일을 요청한다. CSS는 화면에 적용할 스타일을 계산하는 데 필요하므로, 다운로드와 분석이 끝날 때까지 첫 렌더링이 늦어질 수 있다.

여기서는 앱 CSS를 받은 뒤에야 @import로 연결된 Google Fonts CSS를 발견한다. 실제 폰트 주소는 Google Fonts CSS까지 받은 다음에야 알 수 있다.

HTML
└─ 앱 CSS
   └─ Google Fonts CSS
      └─ 폰트 파일

브라우저의 프리로드 스캐너는 기본 HTML 파서와 별도로 문서의 뒤쪽을 미리 살펴본다. 덕분에 HTML에 적힌 이미지나 CSS 주소를 일찍 발견해 다운로드를 시작할 수 있다.

하지만 프리로드 스캐너가 살펴보는 대상은 HTML이다. 아직 내려받지 않은 CSS 내부의 @import나 폰트 주소는 알 수 없다. 이 때문에 앱 CSS, Google Fonts CSS, 폰트 파일이 차례로 요청되는 워터폴이 생긴다. Google Fonts처럼 출처가 다르면 별도의 DNS 조회와 연결 과정도 필요할 수 있다.

폰트 발견이 늦어지면 사용자는 대체 폰트를 먼저 보고, 나중에 웹폰트로 바뀌는 모습을 볼 수 있다. 두 폰트의 글자 폭이 다르면 줄바꿈과 요소의 높이가 달라져 화면이 움직일 수도 있다.

애플리케이션 CSS와 Google Fonts CSS 다음에 폰트가 요청되는 워터폴
폰트 최적화 전

이 요청 단계를 줄이기 위해 next/font를 적용했다. next/font/google은 빌드할 때 Google Fonts의 CSS와 폰트 파일을 내려받아 애플리케이션의 정적 파일로 포함한다. 따라서 사용자가 페이지를 열 때 브라우저에서 Google로 요청을 보내지 않는다. 이 실험에서는 폰트를 애플리케이션과 같은 출처에서 받아 Google Fonts를 위한 별도의 DNS 조회와 연결 과정도 사라진다.

import { Noto_Sans_KR } from "next/font/google"

const notoSansKr = Noto_Sans_KR({
  display: "swap",
  subsets: ["latin"],
  weight: "variable",
})

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={notoSansKr.className}>{children}</body>
    </html>
  )
}

Next.js는 이 설정으로 @font-face와 클래스 이름을 만들고 폰트 파일을 연결한다. 루트 레이아웃에서 사용했으므로 해당 레이아웃 아래의 모든 페이지에 같은 폰트가 적용된다.

또한 subsets로 지정한 폰트 파일의 주소를 초기 HTML의 <head><link rel="preload">로 넣는다. 브라우저는 이 주소를 HTML에서 바로 발견해 앱 CSS를 기다리지 않고 폰트 요청을 시작할 수 있다.

subsets: ["latin"]은 라틴 문자용 폰트 파일을 <link rel="preload">로 먼저 요청하도록 지정한다. 한글이 제외된다는 뜻은 아니다. 브라우저는 생성된 CSS의 unicode-range와 화면의 문자를 비교해 필요한 한글 폰트 파일을 추가로 요청한다.

weight: "variable"은 굵기마다 별도의 폰트를 설정하지 않고 하나의 가변 폰트로 필요한 굵기 범위를 사용한다. 다만 문자 범위에 따라 폰트 파일은 여러 개로 나뉠 수 있다.

display: "swap"은 웹폰트를 기다리는 동안에도 글자를 숨기지 않고 대체 폰트로 보여 준다. 이후 웹폰트로 바뀌는 모습은 잠시 보일 수 있다.

next/font는 대체 폰트가 차지하는 공간을 실제 웹폰트와 비슷하게 조정해, 폰트가 바뀔 때 줄바꿈이나 화면 이동이 커질 가능성을 낮춘다. 다만 다른 요소에서도 레이아웃 이동이 발생할 수 있으므로 CLS는 실제 화면에서 함께 확인해야 한다.

초기 문서에서 같은 출처의 폰트가 일찍 발견되는 워터폴
폰트 최적화 후

Network 기록에서 163 kB인 Google Fonts CSS 요청이 사라지고, 첫 번째 폰트 요청이 초기 문서에서 바로 시작됐다. 개발 서버에서 CSS와 폰트 응답을 합친 크기도 약 216.7 kB에서 81.7 kB로 줄었다. 다만 개발 환경에서 측정한 결과이므로 실제 배포 환경에서도 다시 확인해야 한다.

적용 후에도 폰트 요청 세 개가 남아 있다. 폰트가 최적화되지 않은 것이 아니라, 브라우저가 unicode-range에 따라 화면에 필요한 문자 범위의 파일을 고른 결과다. 중요한 변화는 폰트 파일의 개수가 아니라 외부 CSS를 기다리던 요청 단계가 사라진 점이다.

너무 많은 폰트를 preload하면 초기 화면에 필요한 다른 리소스의 다운로드가 늦어질 수 있다. 처음 화면에서 실제로 사용하는 폰트와 문자 범위, 굵기만 선택하고 폰트가 바뀔 때 화면이 움직이지 않는지도 함께 확인해야 한다.

렌더링 최적화

리소스 요청을 정리했으니 이번에는 상품 정보가 렌더링되는 과정을 개선해 보자. 현재는 페이지 전체가 하나의 Client Component이고, 상품 데이터는 브라우저의 useEffect에서 요청한다. 처음 받은 HTML에는 로딩 문구만 있어서 상품명과 가격을 보려면 다음 과정을 모두 기다려야 한다.

HTML
→ JavaScript
→ 하이드레이션
→ product API
→ 상품 표시
JavaScript를 끈 개선 전 페이지에서 상품 정보가 로딩 상태에 머문 화면
서버 렌더링 전

Client Component 자체가 문제인 것은 아니다. Next.js App Router는 브라우저가 페이지를 처음 요청할 때 Client Component도 서버에서 HTML로 미리 렌더링한다. 문제는 상품 정보를 useEffect에서 요청한다는 점이다.

useEffect는 브라우저가 JavaScript를 받고 하이드레이션을 마친 뒤에 실행된다. 따라서 JavaScript를 비활성화하면 상품 요청이 시작되지 않고 로딩 문구에 머문다.

Next.js App Router의 페이지와 레이아웃은 기본적으로 Server Component다. Server Component는 서버에서 데이터를 읽을 수 있다. 반대로 상태와 이벤트, 브라우저 기능이 필요한 부분은 "use client"를 선언한 Client Component로 만든다.

RSC와 SSR

RSC (React Server Components)는 Server Component와 이를 처리하는 방식을 가리킨다. Server Component는 서버에서 실행되고, 그 결과는 RSC Payload에 담긴다. 컴포넌트 코드 자체는 브라우저의 JavaScript 번들에 포함되지 않는다.

RSC Payload 자체는 HTML이 아니다. SSR (Server-Side Rendering)은 RSC Payload와 Client Component를 이용해 최초 페이지 요청에 사용할 HTML을 만드는 과정이다. 브라우저는 이 HTML을 먼저 보여 주고, 하이드레이션이 끝나면 Client Component의 상태와 이벤트를 활성화한다.

이에 맞춰 상품 정보는 Server Component에서 읽고, 색상 선택과 장바구니처럼 사용자 조작이 필요한 부분만 Client Component로 분리했다.

Home                  Server Component
├─ ProductDetails     Server Component
│  └─ ProductPurchase Client Component
└─ ProductStory       Server Component
   └─ Video           Client Component

상품 정보를 Server Component에서 읽으면 렌더링 순서는 다음처럼 바뀐다.

요청
→ 서버에서 상품 조회
→ 상품 정보가 포함된 HTML
→ 하이드레이션 후 구매 기능 활성화
JavaScript 없이도 상품 정보와 가격이 보이는 서버 렌더링 화면
서버 렌더링 후

상품을 조회하는 700ms 자체는 줄어들지 않는다. 달라진 것은 요청을 시작하는 시점이다. 변경 전에는 브라우저가 JavaScript를 받고 하이드레이션을 마쳐야 상품 조회가 시작됐다. 변경 후에는 페이지 요청이 서버에 도착하면 상품 조회도 시작된다. 상품을 보여 주기 전에 기다려야 했던 JavaScript와 하이드레이션 단계가 사라진 것이다.

실험에서는 브라우저의 /api/product 요청이 없어졌고, 상품명과 가격이 HTML에 포함된 것을 확인했다. JavaScript를 꺼도 상품 정보가 보이며, 하이드레이션은 색상 선택과 장바구니 기능에만 필요하다. 로딩 문구가 뒤늦게 상품 정보로 바뀌며 Hero 영역을 밀어내던 원인도 제거했다.

서버가 데이터 소스에 가깝거나 직접 접근할 수 있다면 브라우저의 API 왕복을 줄이고 인증 정보를 노출하지 않을 수 있다. 반대로 데이터 소스가 느리거나 요청을 캐시할 수 없다면 서버가 데이터를 기다리는 만큼 TTFB (Time to First Byte)가 늦어진다. 따라서 실제 상품 표시 시간이 얼마나 줄었는지는 프로덕션 환경에서 별도로 측정해야 한다.

데이터 병렬 처리

앞선 렌더링 최적화에서 하나였던 페이지를 역할에 따라 여러 컴포넌트로 분리했다. 하지만 리뷰와 추천 상품은 여전히 ProductContent라는 Client Component에서 요청하고 있었다. 두 데이터는 서로 의존하지 않지만 리뷰를 받은 다음에야 추천 상품 요청을 시작했다.

const reviewResponse = await fetch("/api/reviews")
const reviews = await reviewResponse.json()

const recommendationResponse = await fetch("/api/recommendations")
const recommendations = await recommendationResponse.json()

이 코드에서는 첫 번째 await가 끝나야 다음 요청을 시작한다. 리뷰를 기다리는 900ms 뒤에 추천 상품의 1,100ms가 이어져 데이터 워터폴이 만들어졌다. 이 요청들은 상품 조회와 하이드레이션까지 끝난 뒤에야 시작됐다.

리뷰 요청이 끝난 뒤 추천 상품 요청이 시작되는 Network waterfall
순차 데이터 요청

먼저 기존 구조를 유지한 채 리뷰와 추천 상품 요청을 Promise.all로 묶었다. 두 요청이 함께 시작되면서 서로를 기다리는 시간은 사라졌다. 하지만 상품 조회와 하이드레이션 이후에 시작된다는 점은 그대로였다. 전체 콘텐츠가 준비되기까지 약 2.1초가 걸려 1.8초로 설정한 테스트 조건을 충족하지 못했다.

그래서 상품, 리뷰와 추천 상품을 모두 서버에서 읽고, 세 요청을 페이지 렌더링과 함께 시작하도록 바꿨다. 이 과정에서 ProductContent를 제거하고, ProductReviewsRelatedProductsHome Server Component가 직접 렌더링하도록 정리했다.

export default async function Home() {
  const [product, reviews, relatedProducts] = await Promise.all([
    getProduct(),
    getReviews(),
    getRelatedProducts(),
  ])

  return (
    <>
      <ProductDetails product={product} />
      <ProductReviews reviews={reviews} />
      <RelatedProducts products={relatedProducts} />
    </>
  )
}

Home은 세 요청을 함께 시작한 뒤 결과를 각 컴포넌트에 전달한다. 브라우저에서 데이터를 가져오는 useEffect는 더 이상 필요하지 않다. 전체 대기 시간도 700ms, 900ms와 1,100ms의 합이 아니라 가장 느린 요청의 1,100ms에 가까워진다. 실제 테스트에서는 모든 콘텐츠가 약 1.4초 안에 준비되어 통과했다.

Promise.all 적용 후 문서 응답을 약 1.15초 기다린 Timing 기록
병렬 데이터 요청

다만 Promise.all은 가장 늦은 작업이 끝나야 결과를 반환한다. 상품 정보가 700ms에 준비되어도 추천 상품이 끝나는 1,100ms까지 HTML을 보낼 수 없다. 요청 하나가 실패하면 전체 작업도 실패한다. 병렬 처리는 작업이 서로 독립적인지 확인하고, 데이터 소스가 동시에 들어오는 요청을 감당할 수 있을 때 적용해야 한다.

Streaming SSR

Promise.all로 전체 대기 시간은 줄었다. 하지만 모든 콘텐츠가 준비될 때까지 응답을 시작할 수 없었다. 먼저 필요한 상품명과 가격까지 리뷰와 추천 상품을 기다릴 이유는 없었다.

그래서 세 요청은 동시에 시작하되 상품 정보만 먼저 기다리도록 바꿨다.

const productPromise = getProduct()
const reviewsPromise = getReviews()
const relatedProductsPromise = getRelatedProducts()

const product = await productPromise

리뷰와 추천 상품은 React의 Suspense로 각각 감쌌다. 서버는 상품 정보와 fallback을 먼저 보내고, 나머지 데이터가 준비되면 실제 HTML을 이어서 보낼 수 있다.

<Suspense fallback={<ProductReviewsFallback />}>
  <ProductReviews reviewsPromise={reviewsPromise} />
</Suspense>

<Suspense fallback={<RelatedProductsFallback />}>
  <RelatedProducts productsPromise={relatedProductsPromise} />
</Suspense>

이 과정에는 React의 동시성 렌더링이 사용된다. 여러 CPU에서 작업을 동시에 실행한다는 뜻은 아니다. React가 데이터가 준비되지 않은 영역을 잠시 멈추고 다른 영역을 처리한 뒤, 준비되면 다시 이어갈 수 있다는 뜻이다. Suspense 경계는 어느 영역까지 기다리지 않고 먼저 보낼 수 있는지 React에 알려 준다.

0 ms      상품, 리뷰, 추천 요청 시작
700 ms    상품과 fallback 전송
900 ms    리뷰 전송
1100 ms   추천 상품 전송
Streaming SSR에서 서버 응답이 시작된 뒤 나머지 HTML이 이어서 다운로드되는 Timing 기록
Streaming SSR 응답

Promise.all에서는 서버 응답이 시작될 때까지 약 1.15초를 기다렸다. Streaming SSR을 적용한 뒤에는 약 749ms 만에 상품 정보와 fallback이 도착했다. 나머지 HTML은 이후 376ms 동안 이어서 전송됐다.

데이터를 가져오는 시간과 서버 작업량이 줄어든 것은 아니다. 달라진 것은 응답을 보내는 순서다. 사용자는 전체 작업이 끝나기 전에 중요한 상품 정보를 먼저 볼 수 있게 됐다.

Suspense 경계

이번에는 첫 화면에 필요한 상품명과 가격을 먼저 기다리고, 리뷰와 추천 상품만 별도의 경계로 나눴다. 경계는 사용자가 어떤 순서로 내용을 봐야 하는지에 맞춰 정하는 편이 좋다. 너무 잘게 나누면 여러 fallback이 제각기 바뀌면서 화면 전환이 잦아질 수 있다.

fallback과 실제 콘텐츠의 높이가 다르면 아래 내용이 밀려 CLS가 생길 수도 있다. 또 자주 바뀌지 않는 데이터라면 Streaming보다 캐시나 정적 렌더링으로 처음부터 완성된 내용을 보내는 편이 나을 수 있다.

레이아웃 안정성

앞선 작업을 마친 뒤 화면 이동도 함께 줄었는지 확인했다. 개선 전 페이지를 프로덕션 모드로 실행해 Chrome DevTools로 측정한 CLS는 0.12였다. Layout shifts 목록에는 .hero-copy, 추천 상품 영역과 푸터가 움직인 요소로 나타났다.

이 목록에 나온 요소가 반드시 문제의 원인은 아니다. 위쪽에 새 콘텐츠가 추가되면서 밀려난 요소도 함께 기록되기 때문이다. 페이지를 살펴보면 상품 정보가 useEffect 실행 후 로딩 문구를 대체하면서 .hero-copy의 높이가 달라졌다. 추천 상품도 짧은 로딩 문구 자리에 카드 세 개가 들어오며 푸터를 아래로 밀었다.

크기를 알 수 없는 이미지와 영상, 웹폰트 교체도 화면 이동의 흔한 원인이다. 브라우저가 콘텐츠가 차지할 공간을 미리 알지 못하면 파일이 도착할 때 주변 요소가 움직일 수 있다.

개선 전 페이지의 CLS 0.12와 이동한 요소 목록
CLS 개선 전

CLS만을 위한 코드는 따로 추가하지 않았다. 앞선 최적화가 화면 이동의 원인을 함께 줄였기 때문이다.

  • 상품 정보가 초기 HTML에 포함되어 대표 영역이 처음부터 완성된 높이로 나타났다.
  • 이미지의 widthheight, 영상의 aspect-ratio가 파일이 도착하기 전에 공간을 확보했다.
  • 리뷰와 추천 상품의 fallback이 실제 콘텐츠와 비슷한 공간을 차지했다.
  • next/font가 대체 글꼴과 웹폰트의 크기 차이로 생길 수 있는 이동을 줄였다.

같은 프로덕션 환경에서 다시 측정한 CLS는 0이었고, Layout shifts 목록에도 기록이 남지 않았다.

개선 페이지의 CLS 0과 비어 있는 Layout shifts 목록
CLS 개선 후

이는 이번 로컬 측정에서 확인한 화면 이동이 사라졌다는 뜻이다. 화면 크기와 콘텐츠, 사용자의 동작이 달라지면 결과도 달라질 수 있다.

결과 요약

작업확인한 변화사용자에게 생긴 변화
이미지13,002 kB PNG → 64.3 kB WebP대표 이미지를 받는 부담 감소
영상첫 화면의 영상 전송량 4,539 kB → 0 kB보지 않을 수 있는 영상의 다운로드를 미루고 첫 화면 자원과의 전송 경쟁 감소
폰트외부 Google Fonts CSS 요청 제거폰트를 더 일찍 발견하고 교체 지연과 화면 이동 가능성 감소
렌더링JavaScript 없이 로딩 문구만 표시 → 상품명과 가격 표시하이드레이션을 기다리지 않고 상품 정보 확인
데이터 요청순차 요청 → 동시 요청, 전체 콘텐츠 약 1.4초 안에 준비독립된 요청 사이의 불필요한 대기 제거
Streaming SSR서버 응답 시작 약 1.15초 → 749ms전체 완료 시간은 비슷하지만 중요한 상품 정보를 먼저 확인
레이아웃로컬 CLS 0.12 → 0같은 측정 조건에서 예상하지 못한 화면 이동 제거

한계

이번 실습은 한 상품 페이지의 이미지와 영상, 폰트, 데이터 요청과 렌더링을 다룬다. 웹 성능의 전부는 아니다. 첫 콘텐츠가 늦다면 FCP (First Contentful Paint), 서버 응답이 늦다면 TTFB (Time to First Byte)를 함께 볼 수 있다. JavaScript가 화면 반응을 막는다면 TBT (Total Blocking Time)와 긴 작업을 살펴봐야 한다.

검색이나 장바구니처럼 실제 사용자가 자주 이용하는 기능에서는 INP도 따로 측정해야 한다. 어떤 지표와 개선 방법이 필요한지는 페이지의 역할과 사용 흐름에 따라 달라진다.

로컬 측정은 원인을 찾고 전후를 비교하는 데 유용하지만, 배포 환경의 CDN과 캐시, 네트워크와 서버 위치까지 재현하지는 못한다. Preview 배포에서 캐시 전후와 Streaming 동작을 확인하고, 운영 후에는 실제 사용자 데이터로 개선 효과가 다양한 환경에서도 이어지는지 살펴보는 게 좋다.

마치며

이전에는 Lighthouse 점수를 먼저 보고, Next.js의 Imagenext/font도 동작 원리를 깊이 이해하지 못한 채 사용하곤 했다. 이번에는 성능 문제가 있는 페이지를 직접 만들고 Network와 Performance 패널에서 전후를 비교했다. 그 과정에서 각 기능이 브라우저의 요청과 렌더링을 어떻게 바꾸는지 알 수 있었다.

연구 결과와 실험을 함께 살펴보니 웹 성능은 단순히 점수를 높이는 일이 아니었다. 기다림과 화면 이동을 줄이는 일은 사용자가 서비스를 편하게 이용하도록 돕고, 그 차이는 비즈니스에도 영향을 줄 수 있다.

모든 페이지에 같은 최적화를 적용할 수는 없다. 먼저 사용자가 어디에서 불편을 겪는지 측정하고, 원인과 우선순위에 맞는 방법을 선택해야 한다. 이번 실습에서 가장 크게 배운 점도 바로 이것이다.


이 글의 측정값은 의도적으로 만든 로컬 실습 페이지에서 얻은 결과입니다. 실제 서비스의 성능이나 비즈니스 효과를 보장하지 않습니다. 사실관계나 해석에 오류가 있다면 댓글로 알려 주세요.

참고 자료


게시물 검색

  • 검색어를 입력하세요
이동열기
ESC닫기