리액트 성능 최적화: 불필요한 리렌더링 줄이는 실전 전략

김현준·2025년 1월 21일
0

SEO와 코드 최적화

목록 보기
7/7

"부모 컴포넌트가 다시 렌더링되더라도, 자식 컴포넌트가 불필요하게 리렌더링되지 않도록" 하고, "Context, 리스트, 무거운 컴포넌트 등에서 메모화와 적절한 분리를 활용"하는 것이 리액트 성능 최적화의 핵심이다.
주로 불필요한 컴포넌트 리렌더링을 방지하고, 꼭 필요한 지점에서만 최적화를 적용하도록 유의하면서 코드 구조를 잘 잡아나가면 된다.

리액트 렌더링 과정 이해

리액트가 화면을 업데이트하는 과정은 크게 3단계로 나뉜다.

1. 트리거
2. 렌더 단계
3. 커밋 단계

이 후에 브라우저가 도큐먼트 객체 모델(DOM) 변경 사항을 실제 화면에 그리는 ‘브라우저 렌더링(Browser Rendering)’이 일어난다.

1. 트리거

  • 초기 렌더링 시
  • 컴포넌트의 상태(state)가 업데이트될 때
  • 부모 컴포넌트가 렌더링될 때 (props 전달 여부 포함)
  • 컨텍스트(Context)가 업데이트될 때
  • 커스텀 훅 내부에서 상태가 업데이트될 때

2. 렌더 단계

  • 리액트가 컴포넌트를 실행하여 가상 DOM(Virtual DOM)을 생성하는 과정
  • 이 과정에서 JSX가 실행되어 리액트 요소(React Element)를 반환
  • 자식 컴포넌트들도 재귀적으로 렌더링된다.
  • 이전 버추얼 돔과 비교(Reconciliation)하여 업데이트가 필요한 변경 사항을 계산한다.
  • 이 과정은 실제 DOM 변경이 발생하지 않고, 변경할 내용을 계산하는 단계이다.

커밋 단계

  • 렌더 단계에서 생성된 가상 DOM의 변경 사항을 실제 DOM에 반영하는 과정
  • 리액트가 변경된 요소만 업데이트하여 브라우저의 화면을 변경
  • 이 단계에서는 DOM 업데이트와 함께 브라우저가 CSS 변경, 애니메이션 처리 등이 수행된다.

브라우저 렌더링

  • 리액트가 DOM을 업데이트하면, 브라우저가 이 변경사항을 실제 화면에 반영한다.
  • 브라우저는 레이아웃 계산(Layout), 페인트(Paint), 컴포지팅(Compositing) 단계를 거쳐 화면을 업데이트한다.

렌더링, 리렌더링

  • 렌더링(Rendering)은 컴포넌트 함수가 실행되어 Virtual DOM을 생성하는 과정
  • 리렌더링(Re-rendering)은 컴포넌트가 다시 실행되는 과정으로, state, props, context 등이 변경될 때 발생

성능 저하의 원인: 불필요한 리렌더링

컴포넌트가 다시 렌더링되는 조건

1. 상태(state)가 변경되는 경우

  • setState 등을 통해 상태가 변경되면, 해당 컴포넌트가 다시 렌더링된다.
  • 단, 이전 상태와 새 상태가 동일하다고 간주(Object.is) 되면 리렌더링이 발생하지 않는다.

2. 부모 컴포넌트가 다시 렌더링되는 경우

  • 부모가 다시 렌더링되면 기본적으로 자식 컴포넌트도 다시 렌더링된다.
  • 하지만, React.memo를 사용하면 props가 변경되지 않은 경우 자식이 다시 렌더링되지 않는다.

3. 컨텍스트(context)가 업데이트된 경우

  • useContext 훅으로 컨텍스트를 사용하는 컴포넌트는 컨텍스트 값이 변경될 때 다시 렌더링된다.

4. 커스텀 훅 내부에서 상태가 업데이트된 경우

  • 커스텀 훅 내부에서 상태가 업데이트되면, 해당 훅을 사용하는 컴포넌트가 다시 렌더링된다.
  • 이는 커스텀 훅이 컴포넌트 내부에 속하기 때문이다.

잘못 알려진 컴포넌트 호출 조건

  • "props가 변경되면 컴포넌트가 리렌더링된다"는 설명은 부정확하다.
    정확히는, 부모 컴포넌트가 리렌더링될 때, 자식 컴포넌트도 기본적으로 함께 렌더링되지만, React.memo 등을 사용하면 불필요한 리렌더링을 방지할 수 있다.
    • 리액트는 이 과정에서 새로운 props를 자식 컴포넌트로 전달하고, 이 props가 이전 값과 다를 경우 리렌더링 과정을 이어간다.
    • React.memo를 사용한 경우, props가 변경되지 않는 한 리렌더링되지 않는다.

성능 개선 전략 개요

이제 실제로 리렌더링을 방지하는 방법을 살펴보자.

1. 큰 컴포넌트를 작은 컴포넌트로 분리

  • 문제: 모든 UI를 하나의 거대한 컴포넌트로 만들어두면, 상태가 조금만 변경되어도 전체 컴포넌트가 다시 렌더링된다.
  • 해결: UI를 작은 단위로 분리하여 필요한 부분만 다시 렌더링되도록 한다.
// 큰 컴포넌트를 분리
function EventsTable() {
  return (
    <>
      <TableHeader />
      <TableRows />
    </>
  );
}
  • 효과: 렌더링 범위가 줄어들어, 0.5초 걸리던 렌더 시간이 150ms로 감소하기도 한다(예시)

2. React.memo()를 활용한 리렌더링 최적화

  • 문제: 부모가 렌더링될 때 자식도 불필요하게 렌더링된다.
  • 해결: React.memo()를 사용하여 props가 변경되지 않은 경우 렌더링을 방지한다.
export const MemoizedRows = React.memo(TableRows);

function TableRows({ rows, prepareRow }) {
  return rows.map(row => (
    <TableRow row={prepareRow(row)} key={row.id} />
  ));
}
  • 효과: 기존에 300ms 소요되던 렌더 시간이 크게 줄어들 수 있다.

3. JSX 내부 함수가 리렌더링을 유발하는 이유 (해결책: 컴포넌트로 변환)

  • 문제: JSX 내부에서 const renderSomething = () => { ... }처럼 함수를 선언하면, 컴포넌트가 리렌더링될 때마다 새로운 함수가 생성되므로, 불필요한 연산이 발생할 수 있다.
  • 해결: 해당 함수를 별도의 컴포넌트로 분리하거나 useCallback을 활용하면, 참조값이 유지되어 불필요한 연산을 방지할 수 있다.
// BEFORE: JSX 내부에서 함수 선언 (렌더링될 때마다 새 함수가 생성됨)
const renderTableTitle = (title, totalRows) => (
  <Flex>
    <Heading>{title}</Heading>
    {totalRows}
  </Flex>
);

// AFTER: 별도 컴포넌트로 분리 (매 렌더링 시 새 함수가 생성되는 문제 방지)
function TableTitle({ title, totalRows }) {
  return (
    <Flex>
      <Heading>{title}</Heading>
      {totalRows}
    </Flex>
  );
}

합성(Composition)을 활용한 최적화

1. 컴포넌트 안에서 컴포넌트 정의하기 (안티 패턴)

// 안티패턴
const Parent = () => {
  const ChildInside = () => <div>Something</div>; // 렌더될 때마다 새로 정의됨

  return <ChildInside />;
};
  • 위 코드에서는 Parent가 렌더링될 때마다 ChildInside새롭게 정의된다.
  • 결과적으로, React는 이 컴포넌트가 매번 새로운 컴포넌트로 인식하고 다시 렌더링한다.

리액트는 state가 변경되거나 부모 컴포넌트가 리렌더링될 때, 해당 컴포넌트를 다시 렌더링한다.
단, 부모의 리렌더링이 항상 자식의 리렌더링을 의미하는 것은 아니며, React.memo 등을 활용하면 불필요한 리렌더링을 방지할 수 있다.

2. 자식 컴포넌트로 상태 내려서 영향 범위 좁히기

무거운 부모 컴포넌트에서 불필요한 상태를 관리하지 않도록 한다.

  • 예를 들어, 모달 다이얼로그를 여닫는 로직을 부모에서 관리하면 부모가 렌더링될 때마다 불필요하게 모달 상태도 영향을 받을 수 있다.
  • 해결: 상태를 자식 컴포넌트로 이동시키면, 부모의 리렌더링이 줄어든다.
// 상태를 자식 컴포넌트에서 관리하도록 변경
function ButtonWithDialog() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Open</button>
      {open && <Dialog />}
    </>
  );
}

// 부모
function Parent() {
  return (
    <>
      <ButtonWithDialog />
      <VerySlowComponent /> {/* 영향을 받지 않음 */}
    </>
  );
}

3. children을 props로 받아 상태 영향 최소화

  • 상태 업데이트가 빈번한 부모 컴포넌트가 느린 컴포넌트를 포함하면 성능 저하 발생 가능
  • 해결: 느린 컴포넌트를 children으로 받아 리렌더링 영향을 최소화한다.

Bad Case: 불필요한 리렌더링 발생

const SlowComponent = () => {
  console.log("SlowComponent 렌더링!");
  return <div>나는 느린 컴포넌트야</div>;
};

const Component = () => {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  const handleMouseMove = (event) => {
    setPosition({ x: event.clientX, y: event.clientY });
  };

  return (
    <div onMouseMove={handleMouseMove}>
      <p>마우스 위치: {position.x}, {position.y}</p>
      <SlowComponent /> {/* ❗ 불필요한 리렌더링 발생 */}
    </div>
  );
};
  1. onMouseMove가 실행될 때마다 setPosition이 실행됨.
  2. position이 변경될 때마다 Component 전체가 다시 렌더링됨.
  3. 그 결과, SlowComponent도 매번 다시 렌더링됨 → 성능 저하 발생!

Good Case: children을 활용한 최적화

const SlowComponent = () => {
  console.log("SlowComponent 렌더링!");
  return <div>나는 느린 컴포넌트야</div>;
};

const MouseTracker = ({ children }) => {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  const handleMouseMove = (event) => {
    setPosition({ x: event.clientX, y: event.clientY });
  };

  return (
    <div onMouseMove={handleMouseMove}>
      <p>마우스 위치: {position.x}, {position.y}</p>
      {children} {/* children은 props이므로 상태 변경 시에도 리렌더링되지 않음 */}
    </div>
  );
};

const Component = () => {
  return (
    <MouseTracker>
      <SlowComponent /> {/* 리렌더링되지 않음! */}
    </MouseTracker>
  );
};
  1. MouseTracker가 마우스 이벤트를 감지하고 position을 업데이트함.
  2. MouseTracker만 다시 렌더링
  3. SlowComponent는 children 으로 전달되므로 불필요한 리렌더링이 발생하지 않음

useMemo, useCallback을 활용한 리렌더링 방지

1. 비원시 타입 props를 메모

  • React.memo는 기본적으로 props의 얕은 비교를 수행한다.
  • 객체, 배열, 함수와 같은 참조 타입 props는 매번 새로운 참조값을 가지므로, 부모 컴포넌트가 다시 렌더링될 때 리액트는 props가 변경(값이 변경)된 것으로 판단하여 자식 컴포넌트가 리렌더링될 수 있다.
  • 이를 방지하려면 useMemo 또는 useCallback을 사용하여 참조값을 고정하면, props가 변경되지 않는 한 불필요한 리렌더링을 방지할 수 있다.
const MemoizedChild = React.memo(Child);

function Parent() {
  //useMemo를 사용하여 객체 참조값 유지
  const obj = useMemo(() => ({ someKey: 'someValue' }), []);
  
  //useMemo 없이 props를 넘길 경우 (문제 발생 가능)
  //새로운 객체가 매번 생성됨
  //const obj = { someKey: 'someValue' };
	
  return <MemoizedChild value={obj} />;
}

2. 무거운 계산 로직 메모

  • 정렬, 필터 등 자바스크립트 연산 자체가 매우 무겁다면 useMemo로 결과를 캐싱하는 것도 도움이 된다.
    하지만 모든 연산에 남발하면 메모리 사용량과 초기 렌더 속도가 느려질 수 있으니 실제 성능 문제가 있는 부분에만 적용한다.

리스트 렌더링 최적화

1. key 속성을 고유한 값으로 설정

  • 배열로 렌더링하는 엘리먼트에는 고유한 key가 필수다.
  • key가 불안정하면 리액트는 매 렌더링마다 새로운 엘리먼트로 인식하여 불필요한 렌더링이 발생할 수 있다.
{items.map((item) => (
  <Child key={item.id} item={item} />
))}

2. React.memo로 리스트 항목 메모

리스트 항목이 변경되지 않을 경우, 렌더링을 방지할 수 있다.

const MemoizedChild = React.memo(Child);

{items.map((item) => (
  <MemoizedChild key={item.id} item={item} />
))}

Context 리렌더링 최적화

1. Provider 값 memo하기

  • Context Providervalue가 변경될 때, 이를 구독하는 모든 컴포넌트가 리렌더링됨.
  • 해결: useMemo를 사용하여 value 객체의 참조값을 고정
function SomeProvider({ children }) {
  const [state, setState] = useState({});
  const memoValue = useMemo(() => ({ state, setState }), [state]);
  return (
    <SomeContext.Provider value={memoValue}>
      {children}
    </SomeContext.Provider>
  );
}

2. 데이터와 API 분리

  • Provider에 데이터와 API가 한꺼번에 들어있으면, 데이터가 변경될 때 API만 쓰는 컴포넌트도 불필요하게 리렌더링될 수 있다.
  • 데이터용 Provider, API용 Provider로 분리하면, 필요한 부분만 리렌더링된다.
function AppProvider({ children }) {
  const [user, setUser] = useState(null);

  return (
    <UserDataContext.Provider value={user}>
      <UserApiContext.Provider value={setUser}>
        {children}
      </UserApiContext.Provider>
    </UserDataContext.Provider>
  );
}

3. Context Selector (고차 컴포넌트 + React.memo)

  • Context가 업데이트될 때, 실제로 사용 중인 값이 변하지 않아도 useContext를 쓰는 모든 컴포넌트가 리렌더링될 수 있다.
  • HOC(고차 컴포넌트)와 React.memo를 조합해 특정 값만 바뀔 때만 렌더링하도록 만들 수도 있다.
  • 다만 최근 React 공식 문서에서는 HOC가 주류 패턴은 아니라고 언급한다.
  • 이럴 때는 use-context-selector 같은 라이브러리를 사용할 수도 있다.

요약

1. 불필요한 호출 줄이기

  • 큰 컴포넌트를 작은 단위로 나누고, React.memo로 감싸서 props 변동이 없으면 건너뛰도록 한다.
  • 함수나 객체 등 비원시 타입을 props로 넘길 때는 useMemouseCallback을 사용해 참조값을 고정한다.

2. 합성(Composition)과 상태 캡슐화

  • 무거운 부모 안에서 불필요하게 공유되는 상태는 작은 컴포넌트로 옮겨서, 상태관리 범위를 최소화한다.
  • children 혹은 별도 props로 무거운 컴포넌트를 받아두면, 상태 변경 시에도 무거운 컴포넌트가 호출되지 않는다.

3. Context를 쓸 때는 Provider 구조와 값 변경에 주의

  • Provider가 자주 호출되는지 확인하고, memo나 Provider 분할, context selector 등을 고려한다.

4. useMemo, useCallback의 남용은 금물

  • 실제로 최적화가 필요한 지점에만 사용해야 한다.
  • 메모화 자체에도 비용이 있기 때문에, 성능 문제가 확실한 부분만 적용한다.

5. 최적화는 문제를 확인하고 나서 적용

  • 성능 문제가 없는데 지나친 최적화를 시도하면 오히려 코드 복잡도만 늘어나기 쉽다.
  • 실제로 UI가 느려지거나, 사용자 경험이 떨어지는 지점에 집중하자.

참고 자료

참고하면 좋은 자료

profile
기록하자

0개의 댓글

관련 채용 정보