React 최적화 패턴 노트 (1)

일어나 개발해야지·2026년 1월 4일

React

목록 보기
7/7

Intro

이 글은 React를 사용하면서 아직 익숙하지 않지만
“언젠가는 반드시 필요해지는 패턴들”을 정리한 메모이다.

callback ref, lazy, portal, useLayoutEffect처럼
일상적인 CRUD 화면에서는 잘 드러나지 않지만
타이밍·성능·레이아웃 문제가 발생하는 순간
선택지를 넓혀주는 도구들을 중심으로 다룬다.

1.callback ref

ref 속성에 useRef 대신 함수를 전달하는 패턴
DOM이 생성되는 시점에 즉시 실행가능 하다

// useRef
  const inputRef = useRef(null);

// callback ref
  const inputCallbackRef = (node) => {
    if (node) {
      node.focus(); // DOM 붙는 즉시 실행 보장
    }
  };

1-1. 언제 필요한가

조건부 렌더링처럼 DOM이 동적으로 생기는 상황에서 정확한 타이밍 포착이 필요할 때

1-2. 코드 예제

// useRef + useEffect

function Home() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus(); // 타이밍 이슈 가능
  }, []);

  return <input ref={inputRef} />;
}

//  callback ref
function Home() {
  const inputCallbackRef = (node) => {
    if (node) {
      node.focus(); // DOM 붙는 즉시 실행 보장
    }
  };

  return <input ref={inputCallbackRef} />;
}

1-3. 동작 방식 비교 (useRef & callback ref)

두 방식 모두 JSX가 평가되는 시점에 요소를 DOM에 연결한다는 점은 같다.
하지만 focus 되는 타이밍이 다르다.
따라서 조건부 렌더링처럼 DOM이 동적으로 생기는 상황에서는 callback ref로 보다 정확하게 타이밍을 포착할 수 있다

  • useRef + useEffect : paint 이후
  • callback ref : paint 이전

useRef + useEffect

1. JSX 평가
2. DOM 연결
3. paint
4. useEffect 실행 → focus()

callback ref

1. JSX 평가
2. DOM 연결 → 즉시 callback 실행 → focus()
3. paint

2. lazy

  • 번들링이 사용된 경우, 초기 화면에 필요하지 않은 JS 파일의 다운로드를 미루기 위한 코드 스플리팅 방식이다.
  • 각 페이지에 필요한 단위로 JS 파일을 분리 후, 불필요한 JS의 다운로드는 뒤로 미룸으로 초기로딩 시간을 줄일 수 있다.
// 일반 import
import HeavyComponent from './HeavyComponent'; // 초기 번들에 포함

// lazy import
const HeavyComponent = lazy(() => import('./HeavyComponent')); // 별도 chunk로 분리

관련 개념

  • 번들링 : 네트워크 요청을 한번에 받아올 수 있도록 파일을 합치는 작업
  • 코드 스플리트 : 합쳐진 코드를 필요한 단위로 분리

2-1. 언제 필요한가

초기 화면에 보이지 않는 무거운 컴포넌트의 로딩을 지연시켜 초기 로딩 시간을 단축할 때

  • 초기 화면에 필요 없는 무거운 컴포넌트
  • 조건부로 렌더링 되는 컴포넌트
  • 라우트별 페이지 컴포넌트

2-2. 코드 예제

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function Dashboard() {
  const [showChart, setShowChart] = useState(false);
  
  return (
    <>
      <Button onPress={() => setShowChart(true)} />
      
      {showChart && (  // false면 로드 안 함
        <Suspense fallback={<Loading />}>
          <HeavyComponent />
        </Suspense>
      )}
    </>
  );
}

2-3. 동작 방식 비교 (일반 import & lazy)

두 방식 모두 컴포넌트를 불러온다는 점은 같다.
하지만 다운로드 타이밍과 번들 구성이 다르다.
따라서 초기 화면에 불필요한 컴포넌트는 lazy로 로딩을 지연시켜 초기 번들 크기를 줄일 수 있다

  • 일반 import : 앱 시작 시 (초기 번들에 포함)
  • lazy : 컴포넌트가 실제로 렌더링되는 시점

일반 import

1. 번들러가 빌드 시 모든 파일을 main.bundle.js로 합침
2. 앱 시작
3. main.bundle.js 다운로드 (HeavyChart 포함)
4. 앱 렌더링 시작
5. showChart === true 시 즉시 표시

lazy

1. 번들러가 빌드 시 HeavyChart를 별도 chunk로 분리
   → main.bundle.js, HeavyChart.chunk.js
2. 앱 시작
3. main.bundle.js 다운로드 (HeavyChart 제외)
4. 앱 렌더링 시작
5. showChart === true 시
   → HeavyChart.chunk.js 다운로드 시작
   → Suspense fallback 표시
   → 다운로드 완료 후 컴포넌트 표시

lazy는 "다운로드 타이밍"을 제어한다는 점에서 성능 최적화의 다른 측면을 다룬다고 볼 수 있다.

3. 게으른 초기화(Lazy Initialization)

  • useState의 초기값 계산을 컴포넌트 마운트 시 한 번만 실행하도록 지연시키는 최적화 기법
  • 초기값 계산 비용이 클 때, 함수를 전달하여 불필요한 재계산을 방지할 수 있음
// 일반 초기화 - 매 렌더링마다 계산
const [state, setState] = useState(expensiveCalculation());

// 게으른 초기화 - 마운트 시 한 번만 계산
const [state, setState] = useState(() => expensiveCalculation());

3-1. 언제 필요한가

useState의 초기값 계산 비용이 클 때, 불필요한 재계산을 방지하여 성능을 최적화할 때

  • localStorage에서 데이터를 읽어올 때
  • 복잡한 배열/객체 변환 작업이 필요할 때
  • 무거운 계산이 초기값에 필요할 때
  • props를 기반으로 복잡한 초기 상태를 만들 때

3-2. 코드 예제

// 일반 초기화 - 매 렌더링마다 localStorage 접근
function TodoList() {
  const [todos, setTodos] = useState(
    JSON.parse(localStorage.getItem('todos')) || []
  ); // 리렌더링마다 실행됨
  
  return <div>{todos.length}개의 할일</div>;
}

// 게으른 초기화 - 마운트 시 한 번만 localStorage 접근
function TodoList() {
  const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem('todos');
    return saved ? JSON.parse(saved) : [];
  }); // 마운트 시 한 번만 실행
  
  return <div>{todos.length}개의 할일</div>;
}

3-3. 동작 방식 비교 (일반 초기화 & 게으른 초기화)

두 방식 모두 state를 초기화한다는 점은 같지만, 초기값 계산 실행 빈도가 다르다.
따라서 초기값 계산 비용이 큰 경우 게으른 초기화로 불필요한 계산을 방지할 수 있다

  • 일반 초기화 : 매 렌더링마다 계산 실행 (결과는 마운트 시에만 사용)
  • 게으른 초기화 : 마운트 시 한 번만 계산 실행

일반 초기화

1. 컴포넌트 렌더링
2. expensiveCalculation() 실행 → 결과 계산
3. useState가 이미 초기화되어 있으면 결과 버림
4. 기존 state 값 사용
(부모가 리렌더링할 때마다 2-4 반복)

게으른 초기화

1. 컴포넌트 첫 렌더링 (마운트)
2. useState가 함수 실행 → expensiveCalculation() 실행
3. 결과를 state 초기값으로 설정
4. 이후 렌더링에서는 함수 실행 안 함
(부모가 리렌더링해도 계산 안 함)

4. portal

컴포넌트를 부모 DOM 바깥에 렌더링하는 기능.
JSX는 부모 컴포넌트 안에 작성하지만, 실제 DOM은 지정한 다른 위치에 생성됨

4-1. 언제 필요한가

모달, 다이얼로그: 부모의 overflow: hidden이나 z-index에 영향받지 않아야 할 때
툴팁, 드롭다운: 부모 영역 밖으로 튀어나와야 할 때
토스트 알림: 화면 최상단에 고정되어야 할 때

4-2. 코드 예제

import { createPortal } from 'react-dom';

function Modal({ children, isOpen }) {
  if (!isOpen) return null;
  
  return createPortal(
    <div className="modal">{children}</div>,
    document.getElementById('modal-root')
  );
}

function App() {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <div style={{ overflow: 'hidden' }}>
      <button onClick={() => setIsOpen(true)}>모달 열기</button>
      <Modal isOpen={isOpen}>
        모달 내용 {/* overflow: hidden 영향 안 받음 */}
      </Modal>
    </div>
  );
}

5. React 메모이제이션

불필요한 리렌더링을 방지하기 위해 함수, 값, 컴포넌트의 참조를 유지하는 최적화 기법
useCallback(함수), useMemo(값), React.memo(컴포넌트) 세 가지를 함께 사용해야 효과가 있다

  • useCallback : 함수의 참조를 유지
  • useMemo : 의 참조를 유지
  • React.memo : Props가 변경될때만 리렌더링

5-1. 언제 필요한가

  • 리렌더링은 기본적으로 React의 정상동작이며
  • "리렌더링이 많다 ≠ 최적화 필요" 라는 점을 확인해야한다.
  • 하지만 자식 컴포넌트가 무거운 렌더링 로직을 가지거나
  • React DevTools Profiler 병목이 확인된 경우 React 메모이제이션이라는 방식으로 최적화가 가능하다
  • React 18 이후에는 React Compiler(실험적) 를 통해 메모이제이션을 자동으로 처리하려는 시도가 등장했다.

5-2. 코드 예제

function Parent() {
  const [count, setCount] = useState(0);
  
  // useCallback: 함수 참조 유지
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);
  
  // useMemo: 객체 참조 유지
  const style = useMemo(() => ({
    color: 'red'
  }), []);
  
  return (
    <>
      <button onClick={() => setCount(count + 1)}>+</button>
      <Child onClick={handleClick} style={style} />
    </>
  );
}

// React.memo: props 변경 시에만 리렌더링
const Child = React.memo(({ onClick, style }) => {
  console.log('Child 렌더링');  // count 변경해도 출력 안 됨
  return <button onClick={onClick} style={style}>클릭</button>;
});

5-3. 주의

  • 계산결과를 로컬어딘가에 저장해야하므로 메모리를 소비함
  • 단순 기능추가가 아닌 복잡도를 증가시키는 요인이라는 점을 인지하는게 중요

참고: React 리렌더링 조건 5가지

1. state 변경*
2. props 변경
3. 부모 컴포넌트가 렌더링될 때
4. forceUpdate() 호출할 때
5. useSyncExternalStore의 외부 객체 값이 변경될 때(React 18+)

6. useLayoutEffect

paint와 useEffect 이전에 실행되는 Effect이다.

useLayoutEffect(() => {
  const rect = ref.current.getBoundingClientRect();
  setPosition({ x: rect.left, y: rect.top }); // 깜빡임 없음
}, []);
[실행 순서]
1. JSX 평가
2. DOM 연결
3. 🔵 useLayoutEffect 실행 (paint 전, 동기)
4. paint
5. 🟡 useEffect 실행 (paint 후, 비동기)

6-1. 언제 필요한가

  • DOM 크기/위치 측정 후 즉시 스타일 변경 (깜빡임 방지)
  • 스크롤 위치 조정
  • 툴팁/팝오버 위치 계산
  • 애니메이션 초기값 설정

6-2. 코드 예제

// useEffect: 깜빡임 발생 가능
function Tooltip() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const ref = useRef(null);
  
  useEffect(() => {
    const rect = ref.current.getBoundingClientRect();
    setPosition({ x: rect.left, y: rect.top - 30 });
  }, []);  // paint 후 실행 → 위치 변경이 보임
  
  return <div ref={ref} style={{ left: position.x, top: position.y }}>툴팁</div>;
}

// useLayoutEffect: 깜빡임 없음
function Tooltip() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const ref = useRef(null);
  
  useLayoutEffect(() => {
    const rect = ref.current.getBoundingClientRect();
    setPosition({ x: rect.left, y: rect.top - 30 });
  }, []);  // paint 전 실행 → 위치 변경이 안 보임
  
  return <div ref={ref} style={{ left: position.x, top: position.y }}>툴팁</div>;
}

6-3. 동작 방식 비교 (useEffect & useLayoutEffect)

두 방식 모두 부수 효과를 처리한다는 점은 같다.
하지만 실행 타이밍과 동기/비동기 여부가 다르다.
따라서 DOM 측정 후 즉시 반영이 필요한 경우 useLayoutEffect로 깜빡임을 방지할 수 있다

  • useEffect : paint 후 (비동기)
  • useLayoutEffect : paint 전 (동기)

useEffect

1. JSX 평가
2. DOM 연결
3. paint (초기 위치로 화면에 표시)
4. useEffect 실행 → DOM 측정 → setPosition
5. 리렌더링
6. paint (새 위치로 화면에 표시) → 깜빡임 발생

useLayoutEffect

1. JSX 평가
2. DOM 연결
3. useLayoutEffect 실행 → DOM 측정 → setPosition (동기)
4. 리렌더링 (paint 전에 완료)
5. paint (새 위치로 화면에 표시) → 깜빡임 없음

6-4. 주의

useLayoutEffect는 동기적이라 무거운 로직 넣으면 렌더링 블로킹됨
대부분의 경우 useEffect로 충분
DOM 측정 → 즉시 반영이 필요할 때만 사용

참고 자료

0개의 댓글