H-3: 웹 성능 측정 ① — 재보고 나서 고칩니다
목차 42
안녕하세요, 홍순구 튜터입니다.
오늘은 숫자 하나로 시작할게요.
0.2214.
우리 인스타그램 클론을 실제로 재서 나온 값이고, 기준은 0.1이라 두 배를 넘겼습니다.
지난 시간에 우리는 오류가 우리에게 오게 만들었고, 지금 오류 목록은 깨끗해서 한 건도 안 올라오는데, 이 숫자는 그동안 계속 저러고 있었고 아무도 우리에게 알려주지 않았습니다.
오류는 터지면 티가 나서 화면이 하얘지고 봉투가 날아오고 우리가 달려가는데, 느린 것은 안 터집니다. 그냥 사람이 조용히 나가고, 나간 사람은 왜 나갔는지 안 알려주고요.
오늘은 그 조용한 쪽을 숫자로 잡는데, 재보면 놀라실 거예요 — 우리가 고쳐야 할 줄 알았던 곳이 아니라 전혀 다른 곳이 문제였거든요.
오늘의 여정
1. 무엇을 재야 하나 LCP · INP · CLS
2. 느낌 말고 숫자로 0.2214
3. 0 이 나왔다고 좋아하면 사진이 안 온 것일 수도
4. 무엇이 밀렸나 LI, LI, DIV...
5. 자리를 미리 잡아둔다 0.2214 -> 0.0000
💡 오늘 수업의 핵심 — "재보기 전에는 어디가 느린지 모릅니다. 그리고 잰 숫자가 0이라고 해서 좋은 것도 아닙니다"
🎯 학습 목표
- 사용자가 실제로 겪는 속도를 재는 세 가지 지표가 무엇이고 왜 그 셋인지 설명할 수 있습니다.
- 우리 앱을 직접 재서 셋 중 무엇이 기준을 넘겼는지 찾아내고, 그 숫자를 믿어도 되는지 스스로 확인합니다.
- 밀림을 일으킨 곳을 거슬러 올라가 찾아내고 고친 뒤, 정말 고쳐졌는지 다시 재서 확인합니다.
Step 1: "터진 건 없는데 사람들이 떠납니다"
느리다는 말은 너무 두루뭉술합니다
"우리 앱 좀 느린 것 같아요"라는 말을 들었다고 해볼게요. 무엇을 고쳐야 할까요?
이 말만으로는 아무것도 할 수 없습니다. 화면이 늦게 뜬다는 뜻일 수도 있고, 버튼을 눌렀는데 반응이 없다는 뜻일 수도 있고, 글을 읽는데 자꾸 움직여서 짜증난다는 뜻일 수도 있는데, 셋은 원인도 고치는 방법도 완전히 다릅니다.
구글이 이 셋을 갈라서 Core Web Vitals(핵심 웹 지표)라는 이름을 붙인 이유가 여기 있어요.
사용자가 겪는 세 가지 답답함
"화면이 언제 뜨지?" -> LCP 가장 큰 것이 그려진 시각
"눌렀는데 왜 반응이 없지?" -> INP 누른 뒤 화면이 답할 때까지
"읽고 있는데 왜 움직여?" -> CLS 화면이 얼마나 흔들렸나
하나씩 볼게요.
LCP(Largest Contentful Paint, 가장 큰 콘텐츠가 그려진 시점)는 화면에서 제일 큰 것 — 보통 큰 사진이나 제목 — 이 그려진 시각입니다. 왜 "제일 큰 것" 이냐면, 사람은 화면에서 제일 큰 것이 나타나야 "아 떴다" 고 느끼기 때문이에요. 구석의 작은 글자가 먼저 떠도 소용없습니다.
INP(Interaction to Next Paint, 상호작용부터 다음 그리기까지)는 사용자가 무언가를 누른 뒤 화면이 실제로 바뀔 때까지 걸린 시간입니다. 여기서 중요한 것은 누가 누르지 않으면 이 숫자가 아예 안 생긴다는 점이에요. 뒤에서 다시 만납니다.
CLS(Cumulative Layout Shift, 누적 레이아웃 이동)는 화면이 얼마나 흔들렸는지입니다. 글을 읽고 있는데 위에서 광고가 툭 튀어나와 읽던 줄을 놓쳐본 적 있으시죠. 그걸 숫자로 만든 것이고, 오늘의 주인공입니다.
기준선이 정해져 있습니다
세 지표에는 "이 정도면 괜찮다"는 선이 있어요.
| 지표 | 좋음 | 나쁨 | 무엇을 재나 |
|---|---|---|---|
| LCP | 2.5초 이하 | 4초 초과 | 화면이 떴다고 느낄 때까지 |
| INP | 200ms 이하 | 500ms 초과 | 누른 뒤 답할 때까지 |
| CLS | 0.1 이하 | 0.25 초과 | 얼마나 흔들렸나 |
LCP와 INP는 시간이라 감이 오는데 CLS는 단위가 없어서 낯설죠. 대략 화면의 몇 퍼센트가 얼마나 움직였는가를 곱해서 더한 값이라고 생각하시면 됩니다. 화면 전체가 절반 높이만큼 밀리면 0.5 쯤 되고, 0.1은 눈에 띄기 시작하는 선이에요.
⚠️ 평균이 아니라 75번째입니다
여기서 많이들 헷갈리는 것 하나를 짚고 갈게요.
이 기준은 평균으로 재지 않습니다. 실제 사용자들의 값을 줄 세워서 75번째 백분위수(아래에서 75% 지점)로 봐요.
왜 그럴까요? 평균은 소수의 아주 나쁜 경험을 다수의 좋은 경험이 덮어버립니다.
사용자 10명의 LCP (초)
0.8 0.9 1.0 1.0 1.1 1.2 1.3 1.4 8.0 9.0
평균 = 2.57초 -> "기준 2.5초를 살짝 넘겼네"
75번째 = 1.4초 -> "대부분은 괜찮다"
그런데 2명은 8초 넘게 기다렸다
평균만 보면 "전체적으로 좀 느리다"는 엉뚱한 결론이 나옵니다. 실제로는 대부분 빠른데 일부가 아주 느린 상황이거든요. 고쳐야 할 것은 전체가 아니라 그 일부예요.
🙋 잠깐, FID는 어디 갔나요
🙋 학생 질문 — "검색하면 FID라는 게 나오던데요?"
옛 자료를 보시면 FID(First Input Delay)라는 지표가 세 번째 자리에 있을 거예요. 2024년 3월에 INP 가 그 자리를 대체했습니다.
둘의 차이는 이렇습니다. FID는 사용자가 누른 뒤 브라우저가 처리를 시작하기까지만 쟀어요. 시작만 하고 처리가 오래 걸려서 화면이 5초 뒤에 바뀌어도 FID는 좋게 나왔습니다.
INP는 누른 순간부터 화면이 실제로 바뀔 때까지를 전부 잽니다. 사용자가 느끼는 것과 훨씬 가깝죠. 대신 통과하기가 훨씬 어려워졌어요. 세 지표 중에서 가장 많이 실패하는 것이 INP입니다.
FID라는 이름이 나오는 자료를 보면 2024년 3월 이전 것이라고 생각하시면 됩니다.
💡 한 줄 정리
"느리다"는 말은 세 가지 다른 문제를 뭉뚱그린 것이고, LCP·INP·CLS로 갈라야 무엇을 고칠지 정할 수 있습니다.
Step 2: "느낌 말고 숫자로 재봅니다"
브라우저가 이미 재두고 있습니다
좋은 소식이 있어요. 이 값들을 우리가 계산할 필요가 없습니다. 브라우저가 화면을 그리면서 이미 기록해두고 있어서, 우리는 받아 적기만 하면 됩니다.
받아 적는 도구가 PerformanceObserver입니다. 이름 그대로 성능 기록을 지켜보는 관찰자예요.
// apps/web-spa/src/lib/h3-vitals.ts
/** 밀림 한 건 — 얼마나 밀었고 누가 밀었나 */
export interface ShiftRecord {
value: number;
sources: string[];
}
export interface VitalsReport {
/** 가장 큰 것이 그려진 시각(ms). 아직 아무것도 안 그려졌으면 0 */
lcp: number;
/** 그 '가장 큰 것' 이 무엇이었나 */
lcpElement: string | null;
/** 밀린 정도의 합계 */
cls: number;
shifts: ShiftRecord[];
}
이제 관찰자를 붙입니다. 두 지표는 성격이 다르다는 것에 주의해주세요.
// apps/web-spa/src/lib/h3-vitals.ts
if (canObserve('largest-contentful-paint')) {
const lcpObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 더 큰 것이 뒤늦게 나타나면 답이 그쪽으로 바뀐다. 그래서 마지막 것이 답이다.
const lcpEntry = entry as PerformanceEntry & { element?: Element | null };
state.lcp = lcpEntry.startTime;
state.lcpElement = describeElement(lcpEntry.element);
}
report({ ...state, shifts: [...state.shifts] });
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
observers.push(lcpObserver);
}
LCP는 마지막에 온 것이 답입니다. 처음엔 제목이 제일 컸다가 나중에 사진이 뜨면 답이 사진으로 바뀌면서 계속 덮어써요.
CLS는 반대로 더하는 값이에요.
// apps/web-spa/src/lib/h3-vitals.ts
if (canObserve('layout-shift')) {
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as PerformanceEntry & {
value: number;
hadRecentInput: boolean;
sources?: { node?: Element | null }[];
};
// 방금 누른 뒤에 화면이 바뀌는 것은 사용자가 시킨 일이다.
// 그것까지 세면 정상 동작이 벌점이 된다.
if (shift.hadRecentInput) {
continue;
}
state.cls += shift.value;
state.shifts.push({
value: shift.value,
sources: (shift.sources ?? [])
.map((source) => describeElement(source.node))
.filter((name): name is string => name !== null),
});
}
report({ ...state, shifts: [...state.shifts] });
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
observers.push(clsObserver);
}
hadRecentInput 줄을 눈여겨봐주세요. 사용자가 "더 보기"를 눌러서 글이 펼쳐지는 것은 사용자가 시킨 일입니다. 그것까지 밀림으로 세면 정상 동작이 벌점이 되니까, 방금 누른 직후의 변화는 빼고 셉니다.
⚠️ 모르는 것을 관찰하려 하면 터집니다
브라우저마다 아는 지표가 다릅니다. 사파리는 오랫동안 layout-shift를 몰랐어요. 모르는 종류로 observe()를 부르면 예외가 납니다.
재려다가 앱을 죽이면 안 되니까, 먼저 물어봅니다.
// apps/web-spa/src/lib/h3-vitals.ts
function canObserve(type: string): boolean {
return PerformanceObserver.supportedEntryTypes?.includes(type) ?? false;
}
앱에 붙입니다
앱이 이걸 켜게 합니다. 그리기 전에 켜야 해요. 늦게 켜면 그 전에 그려진 것은 못 봅니다.
// apps/web-spa/src/main.tsx
let latest = { lcp: 0, cls: 0 };
function printVitals(label: string) {
const images = countFeedImages();
console.log(
`[성능] ${label} — LCP ${Math.round(latest.lcp)}ms · CLS ${latest.cls.toFixed(4)}` +
` · 사진 ${images.loaded}/${images.total}` +
(images.allLoaded ? '' : ' ⚠️ 아직 다 안 왔어요'),
);
}
observeVitals((report) => {
latest = report;
printVitals('재는 중');
});
⚠️ 개발 서버에서 재면 안 됩니다
여기서 아주 중요한 것 하나. 이 코드를 import.meta.env.DEV로 감싸고 싶어지실 거예요. 개발할 때만 보면 되니까요.
그러면 안 되는데, 사용자가 받는 것은 빌드된 배포본이고 개발 서버의 숫자는 그것과 다르기 때문이에요. 개발 서버는 파일을 하나씩 따로 내려주고 압축도 안 하다 보니, 거기서 잰 숫자는 아무도 겪지 않는 숫자라 이렇게 재야 합니다.
npm run build
npm run preview
연습용 API 서버도 함께 띄우고요.
node apps/api-stub/server.mjs
이제 브라우저에서 열고 개발자 도구 콘솔을 봅니다.
[성능] 재는 중 — LCP 0ms · CLS 0.0000 · 사진 0/0 ⚠️ 아직 다 안 왔어요
[성능] 재는 중 — LCP 104ms · CLS 0.0000 · 사진 0/0 ⚠️ 아직 다 안 왔어요
[성능] 재는 중 — LCP 532ms · CLS 0.0000 · 사진 0/10 ⚠️ 아직 다 안 왔어요
[성능] 재는 중 — LCP 532ms · CLS 0.1571 · 사진 0/10 ⚠️ 아직 다 안 왔어요
[성능] 재는 중 — LCP 532ms · CLS 0.2214 · 사진 0/10 ⚠️ 아직 다 안 왔어요
[성능] 재는 중 — LCP 1204ms · CLS 0.2214 · 사진 4/10 ⚠️ 아직 다 안 왔어요
숫자가 나왔는데, 셋 중 하나가 이상하죠.
- LCP 1204ms — 기준 2.5초 이하. 통과입니다.
- CLS 0.2214 — 기준 0.1 이하. 두 배를 넘겼어요.
여러분 화면의 숫자는 이것과 다를 겁니다. 컴퓨터도 다르고 인터넷 속도도 다르니까요. 그래도 CLS가 0.1을 훌쩍 넘는 것은 똑같이 보이실 거예요.
🙋 학생 질문 — "저는 개발자 도구에 Lighthouse라는 게 있던데 그건 뭔가요?"
개발자 도구에 있는 Lighthouse는 정해진 조건을 흉내 내서 재는 도구입니다. 느린 휴대폰과 느린 인터넷을 가정하고 점수를 매겨줘요.
오늘 우리가 하는 것은 지금 이 브라우저에서 실제로 벌어진 일을 받아 적는 것이고, Lighthouse는 가상의 조건에서 다시 돌려보는 것이라 성격이 다릅니다.
둘 다 필요하고, 둘이 아주 다른 답을 줄 때가 많아요. 그 이야기는 다음 시간에 본격적으로 합니다. 오늘은 지금 우리 눈앞의 숫자에 집중할게요.
💡 한 줄 정리
브라우저가 이미 기록해둔 값을 PerformanceObserver로 받아 적으면 되고, 재는 곳은 개발 서버가 아니라 빌드한 배포본이어야 합니다.
Step 3: "0이 나왔다고 좋아하면 안 되는 이유"
같은 숫자가 두 가지 뜻일 수 있습니다
방금 재면서 눈치채신 분이 계실 거예요. 로그 뒤에 이런 게 붙어 있었죠.
· 사진 0/10 ⚠️ 아직 다 안 왔어요
이건 제가 그냥 덧붙인 게 아닙니다. 없으면 우리가 잰 값을 믿을 수 없어서 넣은 거예요.
이유를 보여드릴게요. 제가 이 앱을 재면서 실제로 겪은 일입니다. 몇 번 재보니 이런 결과가 나왔어요.
CLS 0.0000
기분이 좋았습니다. 그런데 이상하죠. 방금 0.2214가 나왔는데 아무것도 안 고쳤는데 0이 나온 거예요.
원인은 이거였습니다. 우리 피드 사진은 외부 사진 서비스에서 받아옵니다. 그 서비스가 가끔 답을 안 줘요. 실제로 재보니 이랬습니다.
같은 주소로 5번 요청
1회 522 51.5초 걸려서 실패
2회 200 2.9초
3회 200 1.3초
4회 200 1.3초
5회 200 1.2초
첫 요청이 51초 만에 실패하면 사진이 한 장도 안 뜨는데, 사진이 없으니 밀 것도 없어서 밀림이 0이 됩니다.
CLS 0 의 두 가지 뜻
사진 10/10 도착 + 안 밀림 -> 진짜로 좋다
사진 0/10 도착 -> 잰 것이 없다
숫자는 똑같이 0.0000
숫자만 보면 완전히 똑같습니다. 앞의 것은 자랑할 일이고 뒤의 것은 다시 재야 하는 상황인데, 화면에 찍힌 글자는 구별이 안 돼요.
그래서 함께 셉니다
측정값 옆에 "이 측정을 믿어도 되는가"를 같이 두는 겁니다.
// apps/web-spa/src/lib/h3-vitals.ts
export function countFeedImages(): { total: number; loaded: number; allLoaded: boolean } {
// ⚠️ 고르는 기준을 width·height 로 삼으면 안 된다. 그 두 속성은 밀림을 고칠 때
// 우리가 붙이는 것이라, 고치기 전에는 한 장도 안 잡혀 0/0 이 나온다.
// "재기 전과 재기 후" 를 같은 자로 재려면 고침과 무관한 기준이어야 한다.
const images = [...document.querySelectorAll('img[alt$="의 게시물"]')] as HTMLImageElement[];
const loaded = images.filter((image) => image.complete && image.naturalWidth > 0);
return {
total: images.length,
loaded: loaded.length,
// 한 장도 없으면 '다 왔다' 가 아니라 '잰 것이 없다' 이다
allLoaded: images.length > 0 && loaded.length === images.length,
};
}
주석 두 개가 다 함정을 막고 있어요.
첫 번째. 사진을 고를 때 width 속성으로 고르고 싶어지는데, 그 속성은 다음 Step에서 우리가 붙일 것입니다. 그걸 기준으로 삼으면 고치기 전에는 사진이 0장으로 잡혀요. 재기 전과 재기 후를 같은 자로 재려면 고침과 무관한 기준이어야 합니다.
두 번째. 사진이 한 장도 없을 때 loaded.length === images.length는 0 === 0이라 참입니다. 그대로 두면 "다 왔다" 고 답해버리니까, 한 장도 없으면 거짓으로 만듭니다.
⚠️ 그런데 마지막 숫자를 볼 수가 없습니다
여기서 문제가 하나 더 생깁니다. 위 로그의 마지막 줄이 사진 4/10 이었죠. 나머지 6장은 그 뒤에 도착하는데, 관찰자는 새로 기록될 것이 있을 때만 울어요. 사진이 도착하는 것 자체는 성능 기록이 아니라 아무도 안 울리기 때문에, "다 왔다"는 순간을 따로 잡아야 합니다.
// apps/web-spa/src/lib/h3-vitals.ts
export function onFeedImagesSettled(callback: () => void): () => void {
let done = false;
const check = () => {
if (done || !countFeedImages().allLoaded) {
return;
}
done = true;
document.removeEventListener('load', check, true);
callback();
};
document.addEventListener('load', check, true);
check();
return () => {
done = true;
document.removeEventListener('load', check, true);
};
}
🙋 학생 질문 — "window의 load를 쓰면 되지 않나요? 그때 사진이 다 온 거잖아요"
일반적인 웹페이지라면 맞습니다. load는 문서와 그 안의 사진이 전부 도착했을 때 울려요.
그런데 우리 앱은 SPA입니다. 최초 HTML에는 사진이 한 장도 없어요. <div id="root"></div> 뿐이죠. 자바스크립트가 돌고, 서버에 게시물을 물어보고, 답이 온 뒤에 React가 사진을 붙이다 보니, load는 사진이 붙기 한참 전에 이미 끝나 있어요. 실제로 재보니 load 시점에 사진이 3/10 이었기 때문에, 사진 하나하나의 도착을 봐야 합니다. img의 load는 위로 안 올라오기 때문에(버블링이 없어요) 내려가는 길에서 잡습니다. addEventListener의 세 번째 인자 true가 그 뜻이에요.
앱에 이 줄을 붙이면 마지막 숫자가 보입니다.
// apps/web-spa/src/main.tsx
// 사진이 다 도착하는 순간. 밀림을 고치고 나면 관찰자가 안 울기 때문에
// 이 줄이 없으면 "다 왔는데 안 밀렸다" 를 확인할 자리가 없다.
onFeedImagesSettled(() => printVitals('사진 다 옴', true));
다시 재봅니다.
[성능] 사진 다 옴 — LCP 1120ms · CLS 0.2214 · 사진 10/10
사진 10/10이니, 이 0.2214는 믿어도 되는 숫자입니다.
💡 한 줄 정리
측정값 옆에는 "이 측정을 믿어도 되는가"가 함께 있어야 하고, 그게 없으면 0이 잘한 것인지 못 잰 것인지 구별할 수 없습니다.
Step 4: "무엇이 밀렸는지 보고 거슬러 올라갑니다"
숫자만으로는 고칠 데를 못 찾습니다
CLS 0.2214는 나쁘다는 것만 알려주지 어디를 고치라는 말은 아닙니다.
다행히 브라우저는 밀림을 기록할 때 어떤 요소가 움직였는지도 같이 남겨둡니다. 우리가 이미 그것을 받아두었으니(ShiftRecord의 sources), 이제 찍어보기만 하면 됩니다.
// apps/web-spa/src/main.tsx
// ⚠️ 여기 찍히는 것은 '민 것' 이 아니라 '밀린 것' 이다.
// 밀어낸 쪽은 자기 자리에서 자랐을 뿐이라 움직이지 않았고, 그래서 목록에 안 나온다.
// 고칠 데를 찾으려면 이 목록을 보고 "이것들 바로 위에 무엇이 있나" 를 거슬러 올라가야 한다.
if (withSources) {
for (const shift of latest.shifts) {
console.log(` ↳ ${shift.value.toFixed(4)} 밀렸다 · ${shift.sources.join(', ')}`);
}
}
⚠️ 이대로 넣으면 타입 검사가 막습니다.
Property 'shifts' does not exist on type '{ lcp: number; cls: number; }'.
Step 2에서 latest를 { lcp: 0, cls: 0 }로 시작했기 때문이에요. 그 두 칸만 있는 것으로 타입이 정해져 버렸습니다. 우리가 받는 것은 그보다 넓으니 제대로 알려줍니다.
// apps/web-spa/src/main.tsx
let latest: VitalsReport = { lcp: 0, lcpElement: null, cls: 0, shifts: [] };
VitalsReport를 import에 추가하는 것도 잊지 마세요. 좋은 오류예요 — 우리가 안 쓰던 값을 쓰기 시작했다는 것을 타입이 먼저 알려준 겁니다.
다시 재봅니다.
[성능] 사진 다 옴 — LCP 1120ms · CLS 0.2214 · 사진 10/10
↳ 0.1571 밀렸다 · LI, LI, DIV.px-(--card-spacing), DIV.flex, BUTTON.cursor-pointer
↳ 0.0643 밀렸다 · DIV.px-(--card-spacing), DIV.flex, BUTTON.cursor-pointer
두 번 밀렸고, 큰 쪽이 0.1571 이네요.
⚠️ 여기 나온 것은 범인이 아닙니다
이 목록을 처음 보면 "아, 여기 있는 것들을 고치면 되겠다" 고 생각하기 쉬운데 그게 아닙니다.
목록에 있는 LI는 댓글 한 줄이에요. DIV.px-(--card-spacing)은 카드 아래쪽 구역이고요. 이것들이 뭘 잘못했을까요? 아무것도 안 했습니다. 그냥 밀려난 쪽이에요.
layout-shift 기록의 sources는 위치가 바뀐 요소를 담습니다. 밀어낸 쪽은 자기 자리에서 커졌을 뿐 움직이지 않았으니 목록에 안 나와요.
사진이 도착하기 전 사진이 도착한 뒤
[ 사용자 이름 ] [ 사용자 이름 ]
[ 사진: 높이 0 ] [ ]
[ 좋아요 ] <- 여기 [ 사진 640px ]
[ 캡션 ] [ ]
[ 댓글 ] [ 좋아요 ] <- 여기로 밀렸다
[ 캡션 ]
[ 댓글 ]
목록에 찍히는 것 = 좋아요·캡션·댓글 (밀린 쪽)
실제 원인 = 사진 (자란 쪽, 목록에 없다)
이 목록을 읽는 방법은 이렇습니다. 밀린 것들을 찾아서 그 바로 위에 무엇이 있는지 거슬러 올라가는 거예요.
댓글과 카드 아래 구역이 밀렸다 → 그것들 위에는 무엇이 있나 → 사진입니다.
사진을 봅시다
// apps/web-spa/src/components/PostImage.tsx
<img
className="w-full cursor-pointer"
src={imageUrl}
alt={`${username} 의 게시물`}
onDoubleClick={handleDoubleClick}
/>
src만 있고 크기에 대한 정보가 하나도 없죠.
브라우저 입장에서 생각해보세요. 이 사진이 가로로 긴지 세로로 긴지, 얼마나 큰지 받아보기 전에는 알 방법이 없다 보니, 일단 높이 0으로 두고 아래 것들을 위로 당겨 그립니다. 그러다 사진이 도착하면 그제서야 자리를 만들고 아래를 전부 밀어내요.
밀린 것이 카드 한 장에 두 곳이고 카드는 열 장이라, 0.2214가 나온 겁니다.
🙋 학생 질문 — "Next.js에서는 이런 걸 겪은 기억이 없는데요?"
정확히 보셨어요. D-9에서 우리는 Next.js 앱의 피드에 사진을 붙이면서 같은 문제를 만났고, next/image로 해결했습니다. 그때 width와 height를 반드시 적게 되어 있었죠.
next/image가 특별한 마법을 부린 게 아니라, 바로 그 두 값으로 자리를 미리 잡아준 것입니다. 억지로 적게 만든 이유가 여기 있었어요.
지금 우리가 있는 곳은 Vite로 만든 SPA라 그런 컴포넌트가 없다 보니, 손으로 해야 하고 그 덕에 next/image가 속으로 무엇을 하고 있었는지 정확히 보게 됩니다.
💡 한 줄 정리
밀림 기록에 찍히는 것은 밀려난 쪽이지 밀어낸 쪽이 아니고, 고칠 곳은 그 목록을 보고 거슬러 올라가 찾아야 합니다.
Step 5: "자리를 미리 잡아두면 안 밀립니다"
두 줄이면 됩니다
브라우저에게 사진의 비율을 미리 알려주면 됩니다. 도착하기 전에 그만큼을 비워두게요.
// apps/web-spa/src/components/PostImage.tsx
<img
className="w-full cursor-pointer"
src={imageUrl}
width={640}
height={640}
alt={`${username} 의 게시물`}
onDoubleClick={handleDoubleClick}
/>
width와 height 두 줄이 전부입니다. className은 한 글자도 안 건드렸어요.
그런데 640px 로 고정되는 거 아닌가요
당연한 걱정입니다. width={640}이라고 적으면 화면이 좁아도 640px 로 나올 것 같잖아요.
안 그렇습니다. 이유가 두 겹이에요.
w-full이 CSS로 width: 100%를 걸고 있는데, CSS 는 HTML 속성을 이겨서 가로는 화면에 맞춰집니다.
그럼 세로는요? Tailwind가 기본으로 깔아주는 규칙(Preflight) 안에 이런 게 있습니다.
img {
height: auto;
}
세로는 auto라서 가로에 맞춰 비율대로 따라오는데, 그 비율을 브라우저가 아는 근거가 방금 적은 두 속성이에요.
정리하면 이렇습니다.
width·height 속성 -> 비율 640:640 = 1:1 을 알려준다 (자리 계산용)
CSS width: 100% -> 실제 가로는 화면에 맞춘다
CSS height: auto -> 세로는 비율대로 따라온다
크기를 고정하는 게 아니라 비율을 알려주는 겁니다.
다시 재봅니다
빌드하고 다시 열어보세요.
npm run build
npm run preview
[성능] 사진 다 옴 — LCP 1076ms · CLS 0.0000 · 사진 10/10
0.2214 → 0.0000. 밀림 목록도 비었습니다.
그리고 여기서 Step 3이 왜 필요했는지가 드러나요. 이 줄의 CLS 0.0000은 아까 사진이 안 왔을 때의 CLS 0.0000과 글자가 완전히 같습니다. 다른 것은 뒤에 붙은 사진 10/10 하나예요.
사진 0/10 · CLS 0.0000 -> 못 쟀다
사진 10/10 · CLS 0.0000 -> 고쳤다
그 한 조각이 없으면 우리는 방금 한 일이 성공인지 아닌지 알 수 없었을 겁니다.
세 지표를 다시 봅니다
| 지표 | 기준 | 고치기 전 | 고친 뒤 |
|---|---|---|---|
| LCP | 2.5초 이하 | 1.2초 | 1.1초 |
| CLS | 0.1 이하 | 0.2214 | 0.0000 |
| INP | 200ms 이하 | ? | ? |
두 개는 정리됐는데 INP 칸이 비어 있죠.
이건 빠뜨린 게 아닙니다. INP 는 아무리 기다려도 안 나와요. 우리가 페이지를 열어놓고 쳐다보기만 했기 때문입니다. 누른 사람이 없으면 "누른 뒤 화면이 답할 때까지"라는 값 자체가 생기지 않아요.
이 지표를 어떻게 재는지, 그리고 그것 때문에 어떤 도구는 이 지표를 원리적으로 만들어낼 수 없다는 이야기가 다음 시간의 출발점입니다.
🙋 학생 질문 — "사진 크기가 게시물마다 다르면 어떡하죠?"
우리 연습용 서버는 모든 사진을 640x640 정사각형으로 주기 때문에 하나의 값으로 충분했어요. 실제 서비스라면 게시물마다 다르겠죠.
그럴 때는 서버가 사진의 가로·세로를 함께 내려줘야 합니다. 게시물 데이터에 imageWidth, imageHeight를 담아 보내고 화면이 그것을 그대로 쓰는 방식이에요.
실제 인스타그램도 이렇게 하는데, 한 발 더 나가서 업로드할 때 정해진 몇 가지 비율로 잘라버려요(1:1, 4:5, 1.91:1). 사진마다 크기가 제각각이면 피드가 들쭉날쭉해 보이기도 하고, 오늘 본 문제를 원천적으로 막는 효과도 있습니다.
어느 쪽이든 원칙은 같아요. 그리기 전에 비율을 알고 있어야 한다. 그걸 서버에서 받아오든 업로드 때 강제하든요.
💡 한 줄 정리
사진에 width와 height를 적어 비율을 미리 알려주면 자리를 잡아두기 때문에 밀림이 사라지고, 그게 next/image가 하던 일이었습니다.
마무리
오늘 배운 핵심 세 가지
💡 하나. "느리다"는 세 가지 다른 문제입니다. 화면이 늦게 뜨는 것(LCP), 눌러도 반응이 없는 것(INP), 화면이 흔들리는 것(CLS)은 원인도 고치는 방법도 다릅니다. 갈라서 재야 무엇을 고칠지 정할 수 있고, 실제로 재보니 우리 앱은 셋 중 하나만 기준을 넘겨 있었어요. 전부 고칠 필요가 없었습니다.
💡 둘. 0이 좋은 소식이 아닐 수 있습니다. 사진이 안 오면 밀 것도 없어서 CLS가 0으로 나옵니다. 고쳐서 나온 0과 못 재서 나온 0은 화면에 찍힌 글자가 똑같아서, 측정값 옆에는 "이 측정을 믿어도 되는가"가 늘 함께 있어야 합니다.
💡 셋. 기록은 밀린 쪽을 알려주지 민 쪽을 알려주지 않습니다. 목록에 찍힌 댓글과 카드 아래 구역은 피해자였고, 원인인 사진은 자기 자리에서 자랐을 뿐이라 목록에 없었어요. 고칠 곳은 거슬러 올라가서 찾아야 했습니다.
다음 시간 예고
오늘 우리는 세 칸짜리 표를 두 칸만 채웠습니다. INP 칸이 비어 있죠.
다음 시간에는 먼저 그 칸을 채웁니다. 누군가 실제로 눌러야만 생기는 값이라 재는 방법 자체가 다르고, 재고 나면 그 시간이 대부분 어디서 쓰이는지가 예상과 다르실 거예요.
그다음에는 조건을 바꿔서 재봅니다. 우리는 지금 각자의 노트북에서 쟀어요. 그런데 우리 사용자가 전부 이런 컴퓨터를 쓰고 있을까요?
같은 앱을 같은 순간에 재는데 LCP가 1.1초로도 나오고 5초 안팎으로도 나옵니다. 어느 쪽이 맞는 숫자인지, 그리고 그 둘 중 하나만 보고 판단하면 무엇을 놓치는지 이야기할게요.
마지막으로, 지금까지 우리가 본 숫자는 전부 우리 화면의 숫자였습니다. 진짜로 알아야 할 것은 사용자들의 숫자죠. 지난 시간에 세워둔 것에 한 줄만 더하면 그게 모이기 시작합니다.
과제
[구현] 다른 화면도 재보세요
오늘 우리가 잰 것은 홈 화면입니다. 그런데 같은 사진이 다른 화면에도 나오죠.
두 화면을 각각 열어서 재보세요. 화면을 옮겨 다니지 말고 그 주소를 직접 열어서(새로고침) 재야 합니다. 우리 측정 코드는 앱이 처음 켜질 때 한 번만 켜지거든요.
http://localhost:4173/explore
http://localhost:4173/p/1
- 두 화면의 CLS는 각각 얼마인가요? 하나는 0이고 하나는 아닐 겁니다.
- 밀림이 있는 쪽에서 밀림 목록을 보고, 오늘 배운 방법대로 거슬러 올라가 원인을 찾아보세요.
- 찾았으면 고치고 다시 재보세요.
- 밀림이 없는 쪽은 왜 없을까요? 그 화면의 사진 코드를 열어보시면 우리와 다른 방법을 쓰고 있습니다. 두 방법의 차이를 설명해보세요.
⚠️ 홈 화면을 고쳤는데 다른 화면이 안 고쳐졌다면, 그건 우리가 놓친 게 아니라 원래 그렇습니다. 왜 그런지도 함께 답해보세요.
[구현] 같은 문제를 다른 방법으로 고쳐보세요
우리는 width·height 속성으로 고쳤습니다. 그런데 방법이 이것 하나는 아니에요.
PostImage.tsx에서 두 속성을 지우고, 대신 className에 aspect-square를 넣어보세요. 빌드하고 다시 재봅니다.
- CLS가 얼마인가요?
- 두 방법 다 통한다면, 무엇이 다른 걸까요?
차이를 보려면 정사각형이 아닌 사진이 필요합니다. 개발자 도구 콘솔에 이렇게 넣어보세요. 16:9 사진을 두 방법으로 각각 그려서 크기를 재는 코드입니다.
const SRC = 'https://picsum.photos/seed/wide/640/360';
const host = document.createElement('div');
host.style.cssText = 'width:470px;position:fixed;bottom:0;right:0;z-index:9999';
document.body.appendChild(host);
for (const [이름, 클래스, 속성] of [
['속성', 'w-full', true],
['aspect', 'aspect-square w-full', false],
]) {
const img = document.createElement('img');
img.className = 클래스;
img.src = SRC;
if (속성) { img.setAttribute('width', '640'); img.setAttribute('height', '640'); }
host.appendChild(img);
img.onload = () => console.log(이름, Math.round(img.getBoundingClientRect().height));
}
- 두 숫자가 다르게 나올 겁니다. 왜 그럴까요?
- 두 방법 중 인스타그램 피드에는 어느 쪽이 맞을까요? 판단의 근거를 적어보세요.
- 과제 1에서 본 탐색 화면은 어느 쪽을 쓰고 있었나요? 그 화면에는 왜 그쪽이 맞을까요?
확인했으면 반드시 되돌려주세요.
[탐구] 폰트를 걷어내면 얼마나 빨라질까요
우리 앱은 Geist라는 웹폰트를 씁니다. 빌드 결과를 보면 폰트 파일이 다섯 개 나와요.
geist-cyrillic-ext-wght-normal 7.42 kB
geist-vietnamese-wght-normal 8.00 kB
geist-cyrillic-wght-normal 15.08 kB
geist-latin-ext-wght-normal 16.51 kB
geist-latin-wght-normal 29.40 kB
우리는 한국어 앱인데 키릴 문자와 베트남어 폰트를 받고 있는 것처럼 보이죠. 합치면 76 kB가 넘습니다.
- 개발자 도구 Network 탭을 열고 새로고침해서, 실제로 몇 개를 내려받는지 세어보세요.
- 그 결과가 예상과 같나요? 다르다면 왜 그럴까요? (힌트: 빌드된 CSS에서
unicode-range를 찾아보세요.) globals.css의 폰트@import줄을 통째로 지우고 빌드해서 세 지표를 다시 재보세요. 얼마나 좋아지나요?
⚠️ 결과가 예상과 다를 겁니다. 그 "다름" 자체가 이 과제의 답이에요. 확인했으면 되돌려주세요.
[탐구] 사진 서비스를 일부러 막아보세요
Step 3에서 이야기한 상황을 직접 만들어봅니다.
개발자 도구 Network 탭에서 사진 요청 하나에 오른쪽 클릭 → Block request domain을 고르고 새로고침해보세요.
- 콘솔에 몇 줄이 찍히나요? 정상일 때와 견줘보세요.
- 마지막 줄이 무엇으로 끝나는지 잘 보세요.
사진 다 옴줄이 아예 안 나올 겁니다. 왜 안 나올까요? - 화면을 보면 사진 자리는 비어 있어도 레이아웃은 안 무너져 있습니다. 개발자 도구 Elements 탭에서
img를 세어보면 10개가 그대로 있어요. 왜 자리가 남아 있을까요? (오늘 고친 것과 관계가 있습니다.) - 우리 코드는 사진이 도착하는 것만 지켜보고 실패하는 것은 안 봐서, "다 왔다"도 "실패했다"도 아닌 채로 조용히 끝납니다. 이걸 고치려면 무엇을 더 지켜봐야 할까요?
⚠️ 마지막 항목이 이 과제의 핵심입니다. 아무 말도 안 하는 것과 "괜찮다" 고 말하는 것은 다른데, 지금 우리 코드는 그 둘을 구별해주지 않아요.
생각해볼 주제
1. 우리가 못 재고 있는 사용자들
오늘 우리는 각자의 노트북에서 앱을 재고 "CLS를 0으로 만들었다"는 결론을 얻었습니다.
그런데 우리 사용자 중에는 지하철에서 5년 된 휴대폰으로 접속하는 사람도 있고, 인터넷이 느린 지역에서 접속하는 사람도 있어요. 그 사람들의 숫자는 우리 노트북 숫자와 같을까요?
세 지표 중에서 사용자의 기기나 인터넷 속도에 크게 영향받는 것과 거의 영향받지 않는 것을 갈라보세요. 그 차이가 "우리가 재서 통과했다"는 결론에 어떤 구멍을 남기는지 이야기해봅시다.
2. 고칠 수 없는 밀림
오늘 우리가 고친 밀림은 원인이 분명했습니다. 사진 크기를 몰라서 생긴 것이고, 알려주니 사라졌어요.
그런데 이런 경우를 생각해보세요. 로그인한 사용자에게만 보이는 알림 배너가 화면 맨 위에 있습니다. 로그인 여부는 서버에 물어봐야 알 수 있고, 답이 오기 전에는 배너를 보여줄지 말지 결정할 수 없어요.
이 배너는 답이 온 뒤에 나타나면서 아래를 전부 밉니다. 크기는 알고 있는데도요.
이런 밀림은 어떻게 다뤄야 할까요? 몇 가지 방법을 떠올려보고, 각각이 무엇을 대가로 치르는지 이야기해봅시다.
3. 숫자를 목표로 삼으면 생기는 일
CLS를 0으로 만들었습니다. 훌륭한 결과죠.
그런데 이런 방법으로도 CLS를 0으로 만들 수 있어요. 피드 전체를 고정 높이 상자에 넣고 넘치는 부분을 잘라버리는 겁니다. 아무것도 안 밀리니까 CLS는 완벽하게 0이 됩니다. 사용자는 게시물을 반쯤만 볼 수 있게 되지만요.
지표를 좋게 만드는 것과 사용자 경험을 좋게 만드는 것이 어긋나는 경우입니다.
이런 어긋남을 팀에서 어떻게 막을 수 있을까요? 오늘 우리가 한 일은 어느 쪽이었는지, 그렇게 말할 수 있는 근거는 무엇인지 이야기해봅시다.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 다른 화면도 재보세요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 두 화면을 각각 새로 열어서 쟀다 | 5 | 화면을 옮겨 다니면 측정이 다시 안 켜진다는 것을 알고 있다 |
| 밀림이 있는 쪽을 정확히 골랐다 | 5 | 탐색은 0, 상세는 0 이 아니다 |
| 밀림 목록을 보고 원인까지 거슬러 올라갔다 | 5 | 상세 화면의 사진이 카드의 것과 다른 코드라는 것을 찾아냈다 |
| 고치고 다시 재서 확인했다 | 5 | 0 이 된 것을 숫자로 보였다 |
| 밀림이 없는 쪽의 이유를 설명했다 | 5 | 탐색 화면이 다른 방법을 쓰고 있다는 것을 짚었다 |
| 합계 | 25 |
풀이 예시
두 화면을 각각 열어서 재면 이렇게 나옵니다.
/explore [성능] 사진 다 옴 — LCP 1088ms · CLS 0.0000 · 사진 10/10
/p/1 [성능] 사진 다 옴 — LCP 908ms · CLS 0.0485 · 사진 1/1
↳ 0.0485 밀렸다 · DIV.px-(--card-spacing)
상세 화면이 아직 밀립니다. 0.0485는 기준 0.1 아래라 통과이긴 하지만 0은 아니에요.
밀림 목록을 보면 DIV.px-(--card-spacing) 하나뿐입니다. 카드 안쪽 내용 구역이죠. 그것 위에 무엇이 있나 거슬러 올라가면 사진입니다. 그런데 우리는 사진을 고쳤는데요?
상세 화면 코드를 열어보면 이유가 나옵니다.
// apps/web-spa/src/routes/PostDetailPage.tsx
<img className="w-full" src={post.imageUrl} alt={`${post.username} 의 게시물`} />
이 화면은 PostImage를 안 씁니다. 자기 <img>를 따로 그리고 있어요. 우리가 고친 것은 피드 카드의 사진이고, 여기는 다른 코드입니다.
같은 방법으로 고칩니다.
// apps/web-spa/src/routes/PostDetailPage.tsx
<img
className="w-full"
src={post.imageUrl}
width={640}
height={640}
alt={`${post.username} 의 게시물`}
/>
다시 재면 이렇게 됩니다.
/p/1 [성능] 사진 다 옴 — LCP 520ms · CLS 0.0000 · 사진 1/1
탐색 화면은 왜 처음부터 0 이었을까요. 코드를 보면 답이 있습니다.
// apps/web-spa/src/routes/ExplorePage.tsx
<img
className="aspect-square w-full rounded-sm object-cover"
aspect-square로 CSS가 비율을 강제하고 있어요. 사진이 오기 전부터 정사각형 상자가 잡혀 있으니 밀릴 것이 없습니다. 우리와 다른 방법인데 결과는 같아요. 둘의 차이는 과제 2에서 다룹니다.
자주 나오는 실수
화면을 옮겨 다니며 쟀다. 홈에서 시작해 링크를 눌러 상세로 가면 숫자가 안 나옵니다. 측정은 앱이 처음 켜질 때 한 번만 시작하고, SPA는 화면을 옮겨도 앱이 다시 안 켜지거든요. 주소창에 직접 넣고 새로고침해야 합니다.
"우리가 Step 5를 잘못했나"로 결론지었다. Step 5는 맞게 했어요. 같은 사진을 그리는 코드가 여러 곳에 있었을 뿐입니다. 한 곳을 고쳤다고 전부 고쳐지지 않는다는 것이 이 과제의 요점이에요.
💡 튜터의 한마디 — 이 과제가 진짜로 알려주는 것은 "고쳤다"를 화면 단위로 말해야 한다는 겁니다. 우리는 홈 화면을 재서 0을 만들었고 그건 사실인데, "우리 앱의 CLS는 0 이다"라고 말하면 거짓이 됩니다. 실제 서비스에서는 화면이 수십 개고, 그중 몇 개를 재봤는지가 그 문장의 무게를 정합니다.
🎯 [과제 2 예시답안] 같은 문제를 다른 방법으로 고쳐보세요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
aspect-square로도 CLS가 0 이 되는 것을 확인했다 |
5 | 방법이 하나가 아니라는 것을 숫자로 봤다 |
| 두 방법의 렌더 결과가 다르다는 것을 재서 보였다 | 5 | 264 와 470 |
| 왜 다른지 설명했다 | 5 | 하나는 비율을 알려주고 하나는 비율을 강제한다 |
| 피드에 어느 쪽이 맞는지 근거를 들어 판단했다 | 5 | 사용자 사진을 그대로 보여줘야 한다 |
| 탐색 화면이 왜 반대 선택을 했는지 설명했다 | 5 | 격자는 크기가 고르게 보여야 한다 |
| 합계 | 25 |
풀이 예시
aspect-square로 바꿔도 CLS는 0입니다.
[성능] 사진 다 옴 — LCP 1048ms · CLS 0.0000 · 사진 10/10
16:9 사진으로 견줘보면 결과가 갈립니다.
aspect 470
속성 264
aspect-square는 470x470 정사각형이고, 속성 방식은 470x264예요. 470 x 9 ÷ 16 = 264 니까 원본 비율 그대로입니다.
왜 이렇게 될까요. 두 방법이 하는 말이 다르기 때문입니다.
width·height 속성 "이 사진의 비율은 아마 1:1 일 것이다"
-> 도착 전에는 그 비율로 자리를 잡는다
-> 도착한 뒤에는 진짜 비율이 이긴다 (height: auto)
aspect-square "무슨 사진이 오든 1:1 로 보여라"
-> 도착 전에도 후에도 1:1
-> 16:9 사진은 세로로 늘어난다
속성은 추정이고 CSS는 명령입니다.
피드에는 속성 방식이 맞습니다. 사용자가 올린 사진을 우리가 임의로 늘리거나 자르면 안 되니까요. 인스타그램에서 세로로 긴 사진이 세로로 길게 보이는 것이 정상입니다.
탐색 화면은 반대예요. 거기는 사진들이 격자로 늘어서는 곳이고, 하나만 세로로 길면 격자가 깨집니다. 그래서 aspect-square로 칸을 고정하고 object-cover로 넘치는 부분을 잘라냅니다. 사진 하나하나보다 전체 모양이 중요한 화면이라 그렇습니다.
자주 나오는 실수
"둘 다 CLS 0 이니까 아무거나 써도 된다"로 끝냈다. 지표만 보면 같지만 사용자가 보는 것이 다릅니다. 지표를 통과시키는 것과 화면을 제대로 만드는 것은 다른 일이에요.
aspect-square만 넣고 object-cover를 안 넣었다. 사진이 늘어나서 찌그러집니다. 잘라내려면 object-cover가 함께 있어야 해요. 탐색 화면 코드에 둘이 같이 있는 이유입니다.
💡 튜터의 한마디 — 속성 방식이 "추정"이라는 것에는 숨은 뜻이 하나 더 있습니다. 우리 연습용 서버가 모든 사진을 640x640으로 주기 때문에 그 추정이 늘 맞아떨어졌어요. 만약 세로로 긴 사진이 하나 섞여 들어오면 도착하는 순간 한 번 밀립니다. 추정이 틀렸기 때문에, 실제 서비스는 사진의 진짜 크기를 서버에서 함께 내려주거나 업로드할 때 비율을 정해버립니다.
🎯 [과제 3 예시답안] 폰트를 걷어내면 얼마나 빨라질까요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 실제 다운로드 개수를 세어봤다 | 5 | 다섯이 아니라 하나다 |
왜 하나인지 unicode-range로 설명했다 |
8 | 선언은 조건부 등록이지 다운로드 지시가 아니다 |
| 폰트를 떼고 세 지표를 다시 쟀다 | 7 | 숫자로 보였다 |
| 결과가 예상과 다른 이유를 설명했다 | 5 | 폰트가 임계 경로에 없다 |
| 합계 | 25 |
풀이 예시
Network 탭을 열고 새로고침하면 woff2 요청이 한 건만 보입니다.
geist-latin-wght-normal-BgDaEnEv.woff2 29.4 kB
빌드 결과에는 다섯 개가 있는데 하나만 받아왔어요. 빌드된 CSS를 보면 이유가 나옵니다.
@font-face {
font-family: 'Geist Variable';
src: url(./files/geist-cyrillic-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
unicode-range는 "이 글자들이 화면에 나오면 이 파일을 받아라"라는 조건입니다. 우리 화면에 키릴 문자는 한 글자도 없으니 그 파일은 영영 안 받아요.
@font-face 선언은 다운로드 지시가 아니라 조건부 등록입니다. 다섯 개를 선언해두면 브라우저가 필요한 것만 골라 받아요. 이미 최적화되어 있던 겁니다.
그럼 남은 하나를 떼면 어떻게 될까요. globals.css의 @import 줄을 지우고 재보면 이렇습니다.
| 폰트 있음 | 폰트 뗌 | |
|---|---|---|
| 받은 폰트 | 1개 29.4 kB | 0개 |
| CSS 크기 | 44.00 kB | 42.50 kB |
| LCP | 1068~1116ms | 1076~1120ms |
| CLS | 0.0000 | 0.0000 |
세 지표가 하나도 안 움직입니다. 29 kB를 아꼈는데 사용자가 겪는 속도는 그대로예요.
이유가 셋입니다.
첫째, 이 폰트에는 font-display: swap이 이미 걸려 있습니다. 폰트가 오기 전에도 대체 글꼴로 글자를 그려주기 때문에 화면이 비어 있는 시간이 없어요.
둘째, 폰트는 141ms 에 시작해서 16ms 만에 끝납니다. 우리 LCP는 1.1초짜리 사진이에요. 폰트는 그 길목에 있지도 않습니다.
셋째, 우리 화면 글자의 대부분은 한글인데 이 폰트에는 한글이 없습니다. 다섯 개 어디에도 한글 영역(U+AC00–D7A3)이 없어요. 한국어는 처음부터 시스템 글꼴로 그려지고 있었습니다.
자주 나오는 실수
빌드 결과의 파일 목록을 다운로드로 읽었다. dist/에 파일이 다섯 개 있는 것과 브라우저가 다섯 개를 받는 것은 전혀 다른 이야기입니다. 재는 곳은 Network 탭이에요.
"안 줄었으니 이 과제는 실패"로 적었다. 안 줄어든 것이 답입니다. 고치기 전에 재보지 않았다면 우리는 없는 문제를 고치느라 시간을 썼을 거예요.
💡 튜터의 한마디 — 이 과제는 "측정 먼저"를 가장 아프게 보여주는 자리입니다. 폰트 다섯 개는 누가 봐도 낭비처럼 생겼어요. 그런데 실제로는 하나만 받고 있었고, 그 하나마저 지표에 영향이 없었습니다. 직관이 두 번 틀렸어요. 반대로 진짜 문제였던 사진은 코드만 봐서는 아무 이상이 없어 보였죠. 재보지 않으면 이 둘을 절대 못 바꿔 놓습니다.
🎯 [과제 4 예시답안] 사진 서비스를 일부러 막아보세요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 정상일 때와 줄 수를 견줬다 | 5 | 마지막 줄이 없어진다 |
사진 다 옴이 안 나오는 이유를 설명했다 |
7 | 도착을 지켜보는데 도착하지 않았다 |
| 레이아웃이 안 무너진 이유를 설명했다 | 6 | 자리를 미리 잡아뒀기 때문 |
| 무엇을 더 지켜봐야 하는지 답했다 | 7 | 실패도 함께 봐야 한다 |
| 합계 | 25 |
풀이 예시
사진 서비스를 막고 새로고침하면 콘솔이 이렇게 끝납니다.
[성능] 재는 중 — LCP 0ms · CLS 0.0000 · 사진 0/0 ⚠️ 아직 다 안 왔어요
[성능] 재는 중 — LCP 108ms · CLS 0.0000 · 사진 0/0 ⚠️ 아직 다 안 왔어요
두 줄에서 멈추고, 사진 다 옴 줄이 아예 안 나와요.
당연합니다. 우리 코드는 사진이 도착하는 것을 지켜보고 있는데 도착하지 않았으니까요.
// apps/web-spa/src/lib/h3-vitals.ts
document.addEventListener('load', check, true);
load는 성공했을 때만 울립니다. 실패한 사진은 error를 울리는데 우리는 그걸 안 듣고 있어요.
한편 화면을 보면 사진 자리는 비어 있는데 아래 내용은 제자리에 있습니다. Elements 탭에서 세어보면 img는 10개가 그대로 있고 문서 높이도 정상일 때와 같아요.
오늘 고친 것 덕분입니다. width·height로 자리를 잡아뒀으니 사진이 오든 안 오든 그 공간은 비워져 있어요. 고치기 전이었다면 사진이 실패했을 때 카드가 납작해졌을 겁니다.
마지막 질문이 이 과제의 핵심이에요. 지금 우리 코드는 이 상황에서 아무 말도 안 하는데, "아무 말도 안 하는 것"과 "괜찮다고 말하는 것"은 다르죠. 로그만 보고는 둘을 구별할 수 없습니다.
고치려면 실패도 함께 지켜봐야 하는데, ⚠️ 리스너 한 줄만 더해서는 안 고쳐집니다.
document.addEventListener('error', check, true); // 이것만으로는 그대로다
check 안에서 allLoaded를 묻고 있기 때문이에요. 실패한 사진은 complete는 true인데 naturalWidth가 0이라 영영 "다 왔다"가 안 되다 보니, error가 울려도 곧바로 돌아 나와요.
세는 방법을 함께 고쳐야 합니다.
// apps/web-spa/src/lib/h3-vitals.ts
const loaded = images.filter((image) => image.complete && image.naturalWidth > 0);
// 실패한 사진도 complete 는 true 다. 갈리는 것은 naturalWidth 뿐이다.
const failed = images.filter((image) => image.complete && image.naturalWidth === 0);
기다림을 끝내는 조건도 "다 왔나"에서 "더 기다릴 것이 남았나"로 바꿉니다.
const check = () => {
const { total, loaded, failed } = countFeedImages();
if (done || total === 0 || loaded + failed < total) {
return;
}
이렇게 고치고 다시 막아보면 로그가 말을 합니다.
[성능] 사진 다 옴 — LCP 116ms · CLS 0.0000 · 사진 0/10 ⚠️ 실패 10장
침묵 대신 실패 10장이라고 알려주죠. 사진이 정상일 때는 전과 똑같이 사진 10/10으로 끝납니다.
자주 나오는 실수
"CLS 0 이니까 괜찮다"로 답했다. Step 3에서 다룬 바로 그 함정입니다. 사진이 안 왔으니 밀 것도 없었을 뿐이에요.
error 리스너만 더하고 끝냈다. 위에서 본 그대로 아무것도 안 달라집니다. 직접 막아보고 로그를 확인하지 않으면 고쳤다고 착각하기 딱 좋은 자리예요. 고친 뒤에는 반드시 다시 막아서 재보세요.
error를 window에 붙였다. 이미지 로딩 실패는 위로 안 올라옵니다. load와 같은 이유로 내려가는 길에서 잡아야 해요(세 번째 인자 true).
💡 튜터의 한마디 — 계측 코드가 조용한 것을 "문제 없음"으로 읽는 실수는 실무에서 아주 자주 일어납니다. 오류 목록이 비어 있으면 앱이 멀쩡한 걸까요, 아니면 오류를 보내는 코드가 죽은 걸까요? 지난 시간에 만든 것도 같은 질문을 안고 있어요. 계측은 "괜찮다"와 "모르겠다"를 구별해서 말할 수 있어야 합니다.
🤔 [생각해볼 주제 1] 우리가 못 재고 있는 사용자들
문제 상황 요약
우리는 각자의 노트북에서 앱을 재고 "CLS를 0으로 만들었다"는 결론을 얻었습니다. 그런데 사용자 중에는 오래된 휴대폰을 쓰는 사람, 인터넷이 느린 곳에 있는 사람이 있어요. 세 지표 중 무엇이 그 차이에 크게 흔들리고 무엇이 안 흔들리는지, 그리고 그것이 우리 결론에 어떤 구멍을 남기는지 봅니다.
튜터의 가이드 및 해설
세 지표를 성격으로 갈라보면 답이 보입니다.
LCP 는 시간입니다. 사진을 받아서 그리기까지 걸린 시간이라 인터넷이 느리면 그대로 늘어나고 기기가 느려도 늘어나요. 느린 휴대폰과 느린 인터넷을 흉내 내서 재보면 우리 LCP 1.1초가 5초 넘게 나옵니다. 같은 앱, 같은 순간인데요.
INP 도 시간입니다. 누른 뒤 화면이 바뀔 때까지인데, 그 사이에 자바스크립트가 도는 시간이 대부분이라 CPU가 느리면 직격탄을 맞습니다. 세 지표 중 기기 성능에 가장 민감한 것이 INP예요.
CLS 는 시간이 아닙니다. "얼마나 움직였나"는 배치의 성질이라 기기가 빠르든 느리든 비슷하게 나옵니다. 실제로 느린 조건에서 재봐도 0.221이 0.289 정도로만 움직여요. 앞의 둘이 5배씩 뛰는 것과 대조적입니다.
여기서 우리 결론의 구멍이 드러납니다.
우리가 재서 얻은 결론 그 결론이 유효한 범위
"CLS 0 이다" 거의 모든 사용자에게 참
"LCP 1.1초라 괜찮다" 우리 노트북에서만 참
"INP 는 아직 모른다" 아무에게도 안 재봤다
CLS에 대한 자신감은 정당합니다. 배치를 고쳤으니 누구에게나 고쳐졌어요.
그런데 LCP에 대한 "통과"는 우리 기계의 이야기일 뿐이고, INP는 우리가 페이지를 쳐다보기만 했기 때문에 아예 숫자가 없어요.
그러면 어떻게 해야 할까요. 두 가지가 필요합니다. 하나는 조건을 바꿔서 재보는 것이고(느린 기기·느린 인터넷을 흉내 낸다), 다른 하나는 실제 사용자에게서 모으는 것입니다. 앞의 것은 우리가 지금 당장 할 수 있고 재현도 되지만 어디까지나 흉내예요. 뒤의 것은 진짜지만 배포하고 기다려야 나옵니다.
둘 다 필요하고, 어느 하나만 보면 반쪽입니다.
🎯 면접관을 홀리는 핵심 멘트
"세 지표 중 CLS는 배치의 성질이라 기기 차이에 거의 안 흔들리지만, LCP와 INP는 시간이라 기기와 회선을 그대로 탑니다. 그래서 저는 CLS는 제 노트북에서 고치고 끝내도 된다고 보지만, LCP·INP에 대해서는 '제 환경에서 통과했다'는 말을 결론으로 쓰지 않습니다. 흉내 낸 조건과 실사용자 값을 함께 보고 나서야 판단합니다."
🤔 [생각해볼 주제 2] 고칠 수 없는 밀림
문제 상황 요약
로그인한 사용자에게만 보이는 알림 배너가 화면 맨 위에 있습니다. 로그인 여부는 서버에 물어봐야 알 수 있어서, 답이 오기 전에는 배너를 보여줄지 말지 정할 수 없어요. 크기는 알고 있는데도 답이 온 뒤에 나타나면서 아래를 전부 밉니다.
튜터의 가이드 및 해설
오늘 고친 밀림과 이것이 무엇이 다른지부터 봅시다.
사진은 있다는 것은 아는데 얼마나 큰지 몰랐던 경우라서, 크기를 알려주니 해결됐습니다. 배너는 반대예요. 크기는 아는데 있을지 없을지를 모르니, 알려줄 것이 없어요.
방법이 몇 가지 있고 전부 무언가를 내줍니다.
하나. 자리를 항상 비워둔다. 배너 높이만큼을 처음부터 비워두고, 배너가 없으면 그냥 빈 채로 둡니다. 밀림은 확실히 사라져요. 대신 로그인 안 한 사용자는 이유 없는 빈 공간을 보게 됩니다. 배너가 클수록 손해가 커요.
둘. 배너를 화면 위에 띄운다. 문서 흐름에서 빼내고(position: fixed 같은 방식) 내용 위에 겹치게 합니다. 흐름 밖에 있으니 아무것도 안 밀어요. 대신 내용을 가립니다. 맨 위 글이 배너에 덮이죠.
셋. 아래가 아니라 위로 밀어낸다. 배너를 화면 맨 아래에 두면 나타나도 밀 것이 없습니다. 다만 알림이 아래에 있으면 사람들이 잘 안 봐요. 알리려고 만든 것이 안 보이면 만든 이유가 사라집니다.
넷. 답을 미리 안다. 로그인 여부를 서버가 화면과 함께 내려주면 처음 그릴 때부터 알 수 있어요. 이러면 밀림이 아예 안 생깁니다. 대신 화면을 서버가 그려서 보내야 하고, 그건 지금 우리 SPA 구조를 바꾸는 일이에요.
방법 없앤 것 내준 것
자리 비우기 밀림 전부 빈 공간
위에 띄우기 밀림 전부 가려지는 내용
아래로 옮기기 밀림 전부 눈에 덜 띈다
미리 알기 밀림 전부 구조를 바꿔야 한다
넷 다 밀림은 없앱니다. 그러니 고르는 기준은 "밀림이 없어지나"가 아니라 "무엇을 내줄 수 있나"예요.
배너가 얇으면 첫 번째가 싸고, 배너가 아주 중요하면 두 번째가 낫고, 마침 서버 렌더링으로 옮기는 중이라면 네 번째가 공짜에 가깝습니다. 상황을 모르면 못 고르는 문제입니다.
🎯 면접관을 홀리는 핵심 멘트
"밀림에는 '크기를 몰라서' 생기는 것과 '있을지를 몰라서' 생기는 것이 있습니다. 앞엣것은 크기를 알려주면 끝나지만, 뒤엣것은 정답이 없고 무엇을 내줄지 고르는 문제가 됩니다. 그래서 저는 이런 요소를 만날 때 먼저 '이 정보를 첫 응답에 실을 수 있나'를 묻고, 안 되면 자리를 비울지 겹칠지를 배너의 중요도로 정합니다."
🤔 [생각해볼 주제 3] 숫자를 목표로 삼으면 생기는 일
문제 상황 요약
피드 전체를 고정 높이 상자에 넣고 넘치는 부분을 잘라버리면 CLS는 완벽하게 0이 됩니다. 아무것도 안 밀리니까요. 사용자는 게시물을 반쯤만 볼 수 있게 되지만, 지표는 만점입니다. 지표를 좋게 만드는 것과 사용자 경험을 좋게 만드는 것이 어긋나는 경우입니다.
튜터의 가이드 및 해설
이런 어긋남에는 이름이 있습니다. 측정값이 목표가 되면 좋은 측정값이기를 그만둔다는 이야기예요. 성능 지표만의 문제가 아니라 테스트 커버리지, 응답 시간, 버그 건수 어디서나 일어납니다.
왜 일어날까요. 지표는 결과를 요약한 숫자인데, 그것을 목표로 삼는 순간 사람들이 요약을 직접 만들려고 하기 때문입니다. 원래 재려던 것을 거치지 않고 숫자만 만드는 지름길이 항상 있어요.
CLS가 재려던 것은 "읽는 중에 화면이 흔들려서 짜증나는가" 였는데, 피드를 잘라버리면 흔들림은 없어지지만 읽을 것 자체가 없어져요. 재려던 문제를 푼 게 아니라 재는 행위를 피한 겁니다.
막는 방법은 몇 가지 있습니다.
지표를 혼자 보지 않는다. CLS만 보면 자르는 게 이득이지만, 옆에 "게시물을 몇 개나 끝까지 봤나" 같은 숫자를 두면 자르는 순간 그쪽이 무너집니다. 지름길이 다른 지표를 깨뜨리게 만드는 거예요.
바꾼 것을 함께 적는다. "CLS 0.22 → 0"만 적으면 통과지만, "무엇을 해서"를 함께 적으면 리뷰에서 걸립니다. 숫자만 오가는 보고는 지름길을 숨기기 좋아요.
숫자를 목표가 아니라 신호로 쓴다. "이번 분기 CLS 0.1 이하 달성"은 목표고, "CLS가 0.1을 넘으면 알림을 받고 원인을 본다"는 신호입니다. 앞엣것은 숫자를 만들게 하고 뒤엣것은 원인을 보게 해요.
그럼 오늘 우리가 한 일은 어느 쪽이었을까요.
우리는 사진에 width와 height를 적었습니다. 그 결과로 사용자가 겪는 일이 실제로 달라졌어요. 사진이 도착할 때 읽던 줄이 안 밀립니다. 지표가 좋아진 것은 그 변화의 결과이지 목적이 아니었죠.
구별하는 질문은 이겁니다. 지표를 안 재고 있었어도 이 변경을 했을까? 사진 밀림은 지표가 없어도 고칠 만한 문제였습니다. 피드 자르기는 지표가 없으면 아무도 안 할 짓이고요.
🎯 면접관을 홀리는 핵심 멘트
"지표가 목표가 되면 사람들은 지표를 만들지 문제를 풀지 않습니다. CLS는 피드를 잘라버리면 0이 되니까요. 그래서 저는 성능 지표를 목표치가 아니라 알림 기준으로 씁니다. 그리고 개선안을 볼 때 '이 지표를 안 재고 있었어도 이걸 했겠나'를 묻습니다. 답이 아니오면 숫자만 만든 겁니다."