[React] 렌더링 최적화

PinkTopaz·2023년 2월 16일
1
post-thumbnail
post-custom-banner

내가 현재 참여하고 있는 프로젝트인 pic.me 의 회원가입 기능을 리팩토링하면서 여러 가지 이유로 기존에 사용하던 React-hook-form을 들어내고, 직접 구현하게 되었다.
이 과정에서 마주한 문제점은 바로 "리렌더링"이었다.

React-hook-form에서는 register과 getValue를 이용해서 여러 state를 선언하지 않고도 상태를 관리하기가 용이했다.
하지만 직접 구현을 하다보니 여러 state가 생기게 되었고 각 state에서 유효성 검사를 실시하면서 (특히나 기획 요구 상 onChange에서 유효성 검사를 실시하게 되면서) 많은 리렌더링이 발생하게 되었다.

이를 조금이라도 개선하기 위해서 렌더링 최적화 방법에 대해 공부하게 되었고, 공부한 내용을 정리해보려고 한다.


리액트에서의 렌더링이란?

함수를 호출하는 것!

function App() {
	const handleClick = () => {
    	//로직 생략
    }
    return <h1 onClick={handleClick}>렌더링은 함수를 호출하는 것입니다.</h1>;
}

렌더링이란, App 컴포넌트가 실행이 되고 내부 로직이 실행이 되고, return문을 통해 element가 반환이 되는 과정이다.

렌더링 과정을 살짝 보자면,

  1. 리액트는 HTML을 파싱(컴파일 후 코드를 분해하는 것)해서 HTML DOM을, CSS를 파싱해서 CSSOM(CSS Object Model)을 만든다.
  2. DOM과 CSSOM을 결합하여 Render Tree (CSSOM의 스타일 규칙을 DOM에 적용)를 만든다.
  3. Render tree와 브라우저의 viewport의 width를 통해서 각 요소들의 위치와 크기를 계산한다. (Layout)
  4. 계산된 정보를 이용해 Render Tree 요소들을 픽셀로 화면에 그려준다.(Paint)

PROBLEM

function Parent() {
	const [valueForFirstChild, setValueForFirstChild] = useState(null);
  
  const handleClick = () => {}
  
  useEffect(()=>{
  	setTimeout(()=>{
    	setValueForFirstChild('changedValue');
    },3000)
  })
  
  return (
  	<>
    	<FirstChild value={valueOfFirstChild}>
    	<SecondChild onClick={handleClick}>
    </>
  )
}

useEffect에 의해 state의 값이 변경되면 Parent 컴포넌트가 리렌더링된다.(리렌더링은 함수가 실행되는 것이기 때문에)
또한 Parent 컴포넌트의 return 문을 실행하면서 FirstChild, SecondChild 컴포넌트도 리렌더링이 된다.

First/SecondChild가 리렌더링이 되는 이유는?!

컴포넌트가 리렌더링 되는 조건은 두 가지가 있다.

  • state가 변경되었을 때
  • props가 변경되었을 때

FirstChild : 변경된 state값(valueForFirstChild)을 props로 받기 때문에 리렌더링
SecondChild : Parent 컴포넌트가 리렌더링이 될 때마다 handleClick 함수가 재생성이 되고, 이에 따라 참조값이 달라진 함수를 props로 넘겨받기 때문에 리렌더링 (props가 변경되었을 때에 해당 )

여기서 잠깐!
FirstChild에는 변경된 state 값을 전달해주었기 때문에 리렌더링이 되는 것이 당연하지만,
SecondChild 입장에서는 변화한 것이 없는데 ParentChild에 의해 불필요한 리렌더링이 발생한다.

function FirstChild({value}){
	return <div>{value}</div>
}

function SecondChild({onClick}){
	return (
    	<div onClick={onClick}>
      		{Array.from({length:1000}).map((_,idx) =>( 
            	<GrandChild key={idx} order={idx+1}/>
            ))}
      	</div>
    )
}

SecondChild 컴포넌트가 불필요하게 리렌더링이 되면, SecondChild 내부의 1000개의 GrandChild도 리렌더링이 되어 굉장히 많은 리렌더링이 발생하게 된다.


SOLUTION

SecondChild의 불필요한 리렌더링을 막아주려면 어떻게 해야할까?
Parent 컴포넌트가 리렌더링이 될 때마다 handleClick의 참조값이 변하지 않도록 처리를 해주면 될 것이다.
어떻게?

useCallback

\rightarrow 함수를 메모이제이션 해주는 훅. 메모이제이션이란, 기존에 수행한 연산의 결과값을 어딘가에 저장해두고 필요할 때 재사용하는 것을 말한다.

function Parent() {
  const [valueForFirstChild, setValueForFirstChild] = useState(null);
  
  const handleClick = useCallback(() => {});
  
  ...
}

이렇게 handleClick을 useCallback으로 감싸주면, 의존성 배열의 값에 변화가 생기지 않는 한 handleClick의 참조값이 변하지 않아 SecondChild가 리렌더링되지 않을 것이라고 예상할 수 있다.

하지만

위와 같이 handleClick을 useCallback으로 감싸주어도 SecondChild와 그의 자식인 GrandChild는 계속 리렌더링되는 것을 확인할 수 있다.

왜?

Parent 컴포넌트를 Babel로 컴파일해보면 알 수 있는데, 부모컴포넌트를 실행하면 이에 따라 React.createElement로 FirstChild, SecondChild 엘리먼트가 생성되어 반환이 되기 때문에 어쩔 수 없이 리렌더링이 되는 것이다.

그러면 useCallback은 렌더링 최적화에 아무런 효과가 없는 것인가?

리액트의 렌더링 과정
  1. Render Phase
    • 컴포넌트(함수)를 호출하고, React.createElement로 만들어진 내용을 반환한다.
    • 만약 첫번째 렌더링이 아니라면, 이전 Virtual Dom과 현재 Virtual Dom을 비교하여 실제 Dom에 변경이 필요한 부분을 체크하는 재조정(Reconciliation) 과정도 포함한다.

  2. Commit Phase
    • Render Phase에서 확인한 실제 DOM에 변경이 필요한 부분을 반영한다. 변경이 필요한 부분이 없다면, Commit Phase는 skip 된다.

useCallback은 Render Phase는 실행이 되지만 함수의 참조값을 같게 해주어 props에 이전과 같게 해주었기에 SecondChild에서 commit phase가 일어나지 않게 해준다.
따라서 최적화에 효과가 있다고 말할 수 있다.


그런데 나는 Render Phase도 막아주고 싶어요!

React.memo : 얕은 비교를 통한 props 비교

컴포넌트 전체에 대하여 메모이제이션을 진행한다.

얕은 비교 / 깊은 비교

얕은 비교

  • 기본 타입 : 값 비교

  • 참조 타입 (ex. 객체, 배열) : 참조값 비교
    const obj1 = {a : 1, b : 2};
    const ob2 = {a : 1, b : 2};

    console.log(obj1 === obj2); // false

깊은 비교

  • 기본 타입, 참조 타입 모두 값으로 비교
    console.log(JSON.stringify(obj1) === JSON.stringify(obj2)); // true
function SecondChild({onClick}){
	return (
    	<div onClick={onClick}>
      		{Array.from({length:1000}).map((_,idx) =>( 
            	<GrandChild key={idx} order={idx+1}/>
            ))}
      	</div>
    )
}

export default React.memo(SecondChild);

SecondChild 컴포넌트가 Render Phase에 들어가기 전에 props로 들어온 onClick의 참조값이 이전과 같은 지 비교하고, useCallback이 적용되어 있어 참조값이 같다면 메모이제이션 해두었던 컴포넌트를 그대로 사용해 SecondChild의 리렌더링 과정이 생략된다.
(즉, React.memo와 useCallback을 함께 사용하여 리렌더링을 막을 수 있다.)
이에 따라 GrandChild 컴포넌트도 리렌더링 되지 않는다.


만약 props가 객체로 들어온다면?

props가 객체라면 useCallback을 적용할 수도 없기 때문에 매번 같은 객체가 들어오더라도 참조값이 계속 변화할 것이다.
따라서 React.memo를 사용해도 리렌더링을 막을 수 없다.
이러한 상황에서 SecondChild의 리렌더링을 막을 수 있는 다른 방법이 없을까?

useMemo

함수 자체에 대하여 메모이제이션을 진행하는 useCallback과 달리, useMemo는 값에 대한 메모이제이션을 진행한다.

function Parent() {
	const [valueForFirstChild, setValueForFirstChild] = useState(null);
  
  const item = {
  	name : "React",
    isGood : true
  }
  
  //처음 마운트 될 때 들어온 그 값을 메모이제이션
  const memoization = useMemo(()=> item, []);
  
  useEffect(()=>{
  	setTimeout(()=>{
    	setValueForFirstChild('changedValue');
    },3000)
  })
  
  return (
  	<>
    	<FirstChild value={valueOfFirstChild}>
    	<SecondChild onClick={memoization}>
    </>
  )
}

이렇게 되면 SecondChild의 props로 계속 참조값이 같은 인자가 들어가기 때문에, React.memo(SecondChild)가 의도한대로 동작해 SecondChild의 리렌더링을 막아줄 수 있다.


그렇다면 항상 최적화를 하는 게 좋을까?

Do not optimize rendering prematurely, do it when needed

React.Memo, useCallback, useMemo 모두 내부적으로 특정한 동작을 실행시켜주는 함수이기 때문에 사용하는 것이 모두 비용이다.

예를 들어 항상 props로 다른 값이 들어가는데 useMemo를 쓴다거나, 리렌더링이 자주 되는 컴포넌트라고 해서 내부 함수를 무조건 useCallback으로 감싸주는 경우도 있을 수 있다.

이러한 경우에는 최적화를 사용하기 전보다 최적화를 사용하고 난 후가 더 웹사이트 성능이 안 좋아질 수 있다.

앞서 내가 React-hook-form을 들어내며 마주한 리렌더링 문제도, 의존성 배열로

언제 최적화를 사용해야하는지는 항상 깊게 고민해봐야하는 문제이다.


기타 궁금증 정리

useEffect vs useCallback
1. useCallback : 의존성 배열의 값이 변하는 경우 함수를 갱신

const printMenu useCallback(() => {
//food가 업데이트 될 때만 console.log(...)가 갱신된다.
console.log(`[ Today's Dinner Menu : ${food} ]`);
}, [food]);

2. useEffect : 의존성 배열의 값이 변하는 경우 함수를 실행

useEffect(() => { state가 업데이트 될 때만 이 부분이 실행된다 }, [state]);

React.memo vs useMemo vs useCallback
1. React.Memo : 컴포넌트 전체에 대하여 메모이제이션 진행하기 때문에, 컴포넌트 전체에 대한 재사용 (Render Phase를 진행하지 않도록 도와줌)
2. useMemo : 값의 재사용
3. useCallback : 함수의 재사용

참고자료 및 출처
[10분 테코톡] 앨버의 리액트 렌더링 최적화

profile
🌱Connecting the dots🌱
post-custom-banner

0개의 댓글