treeru.com
개발

LCP 110초를 3.4초로 — motion/react의 opacity:0 트랩

2026-01-23
Treeru

이미지도 압축했고, CLS도 0으로 잡았고, 폰트도 최적화했습니다. 그런데 Mobile Performance가 74점에서 더 안 올라갔습니다. LCP가 4.7초로 여전히 빨간색이었습니다.

원인을 추적해보니 motion/react (Framer Motion)의 fadeIn 애니메이션 한 줄이 범인이었습니다. initial={{ opacity: 0 }} — 이 코드가 SSR에서 어떤 일을 하는지 알고 나면, 더 이상 LCP 이미지 wrapper에 opacity 애니메이션을 넣지 않게 됩니다.

4.7초
Before LCP
3.4초
After LCP
74→88
Mobile 점수
0.6초
Desktop LCP

1LCP란 무엇인가

LCP(Largest Contentful Paint)는 뷰포트에서 가장 큰 콘텐츠 요소가 화면에 렌더링되는 시점입니다. 보통 Hero 이미지나 큰 텍스트 블록이 LCP 요소가 됩니다. Google은 2.5초 이내를 "Good"으로 봅니다.

LCP 가중치

PageSpeed 점수에서 LCP의 가중치는 25%입니다. LCP가 4.7초에서 3.4초로 줄면 점수에 직접적으로 10점 이상 영향을 줍니다. LCP는 "사용자가 화면에서 의미 있는 콘텐츠를 처음 본 시점"이므로 체감 속도와도 직결됩니다.

문제는 Lighthouse가 LCP 요소를 판단하는 방식에 있습니다. opacity가 0인 요소는 LCP 후보에서 제외됩니다. 사용자 눈에 보이지 않으니까요. JavaScript가 실행되어 opacity가 1로 바뀌면 그제서야 LCP로 재측정합니다. 이게 바로 motion/react의 트랩입니다.

2범인 — opacity:0

motion/react(구 Framer Motion)를 사용하면 거의 모든 튜토리얼에서 이런 코드를 봅니다:

// ❌ LCP를 망치는 코드
<motion.div
  initial={{ opacity: 0, y: 60 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ duration: 0.8 }}
>
  <img src="/hero.avif" fetchPriority="high" />
</motion.div>

화면에 부드럽게 나타나는 효과를 주기 위해 opacity: 0에서 시작합니다. 문제는 Next.js 같은 SSR 프레임워크에서 이 initial 값이 서버 렌더링 HTML에 인라인 스타일로 박힌다는 점입니다.

핵심 문제

SSR로 렌더링된 HTML에 style="opacity:0;transform:translateY(60px)"이 인라인으로 들어갑니다. 브라우저는 이 이미지를 "보이지 않음"으로 판단하고, LCP 후보에서 제외합니다. JavaScript 번들이 로드되고 motion/react가 초기화된 후에야 opacity가 1로 바뀌면서 LCP가 측정됩니다.

LCP 원인 추적에는 Chrome DevTools 디버깅이 유용합니다. 실제로 서버가 보내는 HTML을 확인하면 이렇게 되어 있습니다:

<!-- 서버가 보내는 HTML (curl로 확인) -->
<div style="opacity:0;transform:translateY(60px)">
  <img src="/hero.avif" fetchpriority="high"
       width="900" height="1118" />
</div>

<!-- JS 실행 후 (수 초 뒤) -->
<div style="opacity:1;transform:translateY(0px)">
  <img src="/hero.avif" fetchpriority="high"
       width="900" height="1118" />
</div>

이미지 자체는 빠르게 로드되는데, wrapper의 opacity가 0이라 Lighthouse가 "아직 안 보인다"고 판단합니다. JavaScript가 실행되어 opacity가 1로 바뀌는 순간이 LCP입니다. Mobile에서 JS 실행까지 3~5초가 걸리니, 이미지가 준비되어 있어도 LCP는 늦습니다.

3SSR에서 벌어지는 일

일반 React SPA에서는 이 문제가 덜합니다. 어차피 전체가 클라이언트 렌더링이니까요. 하지만 Next.js는 SSR로 초기 HTML을 생성합니다. motion/react는 initial 값을 서버 사이드에서도 적용해서, hydration 전에 애니메이션 시작 상태를 보여줍니다.

이게 의도된 동작입니다. 페이지가 로드되면 opacity:0 → 1 애니메이션이 자연스럽게 보이도록. 하지만 LCP 관점에서는 치명적입니다.

시점상태Lighthouse 판단
0msHTML 도착, opacity:0 인라인이미지 안 보임 → LCP 후보 제외
~500ms이미지 다운로드 완료여전히 opacity:0 → 무시
~1,500msFCP (텍스트 렌더링)다른 요소가 임시 LCP
~3,000msJS 번들 로드 + hydrationmotion/react 초기화 시작
~4,700msopacity: 0 → 1 전환이제서야 LCP 측정 → 4.7초

이미지가 500ms에 준비되어 있어도, opacity가 1로 바뀌는 4,700ms가 LCP입니다. 이미지 최적화를 아무리 해도 LCP가 줄지 않는 이유였습니다.

4해결 — scale로 전환

핵심은 단순합니다. LCP 이미지를 감싸는 요소에서 opacity 애니메이션을 제거하면 됩니다. 대신 시각적으로 비슷한 효과를 주는 scale 트랜스폼을 사용합니다.

// ❌ Before — opacity:0이 LCP를 지연시킴
<motion.div
  initial={{ opacity: 0, y: 60 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ duration: 0.8 }}
>
  <img src="/hero.avif" fetchPriority="high" />
</motion.div>

// ✅ After — scale만 사용, opacity는 항상 1
<motion.div
  initial={{ scale: 0.97 }}
  animate={{ scale: 1 }}
  transition={{ duration: 1.2, delay: 0.4 }}
>
  <img src="/hero.avif" fetchPriority="high" />
</motion.div>

왜 scale은 괜찮은가?

transform: scale(0.97)은 요소를 3%만 작게 보여줍니다. 하지만 opacity는 여전히 1이므로 Lighthouse는 이미지가 "보이는 상태"로 판단합니다. 이미지 로드가 완료되는 즉시 LCP로 측정됩니다.

scale 0.97과 1의 차이는 육안으로 거의 구분되지 않습니다. 자연스러운 "살짝 커지는" 느낌만 줄 뿐, 사용자 경험에는 영향이 없습니다.

나머지 영역 — 텍스트, 버튼, 아래 fold 콘텐츠 — 에서는 opacity 애니메이션을 계속 써도 됩니다. LCP 이미지 wrapper에서만 제거하면 됩니다.

// ✅ 텍스트, 버튼 — opacity 애니메이션 OK
<motion.div
  initial={{ opacity: 0, y: 30 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ delay: 0.6 }}
>
  <h2>서비스 소개</h2>
  <p>설명 텍스트...</p>
</motion.div>

// ✅ 아래 fold 이미지 — 어차피 LCP 아님
<motion.div
  initial={{ opacity: 0 }}
  whileInView={{ opacity: 1 }}
  viewport={{ once: true }}
>
  <img src="/below-fold.webp" loading="lazy" />
</motion.div>

규칙은 간단합니다: "LCP 후보가 될 수 있는 요소의 wrapper에는 initial opacity:0을 쓰지 않는다."

5두 번째 함정 — lazy 누락

opacity 문제를 수정한 뒤에도 LCP가 기대만큼 줄지 않았습니다. 원인을 더 파고들어보니, 아래 fold 이미지들에 loading="lazy"가 빠져 있었습니다.

일반 <img> 태그에 loading 속성이 없으면, 브라우저는 모든 이미지를 즉시 다운로드합니다. 6장의 이미지가 동시에 네트워크 대역폭을 차지하면, 정작 중요한 Hero 이미지의 다운로드가 느려집니다.

대역폭 경쟁

HTTP/2에서는 하나의 연결로 여러 리소스를 동시에 받습니다. 하지만 대역폭은 유한합니다. Hero 이미지(124KB)와 아래 fold 이미지 5장(총 580KB)이 동시에 다운로드되면, Hero 이미지가 받아야 할 대역폭을 다른 이미지들이 가져갑니다. 특히 Mobile 3G/4G 환경에서 이 차이는 큽니다.

// ❌ Before — 모든 이미지가 즉시 로드
<img src="/service-ai.webp" />
<img src="/service-network.webp" />
<img src="/service-software.webp" />
<img src="/bg-texture.webp" />
<img src="/bg-meeting.webp" />

// ✅ After — 아래 fold는 lazy, Hero만 즉시 로드
<img src="/hero.avif" fetchPriority="high" />  // Hero
<img src="/service-ai.webp" loading="lazy" />
<img src="/service-network.webp" loading="lazy" />
<img src="/service-software.webp" loading="lazy" />
<img src="/bg-texture.webp" loading="lazy" />
<img src="/bg-meeting.webp" loading="lazy" />
전략대상속성
즉시 로드Hero 이미지 (첫 화면)fetchPriority="high"
지연 로드아래 fold 콘텐츠 이미지loading="lazy"
주의Hero 이미지에 lazy 절대 금지LCP가 뷰포트 진입 시까지 지연됨

6Preload + AVIF 마무리

opacity와 lazy를 수정한 다음, 마지막으로 두 가지를 더 했습니다.

Hero 이미지 AVIF 전환

WebP로 압축한 Hero 이미지를 AVIF로 한 번 더 변환했습니다. 같은 품질에서 AVIF가 40~60% 더 작습니다.

포맷용량LCP 기여
원본 PNG5.7MB다운로드만 수 초
WebP q80124KB충분히 빠름
AVIF q40~60KB추가 단축

AVIF의 단점은 인코딩 속도입니다. 빌드 시간이 좀 느려지지만, Hero 이미지는 1장이라 빌드에 미치는 영향은 미미합니다. 디코딩 속도도 WebP보다 살짝 느리지만 현대 브라우저에서 체감 차이는 없습니다.

Preload는 Hero만

<link rel="preload">를 사용하면 브라우저가 HTML을 파싱하기 전에 리소스 다운로드를 시작합니다. 하지만 preload를 남용하면 역효과입니다.

<!-- ✅ Hero 이미지만 preload -->
<link
  rel="preload"
  href="/images/hero.avif"
  as="image"
  type="image/avif"
  fetchpriority="high"
/>

<!-- ❌ 이러면 안 됨 — 모든 이미지 preload -->
<link rel="preload" href="/images/service-1.webp" as="image" />
<link rel="preload" href="/images/service-2.webp" as="image" />
<link rel="preload" href="/images/bg-texture.webp" as="image" />

preload 규칙

  • preload는 LCP 이미지 1장에만 사용합니다.
  • 여러 리소스를 preload하면 우선순위가 분산되어 효과가 사라집니다.
  • preload한 리소스를 3초 내에 사용하지 않으면 Chrome이 경고를 보냅니다.
  • 아래 fold 이미지에 preload를 넣으면 Hero 이미지와 대역폭을 경쟁합니다.

7결과

세 가지 수정 — opacity 제거, lazy 추가, AVIF 전환 — 의 결과입니다.

지표BeforeAfter변화
Mobile Performance7488+14점
Desktop Performance9697+1점
LCP (Mobile)4.7초3.4초-1.3초
LCP (Desktop)1.0초0.6초-0.4초

Mobile LCP가 4.7초에서 3.4초로 줄었습니다. 여전히 2.5초 이하의 "Good" 기준에는 못 미치지만, 이전 Phase들(이미지 압축, CLS 수정, 폰트 최적화)과 합치면 초기 110.2초에서 3.4초까지 내려온 겁니다.

수정 항목별 기여도

opacity:0 → scale(0.97)(LCP 4.7초 → 3.8초)

JS 실행 대기 시간 제거

loading='lazy' 추가(LCP 3.8초 → 3.5초)

대역폭 경쟁 해소

Hero AVIF 전환 + preload(LCP 3.5초 → 3.4초)

다운로드 용량 추가 절감

LCP 최적화 체크리스트

LCP 이미지 wrapper에 initial={{ opacity: 0 }}을 쓰지 않는다
opacity 대신 scale(0.97→1) 같은 transform 애니메이션 사용
SSR HTML을 curl로 확인해서 opacity:0 인라인 스타일 없는지 점검
아래 fold 이미지에는 반드시 loading='lazy' 추가
Hero 이미지에만 fetchPriority='high' + preload 사용
AVIF 포맷으로 추가 용량 절감 (Hero 이미지 1장만으로도 효과적)
텍스트, 버튼 등 LCP 후보가 아닌 요소에는 opacity 애니메이션 OK

이 글에서 소개한 수치는 특정 사이트의 실측 결과이며, 사이트 구조와 서버 환경에 따라 결과가 다를 수 있습니다. PageSpeed 점수는 측정 시점에 따라 ±3~5점 변동합니다.

PageSpeed 실전 최적화 시리즈

Mobile 38점에서 88점까지 올린 전체 과정을 단계별로 정리했습니다.

T

Treeru

웹 개발, IT 인프라, AI 솔루션 분야의 실무 인사이트를 공유합니다. 기업의 디지털 전환을 돕는 IT 파트너, Treeru입니다.

공유

댓글

(4개)
4.63/ 5

로그인하면 댓글을 작성할 수 있습니다.

2026-02-05
555.0

CLS 글이랑 연결해서 보면 완벽합니다. 애니메이션 때문에 CLS도 높고 LCP도 높았는데, visibility:hidden이랑 scale 조합으로 둘 다 해결할 수 있네요.

2026-02-02
4.554.5

loading=lazy 안 넣으면 Next.js가 자동으로 preload 넣는다는 게 놀랍네요. 이미지 6장이 동시에 다운로드되면 Hero가 느려질 수밖에 없겠다.

2026-01-30
454.0

SSR에서 인라인 style로 opacity:0이 박힌다는 거, 직접 curl로 확인해보니까 진짜 그렇더라고요. 서버에서 보내는 HTML을 꼭 확인해봐야 합니다.

관련 글

© 2026 TreeRU. All rights reserved.

본 콘텐츠의 저작권은 TreeRU에 있으며, 출처를 밝히지 않은 무단 전재 및 재배포를 금합니다. 인용 시 출처(treeru.com)를 반드시 명시해 주세요.