프론트엔드/React

React 렌더링

Hyeon_E 2023. 11. 21. 20:48

[ 렌더링 ]

현재 props 및 상태를 기반으로 리액트가 컴포넌트에게 UI 영역이 어떻게 보이길 원하는지 설명을 요청하는 프로세스

 

▶ 렌더링 프로세스 개요

리액트는 컴포넌트 트리의 루트에서 시작해 업데이트가 필요하다가 표시된 모든 컴포넌트를 찾기 위해 아래로 순회

플래그가 지정된 각 컴포넌트에 대해 함수 컴포넌트의 경우 FunctionComponent(props)를 호출하고 클래스 컴포넌트의 경우 classComponentInstance.render()를 호출하고 렌더 패스의 다음 단계를 위해 렌더 출력으로 저장함

컴포넌트 렌더 출력은 일반적으로 JSX 구문으로 작성되며 JS가 컴파일되고 배포를 준비될때 React.createElement() 호출로 변환됨. creatElement는 의도된 UI의 구조를 설명하는 일반 자바스크립트 객체인 React 요소를 반환

 

- 렌더 출력

해당 컴포넌트가 화면에 어떻게 보여져야 하는지에 대한 정보를 담고 있는 것

출력물은 React 엔진에 의해 DOM에 삽입되거나 업데이트되어 사용자가 실제로 볼 수 있는 웹 페이지의 내용으로 표현됨

클래스 컴포넌트의 경우 render()메서드, 함수 컴포넌트의 경우 함수 본문을 실행하여 JSX나 다른 리액트 요소들을 반환

 

// JSX 구문입니다.
return <MyComponent a={42} b="testing">Text here</MyComponent>

// 아래와 같은 호출로 변환됩니다.
return React.createElement(MyComponent, {a: 42, b: "testing"}, "Text Here")

// 그리고 이것은 다음과 같은 요소 객체가 됩니다.
{type: MyComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}

// 그리고 내부적으로는 리액트가 실제 함수를 호출해 렌더링 합니다.
let elements = MyComponent({...props, children})

// HTML 처럼 보이는 "호스트 컴포넌트"의 경우
return <button onClick={() => {}}>Click Me</button>
// 아래와 같이 호출되어
React.createElement("button", {onClick}, "Click Me")
// 최종적으로 아래와 같이 됩니다.
{type: "button", props: {onClick}, children: ["Click me"]}

 

컴포넌트 트리 전체에서 렌더 출력을 수집한 후 리액트는 새로운 객체트리(가상돔)와 비교해 실제 돔(DOM)을 현재 원하는 출력과 같이 보이게 하기 위해 적용해야 할 모든 변경 사항을 수집

비교 및 계산 프로세스는 재조정(reconiliation)으로 알려져 있음

그런 다음 리액트는 계산된 모든 변경 사항을 하나의 동기 시퀀스로 돔에 적용

 

▶ 렌더 및 커밋 단계

https://wavez.github.io/react-hooks-lifecycle/

  • 렌더 단계: 컴포넌트 렌더링하고 변경 사항을 계산
  • 커밋 단계: 렌더 단계에서 계산된 변경 사항을 DOM에 적용

리액트는 커밋 단계에서 DOM 업데이트 한후 요청된 DOM 요소 및 컴포넌트 인스턴스를 가리키는 모든 참조를 적절하게 업데이트함

그 다음 componentDidMountcomponentDidUpdate 클래스 라이프 사이클 매서드와 useLayoutEffect 훅을 동기적으로 실행

 

리액트는 짧은 시간 제한을 설정하고 이 시간이 만료되면 useEffect 훅을 실행함(패시브 이펙트 단계 - Passive Effects)

 

리액트 18은 useTransition과 같은 '동시 렌더링' 기능을 추가했음

이를 통해 리액트는 브라우저가 이벤트를 처리할 수 있도록 렌더링 단계에서 작업을 일시 중지할 수 있음

리액트는 나중에 적절하게 작업을 재개하거나 폐기하거나 다시 계산

렌더 패스가 완료되면 리액트는 마찬가지로 커밋 단계를 동기적으로 실행함

 

핵심은 '렌더링'은 '돔 업데이트'와 같지 않으며 결과적으로 어떠한 가시적인 변경도 일어나지 않고 컴포넌트가 렌더링 될수 있다는 것

  • 컴포넌트가 지난번과 동일한 렌더 출력을 반환해 변경이 필요하지 않을 수 있음
  • 동시 렌더링에서 리액트는 컴포넌트를 여러 번 렌더링할 수 있지만 다른 업데이트로 인해 현재 수행 중인 작업이 무효화되는 경우 렌더 출력을 버림

 

▶ 리액트의 렌더링 방식

렌더링 큐에 렌더링 등록하기

첫 렌더링이 완료된 후 리액트에게 리렌더링을 큐에 등록하도록 지시하는 몇가지 다른 방법이 있음

  • 함수 컴포넌트
    • useState setter
    • useReducer dispatch
      • forceUpdate처럼 동작
  • 클래스 컴포넌트
    • this.setState()
    • this.forceUpdate()
  • 기타
    • ReactDOM의 최상위인 render(<App>) 메서드를 다시 호출
      • 루트 컴포넌트에서 forceUpdate를 호출하는 것과 동일
    • 새로 추가된 useSyncExternalStore 훅에서 트리거된 업데이트

 

일반적인 렌더링 동작

리액트의 기본 동작은 상위 컴포넌가 렌더링될 때 리액트가 해당 컴포넌트 내부의 모든 하위 컴포넌트를 순환하며 렌더링

일반적인 렌더링에서 리액트느 props가 변경되었는지 여부를 신경쓰지 않으며 부모가 렌더링되며 무조건 하위 컴포넌트를 렌더링 함

 

트리에서 대부분의 컴포넌트가 직전과 정확히 동일한 렌더 출력을 반환할 가능성이 매우 높으므로 리액트는 DOM을 변경할 필요가 없지만 컴포넌트 자체 렌더링을 요청하고 렌더 출력을 비교하는 작업을 수행해야 함

이는 시간과 노력 둘다 필요함(손해). 그래서 리액트가 실제로 DOM을 변경해야 하는지 여부를 알게 하는게 핵심 포인트

 

▶ 리액트 렌더링 규칙

리액트 렌더링 기본 규칙 중 하나는 렌더링이 '순수'해야 하며 어떠한 사이드 이펙트도 없어야 한다는 것

문제는 많은 사이드 이펙트가 명확하지 않고 결과적으로 어떤 것도 망가뜨리지 않기 때문(Ex. console.log())

 

렌더 로직은 다음을 수행하면 안됨

  • 기존 변수 및 객체를 변경할 수 없음
  • Math.random() 또는 Date.now()와 같은 임의의 값을 생성할 수 없음
  • 네트워크 요청을 할 수 없음
  • 상태 업데이트를 큐에 추가할 수 없음

렌더 로직은 다음을 수행할 수 있음

  • 렌더링 도중 새로 생성된 객체 변경
  • 오류 발생
  • 캐시된 값과 같이 아직 생성되지 않은 데이터에 대한 "지연 초기화"

 

▶ 컴포넌트 메타데이터와 파이버(Fibers)

리액트는 애플리케이션에 현재 존재하는 모든 컴포넌트 인스턴스를 추적하는 내부 데이터 구조를 저장

이 데이터 구조의 핵심 부분은 '파이버(Fiber)'라고 불리는 객체로 메타 데이터 필드를 포함

 

포함하는 메타 데이터 필드

  • 컴포넌트 트리의 해당 지점에서 렌더링되어야 할 컴포넌트 타입
  • 해당 컴포넌트와 관련된 prop, 상태
  • 상위, 형제 및 하위 컴포넌트에 대한 포인터
  • 리액트가 렌더링 프로세스를 추적하는 데 사용하는 기타 내부 메타데이터

리액트 파이버는 리액트 16에 출시되어 이후 모든 버전에서 사용하고 있음

 

//Fiber 타입
export type Fiber = {
  // 파이버 타입을 식별하기 위한 태그입니다.
  tag: WorkTag;

  // 해당 요소의 고유 식별자 입니다.
  key: null | string;

  // 파이버와 관련된 것으로 확인된 함수/클래스 입니다.
  type: any;

  // 단일 연결 리스트 트리 구조입니다.
  child: Fiber | null;
  sibling: Fiber | null;
  index: number;

  // 파이버로 입력되는 데이터 입니다. (arguments/props)
  pendingProps: any;
  memoizedProps: any; // 출력을 만드는데 사용되는 props입니다.

  // 상태 업데이트 및 콜백 큐 입니다.
  updateQueue: Array<State | StateUpdaters>;

  // 출력을 만드는데 사용되는 상태입니다.
  memoizedState: any;

  // 파이버에 대한 종속성(컨텍스트, 이벤트)입니다.(존재하는 경우)
  dependencies: Dependencies | null;
};

 

 렌더링 패스 동안 리액트는 이 파이버 객체 트리를 순회하고 새 렌더링 결과를 계산할 때 업데이트 된 트리를 구성함

'파이버' 객체는 실제 컴포넌트 props와 상태 값을 저장

컴포넌트에서 props와 state를 사용할 때 리액트는 사실 파이버 객체에 저장된 값에 대한 접근을 제공하는 것

상위 컴포넌트가 주어진 하위 컴포넌트를 처음으로 렌더링할 때 리액트는 컴포넌트의 '인스턴스'를 추적하기 위해 파이버 객체를 만듬

 

▶ 컴포넌트 타입(Component Types)과 재조정(Reconciliation)

리액트는 기존 컴포넌트 트리와 DOM 구조를 가능한 많이 재활용하여 효율적으로 리렌더링 하려고 노력

트리의 일한 위치에 동일한 타입의 컴포넌트 또는 HTML 노드를 렌더링하도록 리액트에 요청하면 리액트는 처음부터 다시 만드는 대신 필요에 따라 업데이트를 적용함

즉, 같은 위치에 해당 컴포넌트 타입을 렌더랑 하도록 리액트에 계속 요청하는 동안 리액트는 컴포넌트 인스턴스를 활성 상태로 유지하는 것

 

리액트 렌더링 로직은 먼저 === 참조 비교를 사용해 요소의 type 필드를 기준으로 비교함

지정된 지점의 요소가 <div>에서 <span> 또는 <ComponentA>에서 <ComponentB>로 변경되는 것과 같이 다른 타입으로 변경된 경우 리액트는 전체 트리가 변경되었다고 가정해 비교 프로세스의 속도를 높임

결과적으로 리액트는 모든 돔 노드를 포함해 기존 컴포넌트 트릴 부분 전체를 삭제하고 새 컴포넌트 요소 인스턴스로 처음부터 다시 만듬

 

즉, 렌더링하는 동안 새 컴포넌트 타입을 생성해서는 안

새 컴포넌트 타입을 생성할때마다 이 타입은 다른 참조가 되며, 이로 인해 리액트가 하위 컴포넌트 트리를 반복적으로 삭제 및 재생성함

 

// ❌ BAD!
// 이는 매번 새로운 `ChildComponent` 참조를 생성
function ParentComponent() {
  function ChildComponent() {
    return <div>Hi</div>;
  }

  return <ChildComponent />;
}

// ✅ GOOD
//항상 컴포넌트를 별도로 정의
// 이는 하나의 컴포넌트 타입 참조만 생성
function ChildComponent() {
  return <div>Hi</div>;
}

function ParentComponent() {
  return <ChildComponent />;
}