Skip to content

[volume-7] 프론트엔드 성능 최적화 - 김소연 - #156

Open
manual-hue wants to merge 46 commits into
loopers-labs:manual-huefrom
manual-hue:feat/week-07
Open

[volume-7] 프론트엔드 성능 최적화 - 김소연#156
manual-hue wants to merge 46 commits into
loopers-labs:manual-huefrom
manual-hue:feat/week-07

Conversation

@manual-hue

Copy link
Copy Markdown
Contributor

📌 이번 PR 요약

  • 주차: 7주차 초기 로딩 성능과 목록 상태 설계
  • 무엇을 / 왜:
    • Slow 4G에서 7.20MiB Hero 이미지 전송이 LCP를 지배하는 병목을 확인하고, 반응형 AVIF·WebP 이미지와 안정적인 렌더링 경계를 적용했습니다. 같은 모바일 조건에서 LCP 중앙값을 44,846ms에서 2,262ms로 95.0% 줄였습니다.
    • 상품 목록의 최초 로딩과 기존 목록 갱신을 분리하고, 빈 결과·최초 실패·갱신 실패·요청 취소까지 여섯 가지 상태를 각각 다른 화면과 복구 흐름으로 표현했습니다.
    • 동적 metadata와 본문이 같은 데이터를 조회할 때 서버 요청이 중복되는 원인을 확인했습니다. 서버 fetch에서는 AbortSignal을 제외해 request memoization을 적용하고, 클라이언트에서는 요청 취소 기능을 유지했습니다.
    • 측정 조건, 5회 원시값, 판단 근거, DevTools 캡처와 남겨둔 한계는 Week 07 성능 보고서에 정리했습니다.

결과 요약

공식 Before·After 비교는 production build, 모바일 412×823, DPR 1.75, Slow 4G, CPU 4x slowdown, cold load 조건에서 각각 5회 측정했습니다.

항목 Before After 판단
LCP 중앙값 44,846ms 2,262ms 42,584ms 단축, 95.0% 감소
LCP 범위 44,718–45,342ms 2,184–2,856ms 이미지 전송 병목이 크게 줄어듦
FCP 중앙값 2,723ms 2,198ms 525ms 단축, 범위가 겹쳐 주된 성과로 해석하지 않음
홈 CLS 중앙값 0 0 레이아웃 안정성 유지
대표 Hero 전송 크기 7,545,525B 60,748B viewport별 AVIF·WebP 후보 제공
상품 목록 CLS 0.54 0.01 목록·스켈레톤 높이 예약 후 별도 DevTools 재측정

Before Lighthouse run-04 요약 After Lighthouse run-04 요약

주요 구현과 판단

1. Hero LCP 병목

  • Before 대표 run의 LCP 44,846ms 중 이미지 전송이 44,141ms였습니다. 요청 시작 대기보다 7.20MiB JPEG 전송이 지배적인 병목이라고 판단했습니다.
  • 640px·1080px·1920px AVIF·WebP 후보와 실제 CSS 슬롯에 맞는 srcSet·sizes를 적용했습니다.
  • 모바일 4:5와 데스크톱 16:9 영역을 미리 예약해 이미지가 바뀌어도 레이아웃이 이동하지 않게 했습니다.
  • fetchPriority="high"를 적용했지만 대표 After run에서 관찰된 priority는 Low였습니다. 근거가 확인되지 않았으므로 priority 설정을 LCP 개선 원인으로 계산하지 않았습니다.
  • Hero와 정적 h1·설명을 Suspense 밖에 두어 slow API를 기다리는 동안에도 페이지의 핵심 구조를 먼저 표시했습니다.

2. 상품 목록의 여섯 가지 상태

상태 사용자에게 보이는 화면
데이터가 없는 최초 진입 실제 카드와 같은 크기의 스켈레톤
기존 목록이 있는 갱신 기존 목록 유지 + 갱신 상태 표시
성공 + 0건 현재 검색 조건의 빈 결과 안내
최초 실패 오류 안내 + 다시 시도
갱신 실패 기존 목록 유지 + 인라인 오류 배너 + 다시 시도
취소된 이전 요청 현재 URL의 active query 결과만 유지
  • useQuerykeepPreviousData로 최초 pending과 갱신을 분리했습니다.
  • query key가 바뀌면 TanStack Query의 AbortSignal을 실제 클라이언트 fetch에 전달해 이전 요청을 취소했습니다.
  • 갱신 실패 시 이전 서버 응답을 별도 로컬 상태나 Zustand에 복사하지 않았습니다. 마지막 성공 query key만 기억하고 TanStack Query cache에서 기존 목록을 조회해 Source of Truth를 유지했습니다.
  • 검색·카테고리·정렬·페이지를 연속 변경해도 주소창 URL, 입력값과 최종 목록이 같은 active query를 가리키는지 확인했습니다.

3. 동적 metadata와 서버 요청

  • 홈과 상품 목록에 generateMetadata를 적용하고, metadata와 본문 prefetch가 같은 query factory의 응답을 사용하도록 맞췄습니다.
  • 서버에서 서로 다른 AbortSignal을 전달하자 동일한 URL도 document당 두 번 호출됐습니다. 서버 fetch에서만 signal을 제외해 동일한 URL·options에 native fetch memoization이 적용되도록 했습니다.
  • 클라이언트 fetch의 signal은 유지해 빠르게 검색 조건을 바꿀 때 이전 요청을 계속 취소합니다.
  • normal·정상 empty·metadata query failure의 초기 HTML, Open Graph fallback, 최종 URL과 Route Handler 호출 횟수를 production 응답에서 확인했습니다.
  • ensureQueryData가 prefetch 실패를 먼저 throw하던 경로는 prefetchQuery로 바꿨습니다. 서버 prefetch가 실패해도 클라이언트 Query가 조회와 인라인 복구를 이어받습니다.

4. 구조와 회귀 방지

  • page slice 외부에서는 선별된 Public API만 import하도록 홈·상품 목록·상품 상세의 경계를 정리했습니다.
  • Pretendard를 self-hosting하고 폰트 fallback metric을 맞춰 글꼴 교체로 인한 CLS를 방어했습니다.
  • 페이지별 metadata, robots.ts, sitemap.ts, Cache-Control 정책을 추가했습니다.
  • 기존 검색·필터·페이지네이션·URL 복원·장바구니·위시리스트 동작은 유지했습니다.

📚 이번 주 학습

  • 학습 주제:

    • Lighthouse와 Chrome DevTools를 사용한 LCP 구간 분석
    • 반응형 이미지의 전송 크기와 렌더링 경계 최적화
    • TanStack Query의 최초 pending·갱신·실패·취소 상태 설계
    • Next.js 동적 metadata와 request memoization 범위
    • 측정값과 원인 가설을 분리해 기록하는 방법
  • 배운 것 / 새로 적용한 것:

    • Lighthouse 점수 하나보다 LCP를 서버 응답 대기·요청 시작 대기·이미지 전송·화면 표시 구간으로 나누어야 실제 병목을 선택할 수 있었습니다.
    • next/image나 preload를 사용했다는 사실만으로 최적화 효과를 주장할 수 없었습니다. 실제 요청 URL·전송 크기·waterfall과 LCP 구간이 함께 달라졌는지 확인해야 했습니다.
    • isPendingisFetching은 각각 데이터가 없는 최초 화면과 기존 데이터를 유지하는 갱신 화면에 대응합니다. 같은 로딩 UI를 공유하면 사용자가 보던 결과를 불필요하게 잃을 수 있습니다.
    • 서버의 request memoization과 클라이언트 요청 취소는 signal 적용 범위가 다릅니다. 서버와 클라이언트 fetch를 같은 방식으로 처리하면 두 요구사항 중 하나가 깨질 수 있었습니다.
    • metadata는 검색 결과를 설명하는 데 유용하지만, 데이터 조회가 느리면 crawler 응답 시점도 늦어집니다. SEO 정보의 정밀도와 응답 비용을 함께 판단해야 했습니다.

🤔 고민한 점 / 막혔던 부분

  • 공식 성능 비교 환경은 과제 조건에 따라 모바일 412×823 하나로 고정했습니다. 요청 취소와 URL 복원 과정에서 사용한 데스크톱 DevTools 캡처는 동작을 설명하는 보조 증거이며 Lighthouse 수치와 섞지 않았습니다.
  • 목록 갱신 실패 때 기존 목록을 보존하려면 마지막 성공 응답이 필요했습니다. 응답을 로컬 state에 복사하면 서버 데이터의 원본이 둘이 되므로, query key만 기억하고 Query cache에서 읽는 방식을 선택했습니다.
  • Hero preload가 root layout에 있어 상품 목록에서도 요청 후보가 만들어집니다. 현재 브라우저가 낮은 우선순위로 처리하고 구조 변경 비용이 더 크다고 판단해 유지했지만, route별 preload로 좁히는 편이 나은지는 고민이 남았습니다.
  • 동적 metadata는 slow scenario에서 crawler의 TTFB를 약 1.5초 늦췄습니다. 이번 주차에서는 검색 조건을 반영한 공유 정보를 유지했지만 실제 API 지연과 SEO 요구가 커지면 데이터 의존 범위를 다시 줄여야 할 것 같습니다.

🙋 피드백 받고 싶은 부분

  • Hero preload를 root layout에 둬서 상품 목록에서도 낮은 우선순위 요청이 생깁니다. 이 정도 요청을 감수할지, route별로 분리할지 잘 모르겠습니다.
  • 갱신 실패 시 직전 응답을 별도 state에 복사하지 않고 Query cache에서 꺼냅니다. 데이터 원본은 하나로 유지할 수 있지만 구현이 복잡해져 올바른 방법이었는지 여쭤보고 싶습니다.
  • 서버 fetch에서는 memoization을 위해 signal을 빼고, 클라이언트 fetch에서만 요청 취소를 유지했습니다. 이 경계에서 놓친 상황이 있는지 여쭤보고 싶습니다.
  • 검색 조건을 metadata에 반영하면서 slow API에서는 crawler 응답도 약 1.5초 늦어집니다...... metadata 범위를 줄이는 편이 나을까요?

✅ 과제 체크리스트 검증 요약

측정값과 캡처, 원시 증거는 Week 07 성능 보고서에서 확인 부탁드립니다!

0단계 / Before와 측정 조건

  • build와 runtime에 APP_ORIGIN=http://localhost:3000을 동일하게 적용한 production 환경에서 Before·After를 각각 5회 측정했습니다.
  • 공식 비교는 모바일 412×823, DPR 1.75, Slow 4G, CPU 4x slowdown, cold load 조건으로 고정했습니다.
  • Before·After SHA와 Chrome·Lighthouse 버전, 별도 브라우저 프로필을 기록했습니다.
  • FCP·LCP·CLS 원시값과 중앙값·최솟값·최댓값을 남겼습니다.
  • Lighthouse에서 LCP element·filmstrip·LCP 4구간을, DevTools에서 요청 waterfall·전송 크기·Layout Shifts를 직접 확인했습니다.

1단계 / Hero LCP

  • 7.20MiB Hero 원본을 사용하는 Before를 먼저 측정하고 이미지 전송 구간을 병목으로 특정했습니다.
  • viewport별 AVIF·WebP 후보를 적용한 뒤 실제 요청 URL, 표시·전송 크기와 LCP 변화를 비교했습니다.
  • Header·h1·페이지 설명과 Hero를 slow API 및 Suspense fallback 밖에서 한 번만 렌더했습니다.
  • Hero의 16:9·모바일 4:5 영역을 예약하고 fallback이 실제 콘텐츠로 바뀌는 과정을 확인했습니다. 홈 CLS는 Before·After 모두 0이었습니다.
  • 시각적 품질과 주요 피사체를 유지했으며, 효과가 확인되지 않은 fetchPriority는 개선 원인에서 제외했습니다.

2단계 / 목록과 CLS

  • 최초 pending·기존 목록 갱신·정상 0건·최초 실패·갱신 실패·취소 상태를 각각 확인했습니다.
  • 빠르게 조건을 바꿔도 이전 요청이 현재 화면을 덮지 않고 최종 URL·active query·화면 결과가 일치하는지 확인했습니다.
  • 취소된 요청이 오류로 보이지 않는지 Network와 화면에서 확인했습니다.
  • 서버 응답을 Zustand나 별도 로컬 state에 복사하지 않고 TanStack Query cache를 데이터 원본으로 유지했습니다.
  • 카드·스켈레톤·main 높이를 보완한 뒤 목록 교체 CLS가 0.54에서 0.01로 줄어든 것을 Performance에서 확인했습니다.

3단계 / metadata와 Open Graph

  • root·홈·상품 목록 metadata와 Open Graph 합성, 공통 필드, 검색 조건별 title·description 규칙과 기본 index, follow 상태를 확인했습니다.
  • JavaScript 실행 전 production HTML에서 제목·설명·주요 구조·링크·이미지 대체 텍스트를 확인했습니다.
  • metadata와 본문 prefetch가 같은 query factory·GET URL·options를 사용하도록 맞췄습니다.
  • 서버 getQueryClient()는 호출마다 새 인스턴스를 사용하고, 같은 request 안의 동일한 native fetch만 memoization되는 범위를 확인했습니다.
  • normal·정상 empty·metadata query failure에서 Open Graph fallback과 root metadata 상속을 확인했습니다.
  • Route Handler 호출 횟수는 서버 로그로 확인한 뒤 임시 계측을 제거했고, 일반 document 요청과 facebookexternalhit의 응답 시점을 비교했습니다.

4단계 / After와 회귀

  • 같은 조건의 5회 측정값으로 Before·After를 비교하고 LCP 개선과 FCP·CLS의 변화 범위를 구분했습니다.
  • 검색·카테고리·정렬·페이지 URL 공유, 새로고침, 뒤로 가기·앞으로 가기 복원을 확인했습니다.
  • 장바구니·위시리스트·Header 개수와 로딩·오류·빈 결과·재시도 흐름을 다시 확인했습니다.
  • FSD 의존 방향과 page slice Public API를 확인했습니다.
  • 효과가 확인되지 않은 priority 설정과 남은 이미지 요청 시작 대기 등 한계도 기록했습니다.

공통 검증

  • 관찰한 사실, 원인 가설, 반증 조건과 가장 작은 변경을 판단 기록에 남겼습니다.
  • AI 활용 범위와 직접 확인한 DevTools·Lighthouse·production 응답 증거를 구분했습니다.
  • pnpm check 통과: Vitest 7개 파일·75개 테스트, lint 0 errors·기존 warnings 3개, typecheck, Next.js 16.2.10 production build

🤖 AI 활용과 직접 검증

  • AI는 코드베이스 조사, 원인 가설과 반증 조건 정리, 과제 체크리스트 대조, 캡처 자동화 보조, README와 PR 초안의 문장 정리에 사용했습니다.
  • Lighthouse CLI의 JSON은 Before·After 5회 값을 빠짐없이 집계하고 중앙값·범위를 계산하는 데 사용했습니다.

yunsung-hodoopapa and others added 30 commits August 2, 2026 23:59
* chore: 7주차 성능 최적화 과제 추가

* chore: 7주차 성능 과제 퀘스트와 동적 메타데이터 요구사항 보강

* fix: 7주차 과제 문서를 단일 정본으로 통합

* docs: 실행 환경 버전 정본을 저장소 설정으로 연결

---------

Co-authored-by: yunsung-miso <yunsung@getmiso.com>
merge 시 실수로 포함된 pnpm 캐시 파일 정리
upstream/main에 추가된 slow mock 시나리오를 기존 코드에 통합
upstream merge 시 삭제된 로컬 lint 게이트 재설치
typecheck만 자동 실행하던 것을 lint도 포함하여 커밋 전 누락 방지
ProductListContent의 no-misused-promises 위반 해결, HeroSection의 미등록 룰 disable 주석 제거
squash merge 커밋에 FSD 이전 전 파일이 포함되어 있어 중복 유입됨
중복 로직·단일 소스 위반을 사후에라도 잡기 위해 작업 완료 후 코드리뷰 단계 추가
PERF-004: searchParamsParsers를 entities/product/lib로 추출하여 서버와 클라이언트가 동일 파서 사용
PERF-005: await를 Suspense 안쪽 async Server Component로 이동하여 스트리밍 활성화
useQuery → useSuspenseQuery 전환, startTransition으로 필터 변경 시 이전 화면 유지
ErrorBoundary 추가로 에러 처리 경계 분리
useSuspenseQuery가 Suspense/ErrorBoundary에 위임하므로 컴포넌트 내부 isLoading/isError 분기 제거
isPending(useTransition)으로 필터 변경 중 로딩 표시
product가 없으면 다이얼로그가 아예 안 뜨는 문제 수정, 상품 정보만 조건부 렌더링
7주차 성능 측정 Before 기준선을 위해 고용량 원본 이미지(3840×2160) 상태로 연결
기존 배너와 prefetch 로직은 과제 기간 동안 주석 처리
서버 컴포넌트에서 클라이언트 ErrorBoundary로 함수 prop을 넘길 수 없어 빌드 실패
클라이언트 래퍼(ProductListErrorBoundary)로 경계를 분리하여 해결
웹폰트 swap 시 레이아웃 밀림을 방지하기 위해
Arial 기반 fallback에 size-adjust/ascent-override 적용
layout에서 max-width를 일괄 관리하도록 변경하고
HeroSection을 flex overlay 구조로 개선
HTML은 매번 검증, 정적 이미지는 1년 장기 캐시로 분리
외부 CDN 연결 비용을 없애기 위해
Pretendard subset woff2 92개를 직접 호스팅
모바일에서 640 이미지를 선택하도록 sizes 지정하고
media 분기 preload로 이미지 발견 시점을 앞당김
라틴 문자(subset.91)를 preload하여 초기 텍스트 렌더링 속도 개선
OG/Twitter Card 메타데이터 및 title template 추가로 SEO 기반 마련
Lighthouse SEO 점수 개선을 위해 동적/정적 메타데이터와 크롤링 설정 보강
페이지에서 openGraph를 주면 루트 객체가 통째로 교체되어
siteName, locale, type이 사라지는 문제 수정
최초 진입 시 12장 카드 골격으로 목록 크기를 예상할 수 있도록 개선
그리드를 2열→3열(sm)→4열(md)로 단계적 확장
배너의 title, description, image를 본문과 같은 데이터 소스에서
가져와 OG 태그에 반영
useSuspenseQuery → useQuery + keepPreviousData 전환으로
필터/정렬/페이지 변경 시 기존 목록 유지.
isError 분기로 최초 실패·갱신 실패 각각 재시도 UI 제공.
Rules of Hooks 위반(조건부 훅 호출) 수정.
검색어→title, 카테고리·정렬→description, 2페이지 이상→title에 반영.
정상 empty는 0건 설명 + fallback image, query failure는 root 상속.
본문과 같은 searchParamsCache·getProductList 경로 사용.
이미지 후보 너비(640/1080/1920px)가 아닌 실제 레이아웃 너비
(100vw, max 1280px)로 변경해 모바일에서 불필요하게 큰 이미지
다운로드 방지. preload도 imageSrcSet+imageSizes로 통합.
getProductList() 직접 호출 → fetchProductList()로 /api/products를
경유하도록 변경. scenario=slow/error 재현, AbortSignal 취소,
서버 호출 계수 확인이 가능해짐. generateMetadata도 같은 fetch URL을
사용해 Next.js request memoization으로 중복 요청 방지.
hero-original.jpg(3840×2160) → hero-1080.webp(1920×1080)로 변경.
AVIF/WebP srcSet 후보 존재 여부 검증 추가.
queryFn에 signal을 전달해 queryKey 변경 시 이전 요청 취소 가능.
scenario 파라미터를 페이지 URL → API URL로 전달해
slow/error/empty 시나리오 재현 가능.
lighthouse 측정 임시 파일이 커밋에 포함되지 않도록 패턴 추가.
이미 추적 중이던 docs/user/week-05-*.md는 .gitignore 대상이므로 추적 해제.
7.2MB 원본을 640/1080/1920 × AVIF/WebP 6개 후보로 변환하여
실제 표시 크기에 맞는 이미지만 전송되도록 준비.
sharp devDep 추가, scripts/ 디렉토리 lint 제외.
홈 데이터 조회를 getHomeData() 직접 호출에서 /api/home fetch로 전환하여
scenario=slow/error/empty 시나리오를 실제 fetching 경로에서 재현 가능하게 함.
Suspense 밖에 정적 h1과 페이지 설명을 배치하여 데이터 대기 없이 즉시 렌더.
metadataBase를 APP_ORIGIN 기반으로 설정하여 OG URL 절대 경로 보장.
dynamic='force-dynamic'으로 전환 — API 의존 홈에 static generation은 부적합.
Route Handler 전환 시 waitForMockApi(500)로 정상 요청에도 500ms 지연 추가됨.
과제의 /api/home?scenario=slow는 렌더링 경계 검증 도구이지
홈 production 경로를 Route Handler로 바꾸라는 요구가 아님.
getHomeData()를 homeData.ts로 분리, fetchHomeData 삭제.
홈·상품 모두 generateMetadata에서 직접 데이터 함수를 호출하던 것을
queryClient.fetchQuery(queryOptions())로 변경해 본문과 동일 query factory 경유.
상품 metadata에 scenario 파라미터 누락도 함께 수정.
generateMetadata와 본문이 각각 다른 AbortSignal을 fetch init에
전달해 Next.js request memoization이 동작하지 않았음.
서버에서는 signal이 불필요하므로 init 없이 fetch하도록 분기 처리.
Route Handler 호출 횟수 2회 → 1회로 감소.
Suspense 밖 별도 section으로 h1을 배치했으나 디자인과 맞지 않아
Hero 오버레이 내부 h2를 h1으로 변경. 홈 데이터가 동기 메모리 함수라
Suspense 대기가 발생하지 않으므로 h1 지연 문제 없음.
Hero 데이터가 동기 함수(getHomeData)라 대기 없이 즉시 렌더 가능하므로
Suspense 경계 밖에서 직접 렌더. HomeClient에서 중복 Hero 렌더 제거.
fallback은 카테고리·상품 콘텐츠 영역의 로딩 UI만 담당.
ProductListContent와 ProductListSkeleton에 중복되던 h1을
ProductListIntro 컴포넌트로 통일. 초기 HTML에 페이지 설명 추가.
ensureQueryData는 실패 시 throw하여 스트리밍 중 Server Component
오류가 상위로 전파됨. prefetchQuery로 변경하여 서버 prefetch 실패 시
클라이언트 useQuery가 조회와 인라인 오류 UI를 담당하도록 함
keepPreviousData는 에러 확정 시 data를 undefined로 소실하므로
isError && data 분기에 도달할 수 없음(TanStack Query v5 의도된 동작).
실패 시 에러 화면 + 재시도 버튼으로 통일
keepPreviousData는 에러 확정 시 data를 소실하므로(GitHub #3046),
React 렌더 중 state 조정 패턴으로 마지막 성공 데이터를 보존하여
갱신 실패 시 기존 목록 유지 + 인라인 배너를 표시
서버 응답을 로컬 state에 복사하는 것은 과제 명세가 금지하므로,
useState에 query key만 저장하고 실제 데이터는
queryClient.getQueryData로 캐시에서 읽도록 변경
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants