구글이 3월 코어 업데이트에서 INP를 공식 랭킹 시그널로 격상시켰다. LCP 임계값도 2.5초에서 2.0초로 졸라맸다. 그런데 현실은? 전체 웹사이트의 43%가 아직도 Interaction to Next Paint 200ms 기준을 못 넘기고 있고, 그중 React·Next.js 기반 사이트의 실패율은 서버 렌더링이나 정적 사이트 대비 3배다. 프레임워크 탓이라고? 아니다. 우리가 짠 코드 탓이다.
43%라는 숫자가 말해주는 것
FID 시절에는 대부분 쉽게 통과했다. 첫 번째 입력의 딜레이만 보니까, 페이지 로드 직후 한 번만 빠르면 됐다. 응답성 지표가 바뀌면서 게임이 완전히 달라졌다. 페이지에서 발생하는 모든 인터랙션 중 가장 느린 놈이 최종 점수를 결정한다. 클릭, 탭, 키보드 입력 — 전부 감시 대상이다. "초기 로딩은 괜찮은데 쓰다 보면 버벅거리는" 사이트가 전부 걸린다. 그리고 그게 React SPA의 전형적인 증상이다.
하이드레이션이 진짜 범인
React 앱의 인터랙션 성능이 나쁜 가장 큰 원인은 하이드레이션이다. 서버에서 보낸 HTML에 이벤트 핸들러를 다시 붙이는 과정이 메인 스레드를 장시간 점유한다. 이 작업이 50ms를 넘기면 브라우저는 롱 태스크로 분류하고, 그 사이에 사용자가 버튼을 누르면 클릭 이벤트는 롱 태스크가 끝날 때까지 대기열에 갇힌다. 사용자 입장에서는 "눌렀는데 아무 반응 없음" → 200ms 초과 → 빨간불.
Next.js App Router와 서버 컴포넌트 조합이 이 문제를 부분적으로 해결하긴 한다. 서버 컴포넌트는 JavaScript를 아예 클라이언트로 안 보내니까. 하지만 현실에서 벌어지는 일은 다르다. "use client" 디렉티브를 습관적으로 남발하면서 서버 컴포넌트의 이점을 통째로 상쇄하는 팀이 많다. RSC를 도입하고도 Lighthouse 점수가 안 나아지는 이유가 십중팔구 이거다.
한 번 진지하게 물어보자. 당신 프로젝트에서 "use client"가 붙은 컴포넌트 중, 진짜로 브라우저 API를 쓰거나 상태를 관리해야 하는 게 몇 퍼센트인가? 내 경험상 절반 이하다. 나머지는 서버로 올릴 수 있고, 올리는 순간 그만큼의 JS 번들이 사라진다.
scheduler.yield()로 롱 태스크 쪼개기
그래도 클라이언트에서 돌아가야 하는 무거운 로직은 있다. 이때 가장 효과적인 무기가 scheduler.yield()다.
예전에는 setTimeout(fn, 0)으로 메인 스레드를 양보했는데, 이 방식은 태스크 큐 맨 뒤로 밀려나는 문제가 있었다. 서드파티 스크립트가 끼어들면 실행 순서가 꼬인다. scheduler.yield()는 양보하되 우선순위를 유지한다. 브라우저에게 "보류 중인 사용자 입력 있으면 먼저 처리해, 끝나면 내 코드로 돌아와"라고 말하는 셈이다.
async function processLargeList(items) {
for (const item of items) {
renderItem(item);
await scheduler.yield(); // 매 아이템 후 양보
}
}
// 이벤트 핸들러에서도 동일하게
button.addEventListener('click', async () => {
updateUI();
await scheduler.yield();
sendAnalytics();
await scheduler.yield();
prefetchNextPage();
});
Chrome은 이미 지원하고, 다른 브라우저용으로 scheduler-polyfill npm 패키지가 있다. 이걸 무거운 이벤트 핸들러에 끼워넣는 것만으로 응답성 점수가 "poor"에서 "good"으로 뒤집히는 사례를 여러 번 봤다.
어디가 느린지 찾는 법
최적화 전에 병목을 정확히 찍어야 한다. 이 지표는 세 구간으로 나뉜다:
| 구간 | 뭘 측정하나 | 흔한 원인 |
|---|---|---|
| Input Delay | 터치 시점 → 핸들러 실행 시작 | 메인 스레드를 점유 중인 다른 태스크 |
| Processing Time | 핸들러 실행 시간 자체 | 무거운 상태 업데이트, DOM 조작 |
| Presentation Delay | 핸들러 완료 → 화면 갱신 | 레이아웃 스래싱, 리페인트 |
DevTools Performance 패널에서 Interactions 레인을 펼치면 각 인터랙션별로 이 세 구간이 시각화된다. 200ms를 넘기면 빨간 줄무늬가 뜨니까 못 놓친다. 필드 데이터까지 보고 싶으면 LoAF(Long Animation Frame) API를 RUM 도구에 연결하면 된다. 실사용자 기기에서 어떤 함수가 병목인지 핀포인트로 잡아준다.
그런데 제일 중요한 건 — DevTools에서 CPU 4x 쓰로틀링을 걸고 테스트하는 거다. 맥북 프로 M4에서 쾌적하면 갤럭시 A15에서도 쾌적한 줄 아는 개발자가 너무 많다.
오늘 당장 할 수 있는 것
모든 걸 한꺼번에 고칠 필요는 없다. 임팩트 순으로 네 가지:
"use client"감사(audit) — 프로젝트 전체에서 grep 한 번 돌려보자. 브라우저 API 안 쓰는 컴포넌트는 서버로 올린다. JS 번들이 줄면 하이드레이션 시간이 그만큼 짧아진다.무거운 핸들러에 yield 삽입 — 100ms 넘는 이벤트 핸들러를 Performance 패널로 찾고,
scheduler.yield()로 분할한다.서드파티 스크립트 감사 — Google Tag Manager, 채팅 위젯, 분석 SDK가 메인 스레드를 몰래 먹고 있을 확률이 높다.
requestIdleCallback이나 동적 임포트로 뒤로 밀어넣는다.저사양 기기 테스트를 루틴으로 — CI에 Lighthouse를 붙이든, 수동으로 쓰로틀링을 걸든, 개발 기기와 실사용 기기의 격차를 인식해야 한다.
인터랙션 응답성은 "다 만들고 나서 최적화"하는 지표가 아니다. 이벤트 핸들러 작성하는 순간부터 메인 스레드 점유 시간을 의식하는 게 맞다. Lighthouse에 초록불 네 개 켜지는 그 순간의 도파민은 — 프레임워크 몇 번 갈아타본 사람만 안다.