65점이라는 성적표가 말해주는 것
사용자 입장에서는 8.7초짜리 LCP가 "느리다"가 아니라 "안 열린다"입니다. 구글 통계에 따르면 3초만 넘어가도 절반 이상이 탭을 닫는데, 거기서 5.7초를 더 기다려주는 사용자는 거의 없죠. velog에 공유된 본부 홈페이지 Lighthouse 개선 사례(@heewoong96)가 정확히 이 지점을 보여줍니다. Performance 65점—빨간불이 켜진 성적표 앞에서 가장 먼저 들여다본 건, 역시 LCP 항목이었습니다.
병목의 정체: background-image라는 구조적 함정
문제의 핵심은 히어로 섹션의 배경 이미지를 background-image로 처리하고 있었다는 점입니다. Vue의 computed로 런타임에 URL을 조합하는 구조였는데, 이게 왜 치명적이냐면—브라우저는 HTML → CSS → JS 순서로 파싱하거든요. JS 실행이 끝나야 "아, 이미지가 필요하구나"를 인식하는 겁니다. fetchpriority="high"를 줄 수도 없고, 브라우저의 이미지 프리로드 알고리즘이 개입할 여지 자체가 없는 구조인 거죠.
Figma에서 볼 때는 그냥 예쁜 풀블리드 히어로 이미지인데, 실제로 구현하면 "CSS background로 넣을까, <img> 태그로 넣을까"의 선택이 LCP 수 초를 좌우합니다. 이건 디자이너한테는 보이지 않는 갭이에요. 사실 이건 <img> 태그에 fetchpriority="high"를 주면 해결되는 문제이지만, 그걸 모르면 영원히 background-image 안에 갇혀 있게 됩니다.
세 가지 레이어로 깎아낸 LCP
해당 사례에서 적용한 최적화는 결국 세 겹입니다:
- 포맷 전환: PNG → WebP로 교체해 용량을 약 1/3로 줄임
- 태그 구조 변경:
background-image대신<NuxtImg>(내부적으로<img>) 사용,fetchpriority="high"부여 - 프리로드 힌트:
<NuxtImg>의preload옵션으로<head>에<link rel="preload">를 자동 삽입—JS 실행 전에 이미지 다운로드 시작
이 세 가지가 합쳐져 65점이 90점으로 뛰었습니다. 개별적으로는 "WebP 쓰세요", "preload 하세요" 같은 뻔한 조언인데, 실전에서는 이걸 한꺼번에, 정확한 지점에 적용해야 효과가 나옵니다. 하나만 빼먹어도 Load Time 7.4초가 고작 6초로 줄어드는 정도에서 멈출 수 있어요.
캐시라는 또 다른 지뢰밭
여기서 한 발 더 나가면, 이미지 최적화만으로는 해결되지 않는 성능 이슈가 있습니다. 금은시세 조회 사이드 프로젝트(@grace287)의 트러블슈팅 사례가 정확히 이걸 보여줍니다. 데이터가 DB에는 분명히 쌓이고 있는데 화면에는 빈 값만 나오는 상황—원인이 세 겹으로 쌓여 있었습니다.
첫째, API 포트 불일치(8080 vs 8000). 둘째, 크롤러가 데이터를 넣기 전에 페이지를 열어서 { gold: null, silver: null }이 Redis에 1분간 캐시됨. 셋째, Next.js의 fetch 기본 캐시 동작이 그 빈 응답을 또 한 번 잡아둠. 사용자 입장에서는 그냥 "시세가 안 나와요"인데, 개발자는 Redis TTL, Next.js cache: 'no-store', 포트 설정 세 곳을 동시에 뒤져야 하는 거예요.
빈 응답도 캐시된다—이건 교과서에 잘 안 나오는 실전 교훈입니다. 해결책은 의외로 단순합니다. 값이 null이면 Redis에 넣지 않고, Next.js fetch에 cache: 'no-store'와 revalidate = 0을 명시하는 것. 하지만 이걸 모르면 "DB에는 있는데 왜 안 나오지?"를 며칠째 디버깅하게 됩니다.
클라이언트 상태관리와 캐싱의 접점
React Query의 staleTime 설정이 이 맥락에서 다시 중요해집니다. dev.to에 공유된 Tour App 사례(@m_saad_ahmad)에서는 국가 데이터에 10분, Unsplash 이미지에 5분의 staleTime을 부여하고 있는데, 이건 "데이터가 자주 안 바뀌는 API"이기 때문에 성립하는 전략이에요. 금은시세처럼 실시간성이 중요한 데이터에 같은 설정을 쓰면 캐시 지뢰를 밟게 됩니다.
결국 캐싱 전략은 "얼마나 빠르게 보여줄 것인가"와 "얼마나 신선한 데이터를 보여줄 것인가" 사이의 트레이드오프입니다. staleTime을 넉넉히 잡으면 Lighthouse Performance는 올라가지만 데이터 신뢰도가 떨어지고, no-store로 매번 요청하면 TTFB가 늘어나죠. 여기서 로딩 스켈레톤 넣으면 어떨까요?—체감 성능을 확보하면서 신선한 데이터를 가져오는 절충안이 됩니다.
시사점: 성능 최적화는 단발이 아니라 파이프라인이다
이 세 사례를 엮어서 보면, 프론트엔드 성능 최적화의 실전 체크리스트가 그려집니다:
- LCP 요소 식별: background-image인지
<img>인지, 그것부터 확인 - 이미지 포맷·프리로드: WebP/AVIF 전환 +
fetchpriority="high"+<link rel="preload"> - 캐시 레이어 점검: 서버(Redis) → 프레임워크(Next.js fetch) → 브라우저, 세 겹 모두 확인
- 빈 응답 캐시 차단: null 데이터를 캐시하지 않는 방어 로직
- staleTime 전략: 데이터 특성에 맞는 캐시 수명 설계
원문 저자가 언급했듯이, 이런 점검을 CI 파이프라인에 Lighthouse CI로 녹여내면 "배포할 때마다 성능 회귀를 잡는" 자동화가 가능합니다. 사실 진짜 무서운 건 65점으로 떨어지는 게 아니라, 90점이었던 게 어느 날 모르게 75점이 되어 있는 것이거든요. Core Web Vitals는 한 번 맞추는 게 아니라 계속 지키는 것이고, 그래서 이건 체크리스트가 아니라 파이프라인이어야 합니다.