2025년 7월 어느 월요일 오전, PM이 슬랙에 메시지를 올렸다. "우리 사이트 왜 이렇게 느려요? 경쟁사는 바로 뜨는데." 그 메시지 아래에 경쟁사 URL이 덩그러니 붙어 있었다. 나는 그날 오후 우리 랜딩 페이지를 Lighthouse로 돌렸고, 숫자를 보고 의자에서 미끄러질 뻔했다.
Performance 점수 42점. LCP 3.2초. CLS 0.28.
우리 팀은 5명이었고, 프론트엔드는 나 포함 2명이었다. 전담으로 성능 최적화만 할 여유 같은 건 없었다. 스프린트 사이사이에 시간을 쪼개서, 약 3주에 걸쳐 LCP를 0.9초까지 끌어내렸다. 그 과정을 기록해둔다.
현실 파악: 느린 건 알겠는데 뭐가 느린 거야
성능 최적화에서 제일 위험한 건 "감으로" 시작하는 거다. 나도 처음에 "번들이 크겠지" 하고 webpack-bundle-analyzer부터 켰다. 결과적으로 그것도 문제긴 했는데, 가장 큰 문제는 거기가 아니었다.
Lighthouse의 LCP 항목을 클릭하면 어떤 element가 LCP로 잡혔는지 보여준다. 우리 랜딩 페이지의 LCP element는 hero 섹션의 배경 이미지였다. 2400x1600 사이즈의 PNG 파일, 1.8MB.
# 이미지 크기 확인
curl -sI https://example.com/hero-bg.png | grep content-length
# content-length: 18874361.8MB짜리 PNG를 아무 최적화 없이 CSS background-image로 불러오고 있었다. loading="lazy"도 안 먹히는 CSS 배경이미지라서, 브라우저가 HTML 파싱하다가 CSS 만나고, CSS 파싱하다가 이미지 URL 발견하고, 그제서야 이미지를 요청하는 구조였다. 이미지 다운로드 시작까지만 해도 이미 1초가 넘게 걸리고 있었다.
DevTools의 Network 탭에서 Waterfall을 보면 이게 명확하게 보인다. HTML → CSS → 이미지 순서로 직렬 로딩되고 있었다.
1차 시도: hero 이미지 최적화
가장 임팩트가 클 게 뻔한 곳부터 건드렸다.
우리 프로젝트는 Next.js 기반이었으니까 next/image를 쓸 수 있었다. CSS background-image를 걷어내고 next/image로 교체했다.
// Before: CSS background-image
<section className="hero" style={{ backgroundImage: 'url(/hero-bg.png)' }}>
<h1>서비스 소개</h1>
</section>
// After: next/image + priority
import Image from 'next/image';
<section className="hero">
<Image
src="/hero-bg.png"
alt="서비스 메인 배경"
fill
priority
sizes="100vw"
style={{ objectFit: 'cover' }}
/>
<h1>서비스 소개</h1>
</section>priority prop이 핵심이다. 이걸 붙이면 Next.js가 자동으로 <link rel="preload">를 HTML <head>에 삽입한다. 브라우저가 CSS를 기다리지 않고 바로 이미지를 요청할 수 있게 된다.
여기에 추가로 이미지 포맷을 WebP로 변환했다. Next.js의 Image Optimization을 쓰면 자동으로 해주지만, 우리는 CDN에서 직접 서빙하고 있어서 수동으로 변환했다.
# cwebp로 변환 (Google에서 제공하는 CLI 도구)
cwebp -q 80 hero-bg.png -o hero-bg.webp
# 결과 확인
ls -la hero-bg.*
# hero-bg.png 1.8MB
# hero-bg.webp 187KB1.8MB에서 187KB로. 약 90% 감소. 이것만으로 LCP가 3.2초에서 1.9초로 떨어졌다.
1차 Lighthouse 결과: 42점 → 61점, LCP 3.2s → 1.9s
폰트 삽질기
이미지 다음으로 눈에 들어온 건 폰트였다. 우리는 Pretendard를 쓰고 있었는데, woff2 파일 4개를 로드하고 있었다. Regular, Medium, SemiBold, Bold. 합쳐서 약 400KB.
처음에는 font-display: swap으로 설정해뒀었다. FOUT(Flash of Unstyled Text)가 발생하긴 하지만, 텍스트는 바로 보이니까 LCP에 유리할 거라고 생각했다.
그런데 문제가 있었다. hero 섹션의 <h1> 텍스트가 LCP element로 잡히는 경우가 있었다. 이미지를 최적화하고 나니까, 이미지보다 <h1>이 더 늦게 "최종 렌더링"되는 케이스가 생긴 거다. font-display: swap이면 시스템 폰트로 먼저 렌더링하고 웹폰트가 로드되면 교체하는데, 이 교체 순간이 LCP로 잡힌다.
font-display: optional로 바꿨다.
@font-face {
font-family: 'Pretendard';
font-weight: 400;
font-display: optional;
src: url('/fonts/Pretendard-Regular.subset.woff2') format('woff2');
unicode-range: U+AC00-D7A3, U+0020-007E;
}optional은 폰트가 이미 캐시에 있으면 사용하고, 없으면 시스템 폰트로 그냥 간다. 교체가 일어나지 않으니 layout shift도 없고, LCP 재계산도 없다. 첫 방문 유저에게는 시스템 폰트가 보이지만, 두 번째 방문부터는 웹폰트가 적용된다.
디자이너한테 얘기했더니 처음엔 난색을 표했다. "첫 방문 유저에게 Apple SD Gothic이 보인다고요?" 근데 LCP 수치를 보여주니까 납득했다. 사실 대부분의 유저는 차이를 못 느낀다. Pretendard 자체가 시스템 폰트와 비슷한 느낌으로 디자인된 폰트이기도 하고.
추가로 unicode-range를 지정해서 한글과 기본 ASCII만 포함하는 subset 폰트를 만들었다. 원본 400KB에서 subset 후 95KB로 줄었다.
preload의 함정
여기서 욕심이 났다. "preload를 더 걸면 더 빨라지지 않을까?"
<!-- 이런 짓을 했다 -->
<link rel="preload" href="/fonts/Pretendard-Regular.subset.woff2" as="font" crossorigin>
<link rel="preload" href="/fonts/Pretendard-Bold.subset.woff2" as="font" crossorigin>
<link rel="preload" href="/hero-bg.webp" as="image">
<link rel="preload" href="/api/main-data" as="fetch" crossorigin>
<link rel="preload" href="/chunks/landing-abc123.js" as="script">
결과? 오히려 느려졌다. LCP가 1.6초에서 1.8초로 올라갔다.
이유는 간단했다. preload가 너무 많으면 브라우저의 네트워크 대역폭을 놓고 리소스끼리 경쟁한다. 모든 걸 "높은 우선순위"로 올려놓으면, 결국 아무것도 높은 우선순위가 아닌 것과 같다. web.dev에서도 이 점을 명확하게 경고하고 있다: preload는 현재 페이지에서 반드시 필요한 리소스에만 써야 한다.
hero 이미지 preload만 남기고 나머지를 전부 제거했다. 폰트는 font-display: optional이니까 preload할 필요가 없다. 안 와도 괜찮은 리소스를 preload하는 건 모순이다.
번들 사이즈 줄이기
next build를 하고 나오는 빌드 결과를 살펴봤다.
npx next build
Route (app) Size First Load JS
┌ ○ / 142 kB 387 kB
├ ○ /about 28 kB 273 kB
├ ○ /dashboard 89 kB 334 kB
└ ○ /settings 45 kB 290 kB
First Load JS shared by all routes: 245 kB공통 번들이 245KB. 이 안에 뭐가 들어있는지 까봐야 했다.
# bundle analyzer 실행
ANALYZE=true npx next build범인이 보였다. core-js polyfill이 공통 번들의 약 30%를 차지하고 있었다. 우리 서비스의 타겟 브라우저는 Chrome 90+, Safari 15+, Edge 90+ 정도였는데, IE 지원용 polyfill이 잔뜩 들어가 있었다.
원인은 .browserslistrc 파일이었다.
# 기존 설정 (누가 이렇게 해둔 거야...)
> 0.5%
last 2 versions
not dead
이 설정이면 전 세계 사용률 0.5% 이상인 브라우저를 전부 지원하게 된다. Samsung Internet 구버전이나 UC Browser 같은 것까지 포함된다.
# 수정 후
last 2 Chrome versions
last 2 Firefox versions
last 2 Safari versions
last 2 Edge versions
not dead
그리고 next.config.js에서 불필요한 polyfill import를 제거했다.
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ['lodash-es', '@headlessui/react'],
},
};추가로 lodash 전체를 import하고 있는 파일이 3개 있었다. import _ from 'lodash' 대신 개별 함수만 가져오도록 바꿨다.
// Before
import _ from 'lodash';
const sorted = _.sortBy(items, 'date');
const grouped = _.groupBy(items, 'category');
// After
import sortBy from 'lodash-es/sortBy';
import groupBy from 'lodash-es/groupBy';
const sorted = sortBy(items, 'date');
const grouped = groupBy(items, 'category');공통 번들이 245KB에서 156KB로 줄었다.
2차 Lighthouse 결과: 61점 → 78점, LCP 1.9s → 1.3s
Code Splitting: 안 보이는 건 나중에
랜딩 페이지 하단에 FAQ 아코디언, 고객 후기 캐러셀, 문의 폼이 있었다. 이것들은 스크롤하지 않으면 보이지 않는다. 그런데 초기 번들에 전부 포함되어 있었다.
Next.js의 dynamic을 썼다.
import dynamic from 'next/dynamic';
const FAQ = dynamic(() => import('@/components/landing/FAQ'), {
loading: () => <div className="faq-skeleton" />,
});
const Testimonials = dynamic(() => import('@/components/landing/Testimonials'), {
loading: () => <div className="testimonials-skeleton" />,
});
const ContactForm = dynamic(() => import('@/components/landing/ContactForm'), {
loading: () => <div className="contact-skeleton" />,
});이렇게 하면 이 컴포넌트들의 JS가 초기 번들에서 빠지고, 실제로 렌더링될 때 별도 chunk로 로드된다.
한 가지 주의할 점. skeleton UI를 loading에 넣어줘야 CLS가 안 생긴다. 처음에 빈 div만 넣었다가 컴포넌트 로드되면서 레이아웃이 밀리는 문제가 있었다.
이미지 lazy loading 세부 튜닝
랜딩 페이지에 이미지가 총 12개 있었다. hero 이미지만 priority로 하고 나머지는 기본 lazy loading을 적용했는데, 여기서 한 가지 더 신경 쓴 게 있다.
// viewport 바로 아래에 있는 이미지들은 loading="eager"로
// 스크롤 많이 해야 보이는 이미지들만 loading="lazy"로
// hero 바로 아래 섹션의 이미지
<Image
src="/feature-1.webp"
width={600}
height={400}
alt="기능 소개 1"
loading="eager" // fold 근처이므로 eager
sizes="(max-width: 768px) 100vw, 50vw"
/>
// 페이지 하단의 이미지
<Image
src="/team-photo.webp"
width={800}
height={500}
alt="팀 사진"
loading="lazy" // 스크롤 많이 해야 보이므로 lazy
sizes="(max-width: 768px) 100vw, 50vw"
/>sizes 속성도 중요하다. 이걸 제대로 안 쓰면 모바일에서 데스크톱 사이즈 이미지를 받아온다. 모바일 네트워크에서 불필요하게 큰 이미지를 받는 건 LCP에 직격탄이다.
서드파티 스크립트 정리
거의 다 왔다 싶었는데 Lighthouse가 아직 뭔가를 불평하고 있었다. "Reduce the impact of third-party code" 항목이었다.
우리 랜딩 페이지에 붙어 있던 서드파티 목록:
- Google Analytics (gtag.js)
- Hotjar
- Channel Talk (채널톡)
- Facebook Pixel
이 네 개가 합쳐서 약 200KB의 JS를 초기 로딩 때 가져오고 있었다. 특히 Hotjar가 무거웠다.
전부 다 afterInteractive 전략으로 바꿨다. Next.js의 next/script를 쓰면 된다.
import Script from 'next/script';
// GA는 afterInteractive (기본값)
<Script
src="https://www.googletagmanager.com/gtag/js?i=G-XXXXXXX"
strategy="afterInteractive"
/>
// Hotjar와 채널톡은 lazyOnload
<Script id="hotjar" strategy="lazyOnload">
{`(function(h,o,t,j,a,r){ ... })(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');`}
</Script>
<Script id="channel-talk" strategy="lazyOnload">
{`(function(){var w=window; ... })();`}
</Script>Hotjar와 채널톡은 lazyOnload로 설정했다. 페이지 로딩이 완전히 끝나고 브라우저가 idle 상태일 때 로드된다. 유저가 페이지를 보는 시점에는 이미 히어로 섹션이 다 렌더링된 후다.
Facebook Pixel은 PM한테 물어봤더니 "그거 지금 안 쓰는데?"라고 해서 그냥 제거했다. 안 쓰는 스크립트가 200ms를 잡아먹고 있었던 거다.
3차 Lighthouse 결과: 78점 → 91점, LCP 1.3s → 0.9s
최종 결과 비교
| 지표 | Before | After |
|---|---|---|
| Lighthouse Performance | 42 | 91 |
| LCP | 3.2s | 0.9s |
| CLS | 0.28 | 0.04 |
| Total Blocking Time | 890ms | 180ms |
| First Load JS (랜딩) | 387KB | 198KB |
3주 동안 스프린트 틈틈이 작업한 결과다. 풀타임으로 했으면 일주일이면 됐을 것 같다.
효과가 컸던 것, 작았던 것
임팩트 순서대로 정리하면 이렇다.
hero 이미지를 WebP로 변환하고 next/image + priority 적용한 게 가장 컸다. LCP의 절반 이상이 여기서 줄었다. 성능 최적화에서 이미지는 거의 항상 1순위다.
번들 사이즈 줄이기와 code splitting은 예상보다 LCP 자체에 미치는 영향은 적었다. 하지만 TBT(Total Blocking Time)와 TTI(Time to Interactive)에는 확실한 효과가 있었다. 유저가 버튼을 눌렀을 때 반응하는 속도가 체감될 정도로 빨라졌다.
서드파티 스크립트 정리는 "이렇게 쉬운 걸 왜 안 했지?" 싶은 작업이었다. 안 쓰는 Facebook Pixel 하나 제거하고 나머지를 lazy로 바꾸는 건 30분도 안 걸렸는데 효과가 좋았다.
반대로 preload를 여러 개 거는 건 역효과가 났다. 그리고 font-display 값을 swap에서 optional로 바꾼 건 수치상으로는 의미 있었지만, 디자이너와의 논의가 필요한 트레이드오프였다. 팀마다 판단이 다를 수 있다.
그 이후
PM이 다시 슬랙에 글을 올렸다. "요즘 사이트 빨라진 것 같은데 뭐 한 거예요?" 팀 올핸즈에서 Before/After Lighthouse 점수를 보여줬더니 박수를 받았다. 나쁘지 않은 기분이었다.
근데 사실 아직 할 게 남아 있다. 모바일 3G 환경에서의 성능은 여전히 아쉽고, 서버 응답 시간(TTFB) 자체가 느린 API가 몇 개 있다. ISR이나 Edge Runtime 같은 것도 검토해봐야 하는데, 그건 프론트엔드만으로는 해결이 안 되니까 백엔드 팀과 같이 봐야 한다.
성능 최적화는 한 번 하고 끝나는 게 아니라, 새 기능이 추가될 때마다 조금씩 다시 나빠진다. 그래서 나는 CI에 Lighthouse CI를 붙여놨다. PR마다 성능 점수가 일정 기준 이하로 떨어지면 경고가 뜨게 했다.
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci && npm run build
- uses: treosh/lighthouse-ci-action@v11
with:
urls: |
http://localhost:3000/
budgetPath: ./budget.json
uploadArtifacts: true// budget.json
[
{
"path": "/",
"timings": [
{ "metric": "largest-contentful-paint", "budget": 1500 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 },
{ "metric": "total-blocking-time", "budget": 300 }
]
}
]이걸 돌려놓으니까 마음이 좀 편해졌다. 누가 거대한 라이브러리를 무심코 import하거나 최적화 안 된 이미지를 올려도, 머지 전에 알 수 있으니까.
성능 최적화를 하면서 느낀 건, 대부분의 성능 문제는 "몰라서" 생기는 게 아니라 "신경 안 써서" 생긴다는 거다. next/image를 쓰면 된다는 걸 모르는 프론트엔드 개발자는 거의 없다. 근데 실제로 프로젝트에서 img 태그를 쓰고 있는 곳이 얼마나 많은가. 알고 있는 것과 적용하는 것 사이의 간극이 성능 문제의 본질인 것 같다. 그 간극을 줄이는 건 결국 "측정"이다. 숫자를 보면 움직이게 된다.
