FrontEnd/React.js

리액트의 컴포넌트 렌더링

SambaLim 2022. 5. 5. 00:54

리액트를 사용해 앱을 개발하다보면 “리액트는 언제 또는 왜 컴포넌트를 렌더링하는지 궁금증을 갖게 됩니다. 렌더링이 언제, 왜 일어나는지 모른다면 리액트 컴포넌트들을 조합하여 사용자가 원하는 UI를 보여주기 어려울 것이고 원하지 않는 렌더링 때문에 사용자에게 좋지 않은 경험을 심어줄 수 있습니다.

따라서 리액트가 언제 컴포넌트를 렌더링 하는지 공부하며 이 글을 적습니다.

“렌더링"이란 무엇입니까?

리액트 컴포넌트의 렌더링은 propsstate 의 조합으로 리액트가 특정 구역의 UI 를 어떻게 보여줄지 설명하는 것을 이야기합니다.

렌더링 프로세스

리액트는 컴포넌트 트리의 Root부터 시작하여 업데이트가 필요한 것으로 보이는 모든 컴포넌트를 찾아 아래쪽으로 순환합니다. 플래그가 지정된(flagged) 컴포넌트에 대해 리액트는 FunctionComponent() 를 호출하고 렌더링 출력을 저장합니다.

컴포넌트의 렌더링 출력은 일반적으로 JSX 구문으로 작성된 다음, React.createElement() 를 사용하는 JS로 컴파일 됩니다. 그리고 UI의 의도(intent)를 설명하는 일반 JS 객체를 반환합니다.

// JSX 구문:
return <SomeComponent a={42} b="testing">Text here</SomeComponent>

// React.createElement()를 사용하는 JS:
return React.createElement(SomeComponent, {a: 42, b: "testing"}, "Text Here")

// 일반 JS 객체
{type: SomeComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}

전체 컴포넌트 트리에서 리액트는 새 객체 트리(”가상 DOM”이라고도 부름)를 비교하고 모든 변경 목록을 수집합니다. 그리고 이를 토대로 진짜 DOM을 렌더링합니다. 비교하는 과정을 리액트는 Reconcilation(재조정)이라고 부릅니다.

리액트는 언제 컴포넌트를 렌더링하나요?

  • ReactDOM.render()를 직접 호출
  • dispatchAction 으로 인하여 컴포넌트의 상태가 바뀌었을 경우
  • 부모 컴포넌트가 렌더링 될 때

ReactDOM.render()를 호출하는 예시

const root = ReactDOM.createRoot(document.getElementById('root'));

function tick() {
  const element = (
    <div>
      <h1>Hello, world!</h1>
      <h2>It is {new Date().toLocaleTimeString()}.</h2>
    </div>
  );
  root.render(element);
}

setInterval(tick, 1000);

root.render(element) 를 통해 매초 전체 UI를 다시 그리도록 만드는 예제입니다.

dispatchAction

useReducerdispatch 함수와 useState 훅의 상태 업데이트 함수는 모두 dispatchAction 을 사용합니다. 이는 컴포넌트의 상태 변경을 위한 업데이트를 예약합니다.

부모 컴포넌트가 렌더링 될 때

리액트는 기본적으로 부모 컴포넌트가 렌더링될 때, 모든 자식 컴포넌트를 재귀적으로 렌더링합니다.

예를들어 컴포넌트 트리가 A > B > C > D 처럼 있고 이미 페이지에 표시되었다고 가정했을 때, B 컴포넌트 내부에서 setState 가 일어났다면 다음과 같은 일이 일어납니다.

  • B 컴포넌트에서 setState() 를 호출합니다.
  • 리액트는 컴포넌트 트리 상단에서부터 비교를 시작합니다.
  • 리액트는 A 컴포넌트의 업데이트가 필요하지 않은 것을 보고 넘어갑니다.
  • 리액트는 B 컴포넌트의 업데이트가 필요한 것으로 표시된 것을 보고 렌더링합니다.
  • C 는 업데이트가 필요한 것으로 표시되지 않았지만, 부모 B가 렌더링되었기 때문에 다시 렌더링합니다.
  • D 도 마찬가지로 부모 C 가 렌더링 되었기 때문에 리액트가 이를 다시 렌더링합니다.

일반적으로 리액트는 렌더링할 때 “props 가 변경되었는지” 여부를 신경쓰지 않습니다. 부모 컴포넌트가 다시 렌더링되기 때문에 자식 컴포넌트를 렌더링한 것입니다.

렌더링 제외 기준

부모 컴포넌트가 렌더링 될 때, 모든 자식 컴포는트는 재귀적으로 렌더링합니다. 하지만 자식 컴포넌트 중 렌더링 제외 기준을 충족하는 컴포넌트가 있다면 해당 컴포넌트는 렌더링 하지 않습니다.

리액트가 렌더링 하는 모든 컴포넌트는 bigWork 를 호출합니다. bigWork소스코드 의 조건문(if)를 보면 리액트 렌더링 제외 로직을 보실 수 있습니다.

  • current !== null
    • 컴포넌트가 이미 마운트된 경우
  • oldProps !== newProps
    • 변경된 props 가 없는 경우
  • hasLegacyContextChanged()
    • 컴포넌트에서 사용하는 context 값의 변경이 없는 경우
  • !hasScheduledUpdateOrContext
    • 컴포넌트 자체에서 업데이트를 약하지 않은 경우

이와 같은 렌더링 제외 로직을 조금 더 세부적으로 봐봅시다.

props 변경을 탐지하는 데 사용하는 규칙 변경 방법

oldProps !== newProps 처럼 리액트는 === 를 사용하여 props 를 비교합니다. React.memo 로 감쌀 경우, 리액트는 props 변경을 모든 프로퍼티에 얕은 비교를 수행하여 확인합니다.

이는 개념적으로 Object.keys(prevProps).some(key => prevProps[key] !== nextProps[key]) 와 유사합니다.

context 값을 변경하지 않는 방법

컴포넌트가 어떤 context 값의 consumer인 경우, provider 가 리렌더링 되고 context값이 변경됬을 때(참조적으로만), 컴포넌트는 리렌더링 됩니다.

이 경우, 가장 쉬운 해결 방법은 context 값을 useMemo 로 래핑하여 provider 컴포넌트가 다시 렌더링되더라도 참조적으로 동일하게 유지하는 것입니다.

function Parent({ children, lastChild }) {
  const contextValue = {};
  const memoizedCxtValue = useMemo(contextValue);
  return (
    <div className="parent">
      <Context.Provider value={memoizedCxtValue}>
        <ChildA />
        {children}
        {lastChild}
      </Context.Provider>
    </div>
  );
}

useMemo 를 사용하여 context 값을 래핑할 필요가 없는 예외가 하나있습니다.

context provider 트리가 컴포넌트 트리의 최상단에 있으면 context의 값을 기억할 필요가 없습니다.

const ContextA = createContext(null);

const Parent = () => {
  const [state, dispatch] = useReducer(reducerA, initialStateA);
  return (
    <ContextA.Provider value={[state, dispatch]}>
      <Child1 />
    </ContextA.Provider>
  );
};

Parent 컴포넌트가 트리의 최상단에 있는 경우(다른 부모 컴포넌트가 없는 경우), 리액트가 Parent 를 렌더링하는 유일한 이유는 dispatch 가 호출되었을 때뿐입니다. 따라서 위의 예시와 같이 값을 직접 전달하는 것이 좋습니다.

암묵적인 전제

위의 모든 이야기는 컴포넌트가 항상 컴포넌트 트리의 같은 위치에 렌더링 된다는 전제하에 이루어졌습니다.

  • 동일한 위치에서 다른 컴포넌트 간에 전환
  • 같은 컴포넌트를 다른 위치에 렌더링
  • 의도적으로 key 를 변경

위와 같은 경우 리액트는 하위 컴포넌트 트리를 파괴하고 처음부터 다시 빌드합니다. 컴포넌트가 다시 렌더링될 뿐만아니라 상태또한 소실됩니다.

마무리

리액트는 propsstate 의 조합으로 리액트 컴포넌트를 렌더링합니다. 그리고 렌더링된 리액트 컴포넌트는 ReactDOM.render() 을 호출했을 경우 혹은 상태가 변경되었을 경우 다시 렌더링됩니다. 리액트는 기본적으로 부모 컴포넌트가 렌더링할 때 자식 컴포넌트 또한 렌더링되는데 여기에 예외 기준들이 존재합니다.

이와 같은 흐름을 통해 리액트 컴포넌트가 렌더링 되는 경우를 알 수 있었습니다. 다양한 상황 속에서 리액트 렌더링으로 인해 어려움을 겪을 때, 이러한 개념들이 큰 도움을 줄 것이라 생각합니다.

참고자료

'FrontEnd > React.js' 카테고리의 다른 글

React Query와 SWR 비교하기  (0) 2023.02.18
JSX.Element와 ReactElement 차이 이해하기  (0) 2023.02.07
React Hook-flow 이해하기  (0) 2021.11.26
useEffect 이해하기  (1) 2021.10.28
React Ref 이해하기  (0) 2021.09.29