리액트 최적화: React.lazy, debounce, useCallback로 성능 끌어올리기

Yujin Jung·2025년 12월 21일

리액트로 서비스를 만들다 보면 어느 순간부터 초기 로딩이 느려지고, 입력할 때 버벅이며, 컴포넌트가 필요 이상으로 리렌더링되는 문제를 겪게 된다.

이 글에서는 리액트 최적화 기법 3가지를 중심으로 “왜 필요한지 → 언제 쓰는지 → 어떻게 적용하는지”를 코드와 함께 정리해보겠다.

  • React.lazy: 초기 번들 크기를 줄이는 코드 스플리팅
  • debounce: 입력/이벤트 폭주를 막는 제어 기법
  • useCallback: 불필요한 리렌더링을 줄이는 함수 메모이제이션

✔️ React.lazy: 초기 로딩을 가볍게 만드는 코드 스플리팅

왜 React.lazy가 필요할까?

리액트 앱은 기본적으로 한 번에 모든 JS 번들을 내려받아 실행한다.
페이지 수가 많아질수록, 또는 차트·에디터·지도 같은 무거운 컴포넌트가 많아질수록 첫 화면을 보기까지의 시간(LCP) 이 급격히 늘어난다.

하지만 모든 컴포넌트가 첫 화면에 꼭 필요하지는 않다. 예를 들어

  • 관리자 페이지
  • 결제 페이지
  • 무거운 모달
  • 통계/차트 페이지

이런 컴포넌트는 “필요해질 때 로드” 하는 것이 훨씬 효율적이다.


React.lazy 기본 사용법

import React, { Suspense } from "react";

const HeavyChart = React.lazy(() => import("./HeavyChart"));

export default function Dashboard() {
  return (
    <Suspense fallback={<div>차트 로딩 중...</div>}>
      <HeavyChart />
    </Suspense>
  );
}
  • React.lazy → 동적 import
  • Suspense → 로딩 중 보여줄 UI 처리

👉 초기 번들에는 HeavyChart가 포함되지 않고, 실제 렌더링 시점에 로드된다.


라우트 단위로 분리하면 효과가 가장 크다

import React, { Suspense } from "react";
import { Routes, Route } from "react-router-dom";

const Home = React.lazy(() => import("./pages/Home"));
const Admin = React.lazy(() => import("./pages/Admin"));
const Payment = React.lazy(() => import("./pages/Payment"));

export default function App() {
  return (
    <Suspense fallback={<div>페이지 로딩 중...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/admin" element={<Admin />} />
        <Route path="/payment" element={<Payment />} />
      </Routes>
    </Suspense>
  );
}

체감 할 수 있는 것

  • 첫 화면 로딩 속도 개선
  • Network 탭에서 JS 파일이 여러 chunk로 나뉘는 것을 확인 가능

모달도 lazy로 분리하면 UX가 좋아진다

import React, { useState, Suspense } from "react";

const ImageEditorModal = React.lazy(() => import("./ImageEditorModal"));

export default function Page() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button onClick={() => setOpen(true)}>모달 열기</button>

      {open && (
        <Suspense fallback={<div>모달 준비 중...</div>}>
          <ImageEditorModal onClose={() => setOpen(false)} />
        </Suspense>
      )}
    </>
  );
}

👉 모달을 열 때만 무거운 라이브러리를 로드하기 때문에 초기 진입 속도와 전체 UX 모두 개선된다.


✔️ debounce: 입력 이벤트 폭주를 막는 방법

debounce가 필요한 이유

검색창에서 키를 누를 때마다 API를 호출하면:

  • 타이핑 10번 → API 10번
  • 네트워크 낭비
  • 응답 순서 꼬임 가능성
  • UX 저하

와 같은 현상이 일어날 수 있다.

👉 debounce는 “마지막 입력 후 일정 시간 동안 추가 입력이 없을 때만 실행” 되도록 만든다.


debounce 구현 예시

import { useEffect, useState } from "react";

export default function SearchBox() {
  const [keyword, setKeyword] = useState("");
  const [debouncedKeyword, setDebouncedKeyword] = useState("");

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedKeyword(keyword);
    }, 400);

    return () => clearTimeout(timer);
  }, [keyword]);

  useEffect(() => {
    if (!debouncedKeyword) return;
    console.log("API 호출:", debouncedKeyword);
  }, [debouncedKeyword]);

  return (
    <input
      value={keyword}
      onChange={(e) => setKeyword(e.target.value)}
      placeholder="검색어 입력"
    />
  );
}
  • keyword → 즉시 반영
  • debouncedKeyword → 입력 멈춘 뒤 확정
  • API 호출은 debounced 값 기준

커스텀 훅으로 재사용하기

import { useEffect, useState } from "react";

export function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}
const debouncedKeyword = useDebouncedValue(keyword, 400);

👉 검색, 필터, 자동완성 등 거의 모든 입력 UX에 활용 가능하다.


✔️ useCallback: 불필요한 리렌더링을 막는 함수 메모이제이션

문제 상황: 함수는 매 렌더마다 새로 만들어진다

const handleClick = () => {
  setCount(count + 1);
};

이 함수는 컴포넌트가 렌더링될 때마다 새로 생성된다.
이걸 props로 내려주면, 자식 컴포넌트는 “props가 바뀌었다”고 인식하고 다시 렌더링된다.


useCallback 없이 발생하는 리렌더

const Child = React.memo(({ onClick }) => {
  console.log("Child 렌더링");
  return <button onClick={onClick}>+</button>;
});

부모의 다른 state가 바뀌어도

  • 함수 참조 변경
  • Child 리렌더 발생

이 일어난다.


useCallback으로 해결하기

import { useCallback, useState } from "react";

const Child = React.memo(({ onClick }) => {
  return <button onClick={onClick}>+</button>;
});

export default function Parent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("");

  const handleClick = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []);

  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <Child onClick={handleClick} />
    </>
  );
}
  • 함수 참조 유지
  • React.memo와 함께 사용할 때 효과 극대화
  • prev 패턴으로 의존성 배열 단순화
profile
매일매일 조금씩 성장하려 노력하는 프론트엔드 개발자입니다!

0개의 댓글