Repository navigation
Project‐Performance
배포 후 8개 화면을 Lighthouse로 측정해 원인을 찾고, 개선 5건(성능 3 · 체감(UX) 1 · 이미지 처리 1)을 하나씩 PR로 적용했다. (2026-10-14, 하루)
| 지표 | 개선 전 | 개선 후 |
|---|---|---|
| 모바일 Lighthouse 점수 (8개 화면) | 78–97 | 93–99 |
| 상품 목록 제목 스켈레톤 (고정 문구) | 303–422ms | 0ms |
| 가장 느리던 화면 (상품 목록) 모바일 | 78점 · LCP 6.24s | 93점 · LCP 3.24s |
| 데스크톱 점수 | 97–100 | 100 (8개 모두) |
| 주문 '상세 보기' 클릭 → 화면 | 2.47s | 0.58s |
| DB 조회 1건 비용 | 359ms | 11ms |
| 메인 전송량 | 1,270KB | 819KB |
| 개선 | 원인 | 바꾼 것 | PR |
|---|
- 서버 함수 지역 | 서버(미국 동부)와 DB(도쿄) · 사용자(한국)가 서로 멂 |
vercel.json"regions": ["hnd1"]한 줄 | #26 - LCP 이미지 우선순위 | 메인 배너는 Medium, 상품 첫 카드는 lazy, 로고가 2번 미리 받아짐 |
fetchPriority·loading속성 | #27 - 서체 통일 | 한글 세리프 폰트가 폰트 전송량의 65–75% | 제목도 Noto Sans KR로 (Figma 텍스트 스타일 함께 수정) | #28
- 카테고리 주소 경로 | 고정 문구인 상품 목록 제목이 0.3–0.4초 스켈레톤으로 보임 (Lighthouse로는 안 잡힘) |
?category=→/products/category/…+ 빌드 때 생성 | #31 - 이미지 썸네일 | 목록과 상세가 같은 원본에서 출발 |
_thumbnail(저해상도) 추가 — 목록은 썸네일, 상세는 원본 | #32
-
도구 하나로 고정: Lighthouse CLI 13.4.1(헤드리스 Chrome), 모바일 기본(Moto G Power · Slow 4G 모의 감속) + 데스크톱. 화면 × 기기마다 5회, 중앙값과 범위를 기록
-
배포 환경에서 측정: 개선마다 Vercel PR 미리보기에서 "후"를 쟀다. 배포 보호는 Protection Bypass for Automation 헤더로 통과
-
예열: 한동안 요청이 없던 미리보기는 첫 측정만 2배 이상 느려서(메인 LCP 7.0s → 3.0s), 측정 전 화면마다 기록하지 않는 요청을 보냄
-
대조 화면: 개선과 상관없는 화면을 같이 재서, 좋아진 것이 변경 덕분인지 확인
-
Lighthouse가 못 재는 것도 측정: 클릭 흐름 시간(Puppeteer로 실제 클릭 → 목표 화면이 그려질 때까지), 실측 LCP(감속 없이 관찰한 값), DB 조회 시간(DB를 조회하는 요청과 안 하는 요청의 차이)
-
재현: 측정 스크립트는 저장소
scripts/perf/에 있다 —pnpm perf:lighthouse·perf:summary·perf:flow·perf:db(README Performance 참고)
처음 분석(DevTools · CLI 혼합 측정)에서 나온 문제 중 두 개는 실제 문제가 아니었다.
| 처음 분석 | 확인 | 결과 |
|---|---|---|
| 데스크톱 CLS 0.752 (점수 −25점) | DevTools Lighthouse에서만 나옴. CLI 0.000, 측정 도구 없이 연 실제 화면(Performance 패널 Live metrics) 0.00 | 측정 환경 현상 → 제외 |
| 폰트 굵기 5 → 2–3개로 줄이면 빨라짐 | Google Fonts가 Noto KR을 가변 폰트 한 파일로 주므로, 굵기를 줄여도 받는 바이트가 같음(702KB → 702KB) | 기각 |
| 한글 서브셋 폰트 직접 호스팅 | KS X 1001 2,350자 서브셋은 모든 화면에서 통째로 받아(Sans 316KB + Serif 654KB) 지금보다 큼 | 기각 |
문제: 데이터가 필요한 화면은 화면 틀이 60ms에 오는데 데이터 부분이 다 오기까지 0.5–2.2s, 주문 '상세 보기'는 2.47s.
원인: 응답 헤더 x-vercel-id: icn1::iad1 — 서버 함수가 Vercel 신규 프로젝트 기본값인 미국 동부(iad1)에서 돌고 있었다. DB는 도쿄. 대시보드에서 DB 지역이 바로 보이지 않아 실측으로 먼저 가렸다: 주문 상세를 DB 조회 없이 / 있게 번갈아 10회씩 요청하니 조회 1건에 359ms(미국 ↔ 도쿄 왕복 약 150ms × 2). 이후 DB 설정 화면에서 도쿄(hnd1)로 확인.
해결: vercel.json에 "regions": ["hnd1"]. 서울(icn1)이 아니라 도쿄를 고른 이유: 한 화면에서 여러 번 하는 DB 조회가 모두 수 ms가 되고, 서울 ↔ 도쿄는 약 30ms라 사용자와의 거리도 충분히 가깝다.
| 측정 | 전 | 후 |
|---|---|---|
| DB 조회 1건 비용 | 359ms | 14ms |
| 주문 '상세 보기' → 화면 | 2,469ms | 617ms |
| 관리자 주문 목록 실측 LCP | 2,339ms | 538ms |
| 관리자 주문 목록 Lighthouse 모바일 LCP | 3.87s | 3.71s |
배운 것 — 지표는 개선 종류에 맞게: 실제 체감은 4배 빨라졌는데 Lighthouse 모바일 점수는 거의 그대로였다. Lighthouse 모바일은 빠른 네트워크로 받은 기록을 바탕으로 Slow 4G를 계산으로 흉내 내는 방식(모의 감속)이라, 서버가 응답을 만들어 보내기까지 기다린 시간은 거의 반영되지 않는다. 서버 쪽 개선은 실측 시간과 클릭 흐름으로 판단해야 한다.
문제: 상품 목록 모바일 LCP 6.28s(첫 상품 사진의 요청 시작이 399–719ms 늦음), 메인 배너 요청 우선순위 Medium.
원인:
- 상품 카드는
next/image기본값loading="lazy"→ 레이아웃이 끝나야 요청 - 메인 배너는 화면 폭별 이미지라
<picture>+getImageProps로 그리는데,priority가<img>에 우선순위로 반영되지 않음 -
React 19는 서버 렌더링 때
<img>를 자동으로 preload한다 —loading="lazy"도fetchPriority="low"도 아니고<picture>밖인 이미지가 대상. 그래서 LCP가 아닌 헤더 로고(모바일 · 데스크톱 2개)는 미리 받아지고,<picture>안의 배너는 빠졌다.priority를 빼고loading="eager"로 바꿔도 로고 preload가 남아서 react-dom 서버 코드를 읽고 찾았다
해결: (Next.js 16 Image 문서: priority는 deprecated, 화면 폭별 이미지는 fetchPriority="high")
- 메인 배너
<img>:loading="eager"+fetchPriority="high" - 상품 목록: 첫 2장(모바일 첫 줄)만
eager+high, 나머지는 lazy 유지(바로 받게 하면 React가 preload해 대역폭을 다툼) - 헤더 로고:
fetchPriority="low"— 바로 받되 자동 preload에서 빠짐
| 화면 (모바일) | 전 | 후 |
|---|---|---|
| 상품 목록 LCP · 점수 | 6.28s · 77 | 3.97s · 87 |
| 메인 LCP · 점수 | 3.32s · 92 | 2.73s · 96 |
| 관리자 주문 목록 (대조) | 3.67s | 3.70s |
받는 양(메인 1,281KB)은 그대로이고 받는 순서만 바꿨다.
문제: 화면마다 폰트 파일 17–27개, 513–731KB. 폰트 파일만 막고 재면 메인 LCP −1.0s, 상품 목록 −1.3s.
원인: 한글 웹폰트는 Google이 글자를 약 120개 조각(unicode-range)으로 나눠 화면에 쓰인 글자의 조각만 받는다. 화면별로 실제 쓰인 글자를 서체 · 굵기별로 모아 계산하니, 제목용 한글 세리프(Noto Serif KR)가 전송량의 65–75% 였다(메인: 세리프 75자 → 456KB). 세리프 한글은 획이 복잡해 조각 하나가 크다.
해결: 서체를 Noto Sans KR 하나로. 제목 위계는 굵기(Bold) · 크기 · 자간(-3 ~ -1.5%)으로 둔다. 디자인이 바뀌는 결정이라 사람이 정했고(선택지: 큰 제목만 세리프 / 전부 Sans / 유지), Figma 텍스트 스타일 5개도 함께 바꿨다. Figma 화면의 텍스트 3,295개가 모두 텍스트 스타일을 쓰고 있어 스타일만 바꾸면 전부 반영됐다.
| 화면 (모바일) | 폰트 전송량 | FCP | LCP |
|---|---|---|---|
| 메인 | 731 → 300KB | 1.22 → 1.07s | 2.72 → 2.87s (편차 범위) |
| 상품 목록 | 513 → 195KB | 1.22 → 1.06s | 3.87 → 3.56s |
| 상품 상세 | 468 → 186KB | 1.22 → 1.06s | 3.88 → 3.31s |
| 주문 내역 | 513 → 195KB | 1.21 → 1.07s | 2.56 → 2.26s |
폰트 선언 CSS도 @font-face 622개 → 125개, CSS 전송량 64 → 37KB로 줄어 모든 화면의 FCP가 빨라졌다. 메인 LCP가 그대로인 것은 개선 2에서 배너를 이미 최우선으로 받게 해서 폰트가 LCP 경로에서 빠졌기 때문이다.
문제: 상품 목록에 들어오거나 새로고침하면 "All Products" · "Bread" 같은 고정 문구인 제목이 0.3–0.42초 스켈레톤으로 보였다. 데이터를 받지 않는데도 받는 것처럼 느껴진다. CLS도 0이고 LCP에도 영향이 작아 Lighthouse는 이걸 문제로 잡지 않는다 — 화면을 직접 보다가 찾았다.
원인: 제목이 주소 값(?category=bread)에 따라 정해지므로, 미리 만들어 두는 화면(정적 셸)에 넣지 못하고 요청 때 채우는 영역(<Suspense>)에 들어가 있었다.
해결: 카테고리를 주소 값이 아니라 경로(/products/category/bread)로 바꾸고 generateStaticParams로 4개를 빌드 때 만든다. 제목이 미리 만든 화면에 들어간다. 검색은 제목에 결과 건수가 들어가 데이터가 필요하므로 /products/search?q=로 분리해 스켈레톤을 유지했다. 예전 주소는 redirect(308)로 새 주소로 보낸다. (클라이언트에서 useSearchParams로 그리는 방법은 직접 진입 · 새로고침 때 JS 실행 전까지 자리표시가 남아 택하지 않았다)
| 화면 | 제목 스켈레톤 노출 (데스크톱 직접 진입 5회) |
|---|---|
| 전체 | 422 → 0ms |
| 카테고리 | 308 → 0ms |
| 카테고리 + 최신순 | 303 → 0ms |
| 검색 결과 (건수가 필요) | 304 → 307ms (의도적으로 유지) |
방식: 상품 이미지마다 저해상도 <이름>_thumbnail.jpg(640px)를 원본(1024px) 옆에 저장한다. 목록(상품 카드 · 장바구니 · 주문 · 관리자 목록)은 썸네일, 상품 상세의 큰 사진만 원본을 쓴다. DB에는 원본 경로만 두고 이름 규칙으로 썸네일을 찾는다. 장당 117–171KB → 34–48KB(약 −70%).
| 측정 | 전 | 후 |
|---|---|---|
| 캐시가 없을 때 이미지 변환 (같은 시간대, 6장) | 0.50–0.76s | 0.28–0.39s (약 −40%) |
| 상품 목록 이미지 로딩 (실제 Chrome · Slow 4G) | 4,789ms · 131KB | 4,607ms · 120KB |
| Lighthouse | — | 편차 범위 |
효과가 작은 이유와 그래도 적용한 이유: Next.js의 next/image가 이미 목록 이미지를 화면 크기(384px WebP)로 줄여 보내고 있어서, 브라우저가 받는 양은 거의 같다. 이득은 서버가 줄이는 작업 쪽(첫 요청 대기)에서 난다. 그래도 "목록은 저해상도 · 상세는 고해상도"는 고용량 이미지를 여러 장 보여 주는 화면의 기본 대처 방식이고, Next.js 밖(React SPA · Spring 같은 서버 · 모바일 앱)에서는 프레임워크가 대신해 주지 않는다. 다른 환경에서도 같은 구조를 쓸 수 있도록 프레임워크에 기대지 않는 방식으로 적용하고 기록했다.
이미지 처리는 어디서나 세 층으로 나뉘고, 환경마다 누가 맡는지만 다르다.
| 층 | 하는 일 | Next.js | 다른 환경 |
|---|---|---|---|
| 저장할 때 미리 만들기 | 업로드 시 큰 이미지 + _thumbnail, 회전 · 위치 정보 정리 |
업로드 처리에서 sharp | Spring Thumbnailator, Python Pillow, S3 이벤트 → 비동기 작업 |
| 요청할 때 변환 | URL에 크기 · 형식을 붙이면 줄여서 줌 | next/image 내장 |
Cloudinary · imgix · Cloudflare Images · imgproxy 등 별도 서비스 |
| 보여 줄 때 고르기 | 화면 크기에 맞는 이미지, 늦게 불러오기, 캐시 |
next/image가 자동 |
웹 <img srcset>, 앱 Glide · Coil · Kingfisher · expo-image (목록에서 다운샘플링 — 앱은 메모리 부족 방지에 필수) |
사용자 업로드가 생기면 "저장할 때 두 장 만들기"와 "요청할 때 변환"을 함께 쓰는 것이 기본이다 — 제각각인 고해상도 원본(휴대폰 사진 4000px · 수 MB)을 그대로 변환하면 첫 요청이 느리고 변환 비용이 커진다.
처음에는 "썸네일"을 크기 7종 WebP를 미리 만들어 srcset으로 고르고 흐린 미리보기를 까는 방식으로 이해해 구현했다가 요청과 달라 롤백했다. 그 과정에서 확인한 사실:
- 흐린 미리보기를 작은 이미지(data URL)로 넣으면 Lighthouse 모의 감속이 그것도 요청으로 세어 LCP 계산이 늦어졌다 (상품 목록 +0.2s)
- 색을 HTML에 넣으면 문서가 압축 후 약 14KB(TCP 첫 전송 한도)를 넘어 왕복이 한 번 늘었다 — 회차 결과가 2.4s / 3.6s 두 갈래로 나뉘는 것이 단서였다
- 원인을 하나씩 떼어 볼 때는 기존 배포와 번갈아 재야 시간대 편차를 지울 수 있다
| 화면 | 점수 | LCP | FCP | 전송량 |
|---|---|---|---|---|
| 메인 | 87 → 93 | 4.07 → 3.16s | 1.37 → 1.21s | 1,270 → 819KB |
| 상품 목록 | 78 → 93 | 6.24 → 3.24s | 1.22 → 1.06s | 1,081 → 726KB |
| 상품 상세 | 89 → 94 | 3.80 → 3.16s | 1.22 → 1.06s | 963 → 647KB |
| 주문 내역 | 97 → 99 | 2.56 → 2.26s | 1.21 → 1.06s | 917 → 566KB |
| 주문 상세 | 86 → 94 | 4.24 → 3.09s | 1.22 → 1.06s | 713 → 541KB |
| 관리자 상품 목록 | 85 → 93 | 4.24 → 3.16s | 1.22 → 1.06s | 787 → 649KB |
| 관리자 주문 목록 | 87 → 95 | 3.87 → 2.94s | 1.22 → 1.06s | 661 → 562KB |
| 관리자 주문 상세 | 90 → 94 | 3.55 → 3.09s | 1.22 → 1.06s | 693 → 520KB |
| 흐름 (데스크톱 · 감속 없음) | 전 | 후 |
|---|---|---|
| 주문 '상세 보기' → 주문 상세 | 2,469ms | 575ms |
| 주문 내역 새로고침 | 657ms | 200ms |
| 상태 필터 클릭 | 431ms | 130ms |
| 상품 목록 제목 스켈레톤 (직접 진입) | 303–422ms | 0ms |
데스크톱은 8개 화면 모두 100점, 상품 목록 LCP 1.28 → 0.69s. 측정 결과 JSON은 pnpm perf:lighthouse로 다시 만들 수 있다.
남은 과제: 모바일 LCP는 주문 내역만 '좋음'(2.5s) 안이고 나머지는 2.9–3.2s다. Slow 4G(약 1.6Mbps)를 가정한 값이라 이미지 크기 · 형식과 JS 번들 같은 전송량 자체를 줄이는 것이 다음 후보다.
| 후보 | 이유 |
|---|---|
| Suspense 범위 조정 (관리자 화면) | 개선 1 후 데이터 대기 중 DB 몫이 14ms뿐이라 구조를 바꿔도 얻을 것이 거의 없음 |
| 주문 내역 스켈레톤 높이 | CLS 0.073으로 이미 '좋음'. 주문 수를 미리 알 수 없어 근본 해결이 없음 (서체 통일 후 0.000이 됨) |
| AVIF 형식 | 전송량은 약 30% 작지만 실험(위 롤백)에서 LCP 개선을 확인하지 못함 |
| favicon · 구형 JS · 안 쓰는 JS | Lighthouse 예상 절감이 LCP 0–150ms |
- web.dev — Optimize Largest Contentful Paint, Fetch Priority, Best practices for fonts
- Vercel — Configuring regions for Vercel Functions
- Next.js 16 Image 문서 —
preload·fetchPriority,prioritydeprecated, art direction
- 🎨 Colors
- ✏️ Design
- 🧩 Components
- 🔀 State
- 🏗️ Architecture
- 🗃️ Data
- ⏱️ Time
- 🤖 Figma MCP
- 🧪 Testing
- 🚀 Performance
- 🛠️ Troubleshooting