H-4: 웹 성능 측정 ② — 조건을 바꿔 재고, 사용자에게서 모읍니다
목차 64
안녕하세요, 홍순구 튜터입니다.
지난 시간에 우리는 지표 세 개를 갈라놓고 두 개만 채웠습니다.
LCP 1068 ms 통과
INP ?
CLS 0.0000 통과 (밀림을 고쳤습니다)
오늘은 저 빈칸부터 채웁니다. 재는 코드는 이미 있어서 몇 줄만 더하면 되고 실제로 십 분이면 붙는데, 막상 붙이고 새로고침하면 이렇게 나와요.
[성능] 사진 다 옴 — LCP 1068ms · INP 아직 없음 · CLS 0.0000 · 사진 10/10
여전히 비어 있죠. 새로고침을 몇 번 더 해도 똑같습니다. 코드가 잘못된 게 아니에요. 이 값은 우리가 기다린다고 생기는 종류가 아닙니다.
오늘은 여기서 출발해서 같은 앱을 조건만 바꿔가며 재보면, 같은 화면인데 숫자가 다섯 배 차이 나는 것을 보게 되실 거예요. 마지막에는 우리 노트북을 떠나서 실제 사용자들이 겪는 숫자도 모으기 시작합니다.
오늘의 여정
1. 눌러야 생기는 값 아직 없음 -> 32ms
2. 그 시간은 어디로 기다림 / 우리 코드 / 그리기
3. 느린 기기에서는 32 -> 112ms
4. 조건을 통째로 바꾸면 1.1s -> 4.9s
5. 그 점수의 정체 79점, 그런데 INP 가 없다
6. 사용자에게서 모은다 0 -> 1
7. 봉투를 열어본다 범인을 이름으로 지목
8. 그 값 +61 kB, 40 kB/방문
💡 오늘 수업의 핵심 — "같은 앱인데 재는 조건이 다르면 숫자가 다섯 배 갈립니다. 어느 쪽도 거짓말이 아니에요"
🎯 학습 목표
- 사용자가 눌러야만 생기는 지표를 직접 재고, 그 시간이 어디에서 쓰였는지 세 칸으로 갈라 읽습니다.
- 같은 앱을 조건만 바꿔 재서 숫자가 어떻게 달라지는지 확인하고, 흉내 낸 조건이 알려줄 수 있는 것과 원리적으로 알려줄 수 없는 것을 구분합니다.
- 우리 화면의 숫자를 넘어 실제 사용자에게서 속도를 모으기 시작하고, 그 대가가 얼마인지 숫자로 답합니다.
Step 1: "INP는 누가 눌러야 나옵니다"
앞의 둘과 셋째는 성격이 다릅니다
지난 시간에 우리가 LCP와 CLS를 어떻게 얻었는지 떠올려볼게요. 우리는 브라우저에게 물어보기만 했습니다. 관찰자를 하나 붙이고 "가장 큰 것이 그려지면 알려줘", "뭔가 밀리면 알려줘"라고 해두면 브라우저가 알아서 기록해뒀다가 넘겨줬어요.
INP는 그렇지 않습니다.
LCP 화면을 그리다 보면 저절로 생긴다
CLS 화면이 움직이면 저절로 생긴다
INP 아무도 안 누르면 영원히 안 생긴다
INP는 Interaction to Next Paint, 우리말로 하면 "누른 뒤 다음 그림까지"입니다. 사용자가 뭔가를 눌러야 시작되는 값이라 아무도 안 누른 화면에는 잴 것이 없는데, 그래서 우리가 지금까지 아무리 새로고침을 해도 칸이 안 채워졌던 거예요.
브라우저에게 무엇을 물어봐야 하나
LCP는 largest-contentful-paint, CLS는 layout-shift였죠. 상호작용은 event입니다.
// apps/web-spa/src/lib/h3-vitals.ts
if (canObserve('event')) {
const inpObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const eventEntry = entry as PerformanceEntry & { interactionId?: number };
// 스크롤처럼 사용자가 답을 기다리지 않는 것도 event 로 들어온다.
// 브라우저가 그런 것에는 interactionId 를 0 으로 줘서 갈라준다.
if (!eventEntry.interactionId) {
continue;
}
const previous = interactions.get(eventEntry.interactionId) ?? 0;
interactions.set(eventEntry.interactionId, Math.max(previous, entry.duration));
}
state.inp = worstInteraction(interactions);
state.interactions = interactions.size;
report({ ...state, shifts: [...state.shifts] });
});
inpObserver.observe(options);
observers.push(inpObserver);
}
interactions라는 Map이 하나 보이시죠. 왜 그냥 배열에 쌓지 않고 번호를 키로 묶는지가 이 Step의 핵심입니다.
한 번 눌렀는데 줄이 여러 개 찍힙니다
앱을 띄우고 하트를 두 번만 눌러보세요. 콘솔에 이렇게 나옵니다.
[성능] 재는 중 — LCP 1068ms · INP 16ms (상호작용 2건) · CLS 0.0000 · 사진 10/10
[성능] 재는 중 — LCP 1068ms · INP 16ms (상호작용 2건) · CLS 0.0000 · 사진 10/10
[성능] 재는 중 — LCP 1068ms · INP 24ms (상호작용 2건) · CLS 0.0000 · 사진 10/10
[성능] 재는 중 — LCP 1068ms · INP 24ms (상호작용 3건) · CLS 0.0000 · 사진 10/10
[성능] 재는 중 — LCP 1068ms · INP 24ms (상호작용 3건) · CLS 0.0000 · 사진 10/10
두 번 눌렀는데 줄은 다섯 개인데, 괄호 안의 숫자를 보시면 2에서 3으로 한 번만 늘었습니다.
이유는 하트를 한 번 누르면 브라우저가 이벤트를 세 개 만들기 때문입니다. 손가락이 내려갈 때 pointerdown, 떼어질 때 pointerup, 그리고 click. 셋을 따로 세면 한 번 누른 것이 세 번으로 보이겠죠.
브라우저는 이 셋에 같은 interactionId를 붙여줘서, 우리는 그 번호로 묶어 "이건 한 번이야"라고 셀 수 있어요. 셋 중에서는 가장 오래 걸린 것을 그 상호작용의 시간으로 치는데, 사용자가 기다린 시간은 셋을 더한 값이 아니라 누른 순간부터 화면이 답할 때까지의 한 덩어리니까요.
🙋 학생 질문 — "상호작용 3건이라는데, 저는 두 번밖에 안 눌렀는데요?"
잘 보셨습니다. 하나가 더 있는데, 로그인 버튼입니다.
좋아요는 로그인해야 서버에 저장되니까 실습 전에 머리말에서 로그인을 하셨을 텐데, 그 클릭도 상호작용입니다. 사용자가 눌렀고 화면이 답했으니까요.
이게 INP의 성격을 잘 보여줍니다. INP 는 우리가 관심 있는 클릭만 세지 않아요. 사용자가 그 화면에서 누른 전부를 세다 보니, 우리가 "좋아요 버튼 성능"을 재고 싶어도 사용자에게는 그 화면에서 겪은 모든 반응이 하나의 경험이거든요.
나중에 실제 사용자 숫자를 볼 때도, INP가 나쁘다고 해서 그게 꼭 우리가 의심하던 그 버튼 때문은 아닙니다.
여럿 중에 무엇이 답인가
열 번 눌렀으면 값이 열 개 나옵니다. 그중 무엇을 INP라고 부를까요?
function worstInteraction(durations: Map<number, number>): number | null {
if (durations.size === 0) {
return null;
}
const sorted = [...durations.values()].sort((a, b) => b - a);
const index = Math.min(sorted.length - 1, Math.floor(sorted.length / 50));
return sorted[index];
}
내림차순으로 정렬한 다음 Math.floor(길이 / 50) 번째를 고릅니다. 열 번 눌렀으면 floor(10 / 50)이 0 이니까 가장 나쁜 하나가 곧 INP예요.
왜 평균이 아니라 최악일까요? 사용자가 앱을 쓰다가 아홉 번은 즉각 반응했고 한 번 크게 버벅였다면, 기억에 남는 것은 그 한 번입니다. 평균을 내면 그 한 번이 아홉 번에 희석돼서 사라져버려요.
그렇다면 / 50은 뭘까요. 오래 쓰는 사람일수록 딸꾹질을 한 번쯤 만날 확률이 높습니다. 그 한 번으로 앱 전체를 판정하면 억울하니까, 상호작용이 50건 늘어날 때마다 가장 나쁜 것을 하나씩 걷어내주는 겁니다. 우리처럼 몇 번 안 눌렀으면 걷어낼 것이 없고요.
⚠️ 그래서 한 번 재고 끝내면 안 됩니다
이 정의 때문에 재미있는 일이 생깁니다. 저는 우리 앱을 같은 방법으로 열다섯 번 재봤는데, 열네 번은 32ms 근처였고 한 번은 248ms가 나왔어요.
그 회차의 상호작용 열 개를 늘어놓으면 이렇습니다.
248 32 24 24 24 24 24 24 24 24
^
이 하나가 INP 를 정한다
아홉 개는 멀쩡한데 하나가 튀었고, 정의상 그 하나가 답이 됩니다. 앱이 갑자기 나빠진 게 아니라 그 순간 브라우저가 다른 일에 잠깐 붙들렸던 거예요.
여기서 배울 것은 두 가지입니다. 하나는 INP 는 한 번 재고 "우리 앱은 32ms"라고 말하면 안 된다는 것. 다른 하나는, 그래서 진짜 답은 우리 노트북에서 몇 번 재보는 게 아니라 많은 사용자에게서 모으는 것이라는 겁니다. 그건 Step 6에서 합니다.
⚠️ 0이 아니라 "아직 없음"입니다
아직 아무도 안 눌렀을 때 우리 코드는 null을 돌려줍니다. 0이 아니에요.
이게 왜 중요한지는 지난 시간에 이미 겪으셨습니다. CLS가 0으로 나왔을 때 그게 "안 밀렸다"인지 "사진이 안 와서 잴 게 없었다"인지 구별이 안 됐죠. 여기서도 똑같습니다.
INP 0ms -> "우와 엄청 빠르다"
INP 아직 없음 -> "아직 아무도 안 눌렀구나"
0으로 두면 아무도 안 써본 화면이 세상에서 가장 빠른 화면으로 보고됩니다. 잰 적이 없는 것과 빠른 것은 다릅니다.
🙋 학생 질문 — "저는 durationThreshold: 16을 40으로 바꿨더니 아무것도 안 나오는데요?"
정확히 그렇게 되는데, 그 이유가 우리 앱의 성격을 알려줘요.
durationThreshold는 "이만큼 넘는 것만 알려달라"는 뜻입니다. 규격이 허락하는 가장 낮은 값이 16이고, 실무에서 흔히 쓰는 값은 40이에요. 40을 쓰는 이유는 그보다 빠른 반응은 사람이 못 느끼니까 굳이 보고받을 필요가 없다는 판단입니다.
우리 앱의 상호작용은 대부분 16~32ms 안에서 끝나서, 40으로 두면 하나도 안 걸려요. 그래서 우리는 16으로 뒀습니다.
여기서 하나 짚고 싶은 게 있어요. 40으로 뒀을 때 화면에 뜨는 건 "INP 아직 없음"이라, "충분히 빨라서 보고할 게 없다"와 "재는 설정이 잘못됐다"가 화면에서 똑같이 보입니다. 우리가 0 대신 "아직 없음"을 쓰기로 한 덕분에 최소한 "0ms 라니 완벽하네"로 오해하지는 않게 됐지만, 그래도 이 둘을 가르려면 결국 상호작용 건수를 함께 봐야 합니다.
⚠️ 타입이 브라우저를 못 따라온 곳
위 코드에서 observe(options)라고 변수를 넘긴 게 눈에 걸리셨을 수 있습니다. 보통은 이렇게 쓰죠.
inpObserver.observe({ type: 'event', buffered: true, durationThreshold: 16 });
이대로 쓰면 타입 검사가 막습니다.
error TS2353: Object literal may only specify known properties,
and 'durationThreshold' does not exist in type 'PerformanceObserverInit'.
브라우저는 이 옵션을 분명히 받는데 타입 정의에는 아직 없어서 생기는 어긋남이라, 받는 모양을 우리가 한 번 적어주고 변수로 넘깁니다.
interface EventObserverInit extends PerformanceObserverInit {
durationThreshold: number;
}
const options: EventObserverInit = { type: 'event', buffered: true, durationThreshold: 16 };
as로 억지로 통과시킬 수도 있지만, 이렇게 해두면 오타도 잡히고 값이 숫자인지도 검사됩니다. 도구가 아직 모르는 것을 우리가 알려주는 쪽이 낫습니다.
💡 한 줄 정리
INP는 브라우저가 알아서 기록해두는 값이 아니라 누군가 눌러야 생기는 값이고, 한 번 누를 때 나오는 여러 이벤트를 interactionId로 묶은 뒤 그중 가장 나쁜 하나를 답으로 삼기 때문에 한 번 재고 끝내면 안 됩니다.
Step 2: "그 시간은 어디서 쓰였을까요"
24ms 를 알았다고 고칠 데를 아는 건 아닙니다
지난 시간에 CLS가 0.2214라는 것만으로는 아무것도 못 했던 것을 기억하시죠. 무엇이 밀렸는지 목록을 받고 나서야 사진에 닿을 수 있었습니다.
INP도 똑같습니다. "24ms 걸렸다"는 것만으로는 무엇을 고쳐야 할지 알 수 없고, 그 24ms 가 어디에서 쓰였는지를 봐야 합니다.
다행히 브라우저는 그 내역도 함께 줍니다. 누른 뒤 화면이 답할 때까지는 세 단계로 갈립니다.
눌렀다 ─────> 우리 코드가 시작 ─────> 우리 코드가 끝 ─────> 화면에 그려짐
(1) (2) (3)
- (1) 기다림 — 눌렀는데 브라우저가 다른 일을 하고 있어서 우리 코드를 아직 시작 못 한 시간
- (2) 우리 코드 — 우리가 붙인
onClick같은 처리가 실제로 도는 시간 - (3) 그리기 — 그 결과를 화면에 그려서 눈에 닿기까지
세 칸을 계산합니다
브라우저가 주는 엔트리에는 시각이 여럿 들어 있습니다. 그것들을 빼면 세 칸이 나와요.
// apps/web-spa/src/lib/h3-vitals.ts
interactions.set(eventEntry.interactionId, {
duration: entry.duration,
// 눌렀는데 브라우저가 다른 일을 하고 있어서 기다린 시간
inputDelay: eventEntry.processingStart - entry.startTime,
// 우리가 붙인 처리 코드가 도는 시간
processing: eventEntry.processingEnd - eventEntry.processingStart,
// 그 결과가 화면에 그려지기까지
presentation: entry.startTime + entry.duration - eventEntry.processingEnd,
});
startTime이 누른 시각, processingStart와 processingEnd가 우리 코드의 시작과 끝, 그리고 startTime + duration이 화면이 답한 시각입니다. 빼기 세 번이면 끝이에요.
한 가지 더 손봐야 했습니다. 전에는 상호작용마다 숫자 하나(가장 긴 시간)만 기억했는데, 이제 그 시간의 내역까지 들고 있어야 하니 기억하는 것이 기록 하나로 바뀝니다.
function worstInteraction(records: Map<number, InteractionRecord>): InteractionRecord | null {
if (records.size === 0) {
return null;
}
const sorted = [...records.values()].sort((a, b) => b.duration - a.duration);
const index = Math.min(sorted.length - 1, Math.floor(sorted.length / 50));
return sorted[index];
}
고르는 규칙은 그대로고, 고른 뒤에 내역이 딸려온다는 것만 달라졌습니다.
⚠️ 평균이 아니라 그 한 번의 내역입니다
여기서 실수하기 쉬운 곳이 있어요. 상호작용이 열 개면 내역도 열 개인데, 그걸 평균 내면 안 됩니다.
INP는 최악의 한 번이었죠. 그럼 우리가 알고 싶은 것도 그 최악의 한 번이 어디에서 시간을 썼는지입니다. 나머지 아홉 번은 멀쩡했으니 평균에 섞으면 범인이 희석돼요.
그래서 위 코드는 고른 기록의 내역을 그대로 씁니다.
재봅니다
다시 하트를 누르면 줄이 하나 더 붙습니다.
[성능] 재는 중 — LCP 1108ms · INP 24ms (상호작용 1건) · CLS 0.0000 · 사진 10/10
↳ 기다림 5.7ms · 우리 코드 0.9ms · 그리기 17.4ms
세 칸을 더해보시면 5.7 + 0.9 + 17.4 = 24.0으로 정확히 INP와 같은데, 어디가 큰지도 바로 보이시죠. 그리기가 17.4ms 로 압도적이고, 우리가 쓴 코드는 0.9ms 입니다.
이게 무슨 뜻일까요. 우리가 좋아요 버튼에 붙인 코드는 상태를 하나 뒤집고 서버에 요청을 보내는 게 전부입니다. 그 일 자체는 1ms 도 안 걸려요. 시간의 대부분은 브라우저가 바뀐 화면을 실제로 그려서 모니터에 내보내는 데 쓰입니다.
🙋 학생 질문 — "그럼 그리기를 줄이면 INP가 좋아지나요?"
줄일 수 있는 부분과 없는 부분이 섞여 있습니다.
그리기 시간에는 브라우저가 화면을 다시 계산하고 칠하는 시간이 들어갑니다. 우리가 한 번에 화면을 아주 많이 바꾸면 이 시간이 늘어나요. 예를 들어 좋아요 하나를 눌렀는데 화면 전체가 다시 그려지도록 코드를 짜뒀다면 여기서 값을 치릅니다.
우리 앱은 이미 그렇지 않은데, C-8에서 확인했듯이 하트를 눌러도 카드 세 장만 다시 그려지고 그마저도 컴파일러가 대부분 걸러줬어요.
줄일 수 없는 부분도 있습니다. 화면은 정해진 주기로만 갱신되는데, 흔한 모니터가 초당 60번이니까 한 번 갱신에 약 16.7ms 예요. 우리 코드가 0ms 만에 끝나도 다음 갱신 차례를 기다려야 화면이 바뀝니다.
그러니 17.4ms 라는 숫자는 사실 "거의 다음 차례를 기다린 시간"이라, 여기서 더 줄일 여지는 별로 없어요. 우리 앱의 INP가 낮은 이유는 우리가 코드를 잘 짜서라기보다 누를 때 할 일이 거의 없기 때문입니다.
💡 한 줄 정리
INP는 기다림 · 우리 코드 · 그리기 세 칸으로 갈리고, 우리 앱은 거의 전부가 마지막 칸입니다. 갈라서 봐야 "우리 코드를 고쳐야 하는가"와 "원래 그만큼 걸리는가"를 구별할 수 있습니다.
Step 3: "느린 기기에서는 어떨까요"
우리 노트북은 사용자의 기기가 아닙니다
지금까지 잰 숫자는 전부 우리 개발용 컴퓨터에서 나왔는데, 우리 사용자 중에는 몇 년 된 휴대폰으로 지하철에서 접속하는 사람도 있어요.
그 사람의 INP도 32ms 일까요?
직접 확인해볼 수 있습니다. 개발자 도구의 Performance 탭을 열면 톱니바퀴 아이콘 옆에 CPU라는 항목이 있고, 거기서 4x slowdown 같은 것을 고를 수 있어요. 브라우저가 일부러 느리게 도는 겁니다.
⚠️ 조이기는 화면이 다 뜬 다음에 거세요. 처음부터 걸어두면 페이지가 뜨는 것 자체가 느려져서, 우리가 재려는 것(누른 뒤의 반응)이 아니라 다른 것을 재게 됩니다.
배율을 올려가며 재봤습니다
각 배율에서 다섯 번씩, 회차마다 하트를 열 번 누른 결과입니다.
CPU INP 기다림 우리 코드 그리기
1배 32 ms 0.1~0.7 0.0~0.1 31.3~31.8
4배 32 ms 1.6~2.4 0.0~0.1 29.6~30.4
10배 56 ms 5.0~11.0 0.0~0.9 46.3~53.0
20배 112 ms 7.6~21.5 0.0~4.5 86.0~112.1
먼저 눈에 띄는 것은 4배까지는 거의 안 변한다는 겁니다. 32ms 그대로예요.
10배, 20배로 가면 늘어나기 시작하지만, 20배에서도 112ms 고 기준은 200ms 죠. 우리 앱은 스무 배 느린 기기에서도 INP를 통과합니다.
왜 이렇게 잘 버틸까요
세 칸으로 갈라 두길 잘했습니다. 이유가 표에 그대로 보이거든요.
「우리 코드」 칸을 보세요. 어느 배율에서도 0.0~4.5ms 입니다. 20배로 조여도 그렇습니다.
CPU를 20배 느리게 만들면 계산하는 일은 20배 느려지는데, 우리 코드가 하는 계산이 원래 0.1ms 어치밖에 없어서 20배가 되어도 2ms 예요. 늘어날 것이 없는 겁니다.
늘어나는 것은 그리기입니다. 31 → 30 → 50 → 100ms 로 배율을 따라가죠. 화면을 계산하고 칠하는 일은 진짜로 CPU를 쓰는 일이니까요.
CPU 를 20배 조이면
우리 코드 0.1ms -> 2ms (원래 할 일이 없다)
그리기 31ms -> 100ms (진짜로 계산하는 일이다)
그러니 "우리 앱은 느린 기기에서도 잘 반응한다"는 결론은 맞지만, 그 이유는 우리가 최적화를 잘해서가 아니라 버튼이 하는 일이 원래 가벼워서입니다. 만약 좋아요 버튼이 누를 때마다 목록 전체를 다시 정렬하는 코드를 갖고 있었다면 이 표는 완전히 달랐을 거예요.
🙋 학생 질문 — "1배랑 4배가 똑같이 32ms 인 게 이상한데요. 4배면 4배 느려야 하는 거 아닌가요?"
좋은 의심이고, 이 질문이 성능을 재는 일의 성격을 잘 보여줘요.
CPU 조이기는 계산하는 시간을 늘립니다. 기다리는 시간은 안 늘려요.
우리 32ms 의 대부분은 "다음 화면 갱신 차례를 기다리는 시간"이라고 Step 2에서 이야기했죠. 그건 CPU가 빠르든 느리든 똑같이 기다립니다. 모니터가 초당 60번 갱신하는 건 CPU 사정과 무관하니까요.
그러다 보니 4배까지는 늘어난 계산 시간이 그 기다림 안에 다 들어가버립니다. 10배쯤 가야 계산 시간이 기다림보다 커지면서 밖으로 삐져나와요.
이걸 뒤집어 보면 이렇습니다. CPU 를 조여도 숫자가 안 변한다면, 그건 그 화면이 CPU를 별로 안 쓴다는 뜻입니다. 안 변하는 것도 정보예요.
💡 한 줄 정리
CPU를 조여가며 재면 어느 칸이 진짜 계산이고 어느 칸이 기다림인지 갈립니다. 우리 앱은 20배에서도 통과하는데, 그 이유는 우리 코드가 빨라서가 아니라 할 일이 거의 없어서입니다.
Step 4: "조건을 통째로 바꿔서 재봅니다"
CPU만 조이는 걸로는 부족합니다
방금은 CPU 한 가지만 바꿨는데, 지하철에서 휴대폰으로 접속하는 사람은 CPU만 느린 게 아니에요. 인터넷도 느리고, 화면도 작습니다.
이 조건들을 한꺼번에 흉내 내주는 도구가 있습니다. 개발자 도구에 들어 있는 Lighthouse예요. 지난 시간 질문 토글에서 이름만 짚고 넘어갔던 그 도구입니다.
Lighthouse는 "느린 휴대폰 + 느린 인터넷"이라는 정해진 조건을 가정하고 우리 페이지를 처음부터 다시 불러온 뒤, 그 조건에서 나온 숫자로 점수를 매겨줘요.
터미널에서도 돌릴 수 있습니다.
npx lighthouse http://localhost:4173/ \
--only-categories=performance \
--form-factor=mobile \
--throttling.cpuSlowdownMultiplier=4 \
--throttling.rttMs=150 \
--throttling.throughputKbps=1638 \
--view
CPU 4배, 왕복 지연 150ms, 대역폭 약 1.6Mbps. 대충 몇 년 된 안드로이드 폰이 조금 느린 모바일 네트워크에 붙어 있는 상황입니다.
같은 앱인데 숫자가 다섯 배 갈립니다
우리가 직접 잰 값과 Lighthouse가 준 값을 나란히 놓아볼게요.
우리가 잰 값 Lighthouse
LCP 1.1 초 4.9 초
CLS 0.0000 0
INP 32 ms (없음)
LCP가 1.1초에서 4.9초로 뜁니다. 기준이 2.5초니까 한쪽은 여유 있게 통과고 다른 쪽은 확실한 실패예요.
⚠️ 이 4.9초는 제 노트북에서 잰 값이고, 돌릴 때마다 4.7~5.2초 사이를 오갑니다. 우리 피드 사진을 바깥 사진 서비스에서 받아오는데 그쪽 응답이 매번 조금씩 다르기 때문이에요. 여러분 화면에서는 조금 다른 숫자가 나올 겁니다. 중요한 건 정확한 값이 아니라 네다섯 배 차이가 난다는 쪽입니다.
어느 쪽이 맞는 숫자일까요
학생분들이 여기서 가장 많이 묻는 질문입니다. 답은 둘 다 맞다예요.
우리가 잰 1.1초는 이 노트북에서 실제로 벌어진 일입니다. 거짓이 아니에요. Lighthouse의 4.9초는 저 조건이었다면 벌어졌을 일입니다. 이것도 거짓이 아닙니다.
두 숫자가 다른 이유는 앱이 달라서가 아니라 조건이 달라서라, 이 둘에는 이름이 따로 있습니다.
우리가 직접 재는 것 실험실에서 재는 것
Lighthouse 가 재는 것 정해진 조건을 흉내 낸 것
실제 사용자에게서 모으는 것 현장에서 오는 것
Step 6 에서 시작합니다
실험실 값은 조건이 고정돼 있어서 비교하기 좋습니다. 고치기 전과 고친 뒤를 재면 코드가 좋아졌는지 나빠졌는지가 깨끗하게 갈려요. 대신 그 조건이 우리 사용자의 조건이라는 보장은 없습니다.
현장 값은 진짜 사용자의 숫자라 반박할 수 없습니다. 대신 사람마다 기기도 네트워크도 달라서 왜 나빠졌는지 짚기가 어려워요.
🙋 학생 질문 — "그럼 개발할 때는 어느 쪽을 봐야 하나요?"
둘 다 보되, 언제 보느냐가 다릅니다.
코드를 고치는 동안에는 실험실 값을 봅니다. 조건이 고정돼 있어서 "내가 방금 한 게 좋아지게 했나"를 바로 알 수 있거든요. 사용자에게서 모은 값으로 이걸 하려면 배포하고 며칠을 기다려야 합니다.
무엇을 고칠지 정할 때는 현장 값을 봅니다. 실험실이 "LCP를 고쳐라"라고 해도 실제 사용자들은 다 통과하고 있을 수 있고, 우리가 지금 딱 그 상황이라 그 시간을 다른 데 쓰는 게 낫죠.
한 가지 더. 실험실 값이 나쁘다고 무시하면 안 됩니다. 우리 사용자 중에 그 조건에 가까운 사람이 실제로 있을 가능성이 높으니까요. 우리 노트북이 유난히 좋은 것뿐입니다.
💡 한 줄 정리
같은 앱을 같은 순간에 재도 조건이 다르면 LCP가 1.1초로도 4.9초로도 나옵니다. 둘 다 참이고, 실험실 값은 변화를 재기에 좋고 현장 값은 무엇을 고칠지 정하기에 좋습니다.
Step 5: "그 점수는 무엇을 본 숫자일까요"
79점이 나왔습니다
Lighthouse를 돌리면 큰 동그라미 안에 점수가 뜹니다. 우리 앱은 이렇습니다.
성능 점수 79 ~ 81
돌릴 때마다 한두 점씩 오르내리는데, LCP가 사진 도착 시각에 달려 있어서 그렇습니다.
다만 "79점"이라는 숫자 하나만 보면 아무것도 알 수 없습니다. 무엇을 보고 매긴 점수인지를 알아야 해요.
점수는 다섯 항목의 가중 평균입니다
Lighthouse의 성능 점수는 이렇게 만들어집니다.
항목 가중치
FCP 첫 글자가 뜬 시각 10
LCP 가장 큰 것이 뜬 시각 25
TBT 총 차단 시간 30
CLS 화면이 밀린 정도 25
SI 화면이 채워지는 속도 10
----
100
우리 앱의 각 항목은 이렇게 나왔습니다.
FCP 2.1 초 0.81 점
LCP 4.9 초 0.29 점 <- 여기가 깎인다
TBT 0 ms 1.00 점 <- 만점
CLS 0 1.00 점 <- 만점
SI 2.2 초 0.99 점
깎이는 데가 LCP 하나라는 게 분명히 보이죠.
지난 시간에 우리가 한 일이 여기 찍혀 있습니다
재미있는 것을 보여드릴게요. 지난 시간에 사진 크기를 알려주기 전 상태로 되돌려서 같은 측정을 돌려봤습니다.
고치기 전 고친 뒤
성능 점수 66 80
FCP 2.1 초 2.1 초
LCP 4.9 초 4.9 초
TBT 0 ms 0 ms
CLS 0.289 0
SI 2.2 초 2.2 초
움직인 칸이 정확히 하나입니다. 나머지 넷은 소수점까지 같아요.
CLS가 0.289에서 0이 되면서 그 항목 점수가 0.41에서 1.00이 됐고, 가중치가 25 니까 대략 14점이 올랐습니다. 66에서 80 으로요.
지난 시간에 우리가 고친 것은 코드 두 줄이었는데, 그 두 줄이 점수 14점이고 그 14점이 어디서 왔는지가 표에서 한눈에 갈립니다.
⚠️ 그런데 저 다섯 항목에 INP가 없습니다
다시 가중치 표를 보세요.
FCP 10 · LCP 25 · TBT 30 · CLS 25 · SI 10
우리가 오늘 앞의 세 Step을 통째로 써서 잰 INP가 점수에 안 들어갑니다.
Lighthouse 안에 INP 관련 항목이 아예 없는 건 아니에요. inp-breakdown-insight라는 참고 항목이 있는데, 가중치가 0이라 점수에는 영향을 주지 않아요.
왜 그럴까요. Step 1의 이야기가 그대로 답입니다. INP 는 누가 눌러야 생기는 값인데, Lighthouse는 페이지를 열어보기만 하고 아무것도 안 누릅니다. 없는 값으로 점수를 매길 수는 없죠.
이건 Lighthouse가 게을러서가 아니라 원리적으로 못 하는 일입니다. 사용자가 무엇을 어떤 순서로 누를지 도구가 알 수 없으니까요.
⚠️⚠️ 그런데 TBT는 만점입니다
여기가 오늘 가장 조심해야 할 곳입니다.
TBT는 Total Blocking Time, 페이지가 뜨는 동안 브라우저가 다른 일에 붙들려 있던 시간의 합입니다. 우리 앱은 0ms 로 만점이고, 가중치는 다섯 항목 중 가장 큰 30이에요.
TBT가 만점이면 흔히 이렇게 읽습니다. "반응성 문제는 없구나."
그렇게 읽으면 안 됩니다. TBT와 INP는 다른 것을 잽니다.
TBT 페이지가 뜨는 동안 브라우저가 붙들려 있던 시간
-> 아무도 안 눌러도 잴 수 있다
-> 첫 화면이 뜨는 몇 초 동안만 본다
INP 누른 뒤 화면이 답할 때까지
-> 눌러야만 잴 수 있다
-> 사용자가 그 화면에 머무는 내내 본다
TBT가 0 인데 INP가 나쁜 앱은 얼마든지 있을 수 있습니다. 첫 화면은 가볍게 뜨는데 버튼을 누르면 무거운 계산이 도는 앱이 그렇죠. 그런 앱을 Lighthouse로 재면 높은 점수가 나옵니다.
이 문장을 기억해두시면 좋겠어요. Lighthouse 100점을 받고도 실제 사용자의 INP는 기준 미달일 수 있습니다. 저 점수는 아무도 안 누른 화면을 재서 매긴 값이라, 그게 가능한 이유를 우리는 지금 정확히 압니다.
🙋 학생 질문 — "그럼 Lighthouse 점수를 올리는 건 의미가 없나요?"
의미는 있지만, 점수를 목표로 삼는 것과 점수가 가리키는 문제를 고치는 것은 다릅니다.
우리 경우를 보세요. 점수가 66에서 80이 된 것은 우리가 점수를 올리려고 뭘 해서가 아닙니다. 사진이 늦게 와서 화면이 밀리는 진짜 문제를 고쳤더니 점수가 따라온 거예요.
반대 방향도 가능합니다. 지난 시간 마지막 토론 주제에서 이야기했듯이 피드를 고정 높이 상자에 넣고 넘치는 부분을 잘라버리면 CLS는 완벽하게 0이 되는데, 점수는 오르고 사용자는 게시물을 반만 보게 되죠.
그러니 점수는 출발점으로 쓰는 게 좋습니다. "79점이네, 어디가 깎였지?" 하고 항목을 열어보는 용도요. 목표로 삼는 순간 우리 앱이 아니라 채점표를 고치게 됩니다.
💡 한 줄 정리
성능 점수는 다섯 항목의 가중 평균이고 거기에 INP는 없습니다. 가중치가 가장 큰 TBT가 만점이어도 그것은 반응성이 좋다는 뜻이 아니에요 — 아무도 안 누른 화면을 잰 값이기 때문입니다.
Step 6: "그래서 사용자에게서 모읍니다"
지난 시간에 남겨둔 한 줄
오류를 밖으로 내보내기 시작하던 시간에, 우리는 설정 파일에 이런 줄을 하나 두고 지나갔습니다.
// apps/web-spa/src/lib/monitoring.ts
// 오류만 본다. 속도를 재는 일은 나중에 따로 다룬다.
tracesSampleRate: 0,
그 "나중"이 오늘입니다.
tracesSampleRate는 속도 정보를 몇 번에 한 번 보낼까를 정하는 값입니다. 0은 아예 안 보낸다는 뜻이라, 지금까지 우리 오류 수집 서비스에는 오류만 쌓였습니다.
두 가지를 바꿉니다
// apps/web-spa/src/lib/monitoring.ts
const TRACES_SAMPLE_RATE = 1;
export function startMonitoring(): void {
Sentry.init({
dsn: DSN,
// 속도도 함께 본다. 우리 화면에서 재던 것을 이제 사용자에게서 모은다.
tracesSampleRate: TRACES_SAMPLE_RATE,
integrations: [
Sentry.reactRouterBrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
environment: import.meta.env.DEV ? 'development' : 'production',
});
}
하나는 tracesSampleRate를 1로 올린 것. 1은 전부 보낸다는 뜻이라 연습할 때 쓰는 값이에요. 실무에서는 이 값을 훨씬 낮춥니다. 왜 그런지는 Step 8에서 숫자로 보여드릴게요.
다른 하나는 통합을 하나 붙인 것입니다. 우리가 쓰는 라우터에게 "주소가 바뀌면 알려줘"라고 부탁하는 코드인데, 이게 있어야 어느 화면의 숫자인지 갈라서 볼 수 있어요. 라우터가 쓰는 함수 다섯 개를 그대로 넘겨주면 됩니다.
⚠️ 이름이 비슷한 것이 둘 있습니다
검색해보시면 reactRouterV7BrowserTracingIntegration이라는 이름도 나올 거예요. 버전 번호가 붙어 있죠.
그쪽은 더 이상 권장되지 않는 이름이라, 설치된 꾸러미의 타입 정의를 열어보면 이렇게 적혀 있어요.
@deprecated Use `reactRouterBrowserTracingIntegration` instead.
버전 접미사가 없는 쪽이 후계이고, 설명에 "React Router v6+"라고 적혀 있습니다. 우리가 쓰는 8.3.0도 여기 들어가요.
이런 갈림을 만날 때는 검색 결과보다 설치된 꾸러미의 타입 정의를 먼저 열어보세요. 검색 결과는 예전 글이 위에 올라와 있는 경우가 많은데, 우리 node_modules 안에 있는 것은 지금 우리가 실제로 쓰는 그 버전이거든요.
진짜로 오는지 확인합니다
연습용 수집 서비스를 띄우고 앱을 열어보면 이런 줄이 찍힙니다.
[속도받음] 39,021 B · 2164be853dc8482789fedfd927f22b54
transaction — FCP 124 · LCP 1080
[받음] 505 B · (id 없음)
session
[속도받음]이 뜨면 성공입니다. 우리 화면에서 콘솔로만 보던 숫자가 이제 밖으로 나가기 시작했어요.
🙋 학생 질문 — "지난번에는 여기에 [오류받음]이라고 찍혔던 것 같은데요?"
맞습니다. 그것도 오늘 함께 고쳤어요.
연습용 수집 서비스를 처음 만들 때는 오류밖에 안 와서, 받은 것을 전부 [오류받음]이라고 찍어도 아무 문제가 없었죠.
오늘 속도를 보내기 시작하면서 그 말이 거짓이 됐습니다. 속도 봉투에는 오류가 하나도 안 들어 있는데 화면에는 "오류받음"이라고 뜨니까요.
작은 일 같지만 이런 게 실무에서 꽤 많은 시간을 잡아먹습니다. 로그 문구가 코드보다 늦게 늙거든요. 나중에 누군가 저 로그를 보고 "오류가 이렇게 많이 나?" 하고 놀라서 한참 찾다가 "아 이거 성능 데이터네" 하고 끝나는 일이 실제로 생깁니다.
그런 이유로 무엇이 왔는지에 따라 갈라 찍도록 바꿨습니다. 오류면 [오류받음], 속도면 [속도받음], 그 밖이면 [받음].
💡 한 줄 정리
설정 한 줄과 통합 하나로 우리 화면의 숫자가 실제 사용자의 숫자가 되고, 그 과정에서 로그 문구가 코드보다 늦게 늙는다는 것도 함께 보셨어요.
Step 7: "봉투를 열어봅니다"
숫자만 오는 게 아닙니다
방금 온 39 kB 짜리 봉투를 열어보겠습니다. 안에는 이런 것들이 들어 있어요.
measurements
fp 64 ms fcp 124 ms lcp 1080 ms
ttfb 2.8 ms connection.rtt 150 ms
여기까지는 예상하신 대로라, 우리가 콘솔에서 보던 값이 그대로 실려 왔어요.
다만 그 옆에 이런 것이 함께 옵니다.
lcp.element img.w-full.cursor-pointer[alt="jaehoon 의 게시물"]
lcp.url https://picsum.photos/seed/post1/640/640
lcp.size 220900
lcp.loadTime 1053.1
LCP 가 1080ms 라는 것만 말해주는 게 아니라, 그 1080ms 를 만든 것이 무엇인지 CSS 선택자로 지목해줍니다. 어느 사진인지 주소까지 알려주고요.
지난 시간에 우리가 밀림의 범인을 찾을 때 목록을 보고 거슬러 올라가야 했던 것을 생각하면, 이건 꽤 친절한 셈입니다.
기기 조건도 함께 옵니다.
deviceMemory 32 GB
hardwareConcurrency 14
effectiveConnectionType 4g
이게 왜 중요할까요. 나중에 "우리 LCP가 나쁘다"는 보고를 받았을 때, 그게 모든 사용자의 문제인지 특정 기기의 문제인지를 이 값들로 갈라볼 수 있기 때문입니다. Step 3에서 CPU를 조여가며 봤던 것을 실제 사용자 데이터로 하는 셈이에요.
⚠️ 그런데 CLS가 없습니다
봉투를 아무리 뒤져도 cls라는 항목이 없어요. 우리가 지난 시간에 그렇게 공들여 고친 그 값인데요.
버그가 아닙니다. 고쳐서 0이 됐기 때문에 보낼 것이 없는 겁니다. 밀린 것이 하나도 없으면 실을 것도 없죠.
확인해볼 방법은 있습니다. 지난 시간 과제에서 아직 안 고친 화면을 하나 남겨뒀거든요.
안 고친 화면의 봉투는 다릅니다
게시물 상세 화면(/p/1)을 열고 봉투를 받아보면 이렇게 옵니다.
[속도받음] 19,075 B · 5e3c1999711d4cb3aabc98b5146038a3
transaction — FCP 540 · LCP 1092 · CLS 0.0485
CLS가 0.0485로 찍혀 있는데, 안을 열어보면 범인도 함께 옵니다.
cls.source.1 div.px-(--card-spacing)
지난 시간 과제에서 이 화면의 CLS를 재보고 고치라고 말씀드렸는데, 혹시 안 하셨다면 우리가 만든 수집 장치가 그걸 그대로 찾아낸 겁니다.
이게 현장 데이터의 성격을 잘 보여줍니다. 우리는 홈 화면만 열심히 재고 "다 고쳤다" 고 생각했는데, 사용자들은 홈만 보는 게 아니죠. 상세 화면도 열고 탐색 화면도 엽니다. 우리가 안 재본 화면에서 무슨 일이 벌어지는지는 사용자에게서 모아야만 알 수 있습니다.
⚠️ INP는 다른 봉투로 옵니다
한 가지 더 있습니다. 하트를 눌러도 봉투가 안 옵니다. 여섯 번을 눌러도 조용해요.
그러다 화면을 떠나는 순간 작은 봉투가 하나 날아갑니다.
1,057 B · span
measurements.inp = 24.00
왜 이렇게 할까요. INP는 사용자가 더 누르면 더 나빠질 수 있는 값이라, 화면에 머무는 동안은 중간값을 확정할 수가 없습니다. 누를 때마다 보내면 봉투가 수십 개가 되기도 해서, 진짜 마지막 값을 아는 순간인 사용자가 그 화면을 떠나는 순간에 한 번만 보냅니다.
라이브러리가 이 판단을 대신 해주고 있는 겁니다.
🙋 학생 질문 — "화면을 떠날 때 보낸다면, 사용자가 탭을 그냥 닫아버리면 못 받는 거 아닌가요?"
날카로운 질문이고, 여기가 실제로 까다로운 대목이에요.
브라우저에는 이런 상황을 위한 장치가 몇 개 있습니다. 탭이 안 보이게 되는 순간(visibilitychange)이 탭이 닫히는 순간보다 먼저 오기 때문에 그때를 잡는 게 하나고, 페이지가 사라지는 중에도 요청을 끝까지 보내달라고 부탁하는 방법이 또 하나 있습니다.
다만 완벽하지는 않아서, 사용자가 비행기 모드를 켜거나 브라우저가 강제 종료되면 못 받아요.
이건 현장 데이터의 어쩔 수 없는 성질입니다. 우리가 받는 데이터는 실제로 벌어진 일의 전부가 아니라 그중 보낼 수 있었던 것들이라, 현장 숫자를 볼 때는 항상 "이 데이터가 어느 쪽으로 치우쳐 있을까"를 한 번 생각해보는 게 좋습니다. 예를 들어 앱이 너무 느려서 참다 못해 탭을 닫아버린 사용자의 데이터는 안 올라올 가능성이 높죠.
💡 한 줄 정리
봉투는 숫자만 싣지 않고 범인을 CSS 선택자로 지목하고 기기 조건까지 함께 보내며, 우리가 안 재본 화면에서 무슨 일이 벌어지는지도 알려줘요 — 지난 시간 과제로 남겨둔 화면이 그대로 찍혀 올라왔습니다.
Step 8: "그 값은 얼마일까요"
공짜가 아닙니다
여기까지 오면 "그럼 다 켜두면 되겠네" 싶으실 텐데, 값을 먼저 보고 정하는 게 좋습니다. 우리가 오늘 배운 것이 정확히 그거잖아요. 재보고 나서 정하기.
세 가지 값이 있습니다.
첫째, 번들이 커집니다
추적 기능을 켜기 전과 후의 빌드 결과입니다.
켜기 전 621.53 kB (gzip 201.12 kB)
켠 뒤 683.01 kB (gzip 221.72 kB)
-----------
늘어난 것 +61.48 kB
61 kB는 적은 양이 아닙니다. 우리 앱 전체의 10% 예요. 게다가 이건 모든 사용자가 매번 받아가는 파일입니다.
이게 실제로 느려짐으로 이어질까요. 재봤습니다.
켜기 전 켠 뒤
성능 점수 79~81 78~79
FCP 2.1 초 2.3 초
LCP 4.7~5.1 초 4.9~5.2 초
⚠️ 점수만 보면 헷갈립니다. 79가 양쪽에 다 있거든요. 저도 처음에 한 번씩만 재고 "80에서 79로 떨어졌다" 고 쓸 뻔했는데, 여러 번 재보니 그냥 오차 범위 안이었습니다.
겹치지 않고 갈리는 것은 FCP입니다. 켜기 전에는 다섯 번 다 2.1초, 켠 뒤에는 네 번 다 2.3초예요. 첫 글자가 0.2초 늦게 뜹니다.
이게 61 kB의 값입니다. "점수 한두 점"이 아니라 "첫 글자 0.2초"로 말하는 게 정확해요.
둘째, 방문마다 데이터를 보냅니다
한 번 방문할 때 나가는 봉투를 전부 세어봤습니다.
약 39,000 B 화면이 뜰 때 (pageload)
1,057 B 화면을 떠날 때 (INP)
505 B 세션
137 B 그 밖
-------------
약 40 kB / 방문
⚠️ 이 39 kB는 홈 화면을 네 번 재서 나온 값(38,909 ~ 39,116 B)입니다. 고정된 숫자가 아니에요. 사진이 늦게 도착해서 일부가 봉투에 못 실린 회차에는 27,419 B까지 내려갔습니다. 그러니 "우리 앱은 방문당 40 kB"라고 외우기보다 여러분 앱에서 직접 재보시는 편이 낫습니다.
방문자가 하루 만 명이면 400 MB입니다. 이건 우리 서버 쪽 비용이기도 하고, 사용자의 데이터 요금 이기도 해요. 지하철에서 데이터를 아껴 쓰는 사람에게 우리가 성능을 재느라 40 kB를 쓰게 하는 셈입니다.
셋째, 그래서 전부 안 보냅니다
여기서 tracesSampleRate가 다시 나옵니다. 이 값을 낮추면 일부만 보내요.
tracesSampleRate: 1 30번 열어서 30번 보냄 (100%)
tracesSampleRate: 0.1 30번 열어서 4번 보냄 ( 13%)
0.1은 "열 번에 한 번쯤"이라는 뜻이고, 실제로 30번 중 4번이 나갔습니다. 확률이라 정확히 3번은 아니에요.
성능 데이터는 오류와 달리 일부만 모아도 됩니다. 오류는 한 건이 곧 한 명의 망가진 화면이라 놓치면 안 되지만, 속도는 통계니까요. 천 명의 숫자로도 만 명의 경향은 충분히 보입니다.
⚠️ 다만 샘플링이 모든 걸 줄여주지는 않습니다. 위 측정에서 속도 봉투는 30개에서 4개로 줄었는데 세션 봉투는 그대로 매번 나갔습니다. 505 B 짜리는 어차피 매번 나가요. 줄어드는 건 39 kB 짜리 쪽입니다.
우리 앱은 연습용이라 1로 둡니다. 여러분이 봉투를 직접 봐야 하니까요. 실무에서는 0.1 쯤에서 시작해서, 숫자가 흔들리지 않을 만큼만 올리는 게 보통입니다.
🙋 학생 질문 — "샘플링을 낮추면 드물게 일어나는 문제를 놓치지 않나요?"
놓치는데, 그게 이 결정의 진짜 어려운 지점이에요.
예를 들어 백 명 중 한 명에게만 일어나는 심각한 느려짐이 있다고 해봅시다. 샘플링이 0.1이면 그 사람의 데이터가 올라올 확률도 10분의 1이라, 실제로는 천 명 중 한 명꼴로만 보이게 됩니다. 데이터가 쌓이기 전에는 "가끔 이상한 값이 있네" 정도로 보이겠죠.
그러다 보니 실무에서는 몇 가지를 섞어 씁니다. 전체는 낮게 잡되 느린 경우는 더 자주 보내도록 규칙을 다르게 주기도 하고, 중요한 화면만 높게 잡기도 해요.
여기서 기억하실 것은 이겁니다. 샘플링 비율은 기술 설정이 아니라 "얼마를 알기 위해 얼마를 낼 것인가"라는 결정입니다. 그 결정을 하려면 지금 우리가 한 것처럼 값이 얼마인지 먼저 재봐야 합니다.
💡 한 줄 정리
사용자에게서 모으는 일에는 번들 61 kB · 첫 글자 0.2초 · 방문당 40 kB라는 값이 붙습니다. 그래서 전부 보내지 않고 일부만 보내는데, 그 비율을 정하는 것은 설정이 아니라 무엇을 알고 싶은지에 대한 결정이에요.
마무리
오늘 배운 핵심 세 가지
💡 하나. 어떤 값은 기다린다고 생기지 않습니다. LCP와 CLS는 브라우저가 알아서 기록해두지만 INP는 누가 눌러야 생겨요. 그래서 재는 방법 자체가 다르고, 아무도 안 누른 화면의 INP는 0이 아니라 "없음"입니다. 이 구별을 안 해두면 안 써본 화면이 가장 빠른 화면으로 보고됩니다.
💡 둘. 같은 앱이 조건에 따라 다섯 배 다르게 나옵니다. 우리 노트북에서 1.1초인 LCP가 모바일 조건을 흉내 내면 4.9초예요. 둘 다 참이고, 실험실 값은 변화를 재기에 좋고 현장 값은 무엇을 고칠지 정하기에 좋습니다. 한쪽만 보면 반드시 놓치는 게 생겨요.
💡 셋. 점수가 좋다고 사용자가 만족하는 건 아닙니다. 성능 점수의 다섯 항목에 INP는 아예 없고, 가중치가 가장 큰 TBT는 만점이어도 반응성과는 다른 것을 잽니다. 아무도 안 누른 화면을 잰 값으로는 누른 뒤의 경험을 알 수 없어요.
다음 시간 예고
오늘 마지막에 번들이 621 kB에서 683 kB가 됐습니다. 재려고 켠 도구가 앱을 조금 무겁게 만든 거죠. 사실 Lighthouse는 아까부터 우리에게 뭔가를 계속 말하고 있었는데, 점수 화면을 아래로 내려보면 이런 항목이 있어요.
unused-javascript Est savings of 95 KiB
받아는 갔는데 안 쓰는 자바스크립트가 95 KiB 나 된다는 뜻입니다. 우리 번들의 14% 쯤이에요.
이걸 어떻게 찾아내고 어떻게 잘라내는지, 그리고 어디까지 잘라야 하는지를 다루는 시간이 커리큘럼에 따로 준비돼 있습니다. 오늘 우리가 만든 측정 장치가 그때 그대로 쓰여요 — 자르기 전과 후를 재서 정말 좋아졌는지 확인해야 하니까요.
그 시간까지 가지 않으시더라도, 오늘 배운 것만으로 할 수 있는 일이 이미 많습니다. 재고, 조건을 바꿔 재고, 사용자에게서 모으는 것. 이 셋이면 "우리 앱 좀 느린 것 같아요"라는 말에 숫자로 답할 수 있어요.
과제
[구현] 다른 화면의 INP를 재보세요
오늘 우리가 INP를 잰 것은 홈 화면의 좋아요 버튼인데, 우리 앱에는 누를 수 있는 게 더 있어요.
탐색 화면(/explore)에는 태그를 고르는 단추들이 있습니다. C-8에서 이 단추들을 다룬 적이 있죠. 그때 우리는 단추 하나를 누르면 모든 태그 단추가 다시 그려진다는 것을 확인했습니다.
- 탐색 화면을 열고 태그 단추를 열 번쯤 눌러서 INP와 세 칸 내역을 적어보세요.
- 홈 화면의 좋아요 버튼과 견주면 어느 칸이 다른가요?
- C-8에서 잰 "다시 그려진 횟수"와 오늘 잰 "그리기 시간"은 같은 것을 말하고 있나요, 다른 것을 말하고 있나요?
⚠️ 두 화면을 옮겨 다니지 말고 각 주소를 직접 열어서 재세요. 로그인 클릭도 상호작용에 섞이니 상호작용 건수를 함께 보셔야 합니다.
[구현] 샘플링을 낮춰보세요
monitoring.ts의 TRACES_SAMPLE_RATE를 0.1로 바꾸고 빌드한 뒤, 화면을 스무 번쯤 새로고침해보세요.
- 속도 봉투가 몇 개나 왔나요? 기대한 수와 맞나요?
- 세션 봉투는 몇 개 왔나요? 속도 봉투와 같은 비율로 줄었나요?
- 오류도 함께 줄어드는지 확인해보세요. (오류를 하나 일으키는 방법은 지난 시간들에서 해보셨습니다.)
⚠️ 확인이 끝나면 되돌려주세요. 마지막 항목의 답이 왜 그런지 생각해보시면, 오류와 속도를 다르게 다루는 이유가 보이실 겁니다.
[탐구] 재는 문턱을 바꿔보세요
h3-vitals.ts의 durationThreshold를 16에서 40으로 바꾸고 하트를 여러 번 눌러보세요.
- 콘솔에 무엇이 찍히나요?
- 그 화면만 보고 "우리 앱이 빠르다"와 "설정이 잘못됐다"를 구별할 수 있나요?
- 구별하려면 무엇을 함께 봐야 할까요? 지금 우리 코드가 이미 찍고 있는 것 중에 답이 있습니다.
- 반대로 문턱을 낮출 수는 없을까요? 0을 넣고 돌려서 무슨 일이 생기는지도 확인해보세요.
⚠️ 마지막 항목에서 예상과 다른 결과가 나올 겁니다. 왜 그런지 규격 문서에서 durationThreshold를 찾아보시면 한 줄로 적혀 있어요.
[탐구] 봉투가 안 고친 화면을 찾아냅니다
Step 7에서 상세 화면(/p/1)의 봉투에 cls.source.1이 실려 온 것을 봤습니다. 지난 시간 과제로 남겨둔 그 화면이죠.
- 그 화면을 지난 시간 방법대로 고치고, 다시 봉투를 받아보세요.
cls항목이 어떻게 되나요? - 이번에는 탐색 화면(
/explore)의 봉투도 받아보세요. 무엇이 실려 있고 무엇이 없나요? - 세 화면의 봉투 크기가 서로 다릅니다. 왜 다를까요? 봉투 안의
spans개수를 세어보시면 힌트가 됩니다.
⚠️ 마지막 항목이 이 과제의 핵심입니다. 봉투 크기가 화면마다 다르다는 것은 Step 8에서 계산한 "방문당 40 kB"가 사실은 무엇에 대한 숫자였는지를 다시 생각하게 만듭니다.
생각해볼 주제
1. 첫 배포와 첫 데이터 사이
오늘 우리는 사용자에게서 속도를 모으기 시작했는데, 이 장치가 실제로 값을 하려면 배포가 되고 사람들이 들어와야 하죠.
새 기능을 만들어서 배포하는 날을 생각해보세요. 그날 우리에게는 실험실 값밖에 없습니다. 현장 값은 며칠 뒤에나 쌓여요.
이 며칠 동안 성능 문제를 어떻게 다뤄야 할까요? 배포 전에 실험실 값만으로 판단하는 것과, 배포하고 현장 값을 기다리는 것 사이에서 무엇을 기준으로 정할지 이야기해봅시다.
2. 나쁜 숫자를 받았는데 재현이 안 될 때
현장 데이터에서 INP가 아주 나쁜 사용자가 몇 명 보인다고 해봅시다. 그런데 우리 노트북에서는 아무리 눌러도 재현이 안 되고, Step 3처럼 CPU를 20배로 조여도 안 나와요.
우리가 오늘 본 것 중에 이 상황을 설명할 수 있는 것들이 있습니다. INP가 어떻게 정해지는지, 그리고 봉투에 무엇이 함께 실려 오는지를 떠올려보세요.
이 사용자들에게 실제로 무슨 일이 일어났을 가능성이 있는지 몇 가지 떠올려보고, 각각을 확인하려면 무엇을 더 모아야 할지 이야기해봅시다.
3. 재는 일이 대상을 바꿉니다
Step 8에서 우리는 재는 도구를 켰더니 첫 글자가 0.2초 늦게 떴다는 것을 확인했습니다. 재는 행위 자체가 재려는 대상을 바꾼 겁니다.
그렇다면 우리가 지금 보고 있는 현장 숫자는 "우리 앱의 속도" 일까요, 아니면 "측정 장치가 달린 우리 앱의 속도" 일까요?
이 어긋남이 실제로 문제가 되는 경우와 무시해도 되는 경우를 갈라보시고, 이 문제를 줄이려면 어떤 방법이 있을지 각각이 대신 무엇을 포기하는지도 함께 이야기해봅시다.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 다른 화면의 INP를 재보세요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 태그 단추만 골라 눌렀는가 | 6 | 머리말의 밝기 토글도 aria-pressed를 갖는다는 것을 알아챘는가 |
| 세 칸 내역을 함께 적었는가 | 6 | INP 값만이 아니라 기다림 / 우리 코드 / 그리기를 나눠 기록 |
| 두 화면을 견줘 결론을 냈는가 | 7 | 어느 칸이 같고 어느 칸이 다른지 |
| C-8과의 관계를 설명했는가 | 6 | 「다시 그려진 횟수」와 「기다린 시간」이 다른 것임을 짚었는가 |
풀이 예시
탐색 화면을 열고 태그 단추를 열 번 눌러서 나온 값입니다.
[성능] 재는 중 — LCP 1100ms · INP 40ms (상호작용 10건) · CLS 0.0000 · 사진 10/10
↳ 기다림 0.1ms · 우리 코드 0.0ms · 그리기 39.9ms
세 번 재보면 32ms, 40ms, 40ms 가 나오는데, 홈 화면의 좋아요 버튼이 32ms 였으니 사실상 같습니다.
내역을 견주면 이렇습니다.
기다림 우리 코드 그리기
홈 · 좋아요 0.1~0.7 0.0~0.1 31.3~31.8
탐색 · 태그 0.0~0.3 0.0~0.1 31.8~39.9
「우리 코드」 칸이 양쪽 다 0.0ms 입니다.
이게 놀라운 이유는 두 버튼이 하는 일이 전혀 다르기 때문이에요. 좋아요는 카드 하나의 숫자를 바꾸지만, 태그는 주소를 바꾸고 목록을 통째로 다시 그리고 서버에 새 요청까지 보냅니다. 그런데 그 차이가 숫자에 안 나타납니다.
C-8과의 관계를 짚어볼게요. C-8에서 우리는 태그 단추를 누르면 모든 태그 단추가 다시 그려진다는 것을 확인했는데, 그게 왜 여기서 안 보일까요.
두 측정이 다른 것을 재기 때문입니다.
C-8 몇 개의 컴포넌트가 다시 그려졌나 -> React 가 한 일의 양
H-4 사용자가 얼마나 기다렸나 -> 사람이 겪은 시간
단추 열두 개를 다시 그리는 일은 React에게 아주 가벼운 작업이라 1ms 도 안 걸려서, 「우리 코드」 칸에 흔적을 안 남겨요. C-8이 그것을 문제 삼았던 것은 그 자체가 느려서가 아니라, 규모가 커지면 문제가 될 수 있는 패턴이었기 때문입니다.
자주 나오는 실수
밝기 토글까지 눌러놓고 태그를 쟀다고 하는 것. button[aria-pressed]로 단추를 찾으면 머리말의 밝게·어둡게·시스템까지 딸려옵니다. 저도 처음에 이걸 밟아서 열두 개를 눌렀어요. 태그만 세려면 머리말 안쪽을 빼야 합니다.
서버 응답 시간이 INP에 들어간다고 생각하는 것. 태그를 누르면 새 목록을 서버에서 받아오는 데 400ms 가 걸리는데, INP는 40ms 예요. 누른 직후의 다음 그림은 「불러오는 중이에요…」 화면이고, INP는 거기서 끝나기 때문입니다.
이건 INP의 성격을 정확히 보여줍니다. INP는 「완성될 때까지」가 아니라 「반응할 때까지」예요. 눌렀는데 아무 반응이 없는 것과, 눌러서 즉시 「불러오는 중」이 뜨고 데이터가 400ms 뒤에 오는 것은 사용자에게 아주 다른 경험입니다.
💡 튜터의 한마디 — 이 과제의 진짜 수확은 "두 화면이 비슷하더라"가 아니라 "내가 무겁다고 생각했던 것이 실제로는 안 무거웠다"입니다. 성능 작업을 하다 보면 짐작으로 "여기가 느릴 거야" 하고 고치기 시작하는 일이 많은데, 그 짐작이 틀리면 시간만 씁니다. 재보고 나서 정하는 습관이 이래서 중요해요.
🎯 [과제 2 예시답안] 샘플링을 낮춰보세요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 속도 봉투가 줄어든 것을 확인 | 6 | 대략 10분의 1 로 줄어드는 것을 숫자로 |
| 세션 봉투는 안 줄어든 것을 확인 | 7 | 무엇이 샘플링 대상이고 무엇이 아닌지 갈랐는가 |
| 오류가 안 줄어드는 것을 확인 | 7 | 실제로 오류를 하나 일으켜서 확인했는가 |
| 그 차이의 이유를 설명 | 5 | 오류와 속도를 다르게 다루는 근거 |
풀이 예시
TRACES_SAMPLE_RATE를 0.1로 바꾸고 빌드한 뒤 화면을 여러 번 열어봤습니다.
tracesSampleRate: 1 20번 열어서 20번 보냄 (100%)
tracesSampleRate: 0.1 30번 열어서 4번 보냄 ( 13%)
확률이라 정확히 3번은 아니고 4번이 나왔습니다. 여러 번 해보시면 2~5번 사이를 오갈 거예요.
세션 봉투는 안 줄어듭니다. 같은 구간에서 세어보면 속도 봉투는 2개인데 세션 등 그 밖의 봉투는 38개가 그대로 나갔습니다.
오류도 안 줄어듭니다. 확인하려면 연습용 API 서버를 잠깐 내리고 게시물 상세 화면을 열어보세요. 로더가 실패하면서 오류 화면이 뜨고, 수집 서비스에는 이렇게 찍힙니다.
[오류받음] 2,852 B · ApiError: 서버에 연결할 수 없어요
샘플링이 0.1 인데도 매번 옵니다.
왜 다를까요. 설치된 꾸러미의 타입 정의를 열어보면 두 값이 아예 다른 항목입니다.
sampleRate 오류를 몇 %나 보낼까 (기본 1.0)
tracesSampleRate 속도를 몇 %나 보낼까 (기본 없음 = 안 보냄)
샘플링을 판정하는 함수는 tracesSampleRate만 보는데, 설명에 "루트 span이 만들어질 때마다 불린다" 고 적혀 있어요. 오류는 span이 아니니 그 함수를 안 지나갑니다.
이유는 둘의 성격이 다르기 때문입니다.
오류 한 건이 곧 한 명의 망가진 화면이다 -> 놓치면 안 된다
속도 많이 모아서 경향을 보는 것이다 -> 일부만 모아도 된다
오류를 10분의 1만 받으면 열 명이 겪은 버그를 한 명이 겪은 것처럼 보게 됩니다. 반면 속도는 천 명의 숫자로도 만 명의 경향이 충분히 보여요.
자주 나오는 실수
정확히 10%가 안 나온다고 설정이 잘못된 줄 아는 것. 이건 주사위를 던지는 것과 같습니다. 서른 번 던져서 앞면이 정확히 열다섯 번 나오지 않는 것과 같은 이유예요. 횟수를 늘릴수록 10% 에 가까워집니다.
되돌리는 것을 잊는 것. 확인이 끝나면 1로 돌려놓으세요. 우리 앱은 연습용이라 봉투를 눈으로 봐야 다음 실습이 됩니다.
💡 튜터의 한마디 — 실무에서 이 값을 정할 때 흔히 "비용"만 생각하는데, 진짜 질문은 "얼마나 정확히 알아야 하는가"입니다. 하루 방문이 백 명인 서비스에서 0.1을 쓰면 하루에 열 개의 데이터로 판단하게 돼요. 그 숫자로는 아무것도 못 봅니다. 샘플링 비율은 트래픽 규모와 함께 정하는 것이지 그냥 "실무는 0.1"이 아니에요.
🎯 [과제 3 예시답안] 재는 문턱을 바꿔보세요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 40 에서 아무것도 안 나오는 것 확인 | 6 | 화면에 무엇이 뜨는지 정확히 적었는가 |
| 두 경우가 구별 안 되는 것을 짚음 | 6 | "빠르다" 와 "설정이 틀렸다" 가 같아 보인다 |
| 무엇을 함께 봐야 하는지 답함 | 6 | 상호작용 건수 |
| 0 을 넣어보고 결과를 설명 | 7 | 예상과 다른 결과 + 이유 |
풀이 예시
durationThreshold를 40으로 바꾸고 하트를 여러 번 눌러보면 이렇게 나옵니다.
[성능] 사진 다 옴 — LCP 1068ms · INP 아직 없음 · CLS 0.0000 · 사진 10/10
아무리 눌러도 "아직 없음"입니다. 우리 상호작용이 16~32ms 라서 40을 넘는 것이 하나도 없거든요.
두 경우가 화면에서 똑같은데, 여기가 이 과제의 핵심입니다.
아무도 안 눌렀다 -> INP 아직 없음
눌렀는데 문턱을 못 넘었다 -> INP 아직 없음
글자가 한 자도 안 다릅니다. 그런데 뜻은 정반대예요. 하나는 "잴 기회가 없었다"이고 다른 하나는 "재긴 했는데 우리가 안 받기로 했다"입니다.
무엇을 함께 봐야 할까요. 지금 우리 코드가 이미 찍고 있습니다. 상호작용 건수예요.
INP 아직 없음 -> 아무도 안 눌렀다
INP 24ms (상호작용 5건) -> 다섯 번 눌렀고 잘 재고 있다
문턱이 40이면 눌러도 건수가 안 올라갑니다. 아무도 안 누른 것과 구별할 방법이 여전히 없어요.
그래서 정확한 답은 이렇습니다. 지금 화면만으로는 구별할 수 없고, 문턱 값을 함께 찍어야 합니다. 재는 코드가 어떤 설정으로 도는지가 결과를 읽는 데 필요한 정보라면, 그것도 함께 보여줘야 하는 거죠.
0을 넣어보면 에러가 날 것 같지만 안 나고, 결과가 16 일 때와 완전히 같습니다.
같은 화면에서 세 관찰자를 동시에 붙여 여덟 번 눌러본 결과입니다.
문턱 안 줌 0건
문턱 0 24건 [32,32,32,24,16,16,16,16,16,16,24,24,...]
문턱 16 24건 (0 과 글자 하나 안 다름)
두 가지가 한꺼번에 보입니다.
첫째, 문턱을 안 주면 하나도 안 잡힙니다. 기본값이 우리 상호작용보다 훨씬 높다는 뜻이에요. 그래서 우리는 값을 반드시 줘야 합니다.
둘째, 0과 16이 완전히 같습니다. 규격이 16을 하한으로 정해뒀기 때문에 그보다 낮은 값을 줘도 16처럼 다뤄집니다. 그러니 16이 우리가 받을 수 있는 가장 촘촘한 설정이에요.
자주 나오는 실수
0을 넣으면 에러가 날 거라고 짐작하고 안 해보는 것. 실제로 널리 쓰이는 성능 측정 라이브러리도 상호작용 개수를 세는 용도로 0을 씁니다.
값이 왜 전부 8의 배수인지 안 궁금해하는 것. 위 목록을 보시면 16, 24, 32만 있고 20이나 27은 없습니다. 브라우저가 이 시간을 8ms 단위로 반올림해서 주기 때문이에요. 정밀한 시간을 그대로 주면 그것만으로도 사용자를 알아볼 단서가 될 수 있어서 일부러 뭉개는 겁니다.
💡 튜터의 한마디 — 이 과제에서 진짜 배울 것은 문턱 값이 아니라 "측정 설정도 결과의 일부"라는 감각입니다. 나중에 다른 팀이 준 성능 숫자를 볼 일이 생길 텐데, 그때 "어떤 설정으로 잰 거예요?"를 물을 수 있으면 절반은 한 겁니다. 같은 앱도 문턱 하나로 "완벽함"과 "24ms" 사이를 오가니까요.
🎯 [과제 4 예시답안] 봉투가 안 고친 화면을 찾아냅니다
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
고친 뒤 cls가 사라진 것 확인 |
7 | 값이 0 이 된 게 아니라 항목이 없어졌다 |
| 탐색 화면 봉투를 비교 | 6 | 무엇이 있고 무엇이 없는지 |
| 봉투 크기가 다른 이유를 찾음 | 6 | spans 개수와 연결 |
| 방문당 40 kB를 다시 생각함 | 6 | 그 숫자가 무엇에 대한 값이었는지 |
풀이 예시
고치기 전 상세 화면입니다.
19,078 B · spans 18
measurements fcp 528 · lcp 1020 · cls 0.0485
cls.source.1 div.px-(--card-spacing)
PostDetailPage의 사진에 width·height를 넣고 다시 받으면 이렇습니다.
18,023 B · spans 18
measurements fcp 520 · lcp 1072
(cls 항목 없음 · cls.source 도 없음)
cls가 0이 된 게 아니라 항목 자체가 사라집니다. 밀린 것이 없으면 실을 것도 없으니까요. 봉투도 1 kB 쯤 가벼워졌습니다.
탐색 화면은 이렇습니다.
28,213 B · spans 26
measurements fcp 116 · lcp 1084
lcp.element li > a > img.aspect-square.w-full.rounded-sm.object-cover[alt="jaehoon 의 게시물"]
cls가 없습니다. 이 화면은 처음부터 사진에 aspect-square를 걸어둬서 자리가 미리 잡혀 있었거든요. 지난 시간 과제에서 확인하셨던 그대로입니다.
lcp.element가 유난히 긴 것도 눈에 띕니다. 격자 안에 들어 있는 사진이라 조상 요소까지 적어준 거예요. 홈의 img.w-full.cursor-pointer[...]보다 훨씬 자세합니다.
봉투 크기가 다른 이유를 볼게요.
세 화면을 나란히 놓으면 이렇습니다.
화면 크기 spans
홈 약 39 kB 35
탐색 28 kB 26
상세 19 kB 18
크기가 spans 개수를 그대로 따라갑니다. spans은 그 화면이 뜨는 동안 벌어진 일 하나하나예요 — 사진 한 장 받은 것, 스타일 파일 받은 것, 스크립트 실행한 것이 각각 하나씩 들어갑니다.
상세 화면은 사진이 한 장이고 홈은 열 장이니 실을 것이 그만큼 다릅니다.
방문당 40 kB는 무엇에 대한 숫자였을까요. Step 8에서 "방문당 약 40 kB"라고 계산했는데, 그건 홈 화면 한 번을 잰 값입니다.
실제 사용자는 홈만 보지 않아요. 홈에 들어와서 게시물 하나를 열고 탐색을 둘러보고 다시 홈으로 돌아옵니다. 그러면 그 방문 한 번에 봉투가 여러 개 나가죠.
반대로, 상세 화면만 보고 나가는 사용자는 19 kB만 씁니다.
그러니 정확한 문장은 "방문당 40 kB"가 아니라 "화면을 한 번 열 때마다 그 화면의 복잡도에 비례해서 나간다"이고, 비용을 어림잡으려면 방문자 수가 아니라 화면 조회 수를 곱해야 해요.
자주 나오는 실수
cls가 0으로 찍힐 거라고 예상하는 것. 항목이 아예 사라져서, 봉투를 자동으로 읽는 코드를 짤 때 envelope.measurements.cls를 그냥 읽으면 undefined가 나와요. "값이 0"과 "값이 없음"을 갈라 다뤄야 합니다. 오늘 Step 1에서 INP를 null로 둔 것과 정확히 같은 이야기입니다.
봉투 크기를 고정값으로 외우는 것. 홈을 네 번 재도 38,909 ~ 39,116 B로 조금씩 다르고, 사진이 늦게 도착한 회차에는 27,419 B까지 내려갔습니다. 그 회차에는 사진 span이 봉투가 닫히기 전에 못 들어간 거예요.
💡 튜터의 한마디 — 이 과제가 보여주는 것은 우리가 안 재본 화면이 늘 있다는 겁니다. 지난 시간에 우리는 홈을 고치고 "다 됐다" 고 생각했는데, 사용자들이 상세 화면을 열자마자 봉투가 그 사실을 그대로 실어 왔습니다. 현장에서 모으는 일의 값어치가 여기 있어요 — 우리가 무엇을 안 봤는지를 우리 대신 찾아줍니다.
🤔 [생각해볼 주제 1] 첫 배포와 첫 데이터 사이
문제 상황 요약
새 기능을 배포하는 날, 우리에게는 실험실 값밖에 없습니다. 현장 값은 며칠 뒤에나 쌓여요. 이 사이를 어떻게 다뤄야 할까요.
튜터의 가이드 및 해설
먼저 이 상황을 정확히 보는 게 중요합니다. 배포 전에는 현장 값이 없는 게 아니라 「새 코드의」 현장 값이 없는 것인데, 옛 코드의 현장 값은 있죠.
그러다 보니 실무에서 쓰는 방법들이 대체로 이 차이를 메우는 쪽으로 생깁니다.
하나. 실험실 값을 「절대 기준」이 아니라 「변화량」으로 씁니다.
Lighthouse 79점이 좋은지 나쁜지는 사실 답하기 어려운데, "고치기 전 66점, 고친 뒤 80점"은 분명해요. 우리가 오늘 본 그대로입니다.
그래서 배포 전 판단은 "이 점수면 괜찮은가"가 아니라 "내가 한 변경이 어느 방향으로 움직였나"로 하는 게 낫고, 그 비교를 사람이 매번 손으로 하지 않도록 자동으로 재게 만들어두죠.
둘. 지표별로 실험실을 믿는 정도를 다르게 둡니다.
오늘 우리가 본 것을 정리하면 이렇습니다.
CLS 실험실과 현장이 비슷하게 나온다 -> 배포 전에 잡을 수 있다
LCP 조건에 따라 다섯 배까지 갈린다 -> 방향만 믿는다
INP 실험실이 원리적으로 못 준다 -> 배포 후에만 안다
CLS는 대체로 레이아웃 문제라 조건과 덜 관계있습니다. 우리 앱도 실험실 0.289 였고 현장 0.2214 였어요 — 비슷해서, CLS 회귀는 배포 전에 잡는 게 현실적입니다.
INP는 정반대입니다. 배포 전에 할 수 있는 건 "무거운 계산을 클릭 처리에 넣지 않았나"를 코드로 확인하는 정도예요.
셋. 배포 자체를 나눕니다.
전부에게 한 번에 내보내지 않고 일부에게 먼저 내보내면, 며칠이 아니라 몇 시간 만에 현장 값을 얻을 수 있고 나쁘면 되돌리죠.
이게 가장 확실한 방법인데 대신 배포 장치가 그만큼 복잡해집니다. 서비스 규모가 작으면 값이 안 맞을 수 있어요.
🎯 면접관을 홀리는 핵심 멘트
"배포 전에 저는 실험실 값을 절대 기준이 아니라 변화량으로 봅니다. 79점이 좋은 점수인지는 몰라도, 제 변경이 66에서 80으로 움직였다는 건 확실하니까요. 그리고 지표마다 믿는 정도를 다르게 둡니다. CLS는 조건을 덜 타서 배포 전에 잡을 수 있지만 INP는 실험실이 원리적으로 못 주는 값이라 — 아무도 안 누른 화면을 재는 도구니까요 — 배포 후 데이터를 기다리거나 일부에게 먼저 내보내는 수밖에 없습니다."
🤔 [생각해볼 주제 2] 나쁜 숫자를 받았는데 재현이 안 될 때
문제 상황 요약
현장에서 INP가 아주 나쁜 사용자가 몇 명 보이는데 우리 노트북에서는 CPU를 20배로 조여도 재현이 안 됩니다.
튜터의 가이드 및 해설
오늘 본 것들 중에 이 상황을 설명할 수 있는 것이 여럿 있습니다. 하나씩 짚어볼게요.
첫째, 그 값이 정말 「그 사용자의 앱 경험」이 아닐 수 있습니다.
INP는 최악의 상호작용 하나로 정해집니다. 우리도 열다섯 번 재서 한 번 248ms 를 봤죠. 아홉 개는 32ms 였는데 하나가 튀어서 그게 답이 됐습니다.
그 한 번이 왜 튀었는지는 우리 코드와 무관할 수 있습니다. 사용자가 그 순간 다른 탭에서 무거운 페이지를 열었거나, 화상 회의를 켜뒀거나, 백신 검사가 돌았거나요.
확인 방법은 그 사용자의 상호작용이 몇 건인지, 나쁜 값이 한 건뿐인지 여러 건인지를 보는 겁니다. 한 건뿐이면 딸꾹질이고, 대부분이 나쁘면 진짜 문제예요.
둘째, 기기 조건이 우리와 근본적으로 다를 수 있습니다.
봉투에는 deviceMemory·hardwareConcurrency·effectiveConnectionType이 함께 옵니다. 우리 것은 32GB / 14코어 / 4g 였죠.
CPU 조이기는 계산 속도만 흉내 냅니다. 메모리가 부족해서 브라우저가 탭을 들락날락시키는 상황이나, 코어가 하나뿐이라 다른 일과 계속 부딪히는 상황은 못 만들어요.
확인 방법은 나쁜 값을 낸 사용자들의 기기 조건을 모아서 전체 분포와 견주는 겁니다. 한쪽으로 쏠려 있으면 그게 답입니다.
셋째, 우리가 안 눌러본 것을 눌렀을 수 있습니다.
INP는 그 화면에서 누른 전부를 셉니다. 우리는 좋아요와 태그만 재봤어요. 사용자는 우리가 생각 못 한 것을 누릅니다.
확인 방법은 어느 요소가 그 값을 만들었는지를 함께 보내도록 수집 코드를 고치는 겁니다. 우리 화면 코드는 이미 그 정보를 갖고 있어요 — 오늘 만든 관찰자에서 상호작용의 target을 읽을 수 있습니다.
넷째, 우리가 못 본 상태였을 수 있습니다.
우리는 늘 갓 켠 화면에서 잽니다. 사용자는 한 시간째 그 탭을 켜둔 채였을 수 있어요. 목록이 계속 쌓였거나, 실시간 연결이 여러 번 끊겼다 붙었거나요.
정리하면, 재현이 안 될 때 할 일은 더 세게 조이는 게 아니라 무엇이 다른지를 좁혀갈 정보를 더 모으는 것이고, 오늘 우리가 만든 것에 몇 줄만 더하면 그 정보를 얻을 수 있어요.
🎯 면접관을 홀리는 핵심 멘트
"재현이 안 되면 저는 더 세게 조여보는 대신 무엇이 다른지 좁힐 정보를 더 모읍니다. INP는 최악의 상호작용 하나로 정해지는 값이라 딸꾹질 한 번이 그 사용자의 점수를 만들 수 있거든요. 그래서 먼저 그 사용자의 나쁜 값이 한 건인지 여러 건인지를 보고, 그다음 기기 조건 분포를 전체와 견줍니다. CPU 조이기는 계산 속도만 흉내 내지 메모리 압박이나 코어 수 부족은 못 만들어서, 그쪽이면 우리 노트북에서는 영영 안 나와요."
🤔 [생각해볼 주제 3] 재는 일이 대상을 바꿉니다
문제 상황 요약
측정 도구를 켰더니 첫 글자가 0.2초 늦게 떴습니다. 우리가 보는 현장 숫자는 「우리 앱의 속도」일까요, 「측정 장치가 달린 우리 앱의 속도」일까요.
튜터의 가이드 및 해설
정확히 말하면 후자입니다. 그리고 이건 피할 수 없어요. 재려면 코드가 돌아야 하고, 코드가 돌면 시간을 씁니다.
그렇다면 이게 언제 문제가 되고 언제 무시해도 될까요.
먼저 무시해도 되는 경우입니다.
하나. 비교가 목적일 때. 지난주 배포본과 이번 배포본을 견주는 거라면 양쪽 다 측정 장치가 달려 있으니 어긋남이 상쇄됩니다. "느려졌나"를 묻는 데는 문제가 없어요.
둘. 어긋남이 지표보다 훨씬 작을 때. 우리 FCP는 0.2초 밀렸는데 기준은 1.8초입니다. LCP는 4.9초고요. 판정이 뒤집힐 크기가 아닙니다.
반대로 문제가 되는 경우도 있습니다.
하나. 기준선 근처에 있을 때. LCP가 2.4초인 앱에 측정 장치를 달아서 2.6초가 되면, 통과가 실패로 바뀌는데 그 실패는 우리가 만든 것이에요.
둘. 느린 기기에서. 61 kB를 더 받고 더 실행하는 값은 기기가 느릴수록 커집니다. 우리 노트북에서 0.2초면 낡은 휴대폰에서는 1초일 수 있는데, 우리가 가장 알고 싶은 사람이 바로 그 사람입니다. 측정 장치가 가장 많이 방해하는 사용자가 가장 중요한 사용자인 거죠.
셋, 재는 것이 재려는 것과 겹칠 때입니다. INP를 예로 들면, 관찰자 콜백이 도는 것 자체가 브라우저가 하는 일입니다. 그게 다음 상호작용의 「기다림」 칸을 늘릴 수 있어요.
줄이는 방법에는 그만큼의 대가가 따릅니다.
샘플링을 낮춘다 -> 드문 문제를 놓친다
늦게 불러온다 -> 그 전에 벌어진 일을 못 잰다
보낼 것을 줄인다 -> 범인을 못 짚는다
직접 만들어 쓴다 -> 만들고 유지하는 시간을 낸다
마지막 줄이 흥미롭습니다. 우리가 오늘 만든 관찰자 코드는 몇십 줄이고 번들에 거의 영향이 없었어요. 61 kB는 라이브러리를 들이면서 생긴 겁니다.
라이브러리는 우리가 안 만든 것들을 함께 줍니다 — 봉투를 보내는 것, 실패하면 다시 보내는 것, 여러 지표를 한 형식으로 맞추는 것. 그게 61 kB의 내용이에요. 직접 만든다는 것은 그 61 kB 중에 우리가 정말 쓰는 부분만 골라 만든다는 뜻이고, 대신 그 코드를 계속 돌보는 일이 우리 것이 됩니다.
그래서 결론은 이렇습니다. 정직하게 말하면 "재는 장치가 달린 우리 앱"이 맞습니다. 그런데 사용자가 실제로 받는 것도 그거예요. 측정 장치를 뺀 앱은 아무도 안 씁니다.
그러니 질문을 바꾸는 게 낫습니다. "이 숫자가 순수한가"가 아니라 "이 숫자로 내릴 결정이 어긋남 때문에 바뀔 만한가"로요.
🎯 면접관을 홀리는 핵심 멘트
"측정 장치를 켜면 앱이 조금 느려지는 건 피할 수 없습니다. 저희는 첫 글자가 0.2초 밀리는 걸 확인했어요. 그런데 중요한 건 그 어긋남이 있느냐가 아니라 그것 때문에 결정이 바뀌느냐입니다. 기준선에서 멀면 무시해도 되고, 가까우면 문제가 되죠. 특히 조심하는 건 느린 기기예요 — 측정 장치가 가장 많이 방해하는 사용자가 하필 우리가 가장 알고 싶은 사용자거든요."