Jaeilit

렌더링과 리렌더링 최적화(메모이제이션) 본문

TIL

렌더링과 리렌더링 최적화(메모이제이션)

Jaeilit 2024. 5. 2. 16:58
728x90

렌더링과 리렌더링 최적화(Feat. 메모이제이션)

 

리액트에렌더링이란

props와 state를 기반으로 컴포넌트를 구성하는데 이를 요청하고 제공하는 프로세스를 의미한다.

브라우저에서도 렌더링이라는 단어를 사용하기 때문에 리액트 렌더링도 흔히 비슷하게 화면에 그리는 페인팅 과정이라고 오해할 수 있는데 리액트에서의 렌더링은 컴포넌트를 호출하는 것이다. 리액트는 렌더링을 먼저 완료하고 DOM을 업데이트한 후 브라우저가 리페인팅한다.

 

렌더링 프로세스

렌더링 프로세스 자체는 트리거, 렌더, 커밋 총 3단계로 구성된다. 먼저 렌더링을 하기 위해서는 아무때나 시도때도 없이 렌더링을 할 수 없으니 이때는 꼭 렌더링 해주세요! 라는 트리거 장치가 필요하다. 그리고 당연히 이 트리거가 되는 조건이 있다.

 

렌더링을 시키는 트리거의 조건초기 렌더링상태 업데이트 두개로 나뉜다.

 

초기 렌더링

초기 렌더링은 앱이 실행했을 때 트리거 조건이 발동한다. createRoot를 호출하고 render 함수를 호출하여 실행하는 부분을 이야기한다.

createRoot, render 함수 초기렌더링에 해당

 

상태 업데이트

리액트에서 리렌더링을 발생시키는 조건이 props와 state의 변경과 부모 자식 컴포넌트들에 관계 등등 크게 4가지 정도 정의해서 공부한 적이 있다. 하지만 여기서 상태 업데이트만을 트리거의 조건으로 다루는 이유는 사용자가 직접 컨트롤 할 수 있는 영역이기 때문이다.  즉, setState 기능으로 상태를 업데이트하여 추가 렌더링을 트리거하는 것을 말하기 때문에 간단한 예시로는 Count 증가 버튼을 클릭으로 Count State 를 변경하여 리렌더링을 트리거(유발)시키는 경우에 해당한다.

 

트리거가 완료 된 후 리액트는 DOM을 업데이트하기 위해 Render Phase 와 Commit Phase 두 단계로 렌더링 프로세스를 진행한다.

 

Render Phase (렌더링 단계)

렌더링 단계에서는 리액트가 컴포넌트를 호출하여 화면에 표시할 내용을 파악한다. 렌더링은 React가 컴포넌트를 호출 하는 것을 말한다.

이 렌더링 단계의 프로세스는 재귀적으로 더이상 중첩 된 컴포넌트가 없을 최하위 컴포넌트까지 계속된다. 

 

렌더링 단계에서는 초기 렌더링을 제외하고 리렌더링이 발생하면 재조정 과정도 일어나게 되는데 JSX가 바벨로 부터 createElement 혹은 17버전 이상에서는 _jsx() 로 변환된 객체를 비교하는 과정이다. 

// createElement 객체 트리로 변환 되는 과정


// This JSX syntax:
return <MyComponent a={42} b="testing">Text here</MyComponent>

// is converted to this call:
return React.createElement(MyComponent, {a: 42, b: "testing"}, "Text Here")

// and that becomes this element object:
{type: MyComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}

// And internally, React calls the actual function to render it:
let elements = MyComponent({...props, children})

// For "host components" like HTML:
return <button onClick={() => {}}>Click Me</button>
// becomes
React.createElement("button", {onClick}, "Click Me")
// and finally:
{type: "button", props: {onClick}, children: ["Click me"]}

 

바벨에서 직접 해보기

 

17버전 이상에서 _jsx() 함수로 변환 된 모습

Commit Phase(커밋 단계)

컴포넌트를 렌더링(호출)한 후 React는 DOM을 수정한다. 초기 렌더링시에는 React는 DOM API를 사용하여 생성 된 모든 DOM 노드를 화면에 배치합니다. 리렌더링의 경우에는 최신 렌더링 출력과 일치하도록 최소한의 필수 작업만 적용합니다.

 

예시)

// A
function App() {
  console.log("app");

  return <Card />;
}

export default App;


// B
export const Card = () => {
  const [count, setCount] = useState(0);
  console.log("card");

  return (
    <div className="card">
      <Button count={count} onClick={() => setCount((count) => count + 1)} />
      <Text />
    </div>
  );
};


// C-1
export const Button = ({ count, onClick }) => {
  console.log("button");
  return <button onClick={onClick}>count is {count}</button>;
};


// C-2
export const Text = () => {
  console.log("text");
  return (
    <p>
      Edit <code>src/App.tsx</code> and save to test HMR
    </p>
  );
};

 

Count 컴포넌트의 상태변경으로 실제로 이벤트가 발생한 C-1인 button 컴포넌트가 리렌더링 됐고 가만히 있던 C-2 인 Text 컴포넌트도 리렌더링 됐습니다.

 

부모 컴포넌트를 리렌더링하면 기본적으로 해당 컴포넌트의 모든 하위 컴포넌트가 리렌더링 된다는 것을 알 수 있습니다.

Text 컴포넌트를 보면 props 의 변경과 관련없이 부모 컴포넌트가 리렌더링 됐습니다.

 

Text 컴포넌트 입장에서는 변한게 없는데 리렌더링 해야하는 이유가 없습니다. 

 

클래스 컴포넌트에서는 props와 state가 이전과 비교해서 동일하면 부모가 리렌더링 될 때 다시 렌더링 하지 않는 컴포넌트로 만드는 PureComponent 가 있었습니다. 함수형에서는 React.memo로 대체되었습니다.

React.memo 는 useMemo 와 useCallback 과 같은 메모이제이션 기법으로 메모이제이션이란 기억이라고 번역할 수 있습니다.

이전의 props와 state를 기억하고 있으므로 변경이 없으므로 리렌더링 하지 않습니다.

 

Text 컴포넌트를 React.memo로 매핑해주면 Text 컴포넌트는 변하는 상태가 없기 때문에 더 이상 호출되지 않습니다.

 

 

 

콘솔로그처럼 디버깅하지 않아도 시각적으로 리액트 devtool 프로파일러에서도 확인이 가능합니다.

 

 

React.memo 는 shallow equality 로 모든 props가 이전과 동일한지 비교합니다.

예시로 원시타입인 3 === 3은 true 지만 참조타입인 {} === {} 은 false 입니다.

이유는 원시타입은 값이 있는 메모리 주소를 가르키고 있어 3이 할당되어 있는 메모리 주소를 서로 동일하게 가르키기 때문에 true 이고,

참조타입은 {} {} 자체가 이미 다른 주소를 가르키고 있기 때문입니다.

 

예를들어  ojb = { a: 3 }, obj2= { b  3}  이면 이미 {} 이 객체 자체는 다른 주소지만 객체 안에 프로퍼티로 있는 3은 서로 같은 원시타입을 참조하기 때문에 obj === obj2 는 false 지만  obj.a === obj.2 는 true 라고 출력을 합니다. 이러한 이유로 참조타입을 props 로 넘기게되면 매번 새롭게 생성되기 때문에 memo 로 감싸더라도 리렌더링이 발생합니다.

 

이 부분은 useMemo 로 값을 메모이제이션하면 해결할 수 있습니다.

 

useMemo 사용 후

 

useCallback, useMemo, React.memo 의 메모이제이션으로 불필요한 리렌더링을 줄이고 최적화 하는 방법을 알아냈으니 이제 모든 함수나 props로 내려지는 변수와 컴포넌트 등에 저 메서드들을 디폴트로 사용하면 되지않냐고 생각할 수도 있습니다. 메모이제이션 기법은 말 그대로 메모리에 저장해두는 기법이므로 과도한 사용은 오히려 메모리 사용을 늘려서 페이지의 성능을 악화 시킬 수도 있기 때문에 사용에 대한 특별한 기준이 필요합니다. 이 문제에 대해서는 리액트를 다루는 개발자들은 다들 한번쯤은 누군가와 토론을 해봤을 주제라고 생각합니다.

 

컴포넌트의 props나 함수등 자주 변경되는 경우에는 메모이제이션 기법을 사용하는 것이 오히려 더 비효율적이고 메모이제이션 자체도 비용이 들어가기 때문에 자주 메모이제이션을 해야한다면 성능저하를 일으킬 수 있습니다.

 

useMemo의 경우에는 리액트 공식문서에서 1ms초 (0.001초) 이상 걸리는 계산들은 메모이제이션하는 것이 합리적일 수 있다고 설명하고 있습니다.

 

결론

 

컴포넌트에 불필요한 리렌더링을 줄이는 방법에 대해서 메모이제이션 기법을 알아봤습니다.

 

저는 예전에 메인 페이지의 어느 부분에서 최초 렌더링부터 페이지 로드가 완료 될 때까지 총 108번의 리렌더링을 하는 것을 디버깅을 통해 알아냈고 이를 36회까지 감소시킨 경험이 있습니다. 36회도 많아보이긴 하지만 9개의 아이템들이 관련되어 있어 9의 배수로 생각하면 108번은 총 12회, 36회는 총 4번 결과이고 수치상으로 66.67% 입니다.

 

디버깅을 하지 않았더라면 저는 108번 리렌더링이 됐는지도 몰랐을 확률이 높았다는 것입니다. 디버깅을 한 이유도 성능 지표가 안좋거나 사용자 경험(UX)이 안좋아서 성능 저하를 일으키는 원인을 찾기 위해서가 아니라 해당 코드가 동작에 비해 너무 과하지않았나 라는 생각에 코드의 개선 의지를 가지고 디버깅 해본 결과입니다.

 

이 최적화 과정 중에 단 한줄의 메모이제이션 기법을 사용하지 않았습니다. 코드의 개선으로만 이뤄낸 결과입니다. 

메모이제이션을 활용하여 최적화가 필요하다면 불필요한 리렌더링을 발생시키는 코드가 없는 지 근본적인 원인부터 찾아서 수정하는 것을 권장합니다. 또한 리액트는 이미 최적화가 잘되어있고 리렌더링은 무조건 나쁜게 아닙니다. 불필요한 리렌더링 개선에 너무 집착하지 않으시는것도 추천드립니다!

 

참고자료

https://d2.naver.com/helloworld/9223303

https://blog.isquaredsoftware.com/2020/05/blogged-answers-a-mostly-complete-guide-to-react-rendering-behavior/

https://www.joshwcomeau.com/react/why-react-re-renders/#highlighting-re-renders-6

https://react.dev/versions

728x90

'TIL' 카테고리의 다른 글

UI 라이브러리 초기세팅  (0) 2024.06.27
토글버튼 만들어보기  (0) 2024.06.20
Graphql 과 RESTful API 차이점  (0) 2024.04.11
스토리북 chromatic action error  (0) 2024.03.08
자바스크립트의 비동기 프로그래밍 Promise  (0) 2023.11.16