7.2MB를 79KB로 줄이는 것보다, 측정 조건 고정이 더 오래 걸렸다

루프팩 7주차 성능 최적화 회고. LCP 8.2초의 범인은 30초 만에 찾았는데, 그 뒤 대부분의 시간은 "이 숫자를 믿어도 되는가"에 썼다. 조건을 고정하고, 한 번에 하나씩만 바꾸고, 줄일 수 없는 지연은 화면 설계로 감당한 기록.

FrontendPerformanceNext.jsTanStack QueryCore Web Vitals

7주차 과제는 "같은 사용자 경로의 병목을 줄이기"였다. 홈에 들어가 Lighthouse를 한 번 돌렸더니 LCP가 8.2초였고, waterfall 맨 위에 7.2MB짜리 Hero 이미지가 8초 동안 내려오고 있었다. 범인은 30초 만에 찾았다.

Before run-3 트레이스를 DevTools Performance 패널에 로드한 화면 — hero-original.jpg 요청이 Network 트랙을 0.2초부터 8.2초까지 점유하고, 그 옆에서 나머지 요청 34건은 1.6초 안에 다 끝난다. 필름스트립의 Hero 자리는 8초 내내 비어 있다

문제는 그 다음이었다. 이미지를 줄이고 다시 재보려는데, 처음 잰 8.2초와 비교할 수 있는 조건인지 확신이 안 섰다. 그리고 이 의심이 결국 이번 주 작업의 절반을 차지했다.

Lighthouse 결과 JSON이 측정 조건을 기록하지 않았다

처음엔 DevTools의 Lighthouse 패널로 쟀다. 네트워크·CPU 쓰로틀링은 DevTools에 걸어둔 값이 그대로 먹는다고 생각했고, 실제로도 그렇게 동작했다. 그런데 결과 JSON을 열어보니 이렇게 기록되어 있었다.

// DevTools 패널 실행 결과 — configSettings.throttling
{
  "requestLatencyMs": 0,
  "downloadThroughputKbps": 0,
  "cpuSlowdownMultiplier": 1
}

쓰로틀링을 걸고 쟀는데 기록은 "쓰로틀링 없음"이다. 패널은 브라우저에 이미 걸린 쓰로틀링을 그대로 물려받아 측정하면서, 결과 JSON에는 Lighthouse 프리셋 기본값을 적는다. 통제 실험으로 확인하고 나서야 이해했다.

이게 왜 문제인가. 지금 잰 값이 어떤 조건에서 나온 건지 파일에 남지 않으면, 일주일 뒤 After를 잴 때 같은 조건인지 확인할 방법이 없다. Before를 다시 재는 수밖에 없고, 그러면 그동안의 비교는 전부 무효가 된다.

그래서 조건을 CLI에 명시하는 쪽으로 옮겼다.

./scripts/lighthouse-measure.sh before 5
# 내부적으로 실행되는 값
#   --preset=desktop --throttling-method=devtools
#   --throttling.requestLatencyMs=167          # 요청당 지연
#   --throttling.downloadThroughputKbps=7910   # 하향 (Fast 4G 상당)
#   --throttling.cpuSlowdownMultiplier=2.4     # CPU 배율

--throttling-method=devtools로 두고 값을 직접 넘기면 그 값이 JSON에 그대로 기록된다. 스크립트가 라벨별 폴더(measurements/<label>/)에 raw JSON·트레이스·filmstrip을 통째로 남기게 해서, 나중에 "그때 뭘로 쟀더라"를 되묻지 않게 했다.

기록값도 믿지 않고 역산해서 대조했다

기록이 남는다고 실제로 적용됐다는 보장은 아니다. 그래서 5회 전부, 네트워크 요청 타이밍에서 대역폭을 역산해 설정값과 맞는지 확인했다.

회차Hero 실효 대역폭소형 요청 중앙 소요
1943,978 B/s (7.55 Mbps)179.9 ms
2942,394 B/s (7.54 Mbps)180.9 ms
3944,070 B/s (7.55 Mbps)178.6 ms
4940,270 B/s (7.52 Mbps)178.1 ms
5944,416 B/s (7.56 Mbps)179.8 ms

설정한 7,910 Kbps의 이론 최대는 1,012,480 B/s인데 실측은 그 93.3%다(프로토콜 오버헤드). 지연도 설정한 167 ms 근처다. 5회 편차는 0.4% 이내였다.

흔들림 범위를 먼저 정하고 시작했다

같은 조건을 5회 반복하니 그 자체의 범위가 보였다.

  • LCP 5회 범위 39.8 ms (중앙값의 0.48%)
  • FCP 5회 범위 36.8 ms (중앙값의 6.98%)

그래서 규칙을 하나 세우고 들어갔다. 5회 raw 값의 범위보다 작은 변화는 개선으로 주장하지 않는다. LCP는 40 ms, FCP는 37 ms가 기준선이다. 이걸 먼저 정해두니 나중에 "37 ms 줄었는데 개선인가?" 같은 애매한 판단을 안 해도 됐다.

production build인지도 매번 확인했다

pnpm start로 띄운 서버인지 부모 프로세스로 확인하고, 네트워크 요청에 hmr-client·next-devtools 청크가 섞였는지 매 회차 셌다. 0단계 5회는 요청 35건 × 5회 모두 dev 마커 0건이었다. dev 서버 값 하나가 섞이면 그 회차만 조용히 느려진다.

한 번에 하나씩 — 순서를 반대로 알았을 뻔했다

Hero 이미지는 3840×2160 원본을 1200×675 자리에 그대로 내려받고 있었다. 고칠 건 두 가지로 보였다. 포맷(JPEG → WebP)과 해상도(3840 → 1200). next/image를 쓰면 둘 다 한 번에 해결된다.

한 번에 넣고 싶었지만, 그러면 "next/image 넣으니 빨라졌다"밖에 남지 않는다. 어느 쪽이 얼마나 기여했는지 모른 채 끝나고, 다음에 비슷한 상황이 오면 또 처음부터 재야 한다. 그래서 하나씩 갈라서 쟀다.

적용한 것Hero 요청전송 크기LCP 중앙값
없음 (Before)hero-original.jpg (3840×2160 JPEG)7,545,525 B8,225.4 ms
포맷만 (WebP, 해상도는 원본)_next/image?...&w=3840409,737 B1,267.3 ms
해상도만 (1200w JPEG 정적 파일)hero-responsive-1200w.jpg215,084 B838.9 ms
포맷 + 해상도_next/image?...&w=120080,966 B538.2 ms

해상도만 줄인 쪽(838.9 ms)이 포맷만 바꾼 쪽(1,267.3 ms)보다 좋았다. 나는 반대로 예상했다. WebP 전환은 "표준 최적화"라는 인상이 있어서 그쪽이 더 큰 몫일 줄 알았는데, 실제로는 10배 넓이의 픽셀을 내려받는 게 더 컸다.

둘을 같이 넣었으면 이 순서를 영영 몰랐을 것이다. 그리고 다음에 시간이 없어 하나만 골라야 하는 상황이 오면 아마 포맷부터 건드렸을 것이다.

최종 코드는 결국 둘 다 적용한 형태다.

// src/_pages/home/ui/HeroSection.tsx
<Image
  className={styles.image}
  src="/images/week-07/hero-original.jpg"
  alt=""
  fill                                            // 부모 박스를 채운다 (aspect-ratio로 공간 예약)
  sizes="(max-width: 1200px) 100vw, 1200px"       // 이 값으로 요청 해상도(w=1200)가 결정된다
  loading="eager"
/>
// next.config.ts
images: {
  formats: ['image/webp']   // 이 배열에 있는 포맷만 협상 대상이 된다
}

실측에서 이게 갈린 지점이 sizes였다. 위 표의 "포맷만" 단계는 해상도 축소 전이라 요청이 w=3840(원본 크기)이었고, sizes를 지정하고서야 w=1200으로 내려갔다. 즉 next/image를 쓴다고 해상도가 자동으로 맞춰지는 게 아니라, sizes가 요청 해상도를 결정한다. 공식 문서 기준으로도 fill에 sizes가 없으면 기본값 100vw로 간주된다.

Before — 1,027ms 시점After — 750ms 시점
Hero 자리가 단색으로 비어 있다Hero가 이미 완성되어 있다

왼쪽이 Before의 1,027 ms 프레임이다. Header·제목·카테고리는 이미 다 그려졌는데 Hero 자리만 단색이고, 이 상태가 8초까지 이어진다. 오른쪽은 개선 후 750 ms 프레임 — 같은 시점에 이미 다 채워져 있다.

줄일 수 있는 구간과 줄일 여지가 없는 구간

LCP를 네 구간으로 쪼개보니 어디를 건드려야 하는지가 수치로 드러났다.

구간Before비중
서버 응답 대기13.9 ms0.17%
이미지 요청 시작까지 대기166.8 ms2.0%
이미지 전송8,000.8 ms97.3%
화면에 그려질 때까지33.6 ms0.41%

fetchpriority="high"를 붙일지 고민했는데, 이 표를 보고 접었다. 그게 줄일 수 있는 건 "요청 시작까지 대기" 구간이고, 그 구간 전체가 166.8 ms(2.0%)라 아무리 잘 줄여도 그 이상은 못 얻는다. 97.3%를 놔두고 2%를 다듬을 이유가 없었다. 나중에 확인한 사실도 이 결정을 지지했다 — Next.js는 loading="lazy"가 아닌 모든 <img>에 preload link를 자동으로 붙이고 있었고(실측 확인), Hero는 이미 초기 HTML에서 발견 가능한 상태였다. 최종 코드의 주석에 이 근거를 남기고 fetchPriority는 추가하지 않았다.

렌더링 경계를 다시 나누는 것도 같은 이유로 접었다. 홈은 페이지 컴포넌트가 await queryClient.prefetchQuery(...)로 홈 데이터를 다 기다린 뒤 응답하는 구조인데, 그 서버 응답 대기가 0.17%라 await를 Suspense 안쪽으로 옮겨 셸을 먼저 내보내도 얻을 게 없었다.

여기서 낸 판단의 근거가 나중에 무너졌다

이후 단계에서 홈의 서버 호출 우회를 걷어내면서, 서버 응답 대기가 13.9 ms → 521.9 ms(LCP의 59%)로 올라갔다. "API 대기 비용이 0에 가깝다"는 근거가 사라진 것이다.

그래도 렌더링 경계는 그대로 뒀는데, 이번엔 근거가 달랐다. 521.9 ms는 /api/home의 고정 지연 500 ms 자체라 경계를 옮겨도 총 대기는 안 줄어든다. 셸이 먼저 나가는 대신 배너·상품 그리드가 그만큼 늦게 채워지는 교환일 뿐이고, 홈 셸에는 그 사이 사용자가 조작할 대상이 없다. (목록에서는 같은 교환을 택했다 — 뒤에 나온다.)

결론은 같았지만 근거는 통째로 바뀌었다. 근거를 안 적어뒀으면 "그때 왜 안 했더라"로 남았을 자리다.

가설이 틀렸다는 걸 소수점 6자리로 확인했다

목록에서 정렬만 바꾸면(결과 건수는 6건 → 6건 그대로) CLS가 0.226 나왔다. 그 시점 카드를 재보니 제목 높이가 69~140px로 들쭉날쭉했다. 상품명이 2줄을 넘는 카드 하나 때문이었다.

가설: 카드 높이가 제각각이라 정렬이 바뀔 때 레이아웃이 밀린다. 제목을 2줄로 클램프하면 해결될 것이다.

반증 조건도 같이 정했다. 높이를 완전히 균일화했는데도 CLS가 그대로면 가설이 틀린 것이다.

/* src/_app/styles/commerce.css — 검증용으로 잠시 적용한 변경 */
.product-card h2, .product-card h3 {
- min-height: 2lh;            /* 하한만 잡혀 있어 3줄이면 카드가 그만큼 커진다 */
+ display: -webkit-box;
+ -webkit-line-clamp: 2;      /* 2줄 넘으면 말줄임 */
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ height: 2lh;                /* min-height가 아니라 height — 상한까지 고정 */
}

결과.

클램프 전클램프 후
카드 제목 높이69~140px (제각각)56px (6장 전부 동일)
본문 높이 (정렬 전 → 후)1703 → 1760 (변함)1617 → 1617 (불변)
CLS0.2174390.217439

소수점 6자리까지 같다. 높이는 완벽하게 균일해졌고 본문 높이도 정렬 전후로 안 변하는데, CLS는 조금도 안 움직였다.

그래서 LayoutShift 엔트리의 sources를 직접 열어봤다.

{
  "value": 0.217439,
  "sources": [
    { "tag": "ARTICLE.product-card", "text": "Loopers Select[1+1] 베이직 무" },
    { "tag": "ARTICLE.product-card", "text": "Loopers Select[Woman]케이블 " }
  ]
}

밀린 건 제목(h2)이 아니라 카드(article) 자체였다. 정렬이 바뀌면 6개 카드의 DOM 순서가 바뀌고, React의 key로 재사용되는 노드가 그리드 안에서 다른 칸으로 물리적으로 이동한다(x=800 → 68). Layout Instability API는 이 이동을 그대로 shift로 집계한다. 카드 높이를 아무리 맞춰도 이동량 자체는 안 줄어든다 — 애초에 무관한 대응이었다.

CLS는 눈에 보이는 "카드 크기 차이"로 추측하면 안 되고, sources로 실제 이동한 노드를 확인해야 원인이 잡힌다. 이번엔 그걸 클램프를 다 적용하고 나서야 알았다.

클램프는 되돌렸다

한동안 고민했다. CLS는 못 줄였지만 필터 결과에 따라 본문 높이가 미묘하게 달라지던 건 없앴으니 남길 이유가 없진 않았다. 다만 그건 이번에 세운 가설과 무관한 부수 효과였고, 상품명을 말줄임으로 자르는 비용까지 감수할 만큼 급한 문제도 아니었다. 결국 min-height: 2lh로 되돌렸다.

되돌리면서 지킨 건 하나다 — 효과가 없었던 변경을 "부수 효과가 있었다"는 이유로 성과 칸에 옮겨 적지 않는 것. 그러면 다음에 이 기록을 읽는 사람이 똑같이 속는다.

줄일 수 없는 1.5초는 화면으로 감당했다

목록 API에는 1.5초 고정 지연이 걸려 있고, 이건 과제 범위상 못 건드린다. 그래서 "얼마나 빨라졌나" 대신 기다리는 1.5초 동안 사용자가 무엇을 보고 무엇을 할 수 있나로 문제를 바꿔 잡았다.

서버에서 다 기다린 뒤 보내던 걸 멈췄다

원래는 서버가 목록 데이터를 prefetch해서 완성된 HTML을 한 번에 내려줬다. 초기 HTML에 데이터가 담기는 건 이점이지만, 그 await가 검색창·카테고리·정렬 컨트롤까지 1.5초 함께 붙잡고 있었다. 사용자는 아무것도 못 하고, pending UI 분기는 애초에 도달하지도 않는다(서버가 이미 채워서 보내니까).

server prefetch를 걷어냈다.

제거 전제거 후
문서 응답 time_starttransfer1.508 ~ 1.522 s0.007 ~ 0.010 s
초기 HTML완성된 목록스켈레톤 12개 + aria-busy="true"
필터 조작 가능 시점1.5초 후즉시

초기 HTML에 담긴 스켈레톤 — 검색·카테고리·정렬은 이미 조작할 수 있다

교환 관계는 분명하다. 초기 HTML의 데이터를 포기하고 셸을 먼저 내보냈다. 홈에서는 같은 교환을 안 했는데, 홈 셸에는 그 사이 조작할 컨트롤이 없어서 얻는 게 없기 때문이다. 같은 기법이라도 그 화면에 조작할 대상이 있느냐에 따라 답이 갈렸다.

스켈레톤은 실제 목록과 같은 .product-grid·.product-card·.product-card-image 클래스를 그대로 재사용했다. 열 수(5/3/2)와 이미지 비율이 자동으로 따라온다. 버튼도 빈 상자가 아니라 실제와 같은 텍스트("찜"·"담기")를 넣고 색만 감췄는데, 텍스트가 만드는 상자 크기까지 같아야 교체 시점에 레이아웃이 안 밀리기 때문이다. 교체 시점 CLS는 5회 모두 0이었다.

// src/_pages/product-list/ui/ProductGridSkeleton.tsx — 실제 카드와 같은 클래스·태그
<article className="product-card product-card-skeleton" key={index}>
  <div className="product-card-image" />
  <p />
  <h2 />
  <strong />
  <div className="product-card-actions">
    <button type="button" disabled>찜</button>
    <button type="button" disabled>담기</button>
  </div>
</article>

측정 중 잡힌 CLS 0.000141의 범인은 그리드가 아니었다

카테고리 select였다. 옵션이 서버 응답으로 채워지며 폭이 66px → 93px로 넓어져 옆의 정렬 라벨을 밀어냈다. min-width: 7rem으로 폭을 미리 잡아 0으로 만들었다. 여기서도 sources를 안 봤으면 그리드를 계속 뒤졌을 것이다.

갱신에 실패하면 보던 목록이 통째로 사라졌다

30개 목록을 보다가 카테고리를 바꿨는데 그 요청이 실패하면, 화면이 이렇게 됐다.

수정 전수정 후
목록도 필터 옵션도 사라지고 mock error만 남는다이전 결과를 유지한 채 실패를 알린다

목록 30개가 사라지고 mock error + "다시 시도" 버튼만 남는다. 카테고리 select도 옵션이 전체 하나로 줄고 비활성화된다. 내가 뭘 눌렀는지, 방금까지 뭘 보고 있었는지가 한 번에 날아간다.

원인은 isError 분기가 기존 데이터 유무를 안 보고 결과 영역 전체를 갈아치우는 것이었다. placeholderData: keepPreviousData가 있으니 괜찮을 줄 알았는데, 실측해보니 그건 요청이 pending인 동안에만 이전 결과를 내주고 에러로 끝나면 안 내준다.

그래서 에러이면서 현재 데이터가 없을 때만, query cache에서 직전 성공 결과를 찾아 화면에 남겼다.

// src/_pages/product-list/api/findLastSuccessfulProductList.ts
export function findLastSuccessfulProductList(queryClient: QueryClient): ProductListResponse | null {
  const latest = queryClient
    .getQueryCache()
    .findAll({ queryKey: productQueries.all() })
    .filter((query) => query.state.status === 'success')
    .sort((first, second) => second.state.dataUpdatedAt - first.state.dataUpdatedAt)[0];

  return latest && isProductListResponse(latest.state.data) ? latest.state.data : null;
}
// src/_pages/product-list/ui/ProductListSection.tsx
const staleList = isError && !productList ? findLastSuccessfulProductList(queryClient) : null;
const shownList = productList ?? staleList;
const isShowingPreviousCondition = !productList && staleList !== null;

여기서 신경 쓴 건 데이터를 로컬 state나 ref에 복사하지 않는 것이었다. 복사해두면 그 순간부터 같은 데이터가 두 곳에 살고, 어느 쪽이 진짜인지 관리해야 한다. 렌더 중에 캐시를 한 번 조회하는 방식이면 소유권은 계속 query cache에 있다.

그리고 지금 보는 게 현재 URL 조건의 결과가 아니라는 사실은 문구로 밝혔다 — "갱신 실패 — 아래는 이전 조건의 결과입니다 · 총 30개". 카테고리 select도 사용자가 시도한 값(캐주얼)을 그대로 유지한다.

고쳤다는 걸 어떻게 확인했나

한 번 보고 "고쳐졌다"고 적기엔 부족했다. 격리한 headless Chrome에 CDP Fetch 도메인으로 /api/products만 500을 강제하고, git stash로 수정 전 코드를 잠시 되살려 같은 시나리오를 먼저 돌린 뒤, git stash pop으로 수정본을 복원해 다시 돌려 대조했다.

수정 전수정 후
카드 개수012
상태 문구없음"갱신 실패 — 아래는 이전 조건의 결과입니다 · 총 30개"
카테고리 selectdisabled, 옵션 1개, 값이 전체로 되돌아감활성, 옵션 6개, 시도한 값(캐주얼) 유지

이번 주에 내린 결정을 한 장으로

관찰가설반증 조건결과
LCP 8,225 ms 중 이미지 전송이 97.3%표시 크기의 10배 원본을 그대로 전송 중바이트를 줄였는데 전송 구간이 비례해 안 줄면 틀림채택 — 7.2MB → 79KB, LCP 8,225 → 538 ms
fetchpriority 미적용요청 시점을 앞당기면 LCP가 준다요청 시작 대기 구간의 크기보류 — 최대 절감 여지가 2.0%
서버 응답 대기 0.17%렌더링 경계 때문에 waterfall이 생긴다서버 응답 대기 구간 값무개입 — 근거는 나중에 바뀌었지만 결론 유지
정렬 변경 시 CLS 0.226, 카드 높이 제각각카드 높이 불균일이 레이아웃을 민다높이 균일화 후에도 CLS 그대로면 틀림반려 — 0.217439로 동일, 원인은 카드 노드의 위치 이동
갱신 실패 시 목록이 통째로 사라짐isError 분기가 기존 데이터를 안 본다실패 재현 시 이전 목록이 남는가채택 — 수정 전 카드 0개 / 수정 후 12개 유지
SSR이 1.5초를 통째로 기다림await가 필터 셸까지 붙잡는다제거 후 문서 응답 시간채택 — 1.508 s → 0.007 s

남은 것

  • AVIF vs WebP: 실측에서 AVIF가 전송 용량·LCP 모두 더 좋았는데(297KB/1,227 ms vs 400KB/1,422 ms) WebP를 골랐다. 당시 기록에 이유를 안 남겨서, 지금은 근거를 다시 세우거나 AVIF로 바꿔야 하는 열린 항목이다. 판단은 남겼는데 근거를 안 남기면 판단이 없는 것과 같다는 걸 이 칸이 증명하고 있다.
  • 정렬 재배치 CLS 0.217: 원인은 확인했지만 대응은 미정이다. 사용자가 명시적으로 요청한 재배치라 무개입 처리할지, View Transitions 쪽을 볼지 아직 못 정했다.
  • AbortSignal과 memoization: 목록 쿼리는 queryFn: ({ signal }) => ...로 signal을 넘기는데, Next.js 기준으로 이러면 fetch memoization 대상에서 빠진다. 지금은 서버 호출자가 하나뿐이라 안 드러나지만, 목록에 server prefetch를 되살리면 중복 요청이 생긴다.

배운 것

가장 오래 남을 것 같은 건 이 세 가지다.

측정 조건이 결과 파일에 남지 않으면, 그 측정은 일회용이다. DevTools 패널로 잰 값은 그 자리에서 보기엔 멀쩡하지만 일주일 뒤의 나와는 비교가 안 된다. 조건을 명시하고, 기록되는지 확인하고, 기록값이 실제로 적용됐는지 역산까지 대조하는 데 든 시간이 결국 가장 값쌌다.

둘을 같이 바꾸면 둘 다 모르게 된다. 포맷과 해상도를 갈라 재지 않았으면 순서를 반대로 알고 있었을 것이다.

반증 조건을 미리 정해두면 틀렸을 때 바로 안다. "높이를 균일화했는데도 CLS가 그대로면 틀린 것"을 먼저 적어뒀기 때문에, 0.217439라는 같은 숫자를 보자마자 가설을 접고 sources를 열 수 있었다. 안 적어뒀으면 아마 클램프를 더 다듬고 있었을 것이다. is-it-going-well