✏️ Re-rendering(리렌더링)이란?
- React가 컴포넌트를 다시 실행하여 새로운 결과물을 생성하면, 그것을 지켜보고 있던 Virtual DOM이 기존 컴포넌트와 현재 컴포넌트의 차이점(diff)을 발견하여 실제 DOM의 변경 사항만 효율적으로 업데이트하는 것.
✏️ Re-rendering Trigger (컴포넌트가 리렌더링 되는 조건)
1. 컴포넌트의 props가 변경되거나,
2. 컴포넌트의 state가 업데이트될 때
기본적인 조건은 이 두 가지이지만,
3. context가 변경될 때
- Context 값이 변경되면, 해당 Context를 사용하는 모든 컴포넌트가 리렌더링 된다.
4. 부모 컴포넌트가 리렌더링 될 때 자식 컴포넌트가 리렌더링 된다.
- 이때 React.memo를 사용하면 불필요한 리렌더링을 방지할 수 있다.
const MemoizedComponent = React.memo(function Component({ onClick }) {
return <button onClick={onClick}>Hello!</button>;
});
이런 추가 조건들도 존재한다.
✏️ 리렌더링과 함수 참조
- 컴포넌트가 리렌더링 될 때마다 컴포넌트의 함수 전체가 다시 실행된다.
- 이때 컴포넌트 내부에 정의된 모든 함수들은 새로운 메모리 주소(참조값)를 할당받게 되는데, 이는 JavaScript의 함수가 참조 타입(Reference Type)이기 때문이다.
✏️ 함수 참조 변경으로 발생하는 문제
- 자식 컴포넌트에 props로 전달된 함수의 참조값이 변경되어 불필요한 리렌더링이 발생할 수 있으며,
- useEffect의 의존성 배열에 포함된 함수가 새로운 참조값을 가지면서 의도치 않은 side-effect가 실행될 수 있다.
이는 성능 저하뿐만 아니라, 앞서 말했던 의도치 않은 Side-effect가 발생하면서 예상치 못한 동작을 유발할 수 있다.
✏️ 이런 현상이 왜 발생하는 걸까?
- 원시 타입(Primitive Type)은 값 자체를 복사하기 때문에 값이 변경되어야 새로운 값이라고 인식하지만,
- 참조 타입(Reference Type)은 참조값(메모리 주소)이 복사되므로 값이 동일해도 메모리 주소(참조값)가 다르면,
React가 다른(새로운) 함수로 인식하기 때문이다.
// Primitive
let a = 10;
let b = 10;
a === b; // true (값이 같으면 같은 데이터로 인식)
// Reference
const obj1 = { count: 1 };
const obj2 = { count: 1 };
obj1 === obj2; // false (내용이 같아도 참조 주소가 다르면 다른 데이터로 인식)
// Reference Type에서 동일한 참조
const obj3 = obj1; // obj3는 obj1과 같은 참조를 가리킴
console.log(obj1 === obj3); // true (참조 주소가 같아야 같은 데이터로 인식)
✏️ 함수 참조 변경 문제를 해결하려면?
- Hook을 통하여 함수 참조를 관리해 주면 되는데, 이때 가장 많이 쓰이는 hook은 useCallback과 useRef이다.
- 둘의 차이점은 아래와 같으며, 상황에 맞게 사용하면 된다.
- useCallback : 함수를 memoization 하며, 의존성 배열을 갖고 있다. 의존성 배열이 변경되기 전까지 동일한 함수 참조를 유지한다.
- useRef : 초기화 시점에 생성된 참조를 컴포넌트의 생명 주기 동안 유지하며, 의존성 배열을 관리하지 않아도 된다.
아래는 useCallback과 useRef를 사용하는 예시이다.
1. useCallback 사용 예시
import React, { useState, useCallback } from 'react';
// Child Component
const Child = React.memo(({ onClick }) => {
return <button onClick={onClick}>Child</button>;
});
// Parent Component
const Parent = () => {
const [count, setCount] = useState(0);
// handleClick 함수가 계속 실행됨
const handleClick = () => {
console.log("isClicked");
};
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
{/* handleClick이 매번 새롭게 생성되어 Child Component가 리렌더링됨 */}
<Child onClick={handleClick} />
</div>
);
};
- 위 예시는 Child Component에서 React.memo가 적용된 상태이지만, Parent Component의 내부 함수인 handleClick 함수가 매번 새롭게 생성되어 Child Component가 불필요하게 계속 리렌더링 되는 예시이다.
// Parent Component
const Parent = () => {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("isClicked");
}, []); // 의존성 배열을 비워두면 초기 렌더링 이후 참조값이 유지된다.
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
{/* handleClick의 참조값이 유지되므로 불필요한 리렌더링 방지 */}
<ChildComponent onClick={handleClick} />
</div>
);
};
- 위 예시처럼 useCallback을 통해 의존성 배열로 참조값을 저장해 놓으면, handleClick의 참조값이 유지되어 Parent Component가 리렌더링 되어도 handleClick 함수가 재실행되지 않는다.
- 함수 참조로 인해 불필요한 리렌더링을 방지할 때 useCallback을 사용하는 것을 권장한다.
2. useRef 사용 예시
import React, { useRef, useEffect } from 'react';
const Timer = () => {
const cbRef = useRef();
// Timer function
const startTimer = () => {
cbRef.current = setInterval(() => {
console.log("Timer is running");
}, 1000);
};
useEffect(() => {
startTimer();
// cleanup 함수로 컴포넌트가 언마운트될 때 타이머 정리
return () => clearInterval(cbRef.current);
}, []);
};
export default Timer;
- 위 예시는 useRef로 cbRef의 current 값을 유지함으로써 컴포넌트가 리렌더링 되더라도 동일한 참조값을 유지하며, useEffect의 cleanup 함수로 컴포넌트가 언마운트될 때 clearInterval을 실행하여 메모리 누수까지 방지해 준다.
- useRef는 리렌더링과 무관하게 특정 데이터(함수 포함)를 저장하고 재사용할 때 적합하다.
이 밖에도 함수를 값으로 memoization 하는 useMemo라던지, 함수를 컴포넌트 외부에 정의하여 안정적인 참조를 유지하는 useReducer 등의 방법도 있다.
다만, useCallback과 useMemo는 React 19에서 'use' hook으로 통합될 예정이라고 하니, 참고하면 좋을 것 같다.
'Deep Dive' 카테고리의 다른 글
[Next.js] NextAuth의 JWT 살펴보기 (1) | 2025.02.18 |
---|---|
[Git] Git 브랜치 구조 및 병합 (1) | 2024.12.18 |
[JavaScript] String의 이중성 (원시 / 객체 타입 사이) (0) | 2024.11.18 |
[TypeScript] String Type 구조 탐구해보기 (1) | 2024.11.15 |
[React] JSX Rendering & Virtual DOM의 동작 원리 (2) | 2024.11.13 |