INP 3,500ms를 쫓다가 Next.js 라우터 소스까지 열어본 이야기

|

안녕하세요. 미용 재료 쇼핑몰 아몬드영에서 프론트와 백엔드를 오가며 개발하고 있는 정중식입니다.

저희는 Microsoft Clarity를 쓰고 있어요. 실제 손님들의 방문을 녹화해 보여주고, 페이지별 로딩 속도나 클릭 반응 속도 같은 성능 지표도 모아 주는 무료 분석 도구입니다.

얼마 전 Clarity에서 URL별 성능 카드를 정렬해 보다가 손이 멈췄어요. 상품 상세 페이지 하나의 INP가 3,500ms로 찍혀 있었거든요. 버튼을 누르고 3.5초 동안 아무 반응이 없다는 뜻입니다.

화면 LCP INP CLS
카테고리 A 18초 약 1,400ms 0.36
상품 상세 B 1.3초 약 3,500ms 0.24
상품 상세 C 3.5초 160ms 7.4
좋은 경험 기준 2.5초 이하 200ms 이하 0.1 이하

최근 3일간 실제 방문 기록을 모은 값이에요. 방문을 느린 순서로 줄 세웠을 때 상위 25% 지점(p75)이라, 방문 4번 중 1번은 이 숫자보다 더 느렸다는 뜻입니다.

저희 방문의 74%가 모바일이고, 그중 대부분이 iPhone Safari예요. 그 손님들이 이런 화면을 보고 있다면 그냥 넘길 수 없었습니다.

이 글은 이 숫자들을 따라간 기록인데요. 먼저 말씀드리면 세 숫자 중 그대로 재현된 건 하나도 없었어요. 대신 재현하려고 이것저것 찍어 보다가, 상품 상세에 들어갈 때마다 페이지를 두 번 받고 있던 걸 찾았습니다.

목차

재현이 안 되는 숫자

먼저 Chrome DevTools Performance 패널로 실서비스 페이지를 찍어 봤어요. 조건은 모바일에 맞췄습니다. 390px, CPU 4x slowdown, Slow 4G.

항목 Clarity (실제 방문자) 직접 측정
카테고리 A의 LCP 18초 5.8초 (캐시 없음)
상품 상세 B의 INP 3,500ms 가장 느린 클릭 424ms
상품 상세 C의 CLS 7.4 0.003

느리긴 한데 Clarity만큼은 아니었어요. 서버 쪽도 봤는데 TTFB가 0.3~0.7초, 스트리밍 본문도 0.4초 안에 다 왔습니다.

여기서 두 가지를 의심하게 됐어요.

하나는 Clarity 숫자 자체입니다. 거의 텍스트뿐인 개인정보처리방침 페이지의 LCP가 31초로 찍혀 있었거든요. 정말 31초가 걸렸다기보다는 백그라운드 탭으로 열었거나, 클라이언트 라우팅 뒤에 다른 URL로 집계됐을 가능성이 더 커 보였어요. CLS 7.4가 나온 URL은 녹화가 3건뿐이었고요.

다른 하나는 제 측정 환경이에요. CPU를 4배 느리게 해도 M 시리즈 맥은 저가 안드로이드폰보다 빠릅니다.

그래서 방향을 바꿨어요. 극단값을 계속 쫓는 대신, 모든 사용자에게 매번 일어나는 낭비가 있는지부터 찾아보기로 했습니다.

처음 세운 가설은 틀렸다

상품 상세에서 스크롤을 내리면 URL이 ?tab=review, ?tab=qna로 바뀝니다. 스크롤 위치에 따라 탭 상태를 URL에 쓰는 구조예요.

처음엔 이게 범인이라고 생각했어요. URL이 바뀔 때마다 서버에서 다시 받아오고, 펼쳐 둔 상세설명이 접히면서 화면이 크게 밀린다면 CLS 7.4도 설명이 되니까요.

상세설명을 펼친 채로 스크롤하면서 layout shift와 네트워크를 같이 기록해 봤습니다. 서버 요청은 0건, 펼침 상태도 그대로, CLS는 0.003이었어요. URL만 바뀌고 다시 불러오지는 않았던 거죠.

측정 없이 이 가설대로 코드를 고쳤다면, 아무 효과 없는 수정을 “CLS 개선”이라는 이름으로 배포했을 거예요.

상세 페이지를 열면 페이지를 두 번 받고 있었다

네트워크 탭을 다시 보다가 이상한 요청 하나가 눈에 들어왔어요. 상품 상세에 들어가면 HTML을 받은 직후에 이런 요청이 하나 더 나갑니다.

GET /kr/products/<id>?v_id=variant_...&_rsc=4so2l   138KB

_rsc는 App Router가 RSC payload를 받을 때 쓰는 요청이에요. RSC payload는 서버 컴포넌트를 렌더링한 결과를 담은 데이터인데요. 처음 들어올 때는 HTML 안에 같이 오고, 이후 클라이언트에서 화면을 갱신할 때는 이것만 따로 받아 옵니다.

그러니까 방금 받은 페이지를 통째로 한 번 더 받고 있었던 겁니다. Slow 4G에서는 이 요청만 2.2초가 걸렸고, 그동안 메인 스레드는 페이지를 다시 그리느라 바빠서 사용자 클릭은 뒤로 밀렸어요.

누가 부르는지

URL에 v_id를 붙이는 코드는 금방 찾았습니다.

useEffect(() => {
  // ...
  window.history.replaceState(null, "", pathname + "?" + params.toString())
}, [selectedItems, pathname, searchParams])

그런데 router.replace가 아니라 history.replaceState예요. Next.js 14.1부터는 이 호출이 라우터와 동기화만 되고 서버 요청은 만들지 않아야 정상입니다.

그럼 대체 누가 부르는 걸까요. 페이지가 뜨기 전에 fetch를 감싸는 스크립트를 심어 두고, 그 요청을 보낸 콜스택을 찍어 봤어요.

at fetchServerResponse
at refreshReducer      ← ACTION_REFRESH
at Object.action
at runAction
at dispatch

router.refresh()였습니다. 문제는 저희 코드 어디에도 상품 상세에서 refresh를 부르는 곳이 없다는 거였어요.

Next.js 액션 큐 안에서

결국 배포된 Next.js 청크에서 ACTION_REFRESH를 dispatch하는 곳을 찾아 들어갔어요. 이런 코드가 있었습니다. (minify된 코드를 읽기 쉽게 풀어 쓴 거라 실제 변수명과는 달라요.)

// 새 액션이 들어왔는데 이미 처리 중인 액션이 있을 때
if (action.type === ACTION_NAVIGATE || action.type === ACTION_RESTORE) {
  pending.discarded = true                // 진행 중이던 액션은 버리고
  if (pending.payload.type === ACTION_SERVER_ACTION) {
    actionQueue.needsRefresh = true       // 버린 게 서버 액션이면 나중에 refresh
  }
}
// ...큐가 비면
if (actionQueue.needsRefresh) dispatch({ type: ACTION_REFRESH })

이 코드와 저희 상세 페이지를 나란히 놓고 보니 흐름이 보였어요.

  1. 상세 페이지가 마운트되자마자 Q&A 목록을 서버 액션으로 조회합니다.
  2. 거의 같은 순간, 옵션 컴포넌트가 history.replaceState로 v_id를 붙여요. 라우터는 이걸 ACTION_RESTORE로 받습니다.
  3. 라우터는 진행 중이던 서버 액션을 버리고 needsRefresh를 켜요.
  4. 큐가 비면 router.refresh()가 돌고, 페이지 전체를 다시 받습니다.

서버 액션은 데이터를 바꿀 수 있다는 전제로 설계되어 있어요. 그래서 중간에 버려지면, Next 입장에서는 화면이 최신이 아닐 수도 있으니 새로고침을 거는 거죠. 저희가 서버 액션을 조회 용도로 쓰고 있었던 게 문제였습니다.

해결: 첫 조회를 서버 컴포넌트로

Q&A 첫 페이지를 서버 컴포넌트에서 받아 초깃값으로 내려주도록 바꿨어요. 리뷰 섹션이 이미 같은 구조여서 그대로 따랐습니다.

// 서버 컴포넌트
export async function QnaSectionWrapper({ productId, ... }) {
  const result = await getQuestionsByProductId({ productId, page: 1, ... })
    .catch(() => null)
  return <QnaList initialQuestions={...} initialTotal={...} />
}

// 클라이언트: 첫 조회는 건너뛰고, 필터나 페이지가 바뀔 때만 서버 액션
const skipInitialFetch = useRef(initialQuestions !== null)
useEffect(() => {
  if (skipInitialFetch.current) {
    skipInitialFetch.current = false
    return
  }
  fetchQuestions(1)
}, [fetchQuestions])

마운트 시점에 서버 액션이 없으니 버려질 액션도 없어요. 로컬에서 v_id가 붙는 같은 조건으로 확인해 보니, 진입할 때 서버 액션 POST와 페이지 재요청이 모두 0건이었습니다. 첫 Q&A가 스켈레톤 없이 바로 보이게 된 것도 반가운 변화였고요.

같이 손본 것들

원인을 찾는 동안 네트워크 탭을 오래 들여다보다 보니 다른 낭비도 몇 개 보였어요.

레거시 상세설명 속 원본 이미지

이전 쇼핑몰에서 옮겨 온 상품은 상세설명이 HTML 문자열이에요. dangerouslySetInnerHTML로 그리다 보니 next/image를 거치지 않았고, 장당 530KB쯤 되는 1000×5000px 원본 JPG를 모바일에서도 그대로 받고 있었습니다.

그래서 HTML 안의 <img>를 찾아 최적화 경로로 바꾸는 함수를 만들었어요. srcset은 직접 계산하지 않고 Next의 getImageProps에 맡겼습니다.

const { props } = getImageProps({
  src, alt: "", width: 860, height: 860,
  sizes: "(max-width: 768px) 100vw, 860px",
})
// src → /_next/image?url=...&w=..., srcset·sizes 추가, loading="lazy"

한 가지 걸리는 게 있었는데요. 이미지 최적화기는 remotePatterns에 없는 호스트를 400으로 거절해요. 그래서 허용된 호스트의 이미지만 바꾸고, 나머지는 loading="lazy"만 붙였습니다. 이 경계는 테스트로 고정해 뒀어요. 장당 530KB 정도이던 이미지가 26~138KB로 줄었습니다.

첫 화면 썸네일에 lazy가 붙어 있었다

카테고리 첫 줄 상품 이미지에 loading="lazy"가 붙어 있었어요. lazy 이미지는 레이아웃이 끝나고 뷰포트 근처에 있다는 게 확인될 때까지 요청을 미룹니다. 그래서 첫 줄 이미지 요청이 4.9초에야 시작됐어요. 가상 스크롤의 첫 행(virtualRow.index === 0)에서 대표 사진에만 priority를 넘기도록 바꿨습니다.

sizes도 실제 레이아웃과 맞지 않았어요. 모바일은 2열 그리드인데 280px 기준으로 잡혀 있어서, DPR 3 기기에서는 1080px짜리 이미지를 받고 있었습니다. 열 수에 맞춰 50vw / 33vw / 25vw로 고치니 640px이 됐어요. 106px로 그려지는 로고도 width={1024} 때문에 2048px짜리를 받고 있었는데, 지금은 256px입니다.

폰트를 불러오는 @import 한 줄

globals.css 첫 줄에 이 코드가 있었어요.

@import url("https://cdn.jsdelivr.net/.../pretendard-dynamic-subset.min.css");

CSS 안의 @import는 그 CSS를 다 받아서 파싱한 뒤에야 발견됩니다. HTML → 메인 CSS → Pretendard CSS → 폰트 순서로 줄줄이 기다리게 되고, 둘 다 render-blocking이라 첫 화면이 그만큼 늦어져요. <head>의 <link>와 preconnect로 옮겨서 메인 CSS와 동시에 받게 했습니다.

사실 폰트를 직접 서빙하는 방식까지 구현했다가 되돌렸어요.

  • next/font/local은 Pretendard의 92조각 dynamic subset(unicode-range)을 지원하지 않아요. 쓰려면 폰트 전체를 받아야 합니다.
  • 조각째 public/에 넣으면 파일 828개, 12MB를 직접 관리해야 해요. URL 숫자 하나만 바꾸면 되던 버전 업데이트가 수작업이 됩니다.
  • 순차 로딩을 끊는 효과는 <link>만으로도 대부분 얻을 수 있었어요.

직접 서빙이 더 정석에 가깝다는 건 알지만, 지금 팀 규모에서 계속 관리할 수 있는 쪽을 골랐습니다.

바뀐 것

항목 이전 이후
상세 진입 시 추가 RSC 요청 매번 약 138KB + 전체 리렌더 0
레거시 상세 이미지 장당 약 530KB 26~138KB
카테고리 첫 줄 이미지 요청 시작 lazy 대기 (실측 4.9초) HTML 파싱 직후
모바일 썸네일 요청 크기 1080px 640px
폰트 CSS 메인 CSS 다음 메인 CSS와 동시

아직 못 푼 것

위 숫자는 전부 제 맥에서 잰 거예요. 처음에 봤던 18초와 3,500ms는 끝내 재현하지 못했습니다.

다음에는 web-vitals/attribution으로 실사용자 기기에서 LCP 요소와 INP를 일으킨 요소를 직접 수집해 보려고 해요. 추측하고, 재현에 실패하고, 다시 추측하는 걸 반복하지 않으려면 원인을 사용자 쪽에서 받아와야 할 것 같습니다.

그리고 이번에 제일 크게 남은 건 서버 액션을 조회에 쓰면 안 된다는 점이었어요. 버려지면 Next가 refresh로 메운다는 건 문서에서는 잘 보이지 않았고, 소스를 열어 보고서야 알았습니다. 비슷한 증상을 겪고 계신 분께 이 글이 도움이 됐으면 좋겠어요.