React/타입스크립트로 배우는 리액트(React.js) : 기초부터 최신 기술까지

섹션 12. 사이드 이펙트와 컴포넌트 최적화

favor 2026. 5. 22. 02:48

useId

웹 접근성(Web Accessibility) : 모든 사용자가 어떤 신체적, 기술적 조건에 구애받지 않고 웹사이트의 모든 정보에 동등하게 접근하고 이해할 수 있도록 보장하는 것 

 

<input>의 id 속성과 <label>의 htmlFor 속성을 동일한 값으로 매칭하여 참조관계 생성 

  • 스크린 리더가 인풋창을 읽을 때 라벨 텍스트를 함께 읽어줌
  • 라벨 텍스트를 클릭해도 인풋창에 자동으로 포커스 --> UX 향상

 

useId 훅 : 컴포넌트 내부에 고유하고 독립적인 id 값을 생성하기 위한 리액트 내장 훅 

--> 컴포넌트가 여러 번 렌더링되어도 리액트가 알아서 서버와 클라이언트 모두에서 겹치지 않는 안전한 고유 ID 문자열을 뱉어줌 

 

// App.tsx

import Checkbox from "./components/Checkbox";

export default function App() {
  return (
    <>
      <Checkbox />
      <Checkbox />
      
    </>
  );
}
// Checkbox.tsx

import { useId } from "react";

export default function Checkbox() {

  const uuid = useId();

  return (
    <div>
      <input type="checkbox" id={uuid} />
      <label htmlFor={uuid}>사과</label>
    </div>
  );
}

 

할 일 관리 앱(useId)

기존 코드

// Checkbox.tsx

type CheckboxProps = Omit<React.ComponentPropsWithoutRef<"input">, "type"> & {
  type?: "checkbox";
  parentClassName: string;
};
export default function Checkbox(props: CheckboxProps) {
  const { parentClassName, children, ...rest } = props;
  return (
    <>
      <div className={parentClassName}>
        <input {...rest} />
        <label>{children}</label>
      </div>
    </>
  );
}

 

useId 사용 코드 

// Checkbox.tsx

import { useId } from "react";

type CheckboxProps = Omit<React.ComponentPropsWithoutRef<"input">, "type"> & {
  type?: "checkbox";
  parentClassName: string;
};
export default function Checkbox(props: CheckboxProps) {
  const { parentClassName, children, ...rest } = props;
  const uuid = useId();
  return (
    <>
      <div className={parentClassName}>
        <input id={uuid} {...rest} />
        <label htmlFor={uuid}>{children}</label>
      </div>
    </>
  );
}

 

useEffect 이론

컴포넌트의 주된 목적 : 컴포넌트에서 반환하는 JSX 요소를 바탕으로 화면을 렌더링하는 것

 

사이드 이펙트(Side Effect) : 화면 렌더링을 제외한 다른 작업들 

ex) API 통신, 로컬&세션 스토리지 작업, 외부 라이브러리 연동

 

useEffect(effect함수, 의존성배열([]))

  • 의존성 배열에 따라 effect 함수를 호출

 

effect 함수를 호출하는 세 가지 상황

  1. 컴포넌트 생성(=마운트)
  2. 컴포넌트 삭제(=언마운트)
  3. 컴포넌트 변경(=업데이트)

 

useEffect 실습

// Counter.tsx

import { useEffect, useState } from "react";

export default function Count() {
  const [count, setCount] = useState(0);

  // 의존성 배열이 비어있다면) 컴포넌트가 생성될 때만 호출됨 
  useEffect(() => {
    console.log("Count 컴포넌트 생성");

    // useEffect 내부 return) 컴포넌트가 삭제될 때만 호출됨
    return () => {
      console.log("Count 컴포넌트 삭제");
    }
  }, []);

  // 의존성 배열에 상태 변경을 감지하고 싶은 값을 넣는다면) 컴포넌트 상태가 변경될 때만 호출됨 
  useEffect(() => {console.log("Count Change : " + count)}, [count]);

  return (
    <>
      <h1>Count: {count} </h1>
      <button onClick={() => setCount((count) => count + 1)}>증가</button>
    </>
  );
}

 

할 일 관리 앱(useEffect)

새로고침을 해도 값을 유지하게 하는 방법 : 데이터베이스 사용 or 로컬 세션 스토리지 or 쿠키 

 

기존 코드

// Todo.tsx

import { useEffect, useState } from "react";
import TodoEditor from "./TodoEditor";
import TodoHeader from "./TodoHeader";
import TodoList from "./TodoList";

export default function Todo() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const addTodo = (text: string) => {
    setTodos((todos) => [
      ...todos,
      {
        id: Date.now(),
        text,
        completed: false,
      },
    ]);
  };
  const toggleTodo = (id: number) => {
    setTodos((todos) =>
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };
  const deleteTodo = (id: number) => {
    setTodos((todos) => todos.filter((todo) => todo.id !== id));
  };
  const modifyTodo = (id: number, text: string) => {
    setTodos((todos) =>
      todos.map((todo) => (todo.id === id ? { ...todo, text } : todo))
    );
  };

  return (
    <>
      <div className="todo">
        <TodoHeader />
        {/* 할 일 등록  */}
        <TodoEditor addTodo={addTodo} />
        {/* 할 일 목록  */}
        <TodoList
          todos={todos}
          toggleTodo={toggleTodo}
          deleteTodo={deleteTodo}
          modifyTodo={modifyTodo}
        />
      </div>
    </>
  );
}

 

useEffect 사용 코드 

// Todo.tsx

import { useEffect, useState } from "react";
import TodoEditor from "./TodoEditor";
import TodoHeader from "./TodoHeader";
import TodoList from "./TodoList";

export default function Todo() {
  const [todos, setTodos] = useState<Todo[]>(JSON.parse(localStorage.getItem("todos") || "[]"));
  const addTodo = (text: string) => {
    setTodos((todos) => [
      ...todos,
      {
        id: Date.now(),
        text,
        completed: false,
      },
    ]);
  };
  const toggleTodo = (id: number) => {
    setTodos((todos) =>
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };
  const deleteTodo = (id: number) => {
    setTodos((todos) => todos.filter((todo) => todo.id !== id));
  };
  const modifyTodo = (id: number, text: string) => {
    setTodos((todos) =>
      todos.map((todo) => (todo.id === id ? { ...todo, text } : todo))
    );
  };

  useEffect(() => {
    // console.log("todos 변경됨");
    localStorage.setItem("todos", JSON.stringify(todos))
  }, [todos]);

  return (
    <>
      <div className="todo">
        <TodoHeader />
        {/* 할 일 등록  */}
        <TodoEditor addTodo={addTodo} />
        {/* 할 일 목록  */}
        <TodoList
          todos={todos}
          toggleTodo={toggleTodo}
          deleteTodo={deleteTodo}
          modifyTodo={modifyTodo}
        />
      </div>
    </>
  );
}

 

컴포넌트 리렌더링 조건

리렌더링 : React에서 컴포넌트가 다시 렌더링되는 것

 

리렌더링이 발생하는 세 가지 경우

  1. 컴포넌트의 정의한 상태가 변경되었을 때
  2. 컴포넌트로 전달하는 props가 변경되었을 때
  3. 부모 컴포넌트가 리렌더링 되었을 때 

 

React.memo

불필요한 리렌더링 : 화면에 실질적인 데이터 변화가 없음에도 불구하고 렌더링이 발생하는 현상

ex) 부모 컴포넌트가 리렌더링되면, 자식 컴포넌트는 props의 변경 여부와 상관 없이 무조건 다시 호출됨 

--> 컴포넌트 구조가 복잡하거나 자식 컴포넌트의 개수가 많을 경우 성능 저하로 이어질 수 있음

 

React.memo : 함수형 컴포넌트를 메모이제이션(memoization)하여 컴포넌트 자체를 메모리에 저장해두는 도구 

 

React.memo 작동 방식

  1. 컴포넌트가 새로운 props를 받았을 때, 이전 props와 새로운 props를 얕은 비교
  2. 두 props가 동일하다면, 컴포넌트 함수를 다시 실행하지 않고 메모리에 저장해둔 이전 렌더링 결과를 그대로 재사용
  3. props가 변경되었다면, 컴포넌트 함수를 실행하여 화면을 다시 그림 

 

// ChildA.tsx

import React from "react";
import ChildB from "./ChildB";

export default React.memo(function ChildA() {
  console.log("ChildA");
  return (
    <>
      <h1>ChildA Component</h1>
      <ChildB />
    </>
  );
});

 

React.memo가 풀리는 순간 

React.memo를 통해 컴포넌트를 메모리에 저장해두었더라도, 아래 조건이 만족되면 메모리가 해제되고 리렌더링이 수행됨

  1. 컴포넌트 자체의 상태가 변경될 때
  2. 컴포넌트로 전달되는 props의 값이 변경될 때 

 

useCallback

useCallback 훅 : 컴포넌트 내부의 함수 자체를 메모이제이션하여, 리렌더링 시 함수가 불필요하게 새로 생성되는 것을 방지

ㄴ 리액트 컴포넌트는 리렌더링될 때마다 내부의 함수들을 매번 처음부터 다시 만듦

--> useCallback으로 감싸두면, 컴포넌트가 리렌더링되어도 동일한 메모리 주소(참조값)를 가진 기존 함수를 그대로 재사용 가능

 

useCallback(callback함수, 의존성배열([]))

 

의존성 배열의 작동 원리

  • 빈 배열인 경우
    • 컴포넌트가 처음 화면에 나타날 때(마운트 시) 함수를 딱 한 번만 메모리에 저장
    • 이후, 컴포넌트가 아무리 리렌더링되어도 처음에 저장해둔 똑같은 함수를 계속 가져다 씀 
  • 배열에 특정 상태값이 들어있는 경우
    • 평소에는 메모리에 있는 함수를 재사용하다가, 배열 안의 상태값이 변경되는 순간에만 함수를 새로 만들어 메모리를 업데이트

 

useMemo

useMemo 훅 : 컴포넌트 내부에서 발생하는 복잡한 연산의 결과 값을 메모이제이션하여 재사용하는 훅

 

useMemo(() => { return ... }, 의존성배열([]))

의존성 배열의 작동 원리 

  • 빈 배열인 경우
    • 컴포넌트가 처음 화면에 나타날 때(마운트 시) 연산을 딱 한 번만 수행하고 그 결과를 메모리에 저장
  • 배열의 특정 상태값이 들어있는 경우
    • 평소에는 메모리에 있는 값을 재사용하다가, 배열 안의 상태값이 변경되는 순간에만 연산 함수를 다시 실행하여 메모리를 업데이트 

 

lazy

파일 상단에 정적으로 선언된 컴포넌트는, 당장 화면에 보이지 않더라도 초기 번들 파일에 무조건 포함됨

--> 앱의 규모가 커질수록 초기 다운로드해야 하는 자바스크립트 파일이 너무 무거워지고, 첫 화면이 뜨기까지 오랜 시간이 걸림

 

코드 분할 : 거대한 하나의 자바스크립트 번들 파일을 여러 개의 작은 조각 파일로 쪼개는 기술

ㄴ 당장 필요한 첫 화면 코드만 먼저 다운로드하고, 나머지 페이지나 무거운 컴포넌트들은 그것이 실제로 필요해지는 순간에 비동기적으로 다운로드

 

React에서는 컴포넌트 수준에서 코드를 분할할 수 있도록 React.lazy 함수를 제공

--> 특정 컴포넌트를 필요할 때 비동기적으로 로딩 가능 

 

기존 코드

import ChildA from "./components/ChildA";
import ChildB from "./components/ChildB";

 

React.lazy 사용 코드 

const ChildA = React.lazy(() => import("./components/ChildA"));
const ChildB = React.lazy(() => import("./components/ChildB"));
const ChildA = lazy(() => import("./components/ChildA"));
const ChildB = lazy(() => import("./components/ChildB"));

 

Suspense

React.lazy 함수의 문제점 : 컴포넌트가 실제 렌더링되는 순간에 서버에서 자바스크립트 파일을 다운로드(import)

--> 인터넷 환경이 느리거나 컴포넌트 파일 크기가 크다면 최초 로딩 시간이 그만큼 느려짐

 

Suspense 컴포넌트 : 자식 컴포넌트가 로딩(비동기 다운로드)되는 동안 화면이 끊기지 않도록, 임시 대체 UI를 띄워주는 React 내장 컴포넌트

로딩 상태를 관리하기 위해 컴포넌트 내부에 별도의 isLoading 같은 상태 변수나 if-else 분기 처리 해줄 필요 X

 

fallback 속성을 사용해 대체 UI 작성 가능

ㄴ HTML/JSX 형태 모두 가능

 

// App.js

import React, { Suspense, useState } from "react";
// import ChildA from "./components/ChildA";
// import ChildB from "./components/ChildB";

const ChildA = React.lazy(() => import("./components/ChildA"));
const ChildB = React.lazy(() => import("./components/ChildB"));

export default function App() {
  const [isShow, setIsShow] = useState(false);

  return (
    <>
      <button onClick={() => setIsShow((isShow) => !isShow)}>toggle</button>
      {isShow && (
        <>
          <Suspense fallback={<h1>ChildA Loading...</h1>}>
            <ChildA />
          </Suspense>
          <Suspense fallback={<h1>ChildB Loading...</h1>}>
            <ChildB />
          </Suspense>
        </>
      )}
    </>
  );
}

 

error-boundary

React는 컴포넌트 트리 중 단 한 곳에서라도 자바스크립트 런타임 에러가 발생하면, 에러가 발생한 컴포넌트뿐만 아니라 부모 트리 전체의 렌더링을 중단시킴 

 

Error Boundary : 에러가 발생해도 전체 앱이 죽지 않고, 에러가 난 그 컴포넌트 위치에만 대체 UI를 출력하여 서비스의 나머지 기능은 정상 작동하도록 보

 

npm i react-error-boundary 명령어를 사용하여 다운로드 

 

에러가 발생해도 전체 트리가 멈추는 것이 아니라, 해당 구역만 대체 UI로 교체됨

// App.tsx

import { lazy, Suspense, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
const ChildA = lazy(() => import("./components/ChildA"));
const ChildB = lazy(() => import("./components/ChildB"));

export default function App() {
  const [isShow, setIsShow] = useState(false);
  return (
    <>
      <button onClick={() => setIsShow((isShow) => !isShow)}>토글</button>
      {isShow && (
        <>
          <Suspense fallback={<h1>ChildA Loading..</h1>}>
            <ChildA />
          </Suspense>
          <ErrorBoundary fallback={<div>Something went worng</div>}>
            <Suspense fallback={<h1>ChildB Loading..</h1>}>
              <ChildB />
            </Suspense>
          </ErrorBoundary>
        </>
      )}
    </>
  );
}

 

fallback 속성 대신 FallbackComponent를 사용하게 되면 동적으로 활용 가능

ㄴ 리액트가 해당 컴포넌트의 props로 error와 resetErrorBoundary를 넘겨줌

 

// App.tsx

import { lazy, Suspense, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
const ChildA = lazy(() => import("./components/ChildA"));
const ChildB = lazy(() => import("./components/ChildB"));

function Fallback({
  error,
  resetErrorBoundary,
}: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div role="alert">
      <p>Something went wrong: </p>
      <pre style={{ color: "red" }}>{error.message}</pre>
      <button onClick={resetErrorBoundary}>retry</button>
    </div>
  );
}

export default function App() {
  const [isShow, setIsShow] = useState(false);
  return (
    <>
      <button onClick={() => setIsShow((isShow) => !isShow)}>토글</button>
      {isShow && (
        <>
          <Suspense fallback={<h1>ChildA Loading..</h1>}>
            <ChildA />
          </Suspense>
          <ErrorBoundary FallbackComponent={Fallback}>
            <Suspense fallback={<h1>ChildB Loading..</h1>}>
              <ChildB />
            </Suspense>
          </ErrorBoundary>
        </>
      )}
    </>
  );
}

 

인자로 받는 2가지 매개변수

  • error
    • 어떤 자바스크립트 에러가 터졌는지 담고있는 객체
  • resetErrorBoundary
    • 에러 바운더리를 무시하고 컴포넌트를 다시 한번 실행하게 하도록 리셋하는 함수