[volume-7] 프론트엔드 성능 최적화 - 김소연 - #156
Open
manual-hue wants to merge 46 commits into
Open
Conversation
* 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로 캐시에서 읽도록 변경
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📌 이번 PR 요약
AbortSignal을 제외해 request memoization을 적용하고, 클라이언트에서는 요청 취소 기능을 유지했습니다.결과 요약
공식 Before·After 비교는 production build, 모바일 412×823, DPR 1.75, Slow 4G, CPU 4x slowdown, cold load 조건에서 각각 5회 측정했습니다.
주요 구현과 판단
1. Hero LCP 병목
srcSet·sizes를 적용했습니다.fetchPriority="high"를 적용했지만 대표 After run에서 관찰된 priority는Low였습니다. 근거가 확인되지 않았으므로 priority 설정을 LCP 개선 원인으로 계산하지 않았습니다.h1·설명을 Suspense 밖에 두어 slow API를 기다리는 동안에도 페이지의 핵심 구조를 먼저 표시했습니다.2. 상품 목록의 여섯 가지 상태
useQuery와keepPreviousData로 최초 pending과 갱신을 분리했습니다.AbortSignal을 실제 클라이언트 fetch에 전달해 이전 요청을 취소했습니다.3. 동적 metadata와 서버 요청
generateMetadata를 적용하고, metadata와 본문 prefetch가 같은 query factory의 응답을 사용하도록 맞췄습니다.AbortSignal을 전달하자 동일한 URL도 document당 두 번 호출됐습니다. 서버 fetch에서만 signal을 제외해 동일한 URL·options에 native fetch memoization이 적용되도록 했습니다.ensureQueryData가 prefetch 실패를 먼저 throw하던 경로는prefetchQuery로 바꿨습니다. 서버 prefetch가 실패해도 클라이언트 Query가 조회와 인라인 복구를 이어받습니다.4. 구조와 회귀 방지
robots.ts,sitemap.ts, Cache-Control 정책을 추가했습니다.📚 이번 주 학습
학습 주제:
배운 것 / 새로 적용한 것:
next/image나 preload를 사용했다는 사실만으로 최적화 효과를 주장할 수 없었습니다. 실제 요청 URL·전송 크기·waterfall과 LCP 구간이 함께 달라졌는지 확인해야 했습니다.isPending과isFetching은 각각 데이터가 없는 최초 화면과 기존 데이터를 유지하는 갱신 화면에 대응합니다. 같은 로딩 UI를 공유하면 사용자가 보던 결과를 불필요하게 잃을 수 있습니다.🤔 고민한 점 / 막혔던 부분
🙋 피드백 받고 싶은 부분
✅ 과제 체크리스트 검증 요약
측정값과 캡처, 원시 증거는 Week 07 성능 보고서에서 확인 부탁드립니다!
0단계 / Before와 측정 조건
APP_ORIGIN=http://localhost:3000을 동일하게 적용한 production 환경에서 Before·After를 각각 5회 측정했습니다.1단계 / Hero LCP
h1·페이지 설명과 Hero를 slow API 및 Suspense fallback 밖에서 한 번만 렌더했습니다.fetchPriority는 개선 원인에서 제외했습니다.2단계 / 목록과 CLS
3단계 / metadata와 Open Graph
index, follow상태를 확인했습니다.getQueryClient()는 호출마다 새 인스턴스를 사용하고, 같은 request 안의 동일한 native fetch만 memoization되는 범위를 확인했습니다.facebookexternalhit의 응답 시점을 비교했습니다.4단계 / After와 회귀
공통 검증
pnpm check통과: Vitest 7개 파일·75개 테스트, lint 0 errors·기존 warnings 3개, typecheck, Next.js 16.2.10 production build🤖 AI 활용과 직접 검증