2025년 04월 23일
사용자가 웹 사이트에 접속한다면
크게 이 세가지가 달성된다면 웹사이트가 내부적으로 어떤 코드로 어떻게 이루어져 있는지는 고객들에게 전혀 중요하지 않다. 모든 서비스가 그렇듯 웹서비스도 마찬가지로 사용자가 느끼는 성능이 가장 중요하다.
그렇다면 이러한 웹사이트의 성능은 어떻게 측정할 수 있을까 ?
구글은 핵심 웹 지표(Core Web Vital)라고 하는, 웹사이트의 우수한 사용자 경험을 제공하는 필요한 몇가지 핵심적인 요소를 꼽고, 이에 대한 지표를 제시하고 있다.
핵심 웹 지표(Core Web Vital)란 구글에서 만든 지표로, 웹사이트에서 뛰어난 사용자 경험을 제공하는 데 필수적인 지표를 일컫는 용어이다. 구글에서는 사이트에서 핵심적인 웹 지표를 몇 가지로 요약하고, 이를 측정할 수 있는 방법, 그리고 좋은 웹사이트로 분류할 수 있는 기준을 제시한다.
구글에서 핵심 웹 지표로 꼽는 지표는 다음과 같다.
다음 두 지표는 핵심까지는 아니지만, 특정 문제를 진단하는 데 사용할 수 있다고 언급했다.
최대 콘텐츠풀 페인트(LCP: Largest Contentful Paint)는 페이지가 처음으로 로드를 시작한 시점부터 뷰포트 내부에서 가장 큰 이미지 또는 텍스트를 렌더링하는 데 걸리는 시간을 의미한다.
뷰포트는 사용자에게 현재 노출되는 화면을 의미하며 뷰포트 내부에서 ‘큰 이미지와 텍스트’는 다음과 같이 정의되어 있다.
실제 크기가 크다고 하더라도 뷰포트 영역 밖에 넘치는 요소가 있다면 해당 영역의 크기는 고려되지 않는다. LCP에 영향을 미치는 부분은 오직 뷰포트 영역뿐이다.
LCP는 페이지 로딩에 따라 변화하는 지표이다. 사용자가 이용하는 디바이스 크기에 따라, 그리고 그것이 이미지와 같이 비교적 크기가 큰 리소스라면 실제로 로딩에 필요한 시간에 따라 LCP 지표의 값이 달라질 수 있다. 예를 들어, 헤더가 가장 먼저 노출된다면 LCP는 헤더이다. 그 다음 콘텐츠가 로딩되며 이미지가 노출된다면 LCP는 이미지로 변경된다.
LCP에서 좋은 점수란 해당 지표가 2.5초 내로 응답이 오는 것이다. 4초 이내로 응답이 온다면 보통, 그 이상이 걸리면 나쁨으로 판단된다.
이 점수를 높이는 방법은 무엇일까 ?
LCP 지표에서 좋은 점수를 얻는 가장 확실한 방법은 뷰포트 최대 영역, 즉 LCP 예상 영역에 이미지가 아닌 문자열을 넣는 것이다. 아무리 이미지를 최적화 하더라도 리소스 다운로드가 필요한 이미지보다 텍스트 노출이 훨씬 더 빠를 것이다.
하지만 사용자에게 좀 더 강한 인상을 주기 위해서는 이미지를 사용하기 원할 것이다. 이미지를 노출할 수 있는 방법에는 여러가지가 있다.
// 1. img
<img src="lcp.jpg" />
// 2. svg
<svg xmlns="http://www.w3.org/1000/svg">
<image href="lcp.jpg" />
</svg>
// 3. video poster
<video poster="lcp.jpg"></video>
// 4. background-image: url()
<div style="background-image: url(lcp.jpg)"></div>
각 방법을 사용했을 때 이미지 로딩 속도에 어떠한 차이가 있는지 살펴보자
아무리 페이지가 빨리 로딩된다 하더라도 사용자가 클릭을 비롯한 웹사이트와 상호작용을 할 수 없다면 사용자는 웹사이트가 느리다고 생각할 것이다. 이처럼 웹사이트의 로딩 속도만큼 중요한 것이 웹사이트의 반응 속도이다. 이러한 웹사이트의 반응성을 측정하는 지표가 최초 입력 지연(FID: First Input Delay)이다. FID는 사용자가 페이지와 처음 상호 작용할 때(링크나 버튼을 클릭하는 등)부터 해당 상호 작용에 대한 응답으로 브라우저가 실제로 이벤트 핸들러 처리를 시작하기까지의 시간을 측정한 것이다. 모든 입력에 대해 측정하는 것이 아니며, 최초의 입력 하나에 대해서만 그 응답 지연이 얼마나 걸리는지 판단한다.
대부분의 이벤트 핸들러에는 이벤트를 즉시 처리하기 위한 코드를 작성하는 것이 일반적이다. 그렇다면 웹사이트 내부의 이벤트가 반응이 늦어지는 이유는 무엇일까 ? 그 이유는 해당 입력을 처리해야 하는 브라우저의 메인 스레드가 바쁘기 때문이다. 메인스레드가 바쁜 경우, 자바스크립트 실행 환경은 싱글 스레드이기 때문에 자바스크립트가 이벤트 리스너와 같은 다른 작업을 실행할 수 없어 지연이 발생한다. 즉, 이벤트가 발생하는 시점에 최대한 메인 스레드가 다른 작업을 처리할 수 있도록 여유를 만들어야 사용자에게 빠른 반응성을 보장할 수 있다.
FID의 좋은 점수를 얻기 위해서는 100ms 이내로 응답이 와야 하며, 300ms 이내인 경우 보통, 그 이후의 경우에는 나쁨으로 처리된다.
FID를 개선하려면 메인 스레드에 이벤트를 실행할 여유를 주어야 한다.
긴 작업이란 실행을 완료하는 데 오래 걸리는 작업을 의미한다. 메인 스레드를 오래 점유해야 하는 긴 작업은 FID뿐만 아니라 웹페이지 전반에 악영향을 미친다. 이 경우 몇가지 대안을 연구해야 한다.
우선, 꼭 웹페이지에서 해야 하는 작업인가를 고민해야 한다. 만약 아니라면 서버로 옮겨서 처리하는 것이 좋다.
만약 꼭 웹페이지에서 처리해야 하는 작업이라면 해당 작업을 여러개로 분리하는 것이 좋다. 일반적으로 크롬의 경우 50ms 이상 걸리면 오래 걸리는 작업이라고 간주한다. 작업을 분리한다는 것은 단순히 실행이 오래걸릴 것 같은 작업을 분리하는 것 뿐만 아니라 웹페이지 최초 로딩에 필요하지 않은 내용을 나중에 불러오는 것도 포함된다. 예를들어, 사용자의 액션으로 인해 노출되는 요소들은 당장의 로딩에 필요하지 않으므로 리소슨는 리액트의 Suspense와 lazy를, 훅은 Next.js의 dynamic을 이용해 나중에 불러오게 할 수 있다.
번들러가 빌드 과정에서 사용되지 않은 코드를 번들링에서 제거하더라도 여전히 경우에 따라 웹페이지를 불러오는 데 사용되지 않는 필요 없는 코드가 존재할 수 있다. 이러한 코드를 크롬 개발자 도구를 통해 확인할 수 있다.
사이트 방문 후, 크롬 개발자도구의 커버리지를 클릭한다(command + shift + p → show command 검색). 기록 버튼을 클릭하면 커버리지가 기록된다. 아래는 나의 블로그의 커버리지를 기록한 것이다.
현재까지 웹페이지에서 사용되지 않은 코드가 얼마나 있는지 확인할 수 있다. 여기에 표시된 모든 코드들이 필요 없는 코드이므로 삭제해야 한다는 것은 아니다. 사용자의 특정 이벤트에 따라 실행되는 코드나, 예기치 못한 상황에서 실행될 코드 등 다양한 것들이 존재할 수 있다. 이러한 코드들은 당장 급한 코드가 아니므로 지연 로딩 기법이나 사용자가 필요로 하는 순간에 불러오거나 우선순위를 낮추어 불러오는 것이 좋다.
Google Analytics나 Firebase와 같이 웹페이지의 통계 집계를 통해 타사 스크립트를 넣는 경우가 있다. 이러한 코드의 실행으로 인해 메인 스레드가 점유되고 이로 인해 사용자에게 안 좋은 반응성을 제공하는 경우가 생길 수 있다.
타사 스크립트는 대부분 웹페이지 로드에 중요한 자원이 아니므로 <script>의 async와 defer를 이용해 지연 불러오기를 하는 것이 좋다.
타사 자바스크립트는 가능하면 async를, 더 가능하다면 defer로 지연하는 것이 좋다.
만약 광고와 같이 실제 사용자의 뷰포트 위치에 따라 불러와야 하는 컴포넌트는 Intersection Observer를 이용해 뷰포트에 들어오는 시점에 불러오는 것이 좋다. 사용자가 기대하지 않은 추가적인 리소스는 실행을 조금 뒤로 미뤄두고 실행하는 시점을 최적화하는 것이 좋다.
웹사이트에서 무언가를 클릭하려 했는데 그 사이 다른 요소가 로딩되면서 원래 클릭하려 했던 요소를 클릭하지 못해 당황했던 적이 있을 것이다. 이처럼 페이지의 생명주기동안 발생하는 예기치 않은 이동에 대한 지표를 계산하는 것이 누적 레이아웃 이동(CLS: Cumulative Layout Shift)이다. 다른 지표와 마찬가지로 이 지표가 낮을수록, 즉 사용자가 겪는 에상치 못한 레이아웃 이동이 적을수록 더 좋은 웹사이트이다.
다음 예제를 살펴보자
function Banner() {
const [show, setShow] = useState(false);
useEffect(() => {
(async function () {
const result = await fetchBannerInfo();
if (result.ok) {
setShow(true);
}
})();
}, []);
if (!show) {
return null;
}
return <BannerHeader>이벤트 진행 중 !</BannerHeader>;
}
이 예제는 useEffect 내부에서 배너 관련 정보를 요청한 다음 응답이 오면 배너를 띄우는 컴포넌트이다. 렌더링이 완료된 이후 API의 응답을 받아 다시 배너가 노출되면 레이아웃이 변경된다. 이처럼 최초 렌더링 이후 실행되는 useEffect가 많을수록, 그리고 이 useEffect가 렌더링에 영향을 미칠수록 CLS에 좋지 못한 점수를 받을 가능성이 크다.
CLS는 뷰포트 내부의 요소에 대해서만 측정한다. 요소가 추가됐다 하더라도 다른 요소의 시작 위치에 영향을 미치지 않았다면 레이아웃 이동으로 간주하지 않는다. 또한 사용자 액션으로 인해 발생한 레이아웃 이동은 점수에 포함되지 않는다.
CLS의 경우 0.1 이하인 경우 좋음, 0.25 이하인 경우 보통이며 그 외에는 개선이 필요한 나쁜 점수로 보고된다.
CLS를 개선할수 있는 방안들에 대해 살펴보자.
대부분의 CLS는 클라이언트에서 삽입되는 동적인 요소로 인해 발생한다. 이 외에도 갑자기 요소의 크기가 바뀌거나, 광고와 같은 타사 라이브러리가 브라우저에서 로드되는 등의 작업 때문에 나타난다.
이러한 영향을 방지하기 위해 useEffect 내부에서 요소에 영향을 미치는 작업, 특히 뷰포트 내부에서 노출될 확률이 높은 작업을 최소화하는 것이 좋다. useEffect 사용이 불가피하다면 useLayoutEffect 훅을 사용해 보는 것도 검토해볼만 하다. 그러나 useLayoutEffect는 동기적으로 발생해 브라우저의 페인팅 작업에 영향을 미치기 때문에 사용자에게 로딩이 오래 걸리는 것과 같이 보일 수 있으므로 신중하게 선택해야 한다.
스켈레톤 UI처럼 미리 무언가가 동적으로 뜰 것으로 예상되는 공간을 미리 확보해 두는 것도 좋은 방법이다. 레이아웃 이동을 막으면서 클라이언트 시점에 정해지는 콘텐츠를 안정적으로 보여줄 수 있다.
가장 좋은 방법은 서버 사이드 렌더링이다. 서버에서 이러한 동적인 요소의 유무를 판단하여 클라이언트에 HTML을 미리 제공한다면 클라이언트에서는 이러한 고민 없이 깔끔하게 처리할 수 있다.
폰트 또한 레이아웃 이동을 일으키는 원인 중 하나이다. 폰트로 인해 발생할 수 있는 문제는 크게 두가지이다.
폰트는 각각 고유의 높이와 너비를 가지고 있기 때문에 지정한 폰트가 다운로드도기 전에 텍스트를 노출하려고 한다면 높이와 크기가 다른 기본 폰트를 기반으로 한 텍스트를 노출해 CLS가 발생할 수 있다. 지정 폰트로 웹페이지를 보여주고 싶다면 다음을 유념해야 한다.
CLS를 최소화 하려면 두 방법을 조합해 불러오는 것이 좋다. 최대한 중요한 폰트의 다운로드를 우선순위에 넣고, 이를 활용했음에도 빠르게 로딩하는데 실패했다면 기본 폰트를 노출하는 것이다.
반응형 웹사이트란 사용자 기기의 크기에 따라 자연스럽게 콘텐츠를 노출할 수 있도록 다양한 요소를 콘텐츠의 기기에 의존하도록 만든 웹사이트를 일컫는다.
img {
width: 100%;
height: auto;
}
위 예제는 너비는 기기의 너비대로, 높이는 그 그림이 너비를 가지면 자동으로 비례해 설정해 달라는 것을 의미한다.
그러나 이 경우 CLS가 커지는 결과를 낳는다. 높이를 이미지가 완전히 다운로드되기 전까지는 알 수 없기 때문에 이미지의 높이를 높게 잡아 뒀다가 이미지가 완전히 로딩된 이후에 기기의 너비만큼 높이를 계산해서 이미지의 크기만큼 자리잡게 된다. height: auto 는 반응형 웹사이트에 최적화할 수 있는 기법으로, 기기의 너비가 어떻게 되든 원본 가로세로 비율이 일정해 사용자에게 최적의 이미지를 보여줄 수 있다는 장점이 있다. 하지만 이미지의 높이를 명확히 알지 못하기때문에 레이아웃 이동이 발생한다는 단점이 있다. 이 문제를 해결하려면,
<img src="/imgae.jpg" alt="이미지" width="1600" height="900" />
<img
width="1000"
height="1000"
src="image-1000.jpg"
srcset="image-1000.jpg 1000w, image-2000.jpg 2000w, image-3000.jpg 3000w"
alt="이미지"
/>
최초 바이트까지의 시간(TTFB: Time to First Byte)은 브라우저가 웹페이지의 첫 번째 바이트를 수신하는 데 걸리는 시간을 의미한다. 요청이 완전히 완료되는 시간을 측정하는 것이 아닌 최초의 응답이 오는 바이트까지가 얼마나 걸리는지를 측정하는 지표이다. 이 지표는 600ms 이상 걸릴 경우 개선이 필요한 것으로 간주된다.
서버 사이드 렌더링을 하고 있는 어플리케이션의 경우 최초 페이지를 만들기 위해 서버에서 어느 정도 작업을 수행해야 한다. 서버에서 첫 HTML을 만들기 위해 해야 하는 작업이 많거나 느릴수록 TTFB 또한 길어지게 될 것이다. 이를 개선하기 위해서는 다음과 같은 상황을 고려해야 한다.
최초 콘텐츠풀 페인트(FCP: First Contentful Paint)란 페이지가 로드되기 시작한 시점부터 페이지 콘텐츠의 일부가 화면에 렌더링될 때까지의 시간을 측정한다. 일반적으로 FCP는 1.8초 이내에 이루어진다면 좋음, 3.0초 이내는 보통, 그 이후는 개선이 필요한 것으로 보고된다.
FCP를 개선하려면 다음을 고려해야 한다.
참고자료
모던 리액트 Deep Dive 12장 모든 웹 개발자가 관심을 가져야 할 핵심 웹 지표 참조