블로그 개발기 17 (로딩 속도 편)
휴대폰에서 늦게 뜨고 밀리던 블로그를 Lighthouse로 재며 글꼴·페이드·목차·캐러셀을 고쳤다
지난 글까지는 관리 화면을 다듬었다. 이번에는 방문자가 보는 쪽으로 돌아왔다. 휴대폰으로 블로그를 열어 보니 첫 화면이 뜨기까지 한참 걸렸고, 글을 열면 다 그려진 줄 알았던 본문이 한 번 더 밀렸다.
"로딩되면서 글씨 크기 같은 게 살짝씩 밀리는 경향이 있네."
느낌만으로는 어디를 고쳐야 할지 알 수 없었다. 그래서 숫자부터 쟀다. 이 글은 그 숫자를 읽는 법과, 숫자가 가리킨 원인 네 가지, 그리고 숫자를 믿으면 안 됐던 순간에 대한 기록이다.
무엇을 재는가
웹 성능은 "빠르다"를 몇 조각으로 나눠 본다
"페이지가 느리다"는 말에는 여러 경험이 섞여 있다. 하얀 화면이 오래 가는 것, 글이 보였는데 눌러도 반응이 없는 것, 읽으려는 순간 글이 밀려 엉뚱한 곳을 누르는 것. 구글은 이 경험을 지표 몇 개로 나눠 이름을 붙였고, 그중 셋을 Core Web Vitals(핵심 웹 지표)로 정했다. LCP(Largest Contentful Paint), CLS(Cumulative Layout Shift), INP(Interaction to Next Paint)다.
| 지표 | 뜻과 사용자가 겪는 것 | 좋음 / 나쁨 |
|---|---|---|
| LCP | 화면에서 가장 큰 글 덩어리나 이미지가 그려진 시각. "내용이 보였다" | 2.5초 이하 / 4초 초과 |
| CLS | 보이던 요소가 예고 없이 움직인 정도의 합. "읽던 글이 밀렸다" | 0.1 이하 / 0.25 초과 |
| INP | 누른 뒤 화면이 반응하기까지 걸린 시간. "눌렀는데 멈췄다" | 200ms 이하 / 500ms 초과 |
이 세 지표가 중요한 이유는 두 가지다. 하나는 실제 사용자가 느끼는 불편을 비교적 정직하게 숫자로 옮긴다는 것이고, 다른 하나는 구글 검색이 이 값을 페이지 경험 신호로 쓴다는 것이다. 기준은 방문의 75%가 "좋음" 안에 들어오는지로 판정한다. 평균이 아니라 느린 쪽 사용자까지 챙기라는 뜻이다.
실험실에서는 다른 지표로 대신 잰다
INP는 사람이 실제로 눌러야 생긴다. 그래서 개발 중에 쓰는 Lighthouse(크롬에 들어 있는 성능 측정 도구)는 누르지 않고도 잴 수 있는 지표로 대신한다. FCP(First Contentful Paint), TBT(Total Blocking Time), TTI(Time to Interactive)다.
| 지표 | 뜻 | 좋음 (모바일) |
|---|---|---|
| FCP | 무엇이든 첫 글자·이미지가 그려진 시각 | 1.8초 이하 |
| TBT | 첫 화면 이후, 브라우저가 50ms 넘는 일에 붙잡혀 입력을 못 받은 시간의 합 | 200ms 이하 |
| TTI | 페이지가 입력에 안정적으로 반응하게 된 시각 | — |
TBT를 이해하려면 긴 작업(long task) 을 알아야 한다. 브라우저는 자바스크립트 실행, 스타일 계산, 배치를 한 줄로 처리한다. 한 작업이 50ms를 넘기면, 그동안 들어온 터치는 그 작업이 끝날 때까지 기다린다. 예를 들어 300ms짜리 작업은 TBT에 250ms를 더한다. TBT가 INP를 직접 재지는 않지만, TBT가 크면 실제 사용자의 INP도 나빠질 가능성이 높다.
Lighthouse는 이 값들을 섞어 0~100점의 성능 점수를 낸다. 버전 12 기준 가중치는 TBT 30%, LCP 25%, CLS 25%, FCP 10%, Speed Index 10%다. 점수보다 각 지표를 따로 보는 편이 원인을 찾기에 낫다.
모바일 측정은 일부러 느린 휴대폰을 흉내 낸다
Lighthouse의 모바일 측정은 내 컴퓨터의 속도로 재지 않는다. CPU를 4배 느리게 하고 회선을 느린 4G 수준으로 묶어 중저가 휴대폰을 흉내 낸다. 그래서 데스크톱에서는 순식간에 뜨는 페이지가 모바일 측정에서는 10초가 나오기도 한다. 이 값은 과장이 아니라 실제로 그런 휴대폰을 쓰는 방문자의 경험에 가깝다.
느리게 만드는 방법은 두 가지다. 기본값인 simulate는 한 번 빠르게 불러온 기록을 바탕으로 느린 환경에서의 시간을 계산하고, devtools는 실제로 브라우저를 느리게 만들어 잰다. 이번 작업에서는 simulate의 계산값이 실제 동작과 맞지 않는 경우가 있어 devtools 방식으로 쟀다.
처음 잰 숫자는 둘 다 "나쁨"이었다
운영 블로그를 모바일로 쟀다.
| 페이지 | LCP | CLS | 문제 |
|---|---|---|---|
| 홈 | 11.5초 | — | 기준(2.5초)의 4배 넘게 늦음 |
| 글 | 7~10초 | 0.053 | 늦고, 밀림 |
둘 다 "나쁨"이었다. 무엇이 LCP인지는 Lighthouse가 알려준다. 홈은 첫 글 카드, 글 페이지는 본문 첫 문단이었다. 그리고 LCP를 네 구간으로 쪼개 보여준다. 서버 응답(TTFB), 리소스 요청이 시작되기까지(Load Delay), 받는 시간(Load Time), 받은 뒤 실제로 그려지기까지(Render Delay)다. 어느 구간이 긴지가 원인을 가리킨다.
화면이 늦게 보이고, 밀렸다
한글 글꼴은 크고, 늦게 출발했다
블로그는 NanumSquareNeo를 쓴다. 굵기마다 woff2 파일이 하나씩 있는데, 크기가 약 400KB였다. 한글은 조합할 수 있는 음절이 11,172자라서 영문 글꼴보다 훨씬 크다.
크기만이 문제가 아니었다. 브라우저는 CSS를 받아서 읽은 뒤에야 "이 글꼴이 필요하구나"를 안다. HTML → CSS → 글꼴 순서로 한 단계씩 늦게 출발하는 것이다. 모바일 측정에서 글꼴이 도착한 시각은 8.5~9.8초였다. 그전까지는 시스템 글꼴로 그려 두었다가, 글꼴이 오면 그 순간 글자 폭이 바뀌며 한 번 더 그린다.
쓰는 글자만 남기고 미리 받게 했다
두 가지를 했다.
첫째, 글꼴을 줄였다. 일상에서 쓰는 한글은 대부분 KS X 1001 완성형 2,350자 안에 있다. 이 2,350자와 한글 외의 글자(영문·숫자·기호)만 남기는 서브셋(subset)을 만들었다. 이렇게 하면 굵기당 약 400KB가 약 148KB로 줄어든다. 백업된 글 전체를 훑어 범위 밖 글자를 찾아보니 한 글자였고, 그것도 "떄"라는 오타였다. 오타는 고쳤다.
NanumSquareNeo-Regular.woff2: 393KB -> 148KB
원본은 assets/fonts로 옮기고, yarn subset:fonts가 원본에서 줄인 파일을 다시 만든다. 범위 밖 글자가 있는지는 --check로 확인한다.
둘째, 미리 받게 했다. HTML 머리에 preload를 두면 브라우저는 CSS를 읽기 전에 글꼴 요청을 시작한다. 거의 모든 화면이 쓰는 세 굵기만 미리 받는다. 소개 페이지에서만 쓰는 굵기까지 미리 받으면 정작 필요한 파일과 대역폭을 다툰다.
const PRELOADED_FONTS = ["Regular", "Bold", "ExtraBold"];
for (const weight of PRELOADED_FONTS) {
preload(`/fonts/NanumSquareNeo-${weight}.woff2`, { as: "font", type: "font/woff2", crossOrigin: "" });
}
처음에는 <link rel="preload">를 JSX로 직접 썼는데, HTML에 같은 줄이 두 번 나왔다. React 19의 preload()는 같은 요청을 한 번으로 합쳐 준다.
Next.js의 next/font를 쓰지 않은 이유도 있다. next/font는 대체 글꼴의 크기를 웹 글꼴에 맞게 보정해 바뀔 때의 밀림을 줄여 준다. 하지만 그 보정은 Arial이나 Times를 기준으로 하기 때문에 한글에는 효과가 없었다.
홈 첫 카드의 썸네일도 함께 손봤다. Next의 이미지는 기본값이 지연 로딩이라서, 화면 맨 위에 있는 첫 썸네일조차 요청이 2~3초 늦게 시작됐다. 첫 카드 하나에만 미리 받기를 켰다.
| 전 | 후 | |
|---|---|---|
| 글꼴 크기 (굵기당) | 약 400KB | 약 148KB |
| 글꼴 도착 | 8.5~9.8초 | 약 4.5초 |
| 홈 LCP | 11.5초 | 2.5~2.8초 |
투명도 0인 본문은 그려진 것이 아니었다
글 페이지는 글꼴을 고친 뒤에도 LCP가 7~10초였다. 구간을 쪼개 보니 거의 전부가 Render Delay였다. 본문은 이미 받아 두었는데 "그려졌다"고 기록되지 않고 있었다.
원인은 본문을 부드럽게 나타나게 하던 애니메이션이었다. 본문 <article>이 투명도 0에서 1초에 걸쳐 1이 됐다.
크롬은 투명도 0인 요소를 화면에 그려진 것으로 치지 않는다. 문제는 그 판정이 애니메이션이 시작된 뒤에도 바로 바뀌지 않는다는 점이다. 이 페이지에서는 페이지의 자바스크립트가 다 준비된 뒤(hydration이 끝난 뒤)에야 본문이 LCP로 기록됐다. 느린 휴대폰에서는 그게 7~10초였다.
hydration: 서버가 미리 그려 보낸 HTML에 브라우저가 자바스크립트를 연결해, 버튼과 상태가 실제로 동작하게 만드는 단계다. 화면은 이미 보이지만 이 단계가 끝나기 전에는 눌러도 반응하지 않는다.
페이드는 없애지 않고 0.2에서 시작하게 했다
애니메이션을 빼자 LCP가 10.3초/7.5초에서 3.0초/3.4초로 줄었다. 그래도 페이드 느낌은 남기고 싶었다. 그래서 0이 아니라 0.2에서 시작하는 400ms 페이드로 바꿨다. 처음부터 조금은 보이는 상태이므로 크롬이 바로 그려진 것으로 친다.
const softFadeIn = keyframes`from { opacity: 0.2; }`;
export const Article = styled.article`
animation: ${softFadeIn} 400ms ease-out;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
다시 재니 LCP가 2.2~2.8초로, 애니메이션이 없을 때와 같았다. 운영체제에서 움직임 줄이기를 켠 사용자에게는 페이드를 아예 끈다.
글씨를 민 것은 글꼴이 아니라 목차였다
글꼴과 페이드를 고친 뒤에도 글을 열면 본문이 살짝 밀렸다. 글꼴이 바뀌는 순간 밀린다고 생각했고, 그래서 글꼴 preload가 밀림도 없애 줄 거라 기대했다. 그런데 CLS는 그대로였다.
"글꼴을 고쳤는데 왜 아직도 밀리지?"
추측을 멈추고 성능 기록(trace)을 열었다. 크롬은 요소가 밀릴 때마다 LayoutShift 이벤트를 남기고, 거기에 어떤 요소가 어디서 어디로 움직였는지를 적는다. 본문의 위치가 314px에서 396px로, 약 82px 내려가 있었다. 그 82px 자리에 들어온 것은 글자가 아니라 목차였다.
목차는 서버가 본문과 같이 그리게 했다
목차는 화면이 뜬 뒤 브라우저에서 만들고 있었다. hydration이 끝나면 본문의 제목(h2, h3…)을 찾아 목록을 만들고, 본문 위에 끼워 넣었다. 서버가 보낸 HTML에는 목차가 없었으니, 목차가 생기는 순간 아래 본문 전체가 밀렸다.
본문 HTML은 서버에 이미 문자열로 있다. 거기서 제목만 뽑아 목차를 만들면, 서버가 처음 보내는 HTML에 목차가 같이 실린다. 제목의 id는 마크다운을 변환할 때 이미 붙어 있어서 링크 대상도 그대로 쓸 수 있었다.
const HEADING = /<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/g;
export default function PostNavigation({ html }: { html: string }) {
const items = useMemo(() => headingsOf(html), [html]);
// ...
}
| 전 | 후 | |
|---|---|---|
| 글 페이지 CLS (모바일) | 0.053 | 0.002~0.003 |
| 글 페이지 CLS (데스크톱) | 0.026 | 0 |
0.053도 "좋음" 기준(0.1) 안이긴 했다. 하지만 숫자가 기준 안에 있다는 것과 사용자가 밀림을 느끼지 않는다는 것은 다른 이야기였다. 실제로 밀림을 느꼈고, 그 원인을 찾는 데 trace가 결정적이었다.
반응이 늦었다
TBT가 남았고, 가장 큰 원인은 캐러셀이었다
LCP와 CLS가 기준 안에 들어오자 남은 것은 TBT였다. 글 페이지의 TBT가 약 2.1초로, 기준(200ms)의 10배였다. 화면은 빨리 뜨지만 그 뒤로 한동안 눌러도 반응이 늦다는 뜻이다.
원인을 찾으려고 글 페이지 아래쪽 컴포넌트를 하나씩 빼 보며 쟀다. "연관된 글" 캐러셀 하나만 빼도 TBT 중앙값이 약 2.1초에서 1.1초로 줄었다. 그리고 긴 작업 목록에 3초 간격으로 반복되는 작업이 있었다. 캐러셀의 자동 넘김 주기와 같았다.
바뀌는 값은 스타일이 아니라 CSS 변수로 넘겼다
블로그의 스타일은 Emotion으로 쓴다. 캐러셀은 몇 번째 카드를 보여줄지를 스타일 안에 직접 넣고 있었다.
// 전: 위치(currentIndex)가 스타일 안에 직접 들어간다
const Track = styled.div<{ visible: number; currentIndex: number; isTransitioning: boolean }>`
--visible: ${(props) => props.visible};
transform: translateX(
calc(-1 * ${(props) => props.currentIndex} * (var(--card) + var(--gap)))
);
transition: ${({ isTransitioning }) => (isTransitioning ? "transform 0.5s ease-in-out" : "none")};
`;
Emotion은 값이 바뀌면 그 값에 맞는 CSS 규칙을 새로 만들어 문서에 추가하고, 요소의 클래스를 바꾼다. 처음 보는 위치마다 규칙이 하나씩 늘었고, 넘길 때마다 카드까지 다시 그렸다. 3초마다 브라우저가 스타일을 다시 계산하는 긴 작업이 생겼고, 그 때문에 TTI가 18~21초까지 밀렸다.
바뀌는 값은 스타일이 아니라 CSS 변수로 넘기게 했다. 규칙은 하나로 고정되고, 넘길 때는 변수 값만 바뀐다.
const Track = styled.div`
--card: calc((100% - var(--gap) * (var(--visible) - 1)) / var(--visible));
transform: translateX(calc(-1 * var(--index) * (var(--card) + var(--gap))));
&[data-transitioning="true"] { transition: transform 0.5s ease-in-out; }
`;
<Carousel.Track style={{ "--visible": visible, "--index": currentIndex } as CSSProperties}>
카드 목록은 useMemo로 묶어 넘길 때 다시 만들지 않게 했다. 마운트할 때 캐러셀 폭을 읽던 코드도 바꿨다. hydration 직후에 요소의 폭을 읽으면, 긴 글 페이지 전체의 배치를 그 자리에서 강제로 다시 계산하게 된다. 폭은 ResizeObserver가 처음 알려 주는 값으로 받는다.
수정 후에는 3초 간격 긴 작업이 사라졌고, TTI는 대부분 7~8초대로 돌아왔다. 넘기는 동안 문서의 CSS 규칙 수도 325개에서 늘지 않았다.
숫자를 믿으면 안 되는 순간
같은 컴퓨터가 매번 다른 속도였다
캐러셀을 고친 뒤 TBT가 얼마나 줄었는지 재려고 했다. 그런데 결과가 이상했다. 같은 코드인데 TBT가 1초가 나오기도 하고 4초가 나오기도 했다.
"같은 코드인데 왜 매번 다른 숫자가 나오지?"
Lighthouse는 결과 파일에 benchmarkIndex라는 값을 남긴다. 측정하는 순간 그 컴퓨터가 얼마나 빨랐는지를 나타내는 점수다. 이 값을 회차마다 모아 보니 522에서 2540까지, 거의 5배 차이가 났다. 다른 작업이 같은 컴퓨터에서 돌고 있으면 측정용 브라우저도 함께 느려진다. 모바일 측정은 CPU를 4배 느리게 만드는 것이 기준인데, 컴퓨터 자체가 그때그때 다르게 느리니 기준이 무너진 셈이다.
같은 시간에 번갈아 쟀지만, 그래도 흔들렸다
시점이 다른 측정끼리 비교하면 그 사이 컴퓨터 상태가 바뀐다. 그래서 수정 전과 후를 각각 빌드해 서로 다른 포트에 동시에 띄우고, 한 번씩 번갈아 6회씩 쟀다.
| TBT 중앙값 | TTI 중앙값 | |
|---|---|---|
| 수정 전 | 2.11초 | 9.5초 |
| 수정 후 | 1.72초 | 10.4초 |
TBT는 줄었는데 TTI는 늘었다. 한 회차 안에서도 컴퓨터 속도가 흔들려서, 이 정도 차이는 우연과 구분할 수 없었다. 숫자만 보면 "효과 없음"이라고 쓸 수도, "20% 개선"이라고 쓸 수도 있는 상황이었다.
흔들리지 않는 신호로 판단했다
이럴 때는 측정 환경에 따라 달라지지 않는 신호를 본다.
- 긴 작업이 언제, 무엇 때문에 생겼는가. 수정 전에는 페이지를 연 지 20초가 넘어서도 3초마다 긴 작업이 찍혔다. 수정 후 6회에서는 한 번도 없었다.
- CSS 규칙이 늘어나는가. 수정 전 빌드는 넘길 때마다 규칙이 늘어 331개에서 멈췄고, 수정 후는 325개 그대로였다.
이 두 가지는 컴퓨터가 빠르든 느리든 같은 답을 준다. 그래서 캐러셀 수정은 의도한 대로 동작한다고 판단했다. 다만 그 덕분에 TBT가 몇 ms 줄었는지는 아직 말할 수 없다.
같은 기록을 다시 보다가 하나를 더 알게 됐다. 캐러셀을 고친 뒤 남은 가장 큰 긴 작업은 페이지를 연 지 약 1.4초 시점의 1.3~1.6초짜리 덩어리 하나였다. 캐러셀이 아니라 페이지 문서 자체에서 생긴 작업이다. 다음에 캐러셀을 더 손보려던 계획은 접었다. 고칠 곳은 이미 다른 데로 옮겨가 있었다.
결과
| 지표 | 전 | 후 | 기준 (좋음) |
|---|---|---|---|
| 홈 LCP | 11.5초 | 2.5~2.8초 | 2.5초 |
| 글 LCP | 7~10초 | 2.7~3.3초 | 2.5초 |
| 글 CLS | 0.053 | 0.003 | 0.1 |
| 글 TBT | 약 2.1초 | 판정 보류 | 200ms |
LCP는 기준 근처까지, CLS는 기준 안으로 들어왔다. TBT는 아직 기준의 몇 배다. 남은 1.4초 덩어리가 HTML을 읽는 일인지, 스크립트를 실행하는 일인지, 화면을 배치하는 일인지를 나누는 것이 다음 할 일이다.
측정은 모두 Lighthouse 12.6.1의 모바일 설정(devtools 방식)으로 했다. 운영과 로컬 next start에서 회차마다 3~6번 재서 범위로 적었다. 위 표의 "후" 값은 측정 컴퓨터가 비교적 빨랐던 회차들이다. 컴퓨터가 느렸던 회차에서는 같은 코드가 이보다 나쁘게 나왔다.
확인하지 못한 것도 있다. 위 값은 모두 실험실 측정이고, 실제 방문자의 기록(필드 데이터)은 아직 보지 않았다. 캐러셀 수정이 TBT를 몇 ms 줄였는지도 측정 컴퓨터가 흔들려 확정하지 못했다.
느낀점
- 느낌은 문제가 있다는 것까지만 알려준다. "글씨가 밀린다"는 느낌은 맞았지만, 원인이 글꼴이라는 짐작은 틀렸다. trace에 무엇이 어디서 어디로 움직였는지 적혀 있었다.
- 보기 좋게 하려던 효과가 지표를 망칠 수 있다. 페이드 하나가 LCP를 5초 넘게 늦췄다. 효과를 없앨 필요는 없었다. 시작점을 0에서 0.2로 바꾸는 것으로 충분했다.
- 글꼴은 언어에 따라 다른 문제다. 영문 글꼴을 기준으로 만든 도구와 조언은 한글 글꼴의 크기와 대체 글꼴 문제를 풀어 주지 못했다. 한글은 쓰는 글자만 남기는 것이 가장 효과가 컸다.
- 바뀌는 값을 어디에 두는지가 성능이 된다. 같은 화면이라도 위치 값을 스타일에 넣으면 3초마다 무거운 일이 생기고, CSS 변수에 넣으면 아무 일도 생기지 않았다.
- 숫자가 흔들리면, 흔들리지 않는 것을 본다. 같은 컴퓨터가 5배씩 속도가 달라지는 상황에서 중앙값 비교는 아무것도 증명하지 못했다. 언제 무엇이 일어났는지는 컴퓨터 속도와 상관없이 같은 답을 줬다.
성능 작업은 "빠르게 만드는 일"이라기보다 "느린 이유를 하나씩 확인하는 일"에 가까웠다. 원인을 하나 고치면 다음으로 큰 원인이 드러났고, 그때마다 다시 쟀다.
읽어주셔서 감사합니다.
댓글