React/모던 리액트 Deep Dive

리액트의 렌더링과 메모이제이션

58청춘 2024. 11. 8. 22:51
728x90

리액트의 렌더링

부라우저에서의 렌더링이란 HTML과 CSS 리소스를 기반으로 웹페이지에 필요한 UI를 그리는 과정이다. 사용자가 보게되는 결과물을 만드는 작업이기 때문에 중요한 과정이며 렌더링 방식이 성능에 큰 영향을 주게된다.

 

리액트의 렌더링이란

리액트에서의 렌더링은 브라우저 렌더링에 필요한 DOM 트리를 만드는 과정을 의미한다. 이는 모든 컴포넌트들이 갖는 props와 state의 값을 기반으로 DOM 결과를 브라우저에 제공할 것인지 계산하는 과정이다.

해당 컴포넌트가 props와 state 같은 상태값을 갖고 있지 않다면 반환하는 JSX 값에 기반해 렌더링이 일어나게 된다.

 

 

리액트 렌더링 트리거

리액트에서 렌더링이 발생하는 시나리오는 다음과 같다.

  • 최초 렌더링: 사용자가 처음 애플리케이션에 진입한 경우
  • 클래스 컴포넌트의 setState 변화
  • 클래스 컴포넌트의 forceUpdate : 강제 리렌더링을 할 수 있음
  • 함수 컴포넌트 useState의 setter 함수 실행
  • 함수 컴포넌트 useReducer()의 dispatch 실행
  • 컴포넌트의 key props 변경
리액트의 key props는 왜 필요한가?

보통 배열로 하위 컴포넌트를 사용하다 보면 key를 설정하라는 경고를 볼 수 있다. 리액트의 key는 리렌더링이 발생하는 동안 형제 요소들 사이에서 동일한 요소를 식별하는 식별값이다. 리렌더링이 발생하면 리액트는 current 트리와 workInProgress 트리 사이에서 변경 사항이 있는 컴포넌트를 구별할 수 있다.

 

  • 상위 컴포넌트로 부터 받은 props가 변경되는 경우
  • 상위 컴포넌트가 렌더링될 경우

 

렌더링 프로세스

렌더링 프로세스가 시작되면 리액트는 컴포넌트의 루트에서부터 아래쪽으로 내려가면서 업데이트가 필요하다고 지정되어 있는 컴포넌트를 찾은 뒤 클래스 컴포넌트는 render()를, 함수 컴포넌트는 FunctionComponent() 그 자체를 호출한뒤 결과를 저장한다.

 

렌더링 결과물은 JSX 문법으로 구성되며, React.createElement()를 호출하는 구문으로 변환된다. 이런 과정을 거쳐 각 컴포넌트의 렌더링 결과물을 수집한 이후 가상 DOM과 비교해 실제 DOM에 반영하기 위한 변경사항을 수집한다.

 

이렇게 계산하는 과정을 리액트의 재조정이라 하며 이 과정이 모두 끝나면 모든 변경 사항을 하나의 동기 시퀀스로 DOM에 적용해 변경된 결과물이 보이게 된다.

 

이때 리액트의 렌더링은 렌더 단계와 커밋 단계라는 총 두 단계로 분리되어 실행된다는 것이다.

 

렌더와 커밋

렌더 단계

렌더 단계는 컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업을 말한다. 즉 렌더링 프로세스에서 컴포넌트를 실행(render() 또는 return)이 결과와 이전 가상 DOM을 비교하는 과정을 거쳐 변경이 필요한 컴포넌트를 체크하는 단계이다.

 

크게 type, props, key 3가지를 보며 3개 중 하나라도 변경된 것이 있으면 변경될 컴포넌트로 체크한다.

 

커밋 단계

렌더 단계의 변경 사항을 실제 DOM에 적용해 사용자에게 보여주는 과정이다.

 

리액트가 DOM을 커밋 단계에서 업데이트한다면 만들어진 모든 DOM 노드 및 인스턴스를 가리키도록 리액트 내부의 참조를 업데이트한다.

 

클래스 컴포넌트는 componentDidMount, componentDidUpdate 메서드를 호출하고, 함수 컴포넌트에서는 useLayoutEffect 훅을 호출한다.

 

 

여기서 중요한 사실은 리액트의 렌더링이 일어난다고 해서 무조건 DOM 업데이트가 일어나는 것은 아니다는 것이다. 즉, 렌더 단계에서 변경 사항이 없다면 커밋 단계는 생략될 수 있다는 것이다.

 

이 두 가지 과정으로 이뤄진 리액트의 렌더링은 항상 동기식으로 작동했다. 따라서 렌더링 과정이 길어질 수록 성능 저하로 이어진다.

 

만약 리액트 렌더링이 비동기식으로 동작한다면 특정 작업의 시간이 길어질 경우 상대적으로 빠른 다른 작업을 먼저 보여줄 수 있다는 장점이 있지만, 하나의 상태에 대해 여러가지 UI를 사용자에게 보여줄 수 있다는 단점이 있다. 이는 A 작업이 완료되었는데, B, C가 완료되지 않아 사용자에게 혼란을 줄 수 있다.

 

하지만 상위 컴포넌트가 변경되었다면 하위 컴포넌트는 무조건 리렌더링이 일어나게된다. 이를 해결하기 위해서는 적절한 memo를 활용해 커밋 단계를 생략할 수 있다.

 

메모이제이션

리액트에서 제공하는 API 중 useMemo, useCallback 훅과 고차 컴포넌트인 memo는 리액트에서 발생하는 렌더링을 최소한으로 줄이기 위해서 제공된다.

 

이러한 기술은 언제 사용해야 할까?

렌더링이 자주 발생하는 컴포넌트?
렌더링이 일아날 것 같은 컴포넌트에 전부?
무거운 연산의 기준?
함수의 실행 속도?
렌더링 비용과 메모이제이션의 비용의 비율?
그냥 모든 컴폰너트에?

 

이러한 논쟁은 리액트 커뮤니티에서 꾸준히 이어지고 있다.

 

주장1 : 꼭 필요한 곳에만 메모이제이션을 추가하자.

function sum(a,b){
  return a+b
}

 

위의 예제 함수는 극단적으로 간단한 함수이다.

 

이때 드는 의문점은 다음과 같다. 함수의 결과를 메모이제이션 하기 VS 매번 새로운 개산 하기

물론 위 예시는 극단적이지만 가벼운 작업의 대부분은 매번 작업을 수행하는 것이 오히려 빠른 경우도 많다.

 

또한 메모이제이션에도 분명 비용이 든다는 것을 알아야 한다.

값을 비교하고 렌더링 또는 재계산이 필요한지 확인하는 작업을 수행한다. 그리고 결과물을 저장하고 꺼내는 비용도 발생한다.

 

따라서 섣부른 최적화는 문제가 될 수 있음을 알고 있어야 한다.

만약 이런 비교, 렌더링이 문제가 된다면 리액트는 모든 컴포넌트에 PureComponent로 만들어 졌어야 한다. 혹은 모든 컴포넌트를 memo로 감싸거나....

 

하지만 그렇지 않은 이유는 있다. 리액트 개발자인 아브라모프는 이런 말을 남겼었다.

 

아무데서나 memo를 사용하지 말것.

Q. 왜 모든 컴포넌트에 memo()를 기본값으로 사용하지 않나요?
스스로에게 물어보자. 왜 lodash의 모든 함수에 memoize()를 사용하지 않는가? 그게 더 빠르지 않을까? 성능을 확인해볼 필요가 있지 않을까?

 

또한 리액트 공식문서에도 useMemo에 관련한 내용이 있다.

 

useMemo – React

The library for web and native user interfaces

react.dev

리액트 공식문서의 useMemo에 대한 글

 

리액트가 캐시 결과를 저장하려고 하지만 캐시가 무효화되는 경우도 있을 수 있다. 개발자가 렌더링이 많이 될 것 같은 부분을 예상하고 메모이제이션하기 보다는 애플리케이션을 어느정도 개발한 뒤 개발자 도구, useEffect 등으로 렌더링이 되는 부분을 확인하고 최적화하는 것이 좋다.

 

 

주장 2: 렌더링 과정은 비싸다. 모조리 메모이제이션 해야한다.

위의 주장과 이번 주장에서 공통적으로 주장하는 전제가 있다. 일부 컴포넌트에서 메모이제이션 하는 것이 분명 성능에 도움이 된다는 것이다.

 

  • memo를 일부에만 적용하는 방법
  • memo를 일단 다 적용하는 방법

 

당연히 1번을 이상적인 방법임을 알고 있지만, App의 규모가 커지고 협업이 이뤄지다 보면 개발자들은 최적화나 성능 향상에 쏟는 시간이 많지 않다는 것은 공감할 것이다. 따라서 memo를 우선 감싸고 생각해보는게 좋다는 의견이 있다.

 

그렇다면 굳이 memo로 안감싸줘도 되는 컴포넌트에 memo를 감싸 역으로 생기는 비용을 생각해보자. 

해당 비용은 props에 얕은 비교가 발생하며 지불해야 하는 비용이며, 메모이제이션을 위해서는 CPU, 메모리를 사용해 이전 렌더링 결과물을 저장하고 리렌더링할 필요가 없다면 이전 작업을 쓰는 것이다.

 

그런데 이러한 방법은 기본적인 리액트의 재조정 알고리즘와 같아 어차피 리액트는 렌더링 결과를 다음 렌더링과 구별하기 위해서 저장해둬야 한다.

 

 

memo를 사용하지 않았을 때 생기는 문제는 다음과 같다.

  • 렌더링을 함으로써 발생하는 비용
  • 컴포넌트 내부의 복잡한 로직의 재실행
  • 위 두 가지 모두가 모든 자식 컴포넌트에서 반복해서 실행
  • 리액트가 구 트리와 신규 트리를 비교

그렇다면 memo를 사용했을 때의 문제는 뭘까?

  • props에 대한 얕은 비교를 하면 생기는 비용

 

이러한 비교를 통해 memo를 사용하지 않을 수 없다는 것이 이번 주장의 핵심이다.

 

useMemo와 useCallback

useMemo와 useCallback을 사용하여 의존성 배열을 비교하고 값을 재계산하는 과정 VS 값과 함수를 매번 재생성 하는 과정 중 무엇이 비용이 저렴한지 매번 계산해야 한다. 그렇다면 이 또한 마찬가지로 무조건 메모이제이션 하는 방법을 먼저 고민해 볼 필요가 있다.

 

또한 리렌더링이 발생할 때 메모이제이션과 같은 별도 조치가 없다면 모든 객체는 재생성되고, 결과적으로 참조는 달라지게 된다. 이 참조에 대한 값이 사용되지 않다면 문제가 되지 않지만, 이 값이 useEffect와 같은 의존성 배열에 사용되면 변경된 참조로 인해 다른 쪽에도 영향을 미칠 수 있다.

import React, { useEffect, useState } from 'react'

function useMath(number: number) {
  const [double, setDouble] = useState(0)
  const [triple, setTriple] = useState(0)

  useEffect(() => {
    setDouble(number * 2)
    setTriple(number * 3)
  }, [number])

  return { double, triple }
}

const App = () => {
  const [counter, setCounter] = useStatE(0)
  const value = useMath(10)

  useEffect(() => {
    console.log(value.double, value.triple)
  }, [value])

  function handleClick() {
    setCounter(counter + 1)
  }

  return (
    <>
      <h1>{counter}</h1>
      <button onClick={handleClick}>+</button>
    </>
  )
}

/* STYLE */

export default App

 

 

위 예시에서 console.log가 계속 출력되는 것을 볼 수 있다. 흐름은 App의 호출 다음에 useMath의 호출 다음에 객체 내부의 값은 같지만 참조가 변하여 console.log가 출력되는 것이다.

 

useMemo를 사용하면 이 문제를 해결할 수 있다.

function useMath(number: number) {
  const [double, setDouble] = useState(0)
  const [triple, setTriple] = useState(0)

  useEffect(() => {
    setDouble(number * 2)
    setTriple(number * 3)
  }, [number])

  return useMemo(()=>({ double, triple }),[double,triple])
}

 

 

정리

두 의견 모두 메모이제이션이 최적화에 도움이 되는것은 인정한다. 하지만 이를 얼마나 적용하는지에 대한 관점이 사람마다 다를 뿐이다. 이 책의 저자는 다음과 같이 이야기 한다.

 

시간적 여유가 있고 리액트에 대해 깊이 공부하고 싶다면 1번 방법으로 적용하는 것이 좋다.

 

하지만 현업처럼 시간적 여유가 없으며 리액트를 사용하고 있다면 2번 방법처럼 모든 컴포넌트에 적용하는 것이 좋을 수 있다.

 

props에 대한 얕은 비교보다 리액트 컴포넌트를 다시 계산해 실제 DOM 까지 비교하는 작업의 비용이 매우 비싸기 때문에 조금이라도 로직이 들어간 컴포넌트는 메모이제이션이 성능 향상에 도움을 줄 수 있다.

728x90