Home 진입 40초 → 2.5초, LCP를 93% 줄인 Next.js 최적화

2025. 8. 5. 13:34·⛲ 프로젝트

Home 화면, 왜 이렇게 느릴까... 🐢 🐢

최근프로젝트를 진행하면서 가장 먼저 마주한 문제는 홈 화면이 너무 느리다는 점이다. 
유저들이 가장 처음 접하는 이 메인 페이지에는 추천 캐러셀부터 인기, 최신, 다양한 카테고리 콘텐츠까지 구성되어 있었다.

문제는 이 다양한 카테고리들이 이 성능에 큰 부담으로 작용하고 있었다는 것이다.....

 

처음엔 그냥 포스터이미지들이 많아서 이미지때문인가 싶었는데, 실제 Lighthouse로 측정해보니…
Performance 점수는 38점,
LCP(Largest Contentful Paint)는 무려 39.8초가 나왔다. 

 

단순히 숫자만 보면 체감이 안 올 수도 있지만, 39.8초면 사용자가 페이지를 클릭하고도 거의 40초 가까이 주요 콘텐츠가 보이지 않는다는 뜻이다. 


이쯤 되면 유저가 로딩을 기다리기보단 뒤로가기를 눌러버릴 가능성이 훨씬 높다. 

그리고 PWA 기반으로 모바일 사용자도 많은데, 이런 느린 로딩은 특히 모바일 환경에서 더 치명적으로 작용된다.

 

 

Lighthouse 결과

 

 

LCP
39.8초 2.5초 93.7% 개선

 

LCP가 가장 크게 개선되었다. 

Performance 점수 38 59 +55%
LCP 39.8초 2.5초 -93.7%
TBT (Total Blocking) 2,770ms 2,330ms -15.9%
Best Practices 점수 75 100 +33%

 

 

기존문제 

기존에는 모든 카테고리 데이터를 초기 렌더링시 동시에 패칭해서 불필요한 네트워킹요청이 발생했다

 

기존 구조: 모든 카테고리를 한 번에 fetch

// 기존
const { data, isLoading } = useCategoryEvents(selected);
// selected가 바뀔 때마다 무조건 실행됨 (모든 카테고리 순회 가능)

 

selected가 바뀔때마다, useCategoryEvents가 항상 실행되고있었다.

-> 카테고리가 선택될때마다 모든 데이터를 불러오게되는거

사용자가 실제로 필요하지 않은 데이터도 요청할 수 있음-> 불필요한 네트워크 낭비 

 

"✨필요한 순간에만, 필요한 것만"

목표

1. 스크롤 내렸을때 가장먼저 보일 "인기"와 클릭률 높을 "최신" 두 개만 초기 로딩

2. 나머지 카테고리는 클릭할 때만 fetch

3. 한 번 fetch한 카테고리는 다시 요청하지 않음

 

enabled 옵션으로 Lazy Fetch 제어

//  개선 후: 필요할 때만 로딩
const { data, isLoading } = useCategoryEvents(selected, {
  enabled: fetchedCategories.includes(selected),
});

 

enabled 옵션을 추가한것 ! 

fetchedCategories라는 배열에 선택된 카테고리가 있을떄( 이미 데이터를 받아온적이 있을때) 로딩이 활성(enbaled)됨

만약 없다면, 요청이 아예 실행되지 않음

-> 아직 안받아온 카테고리가 API 요청이 나가고, 이미 받은건 재요청을 안한다.

네트워크와 리소스 아낄수 있고 UX도 더좋아짐

 

클릭시 on-demand fetch 

const handleCategoryClick = (label: CategoryLabel) => {
  setSelected(label);
  if (!fetchedCategories.includes(label)) {
    setFetchedCategories((prev) => [...prev, label]);
  }
};

 

  • 사용자가 카테고리를 클릭할 때만 fetch 대상에 포함
  • 최초 1회만 fetch되고 이후는 cache 사용 → 불필요한 중복 요청 제거
  • 클릭 기반 데이터 fetch: on-demand fetching 방식 적용

1. 버튼 클릭(카테고리 선택)

2. 만약 fetch한 적 없는 카테고리 -> 배열에 추가 & fetch enabled

3. 조건에 의한 enanled :true -> fetch 발생(on-demand)

4. 이미 받아온 카테고리 -> fetch disabled. 네트워크 요청 생략 

 

 

 

❓lazy laoding 지연로딩

  - 필요한 시점에만 데이터 불러오는 방식(사용자가 탭 클릭할 때마다 필요한 카테고리만 요청)

 

❓on-demand Fetching

  - 사용자가 요청할때마다 데이터를 서버에서 가져오는 방식 = 필요할때, 사용자의 요구(on-demand)에 따라 fetch가 이루어짐

( 각 카테고리의 데이터를 사용자가 실제로 해당 카테고리 선택할때 서버에 실시간 요청)

 

 

=> 실제 fetching은 handleCategoryClick()과 fetchedCategories의 조합으로 

" 처음 보는/ 아직 안받아온 카테고리"일때 딱 한번만 일어나도록 최적화 !!

 → 초기 페이지 JS 실행량, CPU 부하 감소

 

 

최초 렌더링 최적화 (인기, 최신만 로딩)

const [fetchedCategories, setFetchedCategories] = useState<["인기", "최신"]>();
  • 홈 진입 시에는 가장 트래픽이 높은 두 개 카테고리만 미리 로딩
  • 나머지는 UI만 표시해두고, 클릭 시에만 fetch
  • 초기 렌더링 비용 감소 → LCP & JS 실행 시간 ↓

 

Carousel + 주요 컴포넌트 동적 import

const Carousel = dynamic(() => import("./_components/carousel"));
const Card = dynamic(() => import("@/components/main-card"));
const Input = dynamic(() => import("@/components/input"));
  • next/dynamic을 통해 무거운 UI 컴포넌트를 지연 로딩
  • 초기에 필요한 컴포넌트만 포함 → 번들 크기 감소, parsing/rendering 비용 줄임
  • SSR 우선 순위가 필요한 이미지에는 priority, loading="eager" 적용

 

❓ parsing/rendering 비용

- 브라우저가 JavaScript를 실행 가능한 상태로 만들고, 화면에 렌더링하기까지 드는 시간과 리소스

더보기

번들 크기와 브라우저 실행 과정

브라우저는 JS 번들을 받으면 다음 단계를 거칩니다:

  1. Download (다운로드)
    • 서버에서 JS 파일(예: bundle.js)을 내려받음
  2. Parse (파싱)
    • JS 파일을 **텍스트 → AST(Abstract Syntax Tree)**로 바꿈
    • JS 코드를 실행 가능한 내부 형식으로 해석함
    • 크고 복잡할수록 시간이 오래 걸림
  3. Compile (컴파일)
    • 일부 JS는 JIT(Just-In-Time) 방식으로 컴파일됨
    • V8 엔진은 이걸 실행 가능한 형태로 바꿈
  4. Execute (실행)
    • 컴포넌트의 useEffect, 렌더링 로직, 이벤트 핸들러 등이 실행됨
  5. Render (렌더링)
    • 위 JS 실행 결과에 따라 DOM이 업데이트되고 화면이 바뀜
    • React에서는 가상 DOM diff + 실제 DOM patch 과정이 포함됨

parsing/rendering 비용 = "JS 처리에 드는 리소스"

  • Parsing 비용:
    → JS 텍스트 파일을 브라우저가 해석할 때 드는 시간/CPU
    → 번들 크기가 크면 해석하는 시간도 비례해서 증가
  • Rendering 비용:
    → JS 코드에 의해 화면을 구성할 때 필요한 시간
    → React.createElement, render, reconcile, commit 과정 포함
    → 컴포넌트 수가 많거나 복잡하면 렌더링 시간도 증가

그래서 Dynamic Import가 중요한 이유

const Carousel = dynamic(() => import("./Carousel"));

이렇게 하면 해당 컴포넌트는 사용자가 실제로 볼 때만 JS로 파싱 & 렌더링하게 됩니다.

  • 초기 번들 크기 ↓
  • 파싱/렌더링 비용 ↓
  • LCP/TBT/JS execution time ↓

❓  priority, loading="eager"

-  <Image> 컴포넌트에서 priority 를 주면  = 해당 이미지를 최우선으로 프리로드(preload) 하라는 뜻

- loading="eager" 주면, = 해당 이미지를 즉시 로딩하라는 의미 ( 스크롤 위치와 무관하게 즉시 로드)

 

scroll 복원 + 부드러운 트랜지션 처리

const scrollY = sessionStorage.getItem("homeScrollY");
if (scrollY && fromSearch) {
  el?.scrollTo({ top: Number(scrollY), behavior: "auto" });
}
  • 홈 → 상세 → 홈으로 돌아올 때 UX 손실 최소화
  • scroll 위치 복원 및 section별 motion transition으로 자연스러운 UI 흐름 구현

 

 

 

이번 성능개선의 핵심은 

❝사용자가 지금 당장 필요로 하는 것만 로드하자 ❞

 

왜 모바일 환경에서는 JS 실행 시간, 렌더링 비용, 데이터 사용량이 중요한가

더보기

 

1. 하드웨어 성능이 낮기 때문

  • 모바일 기기는 데스크탑이나 노트북에 비해 CPU 성능이 상대적으로 낮다. 
  • JS 실행, DOM 렌더링, 이미지 디코딩 등 모든 작업이 더 느리게 처리됨.
  • 특히 JS가 무거우면:
    • 렌더링 블로킹 발생
    • 스크롤/터치 반응 버벅임
    • 앱이 멈춘 것처럼 보일 수 있음

📌 JS 실행 시간이 길어질수록 TBT (Total Blocking Time), Interaction Latency도 같이 증가

-> 사용자 체감 속도에 직접적인 악영향.


2. 배터리 소모가 크기 때문

  • JS 실행 및 렌더링 연산은 CPU와 GPU를 동시에 사용합니다.
  • 연산이 많을수록 배터리 사용량도 증가하며, 발열도 유발됩니다.
  • 특히 앱이 백그라운드로 가도 리소스를 계속 소비하면 사용자에게 불편을 줍니다.

📌 최적화되지 않은 JS는 사용자 기기의 배터리를 더 빨리 소모시키고, -> 앱에 대한 신뢰도 저하


3. 네트워크가 느리기 때문

  • 모바일 환경에서는 5G, LTE 같은 무선 네트워크를 사용합니다.
  • 네트워크 상태가 고르지 않거나, 데이터 요금제 제한이 있는 경우가 많습니다.
  • JS 번들 크기나 이미지가 클 경우:
    • 다운로드 시간이 길어짐 → LCP 지연
    • 데이터 요금 부담
    • 사용자 이탈 증가

📌 특히 "초기 페이지 로딩 시 1.8MB JS"는 저사양 폰 + 느린 네트워크 환경에서는 치명적입니다.


4. 모바일 UX는 '즉시 반응'이 기본 기대값이기 때문

  • 모바일 앱/웹은 즉각적인 반응성이 매우 중요합니다.
  • 터치 → 반응 지연이 100ms만 넘어가도 사용자는 앱이 느리다고 느낍니다.
  • 무거운 JS는 메인 스레드를 잠식하고, 입력 이벤트 처리까지 지연시키기 쉽습니다.

=> 그래서 모바일 환경에서는,

"보여줄 때만 보여주고, 가져올 때만 가져오고, 가볍게 처리하는 것" 이 성능 최적화의 핵심

체감 성능이 진짜 UX다

이 리팩토링을 통해 단순히 Lighthouse 점수를 높인 것이 아니라, 사용성을 개선했다. 

  • 홈 진입 시 캐러셀 이미지가 즉시 보임
  • 이벤트 카드 렌더링 시간 단축
  • 카테고리 클릭 시 즉시 반응
  • 스크롤/터치 반응 개선 → 버벅임 없는 모바일 UX

 

"빠른 것보다 필요한 것을 빠르게" 을 중요하게 느꼇고 그동안 필요한 기능을 구현하는데 집중했다면, 

사용성 좀 더 신경쓰면서 사용자가 어떤흐름으로 서비스를 이용하고 어떤 순간에 가장 민감하게 반응하는지를

간과했던 것 같다.

 

이번에 웹앱과 pwa 프로젝트를 하며 UX에 대해서 느낀것들이 있는데, 

- 모든 걸 한 번에 보여주려는 욕심은 오히려 UX를 망친다.
- 지금 필요한 것만, 지금 보여주자.
- 모바일에서는 1초의 지연이 곧 이탈이다.

 

 

'⛲ 프로젝트' 카테고리의 다른 글

이벤트 페이지 성능최적화 (LCP 37-> 2.5, CLS 0.219→0.006)  (4) 2025.08.18
10,000건 테스트, MSW 기반 대용량 가상+무한 스크롤 적용  (6) 2025.08.12
카카오톡에서 앱 설치 유도하기: PWA 외부 브라우저 리다이렉션 전략  (5) 2025.08.06
이미지 업로드부터 OCR 자동화까지, 세금계산서 등록 프로세스  (1) 2025.07.21
(husky) Husky와 Lint-staged를 이용한 Lint 자동화  (1) 2024.11.23
'⛲ 프로젝트' 카테고리의 다른 글
  • 10,000건 테스트, MSW 기반 대용량 가상+무한 스크롤 적용
  • 카카오톡에서 앱 설치 유도하기: PWA 외부 브라우저 리다이렉션 전략
  • 이미지 업로드부터 OCR 자동화까지, 세금계산서 등록 프로세스
  • (husky) Husky와 Lint-staged를 이용한 Lint 자동화
gprorogpfus
gprorogpfus
:- >
  • gprorogpfus
    gpfusdldks
    gprorogpfus
  • 전체
    오늘
    어제
    • 분류 전체보기 (55)
      • 🎭JavaScript (2)
      • 👚 CSS (1)
      • ⚛️ React (13)
      • 🌃 Next.js (5)
        • 🔜 next.js-study (3)
      • 🥏TypeScript (10)
      • 🏴알고리즘 (2)
      • 🌴트러블슈팅 (3)
      • ⛲ 프로젝트 (6)
        • 👖gproro-shop-app (8)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 글쓰기
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Redux
    react
    TypeScript
    JavaScript
    GIT
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
gprorogpfus
Home 진입 40초 → 2.5초, LCP를 93% 줄인 Next.js 최적화
상단으로

티스토리툴바