lottie
Seungjun's blog
blog
react && rendering (2)

렌더 동작의 엣지 케이스

     리액트의 <StrictMode > 태그 내부에서는 컴포넌트를 이중으로 렌더링 한다. 렌더링 로직이 실행되는 횟수가 커밋된 렌더링 패스의 횟수와 동일하지 않으며, 렌더링을 수행하는 동안 **console.log()**문에 의존하여 발생한 렌더링의 수를 셀 수 없다.


  대신 React DevTools Profiler를 사용하여 추적을 캡쳐하고, 전체적으로 커밋된 렌더링 갯수를 세거나, useEffect 훅 또는 componentDidMount componentDidUpdate 라이프 사이클에서 로깅을 추가하는 방법을 사용해야 한다. 이렇게 하면 실제로 렌더링 패스를 완료하고 이를 커밋한 경우에만 로그가 찍힌다.

 정상적인 상황에서는 절대로 실제 렌더링 로직에서 상태 업데이트를 대기열에 넣어서는 안된다. 즉, 클릭이 발생할 때setState()를 호출하는 콜백을 사용하는 것은 괜찮지만, 실제 렌더링 동작의 일부로 setState()를 호출하는 것은 안된다.


 그러나 여기에는 한가지 예외가 있다. 함수 컴포넌트는 렌더링하는 동안 setState()를 직접호출할 수 있지만, 이는 조건부로 수행되고 컴포넌트가 렌더링될 때 마다 실행되지 않는다. 렌더링 하는 동안 함수 컴포넌트가 상태 업데이트를 대기열에 밀어 넣어두면, 리액트는 즉시 상태 업데이트를 적용하고 해당 컴포넌트 중 하나를 동기화 하여 다시 렌더링 한 후 계속 진행한다. 컴포넌트가 상태 업데이트를 무한하게 queueing하고 리액트가 다시 렌더링을 하도록 강제하는 경우, 리액트는 최대 50회까지 만 실행한 후에 이 무한반복을 끊어버리고 오류를 발생 시킨다.

 이 기법은 useEffect 내부에 setState()호출과 리렌더링을 하지 않고 prop 값을 기준으로 state의 값을 강제로 업데이트 할 때 사용할 수 있다.

function ScrollView({ row }) {
  const [isScrollingDown, setIsScrollingDown] = useState(false)
  const [prevRow, setPrevRow] = useState(null)
// 조건부로 prop 값을 기준으로 바로 state를 업데이트 때릴 수 있음if (row !== prevRow) {
    setIsScrollingDown(prevRow !== null && row > prevRow)
    setPrevRow(row)
  }
  return `Scrolling down: ${isScrollingDown}`}
  

렌더링 성능 향상시키기

       렌더링은 리액트의 동작 방식에서 일반적으로 예상할 수 있는 부분이지만, 렌더링 작업이 때때로 낭비될 수 있다는 것도 사실이다. 컴포넌트의 렌더링 출력이 변경되지 않았고, DOM의 해당 부분을 업데이트할 필요가 없다면 해당 컴포넌트를 렌더링 태우는 것은 정말로 시간낭비다.


  리액트 컴포넌트 렌더링 결과물은 항상 현재 props와 state의 상태를 기반으로 결정되어야 한다. 따라서 props와 state가 변경되지 않았음을 미리 알고 있다면 렌더링 결과물은 동일 할 것이고, 이 컴포넌트에 대해 변경이 필요하지 않고 렌더링 작업을 건너 뛸 수 도 있다는 것에 대해서도 알아야 한다.


 일반적으로 소프트웨어 성능을 개선하는 건 두가지 접근법이 존재한다.

  • 동일한 작업을 가능한 더 빨리 수행하는 것

  • 더 적게 작업하는 것

 리액트에서 렌더링을 최적화하는 것은 주로 컴포넌트 렌더링을 적절하게 건너뛰어서 작업량을 줄이는 것이다.

컴포넌트 렌더링 최적화 기법

 리액트는 컴포넌트 렌더링을 생략할 수 있는 세가지 API를 제공한다.

  • React.Memo():

     내장 고차 컴포넌트 타입으로, 컴포넌트 타입을 인수로 받고, 새롭게 래핑된 컴포넌트를 리턴된다. 래퍼 컴포넌트의 기본 동작은 props의 변경이 있는지 확인하고, 변경된 props가 없다면 다시 렌더링 하지 못하게 하는 것이다. 함수 컴포넌트와 클래스 컴포넌트는 모두 이 것을 사용하여 래핑 할 수 있다.

 이 기법은 shallow equality (얕은 비교)를 사용한다. 서로 다른 객체에 있는 모든 개별 필드를 검사하여 객체의 내용이 같은지 다른지 확인한다. 다시말해, obj1.a === obj2.a && obj1.b === obj2.b && ........를 수행하는 것이다. 이는 자바스크립트 엔진에서 매우 간단한 작업인 **===**를 사용하므로 매우 빠르게 끝난다.


 여기에 잘 알려지지 않은 기법도 하나 더 있다. 리액트 컴포넌트가 렌더링 결과물을 지난번과 정확히 동일한 참조를 반환한다면, 리액트는 해당 하위 컴포넌트를 렌더링하는 것을 건너 뛴다. 이 기술을 구현하는 방법은 대략 두가지 정도가 있다.

  • 결과물에 **props.children**이 있다면, 이 컴포넌트가 상태 업데이트를 수행해도 element는 동일할 것이다.

  • 일부 Element를 **useMemo()**로 감싸면, 종속성이 변경될 때 까지 동일하게 유지된다.

아래 코드를 살펴보자.

// 상태가 업데이트되도 props.children은 다시렌더링 되지 않는다.function SomeProvider({ children }) {
  const [counter, setCounter] = useState(0)
  return (
    <div>
      <button onClick={() => setCounter(counter + 1)}>Count: {counter}</button>
      <OtherChildComponent />
      {children}
    </div>)
}
function OptimizedParent() {
  const [counter1, setCounter1] = useState(0)
  const [counter2, setCounter2] = useState(0)
  const memoizedElement = useMemo(() => {
// counter2가 업데이트되도 같은 참조를 반환하므로, counter1이 변경되지 않는 한 같은 참조를 리턴할 것이다.return <ExpensiveChildComponent />}, [counter1])
  return (
    <div>
      <button onClick={() => setCounter1(counter1 + 1)}>
        Counter 1: {counter1}
      </button>
      <button onClick={() => setCounter1(counter2 + 1)}>
        Counter 2: {counter2}
      </button>
      {memoizedElement}
    </div>)
}

 이러한 모든 기법들에서, 컴포넌트 렌더링을 건너뛰면 리액트는 마찬가지로 하위 트리의 전체 렌더링을 건너뛰어 이는 "재귀적으로 자식을 렌더링" 하는 동작을 중지하게 된다.


새로운 props의 참조가 렌더링 최적화에 어떻게 영향을 미치는가?

     앞서 보았듯이, 기본적으로 리액트는 중첩된 컴포넌트의 props가 변경되지 않았더라도 다시 렌더링을 수행한다. 이는 하위 컴포넌트에 새로운 참조를 props로 전달하는 것 또한 문제가 되지 않는다는 것을 의미한다. 왜냐하면 같은 props가 오던 상관없이 렌더링을 할 것이기 때문이다. 아래 예제를 살펴보자.

// ParentComponent가 렌더링될때마다, 하위 자식 컴포넌트의 props는 변경되지 않았지만 그것과 상관없이 계속 리렌더링 된다.
function ParentComponent() {
  const onClick = () => {
    console.log('Button clicked')
  }
  const data = { a: 1, b: 2 }
  return <NormalChildComponent onClick={onClick} data={data} />}

  ParentComponent가 매번 렌더링 될 때 마다, 매번 새로운 onClick 함수의 참조와 새로운 data 객체 참조를 만들어서, 이를 props로 자식 컴포넌트에 넘겨줄 것이다.


 이는 또한 <div/>나 <button/>를 React.memo()래핑하는 것 처럼, 호스트 컴포넌트에 대해 렌더링을 최적화 하는 것이 별 의미가 없다는 것을 뜻한다. 이러하나 기본 컴포넌트 하위에 하위 컴포넌트가 없으므로 렌더링 프로세스는 여기서 중지되버리고 말 것이다.


 하지만, 하위 컴포넌트가 props가 변경되었는지 확인하여 렌더링을 최적화 하려는 경우, 새 props를 전달하면 하위 컴포넌트가 렌더링을 수행하게 된다. 새 prop 참조가 실제로 새로운 데이터인 경우에 이방법이 유용하다.

그러나 상위 컴포넌트가 단순히 콜백 함수를 전달하는 수준이면 어떻게 될까?

const MemoizedChildComponent = React.memo(ChildComponent)
function ParentComponent() {
  const onClick = () => {
    console.log('Button clicked')
  }
  const data = { a: 1, b: 2 }
  return <MemoizedChildComponent onClick={onClick} data={data} />}

이제, ParentComponent가 렌더링 될 때 마다 MemoizedChildComponent는 해당 props 가 새로운 참조로 변경되었음을 확인하고 다시 렌더링을 수행한다. onClick 함수와 데이터 객체의 값이 변하지 않았음에도!


이러한 과정을 요약하자면

  • MemoizedChildComponent는 렌더링을 건너뛰고 싶었지만, 항상 다시 렌더링 될 것이다.

  • 새로운 참조가 계속해서 생기기 때문에 props의 변화를 비교하는 것은 무의미한 일이다.

function Component() {
  return (
    <MemoizedChild>
      <OtherComponent />
    </MemoizedChild>)
}

props.children이 항상 새로운 참조를 가리키기 때문에 항상 자식 컴포넌트를 새로 렌더링 할 것이다.


props 참조를 최적화하기

     함수 컴포넌트의 경우, 리액트는 동일한 참조를 재사용하는데 도움이 되는 두가지 훅이 있다. 객체 생성이나 복잡한 계산과 같은 모든 종류의 일반 데이터에 **useMemo**를 사용하거나, 콜백 함수를 만들 때는 **useCallback**을 사용한다.

그냥 전부 메모이제이션?

 위에서 언급했던 것 처럼, 모든 함수와 값을 useMemo **useCallback**으로 감싸서 사용할 필요는 없다. 이러한 처리는 단지 자식 컴포넌트의 동작에 변화를 만들 뿐이다.

    즉, useEffect에 대한 의존성 배열 비교는 자식이 일관된 props 참조를 받기 원하는 경우를 만듦으로써, 상황이 더욱 복잡해 질 수 있다.


 또 다른 질문은 왜 리액트가 기본적으로 모든 것을 **memo**로 감싸지 않았냐는 것이다.


  props을 비교하는 것은 공짜가 아니다. 그리고 컴포넌트가 항상 새로운 **props**를 받기 때문에 메모이션으로 체크한다고 리렌더링을 막을 수 없는 상황 또한 존재한다.


 그럼에도, 개인적으로는 **React.Memo**를 사용하는 것이 전반적인 앱 렌더링 성능에서 순이익이 될 가능성이 높다고 생각한다.


 리액트는 완전히 렌더링을 기반으로 한다. 무엇이든 하려면 렌더링을 해야 한다. 그리고 대부분의 렌더링은 그렇게 비싸지 않다.


 낭비되고 있는 리렌더링을 줄이는 것 만이 능사는 아니다. 전체 앱을 다시 렌더링 하는 일도 잦지 않다. DOM 업데이트가 없는 낭비되고 있는 리렌더링은 CPU를 그렇게 혹사시키지 않는다.


불변성과 렌더링

  리액트의 state 업데이트는 항상 immutable으로 수행되어야 한다. 그 이유는 두가지가 있다.

  • mutate한 값의 대상과 위치에 따라 컴포넌트가 렌더링 되지 않을 수 있다.

  • 데이터가 실제로 업데이트 된 시기와 이유에 대해 혼란을 겪을 수 있다.

 앞서 보았던 것 처럼, React.memo PureComponent shouldComponentUpdate는 얕은 비교를 기반(메모리상 주소, 참조를 비교)으로 이전과 이후의 prop 값을 비교한다. props.value !== prevProps.newValue로 비교할 것이다.


  만약 값의 불변성을 지키지 않았을 경우, **someValue**는 같은 참조를 가지고 있기 때문에 컴포넌트는 아무것도 변경되지 않았다고 생각할 것이다.


 불필요한 리렌더링을 방지하여 성능을 최적화해야 한다는 것을 인지해야 한다. props가 변경되지 않은 경우 렌더링은 불필요하거나 낭비일 뿐이다. mutate 한 값을 사용하면, 컴포넌트가 아무것도 변하지 않았다고 잘못생각할 수 있으며, 개발자는 컴포넌트가 다시 렌더링 되지 않은 이유에 대해서 헷갈릴 수 있다.


  또다른 문제는 useState와 useReducer훅이다. 

setCounter()나 dispatch()가 호출될 때 마다, 리액트는 리렌더링을 큐에 밀어넣을 것이다. 그러나 리액트는 모든 훅의 상태 업데이트에 새 객체/배열의 참조이거나, 새 원시(문자열, 숫자.. 등)로 전달, 반환해야 한다.


 리액트는 렌더링 단계 동안 모든 상태 업데이트를 적용한다. 리액트는 훅에서 상태 업데이트를 적용하려고 하면, 새 값이 동일한 참조인지 확인한다. 리액트는 항상 업데이트 대기열에 있는 컴포넌트 렌더링을 끝낸다. 그러나 이전과 값이 동일한 참조이고, 렌더링을 해야하는 다른 이유가 없다면 (부모 컴포넌트의 리렌더링 등) 리액트는 컴포넌트에 대한 렌더링 결과를 버리고 렌더링 패스를 벗어난다.

const [todos, setTodos] = useState(someTodosArray)
const onClick = () => {
  todos[3].completed = true 
  setTodos(todos)           
}                           
// set함수를 이용하지 않고 직접 state value를 update 
// 이는 state의 refer value를 바꾸지 않는다.
// 리액트는 기본적으로 얕은비교(참조 비교)를 한다.
// 참조는 달라지지 않았으므로 리액트는 바뀐점이 없다고 인식한다.
// 그 결과 리렌더링을 하지 않고 바뀐 값은 반영되지 않는다

 이는 컴포넌트 리렌더링에 실패한다.

기술적으로, 가장 바깥쪽 참조만 반드시 업데이트 해야 한다.

const onClick = () => {
  const newTodos = todos.slice()
  newTodos[3].completed = true
  setTodos(newTodos)
}

 이렇게 하면 새로운 바열 객체를 넘겨줄 수 있고, 컴포넌트는 반드시 리렌더링 될 것이다.


 한가지 알아둬야 할 것은, 클래스 컴포넌트와 함수형 컴포넌트 사이엔 동작에 뚜렷한 차이가 있다는 것이다. 클래스 컴포넌트의 **this.setState()**을, 함수형 컴포넌트의 useState useReducer 훅을 사용한단 것이다. **this.setState()**는 값이 불변이 아니어도 된다. 항상 리렌더링을 한다.


 모든 실제 렌더링 동작의 이면에는, 불변하지 않은 값은 리액트의 단방향 데이터 플로우에 혼란을 야기한다. 불변하지 않은 값은 코드로 하여금 다른 값을 보게 하는데, 기대와는 다르게 동작할 가능성이 크다. 이로 인해 특정 상태가 실제로 업데이트 되어야 하는 시기와 이유, 또 변경사항이 어디에서 발생했는지 알기 어려워진다.


 다시한번 정리하면, 리액트, 그리고 리액트의 에코시스템에서는 모든 것이 불변한 update로 간주된다. 불변하지 않은 값은 버그를 유발할 수 있다.


리액트 컴포넌트 렌더링 성능 측정하기

 React DevTools Profiler를 활용하여 어떤 컴포넌트가 각 커밋 마다 렌더링되는지 살펴보자. (React.memo()로 감싸거나, 부모 컴포넌트가 넘겨주는 props를 메모이즈 하는 등의 방법이 있을 수 있다.)


  또한, 리액트는 dev build에서 느리게 실행된다. development 모드에서는 어떤 컴포넌트가 왜 렌더링 되었는지 살펴보고, 컴포넌트가 렌더링되는데 소요되는 시간등을 비교할 수 있다. 그러나 절대 리액트 development 모드로 렌더링 속도를 측정하서는 안된다. 반드시 프로덕션 빌드로 렌더링 속도를 측정해야 한다.