홈 / Skills / 개발 생산성 / 성능 최적화 Performance Optimization
개발 생산성

성능 최적화 Performance Optimization

Addy OsmaniMITSkill 언어: 영어
라이선스 확인됨스크립트 없음낮은 위험
실행 예시실제 실행 · claude-sonnet-5-5 · 2026년 10월
Our product listing page is slow (fictional example): Lighthouse on mobile shows LCP 4.8 s, and the /api/products endpoint has p95 1.2 s. The endpoint code is below. What would you measure first, what is the likely bottleneck, and what would you change? We have a Postgres database and a React front end. ```js app.get('/api/products', async (req, res) => { const products = await db.query('SELECT * FROM products'); for (const p of products.rows) { p.category = (await db.query('SELECT name FROM categories WHERE id = ' + p.category_id)).rows[0]; p.reviews = (await db.query('SELECT rating FROM reviews WHERE product_id = ' + p.id)).rows; } res.json(products.rows); }); ``` The page renders about 1,200 products at once, each with a full-size 2400px JPEG hero image.
Skill: performance-optimization

There are two separate problems here, one on the server and one in the browser. Both show up in the code and the page description, but neither is measured yet. I'd measure first and confirm which one dominates LCP.

What to measure first

  1. Network waterfall in DevTools, throttled to mobile. Look at three numbers:

    • TTFB and download time for /api/products.
    • The JSON payload size.
    • The total image bytes, and when the LCP element's request starts and finishes.

    This tells you whether LCP is gated by the API, the images, or both. The API call has to finish before React can render any <img>, so the delays add up in sequence.

  2. The LCP element in the Lighthouse or Performance trace. Check which image it is, and look at the breakdown into TTFB, resource load delay, resource load duration and render delay.

  3. Postgres query count and timing for one request. Turn on log_min_duration_statement = 0 temporarily, or use pg_stat_statements. Also run EXPLAIN ANALYZE on the individual queries. With 1,200 products, I expect about 2,401 queries per request.

Likely bottlenecks

Backend: N+1 queries. The endpoint runs 1 query for products, then 1 for the category and 1 for the reviews of each product, one after another with await. Even at about 0.4 ms each, that is around a second of sequential round trips. This matches the p95 of 1.2 s. The endpoint also has these problems:

  • SELECT * with no pagination returns all 1,200 products in one response.
  • It sends every individual review rating, when the list page probably only needs an average and a count.
  • The queries are built by string concatenation. The values are integer IDs from your own database, so this is probably not exploitable today, but it is a bad habit and should use parameters.
  • Check that reviews.product_id has an index. It may already, and the plan will tell you.

Frontend: images and DOM size. 1,200 full 2400px JPEGs is probably several to tens of MB on a phone. That is most likely the main reason LCP is 4.8 s. Rendering 1,200 cards also costs main-thread time, and it will hurt INP.

What I'd change

Change one thing at a time and re-measure after each, as in the sequence below.

API (do first, it's cheap and the cause is clear):

app.get('/api/products', async (req, res) => {
  const limit = Math.min(parseInt(req.query.limit) || 24, 100);
  const offset = Math.max(parseInt(req.query.offset) || 0, 0);
  const { rows } = await db.query(`
    SELECT p.id, p.name, p.price, p.image_url,
           c.name AS category,
           COALESCE(r.avg_rating, 0) AS avg_rating,
           COALESCE(r.review_count, 0) AS review_count
    FROM products p
    LEFT JOIN categories c ON c.id = p.category_id
    LEFT JOIN LATERAL (
      SELECT AVG(rating)::float AS avg_rating, COUNT(*)::int AS review_count
      FROM reviews WHERE product_id = p.id
    ) r ON true
    ORDER BY p.id
    LIMIT $1 OFFSET $2`, [limit, offset]);
  res.json(rows);
});

This turns about 2,400 queries into 1, selects only the columns the list needs, adds pagination with a stable order, and parameterizes the inputs. Make sure reviews(product_id) is indexed. If the aggregate is still slow, store avg_rating and review_count on products or in a materialized view. Keyset pagination is better than OFFSET for deep pages. Add HTTP caching headers or a short TTL cache only if the data is read far more often than it changes, and key the cache on the query parameters.

Front end (probably the biggest LCP win):

  • Show about 24 products per page with "load more" or infinite scroll, or virtualize the list. This is the largest single reduction in DOM size, bytes and requests.
  • Serve resized images: generate 320, 640 and 960px variants in AVIF or WebP at build time or through an image CDN. Use srcset and sizes so a phone downloads a 15–60 KB image instead of a multi-hundred-KB one.
  • Give every <img> a width and height to avoid layout shift. Use loading="lazy" and decoding="async" on everything below the fold. Give the actual LCP image fetchpriority="high" and don't lazy-load it.
  • Consider server-rendering or prefetching the first page, so the LCP image doesn't have to wait for the JS bundle and then the API call.

Verifying

Re-measure the same way as the baseline (same throttling, same data, repeated runs). Compare against run-to-run noise, and revert anything that doesn't clearly move the numbers. Then add a Lighthouse CI budget and field monitoring of LCP p75 with web-vitals.

My expectation, which the measurements need to confirm, is that the query fix takes the p95 from 1.2 s to a few tens of milliseconds. The image and pagination work should bring LCP into the "good" range (≤ 2.5 s). If the waterfall shows something different, such as a slow TTFB from somewhere other than the queries, follow that instead.

Test run on a fictional slow product page. The model said what to measure first, found the N+1 queries and the oversized images, and gave a rewritten endpoint. Its expected speed-up is a prediction, not a measurement, and the code was not run.

하는 일

최적화 전에 반드시 측정하라고 요구합니다. Core Web Vitals 목표(LCP, INP, CLS)를 정한 뒤 측정, 진짜 병목 찾기, 그 한 가지만 수정, 검증과 유지의 루프를 돕니다. LCP가 느릴 때(서버, 리소스 로딩, 렌더링 지연), INP가 나쁠 때, 레이아웃 이동에 대한 결정 트리를 갖추고, 이미지(크기, 최신 포맷, 우선순위와 지연 로딩), 번들 크기와 분할, 렌더링과 재렌더링 제어, 긴 목록, 캐시, 그리고 백엔드(N+1 쿼리, 빠진 인덱스, 제한 없는 목록과 페이지네이션)를 다룹니다. 흔한 안티패턴(기준선 없이 최적화, 쿼리를 고치기 전에 캐시부터 추가)도 나열하고, 한 번에 한 가지만 바꾸고 CI에 성능 예산을 두라고 말합니다.

결과물

먼저 측정할 것, 순서대로 정리한 유력한 병목, 코드가 있는 수정안, 검증 방법.

이런 때 좋습니다

느린 페이지, 느린 엔드포인트, 기준에 못 미치는 Lighthouse 점수, 출시 전 성능 작업 계획. 이 Skill이 참조하는 성능 체크리스트도 함께 들어 있습니다.

참고 사항 및 위험

낮은 위험:지침만 있는 파일로 스크립트가 없습니다. 제안하는 것은 코드 변경과 측정 도구(Lighthouse, DevTools, 쿼리 로그)이며 실행은 직접 합니다. 가상의 느린 상품 페이지로 한 번 시험했고, 먼저 측정할 것을 말한 뒤 N+1 쿼리와 지나치게 큰 이미지를 찾아 다시 쓴 엔드포인트를 줬습니다. 예상 속도 향상은 측정이 아닌 예측이며 코드는 실행하지 않았습니다. 공통 체크리스트도 함께 넣고 경로를 고쳤습니다. 작성자 Addy Osmani, MIT.

SKILL.md 원본패키지에 파일 4개