React

[React] lazy loading을 사용한 초기 렌더링 최적화

Suna[Frontend Study] 2026. 3. 17. 16:16

들어가기 전, 성능 측정에 사용했던  LightHouse에서 필요한 내용에 대해 가볍게 짚고 넘어가자.

 

 

 

FCP(First Contentful Paint)

  • 화면에 처음으로 뭔가 보이는 시점
  • 즉 최초의 DOM 콘텐츠를 렌더링 하는데 걸리는 시간.
  • 만약 FCP가 0.4면? 사이트를 들어가고 0.4초만에 보이는 것

LCP(Largest Contentful Paint)

  • 화면에서 가장 큰 콘텐츠(이미지/텍스트)가 로딩이 완료되는 시점
  • 가장 큰 영역을 차지하는 요소를 페이지의 주요 콘텐츠로 판단
  • 해당 지표를 기준으로 사용자의 페이지 로드 속도를 판단
  • 직관적으로 설명하자면, 만약 LCP가 0.9초라고 가정했을 때, “페이지가 0.9초만에 거의 다 떴네요~” 를 말 한다는 의미

LCP는 두가지에 의해 결정 됩니다.

1. 리소스가 언제 다운로드 되냐
2. 언제 화면에 그릴 수 있냐 

CLS (Cumulative Layout Shift)

  • 화면이 얼마나 흔들리는지
  • 사용자가 로딩 후 폰트 크기가 변경되거나, 예상치못한 레이아웃을 경험 하는 빈도를 정량화해서 시각적인 안정성을 판단
  • 만약 버튼을 누르려고 하는데 갑자기 바뀐다던지 ..하는 것입니다.

TBT (Total Blocking Time)

  • 차단시간(Blocking Time)은 Long Task로 인해 스레드가 오랫동안 점유되어 사용자와 상호작용 하지 못하는 시간
  • JS때문에 화면이 멈춘 시간
  • 페이지가 클릭, 키보드같은 사용자와 상호작용 하지 못했던 시간의 총 합을 측정
  • LCP에 영향을 주기도 한다.(항상 비례관계인것은 아님.)

TBT가 크면 LCP가 느려질 수 있다.

메인 스레드가 막히면 화면 렌더링 자체를 못하기 때문이다.

Speed Index

  • 뷰포트 내의 콘텐츠가 시각적으로 표시되는 진행 속도를 측정 (화면이 얼마나 빠르게 채워졌는지의 평균 속도)
  • FCP + LCP 사이의 체감 속도 느낌

FCP⇒ 처음 보이기 시작

LCP⇒ 거의  다 보임

Speed Index는 그 사이 과정이 얼마나 부드럽고 빠른지를 판단

# ai가 좋은 예시를 들어주네요.

# 🎬 영상 로딩이라고 생각해봐
FCP → 첫 프레임 뜸
LCP → 중요한 장면 다 나옴
Speed Index →
👉 영상이 “버벅 vs 부드럽게” 재생되는 느낌

Speed Index ≈ 화면이 덜 채워진 시간들의 누적 합

 

 

 

 


본론

 

lazy loading이 뭘까? 

 

지금 당장 필요하지 않은 리소스를 나중에 불러오는 최적화 기법이다. 

 

즉 , 처음 페이지 진입 시 모든 것을 다 불러오는게 아니라, 필요한 순간에만 리소스를 로딩하는 방식이다.

 

lazy loading이 없으면 일어나는 단점

 

1. 초기 로딩 시 모든 리소스를 다운로드

  • 사용자가 홈 화면만 들어왔는데도
  • 다른 페이지(JS, 컴포넌트 등)까지 전부 다운로드됨

2. 초기 렌더링 속도 저하

  • JS 번들 크기가 커짐
  • 브라우저가 한 번에 처리해야 할 양 증가 
  • 즉 첫 화면 로딩이 느려지는 LCP저하가 나타난다. 

 

즉 lazy loading은 "지금 필요한 것만 먼저 보여주자"가 핵심 아이디어이다. 

홈 → 홈에 필요한 코드만 로딩
상세 페이지 → 들어갔을 때 로딩
이미지 → 화면에 보일 때 로딩

 

 

프로젝트에서 다음과 같이 기본 라우트에서 lazy loading을 걸어주었다.

.then~으로 걸어주면 코드가 길어져서 , export default로 해당 페이지를 모두 수정한 뒤에 import 하는 방식으로만 걸어주었다.

 

 

import { OnboardingLayout } from "@/layout/OnboardingLayout";
import { PrivateRoute } from "@/layout/PrivateRoiute";
import { SystemLayout } from "@/layout/SystemLayout";

import { lazy } from "react";
import { createBrowserRouter } from "react-router-dom";

// Lazy Loading 적용
const HomePage = lazy(() => import("@/pages/home/HomePage"));
const LoginPage = lazy(() => import("@/pages/Login/LoginPage"));
const KakaoLogin = lazy(() => import("@/pages/Login/KakaoLogin"));
const Onboarding = lazy(() => import("@/pages/onboarding/Onboarding"));
const OnboardingTag = lazy(() => import("@/pages/onboarding/OnboardingTag"));
const EditInterestPage = lazy(() => import("@/pages/mypage/EditInterestPage"));
const MyIntersListPage = lazy(() => import("@/pages/mypage/MyInterstListPage"));
const SettingPage = lazy(() => import("@/pages/mypage/SettingPage"));
const AskPage = lazy(() => import("@/pages/mypage/AskPage"));
const router = createBrowserRouter([
  {
    element: <SystemLayout />,
    children: [
      { index: true, element: <HomePage /> },
      {
        element: <PrivateRoute />,
        children: [
          { path: "/edit", element: <EditInterestPage /> },
          { path: "/interested", element: <MyIntersListPage /> },
          { path: "/setting", element: <SettingPage /> },
          { path: "/ask", element: <AskPage /> },
        ],
      },
    ],
  },

  {
    element: <OnboardingLayout />,
    children: [
      {
        path: "/login",
        element: <LoginPage />,
      },
      {
        path: "/auth/callback",
        element: <KakaoLogin />,
      },
      {
        path: "/onboarding",
        element: <Onboarding />,
      },
      { path: "/onboarding/tag", element: <OnboardingTag /> },
    ],
  },
]);

export default router;

 

 

또한, cardItem에서 백엔드에서 이미지 url을 주는데 png로 주기때문에 용량이 많이 차지한다.

때문에 아래와 같이 이미지에도 lazy를 걸어주었다.

 <img
	src={thumbnailUrl}
	alt={`{${title} - 썸네일}`}
	className=" w-full h-full object-cover"
	loading="lazy"
	decoding="async"
/>

 

또한 homePage안에서도 lazy loading을 걸어주었다.

이때 homaPage 전체를 잘게 쪼개서 전부 lazy loading한 것이 아니라,
초기 화면에서 바로 필요하지 않은 컴포넌트만 선택적으로 lazy loading했다.

import Alert from "@/assets/icons/alert2.svg";
import { TAB_MAP } from "@/constants/tab";
import { useDebounce } from "@/hooks/useDebouce";
import { useGetCompany } from "@/lib/company";
import { usePostRecommendPostList } from "@/lib/recommendation";
import { CompanyFilterList } from "@/pages/home/components/CompanyFilterList";
import PostCardList from "@/pages/home/components/PostCardList";
import { TabSelectList } from "@/pages/home/components/TabSelectList";
import { ErrorBoundary } from "@/shared/ErrorBoundary";
import { Loading } from "@/shared/Loading";
import { SkeletonList } from "@/shared/SkeletonList";
import { useCompanyStore } from "@/store/uesCompanyStore";
import useUserStore from "@/store/useUserStore";
import { lazy, Suspense, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "react-toastify";

const InterestPage = lazy(() => import("@/pages/home/components/InterestPage"));

const SearchPostList = lazy(
  () => import("@/pages/home/components/SearchPostList"),
);

const HomePage = () => {
 
  const handleTabChange = (tab: number) => {
    if (tab === 1 && !isLogin) {
      toast.info("로그인이 필요한 서비스입니다.", {
        icon: <img src={Alert} alt="login으로 이동" />,
      });

      navigate("/login");

      return;
    }
    setSearchParams({});
    setSelectedTab(tab);
  };

  useEffect(() => {
    return () => {
      resetCompanies();
    };
  }, []);

  return (
    <>
      <Helmet>
        <title>
          {isSearching
            ? `"${debouncedInput}" 검색 결과 | TechFork`
            : `${TAB_MAP[selectedTab]} | TechFork`}
        </title>
        <meta property="og:title" content="기업 테크 블로그 모음 | TechFork" />
        <meta
          property="og:description"
          content="네이버, 카카오, 토스 등 최신 기술 아티클을 한눈에 확인하세요."
        />
      </Helmet>
      <div className="bg-bgPrimary  py-12" onClick={() => setModal(false)}>
        <TabSelectList
          className={
            isSearching || [2, 3].includes(selectedTab) ? "mb-20" : "mb-8"
          }
          onChange={handleTabChange}
          selected={isSearching ? null : selectedTab}
          tagList={TAB_MAP}
        />
        {isSearching ? (
          <>
            <ErrorBoundary>
              <Suspense fallback={<SkeletonList />}>
                <SearchPostList query={debouncedInput ?? ""} />
              </Suspense>
            </ErrorBoundary>
          </>
        ) : (
          <>
            {selectedTab === 0 && (
              <>
                <CompanyFilterList
                  companies={companies}
                  companyData={companyData}
                  maxCompany={maxCompany}
                  modal={modal}
                  setModal={setModal}
                  toggleCompany={toggleCompany}
                />
                <PostCardList selectedTab={0} />
              </>
            )}
            {/* 나와맞는 게시글 */}
            {selectedTab === 1 && isLogin && (
              <ErrorBoundary>
                <Suspense fallback={<Loading />}>
                  <InterestPage
                    onRefresh={postRecommendList}
                    isRefreshing={isRefreshing}
                  />
                </Suspense>
              </ErrorBoundary>
            )}

            {(selectedTab === 2 || selectedTab === 3) && (
              <PostCardList selectedTab={selectedTab} />
            )}
          </>
        )}
      </div>
    </>
  );
};

export default HomePage;

 

<포함한 이유> 

1. searchPostList만 lazyloading을 한 이유는, 검색어를 입력했을때만 렌더링 되는 부분이기때문이고 , 

2. interestPage만 lazy loading을 한 이유는 나와 맞는 게시글 탭을 눌렀을때만 필요하기 때문이다. 

게다가 로그인 상태에서만 접근 가능하므로 모든 사용자에게 즉시 필요한 UI가 아니다.

 

<안 포함한 이유> 

3. 반면 TabSelectList는 홈에 진입했을 때 가장 먼저 보이는 탭 UI이며,
CompanyFilterList 또한 기본 탭에서 즉시 노출되는 필터 영역이기 때문에 lazy loading 대상에서 제외했다.
이러한 요소들은 초기 렌더링(LCP)에 직접적인 영향을 주기 때문에 지연 로딩 시 오히려 사용자 경험이 저하될 수 있다.

 

4. PostCardList는 기본 탭(selectedTab === 0)에서 첫 화면의 핵심 콘텐츠로 렌더링되기 때문에 lazy loading을 적용하지 않았다.

이때 PostCardList가 InterestPage 내부에서도 재사용되지만,
이는 lazy loading 여부와는 별개의 문제이다.

InterestPage 자체는 lazy loading을 통해 필요 시점에만 로드되므로,
해당 페이지에 포함된 UI 역시 함께 늦게 로드되는 구조를 가진다.
따라서 공통 컴포넌트를 재사용한다고 해서 lazy loading 전략이 깨지는 것은 아니다.

오히려 PostCardList는 홈의 핵심 콘텐츠이기 때문에 초기 렌더링에 포함되는 것이 자연스럽고,
이후 다른 탭이나 페이지에서 동일한 UI를 재사용하는 것은 코드 재사용성과 유지보수 측면에서 더 유리하다.

 

Suspense와 lazyLoading

 

lazy loading을 한다면 suspense도 같이 해주어야한다.

하지만 나는 routes에서 suspense를 적용하지 않았다. 

 

그 이유는 

라우트 전체에 Suspense를 일괄 적용하는 대신,
실제로 지연 로딩이 필요한 컴포넌트 주변에만 Suspense를 배치했다.
그 이유는 현재 데이터 패칭에 useSuspenseQuery를 사용하고 있어
Suspense 경계가 이미 데이터 로딩 제어에도 활용되고 있기 때문이다.

만약 routes 전체를 다시 Suspense로 감싸면
작은 데이터 로딩이나 일부 lazy 컴포넌트 로딩에도 페이지 전체가 fallback으로 대체될 수 있다.
따라서 현재 구조에서는 페이지 전체를 막기보다,
각 컴포넌트 단위에서 필요한 로딩 UI를 세분화해 보여주는 방식이 더 적합했다.

 

Lazy Loading 적용 전후 성능 비교

이번 비교는 모바일 Lighthouse 기준으로 진행했고,
각각 3회 측정값을 기준으로 확인했다.

 

 

데스크탑 웹인데  모바일을 기준으로 한 이유는 ,

데스크탑 환경에서도 측정을 진행했지만, 이미 대부분의 지표가 90점 이상으로 높게 나타나 성능 차이가 크게 드러나지 않았다.

(보통 데스크탑이 CPU나 네트워크 환경이 더 안정적이고 리소스 처리 여유가 크기 때문..)

이러한  이유로 데스크탑 보다는 CPU등의 성능이 더 안좋은 "모바일" 기준이  성능 병목이 더 쉽게 발생하며, 최적화 기법의 효과가 더 명확하게 드러나기 때문에, 모바일뷰를 선택하였다. 

 

  • 첫 번째 이미지: lazy loading 미적용
  • 두 번째 이미지: 이미지 + 컴포넌트에 lazy loading 적용

🟠🟠🟠🟠

 

 

🟠🟠🟠🟠 

 

 

1) Lazy Loading 미적용

Performance Score 70 67 71 69.3
FCP 2.0s 2.0s 2.0s 2.0s
LCP 3.5s 3.4s 3.5s 3.47s
TBT 710ms 880ms 640ms 743ms
Speed Index 4.0s 4.2s 4.2s 4.13s

 

 

2) 이미지 + 컴포넌트 Lazy Loading 적용


Performance Score 72 78 74 74.7
FCP 2.2s 2.1s 2.1s 2.13s
LCP 3.8s 3.6s 3.7s 3.70s
TBT 430ms 290ms 410ms 377ms
Speed Index 5.2s 5.1s 4.8s 5.03s

 

결과를 보면 TBT의 부담, 즉 메인 스레드의 부담은 크게 줄었고, 그 결과 전체 성능 점수는 상승했다. 

다만 그 외의 지표는 FCP, LCP,Speed Index는 오히려 조금 느려졌다.

 

왜그럴까? 

 

TBT는 브라우저가 초기 로딩 시 긴 JS 작업 때문에 사용자 입력을 바로 처리하지 못한 시간을 의미한다.

 

즉 ,위에서 말했던 것처럼, lazy loading을 적용하면서  필요한 시점에만 해당 코드 청크를 로딩하기 때문에, 초기 번들 크기와

파싱/실행 부담이 줄어든다. 

 

그 결과 JS실행이 한번에 몰리지 않아서, 메인 스레드 blocking이 감소하고  TBT가 개선된것이다.

 

이번에 lazy loading을 하면서 "초기 JS의 부담이 완화" 된 것을 확인할 수 있었다.

 

 

왜 FCP,LCP,Speed Index는 나빠졌을까? 

 

 

아까 이미지에도 lazy를 달아주었다고 언급하였다.

하지만 그 화면은 homepage에 바로 불러져오는 Img였다.(위에 화면 참고)

즉, 초기 화면에서 바로 보여야하는 요소였다는 의미이다.

 

하지만 이미지에 lazy loading을 적용하면 브라우저는 해당 리소스를 즉시 요청하지 않고,

필요한 시점에 지연 로딩하도록 동작한다.

그 결과, 초기 HTML 파싱 직후 바로 표시되어야 할 이미지가

추가적인 로딩 시점 이후에 렌더링되면서, 시각적으로 콘텐츠가 완성되는 시점(LCP)이 지연되는 문제가 발생한 것이다.

 

 

그렇다면 LCP는 왜 증가했을까?

LCP는 다음과같은 두가지에 의해 결정된다.

1. 리소스가 언제 다운로드 되냐
2.언제 화면에 그릴 수 있냐

이 중 , TBT는 2) 에 영향을 미칩니다.

하지만 lazy loading은 1번을 바꿔버리는 겁니다 (요청 타이밍 자체를 늦추기 때문에)

 

lazy 전

이미지 바로 요청됨
=> 다운로드 시작 빠름
=> JS가 좀 막긴 하지만
=> 그래도 빨리 도착해서 LCP 빠름

lazy 후

이미지 요청 자체가 늦어짐
=> 다운로드 시작 늦음 
=> 대신 JS 부담 줄어서 TBT는 감소 

 

때문에 결과적으로 밑에와 같이 되는 것이다. 

lazy로 JS 부담 줄임 → TBT 개선 
근데 LCP 이미지까지 lazy → LCP 악화

 

 

 

 

lazy loading을 잘 활용해야한다.

lazy loading은 JS blocking을 줄이는 데는 효과적이지만,
반대로 어떤 요소는 조금 뒤에 도착해서 렌더링될 수 있으며, 이는 곧 시각적 로딩 성능이 떨어짐을 의미한다. <==이 부분이 단점이 될 수도 있다. 

 

🟠🟠🟠🟠 

 

 

 

3) 컴포넌트만 Lazy Loading 적용


Performance Score 77 74 75 75.3
FCP 2.0s 2.1s 2.1s 2.07s
LCP 3.5s 3.6s 3.6s 3.57s
TBT 480ms 550ms 520ms 517ms
Speed Index 2.8s 3.1s 2.9s 2.93s

 

결론적으로 이미지 lazy loading을 제거한게 , 이미지+컴포넌트 lazy loading을 추가 한 것보다 ,

  • Performance Score가 가장 높았고
  • TBT는 약간 높아졌지만, 그래도 lazy 미적용 대비 충분히 개선됐고
  • LCP와 Speed Index 같은 시각적 성능도 더 안정적이다.

 

[ lazy X     VS  컴포넌트에만 lazy   ] 의 최종 성능 비교 

1. Performance Score

  • 69.3 → 75.3
  • +6.0 상승
  • 약 +8.7% 개선

2. TBT (Total Blocking Time)

  • 743ms → 517ms
  • -226ms 감소
  • 약 30.4% 개선

3. Speed Index

  • 4.13s → 2.93s
  • -1.20초 감소
  • 약 29.1% 개선

결론 

 

세 가지 방식 중 가장 좋은 결과를 보인 것은 컴포넌트만 lazy loading을 적용한 경우이다.

초기 화면에서 바로 보여야 하는 이미지에는 lazy loading을 적용하지 않고, 검색 결과 목록이나 관심 게시글처럼 특정 조건에서만 필요한 컴포넌트만 지연 로딩한 결과, Lighthouse 성능 점수는 가장 높게 나타났다.

또한 TBT는 lazy 미적용 대비 개선되었고, LCP와 Speed Index 역시 이미지까지 lazy loading했을 때보다 더 좋은 수치를 보였다. 이를 통해 lazy loading은 모든 리소스에 일괄 적용하기보다, 초기 화면에 꼭 필요한 요소와 그렇지 않은 요소를 구분해 선택적으로 적용해야 효과적이라는 점을 확인할 수 있었다.

 

따라서 lazy loading은
모든 요소에 일괄 적용하기보다,

  • 라우트 단위 페이지
  • 화면 아래쪽 이미지
  • 바로 열리지 않는 모달
  • 초기 렌더링에 필요하지 않은 컴포넌트

같은 대상에 우선 적용하는 것이 더 효과적이다.