JS/React

React - Rendering에 대하여

shin96bc 2024. 3. 9. 22:59

❓Rendering(렌더링)이란 무엇인가?

React에서 Rendering이란 Component가 현재 props와 state의 상태에 기초하여 UI를 어떻게 구성할지 Component에게 요청하는 작업을 의미합니다.

 

⚙️Rendering의 프로세스는?

Rendering이 일어나는 동안 React는 Component의 루트에서 시작해서 아래쪽으로 쭉 코드를 보면서 업데이트가 필요하다는 flag가 지정되어있는 component를 찾습니다.

 

만약 업데이트가 필요하다는 flag를 만난다면 Class Component의 경우에는 'classComponentInstance.render()'를 호출하고, Function Component의 경우에는 'FunctionComponent()'를 호출하고, Rendering된 결과를 저장합니다.

 

Component의 Rendering 결과물은 일반적으로 JSX 문법으로 구성되어 있으며, JSX는 js가 컴파일되고 배포 준비가 되는 순간에 'React.createElement()'를 호출하여 변환됩니다.

 

'createElement'는 UI 구조를 설명하는 일반적인 js 객체인 React Element를 리턴합니다.

// JSX 문법 예시
return <ButtonComponent width={50} onClick={() => console.log('클릭')}>버튼</ButtonComponent>;

// 변환 작업 예시
return React.createElement(ButtonComponent, {width: 50, onClick: () => console.log('클릭')}, "버튼");

// 호출 결과(element를 나타내는 객체로 변환
{type: ButtonComponment, props: {width: 50, onClick: () => console.log('클릭')}, children: ["버튼"]}

 

 

전체 Component에서 이러한 Rendering 결과물을 수집하고, React는 새로운 Object Tree(가상돔)와 비교하며, 실제 DOM을 의도한 출력처럼 보이게 적용해야 하는 모든 변경 사항을 수집합니다. 이렇게 비교하고 계산하는 과정을 'reconciliation' 이라고 합니다.

 

그 다음 React는 계산된 모든 변경사항을 하나의 동기 시퀀스로 DOM에 적용합니다.

 

Render와 Commit 단계

React는 이 단계를 의도적으로 두개로 분류했습니다.

  • 'Render phase(렌더 페이즈)': Component를 Rendering하고 변경사항을 계산하는 모든 작업
  • 'Commit phase(커밋 페이즈)': DOM에 변경사항을 적용하는 과정

React가 DOM을 Commit phase에서 업데이트 한 이후에, 요청된 DOM Node 및 Instance를 가리키도록 모든 참조를 업데이트 합니다. 그런 다음 Class Component에서는 'componentDidMount' 'componentDidupdate' Method(메소드)를 호출하고, Function Component에서는 'useLayoutEffect'Hook(훅)을 호출합니다.

 

또한 React는 짧은 timeout을 세팅한 이후에 timeout이 만료되면 'useEffect'를 호출합니다. 이 단계를 'PassiveEffects'단계라고 합니다.

 

React lifecycle methods diagram

React 18에서 나온 'Concurrent Mode'의 경우에는 브라우저가 Event를 처리할 수 있도록 Rendering 단계에서 작업을 일시 중지 할 수 있습니다. React는 해당 작업을 나중에 다시시작하거나, 버리거나, 다시 계산할 수 있습니다. Rendering phase가 된 이후에도 React는 Commit단계를 한단계 동기적으로 실행합니다.

 

React가 Rendering을 다루는 방법

 

Rendering 순서를 만드는 법

  • 최초 Rendering이 끝난 이후에 React가 re-rendering을 queueing하는 방법은 여러가지가 있습니다.
  • Class Component
    • 'this.setState()'
    • 'this.forceUpdate()'
  • Function Component
    • 'useState()'의 setter 'setState()'
    • 'useReducer()'의 dispatches
  • 기타
    • 'ReactDOM.render()'를 호출('forceUpdate'와 동일, 18버전 이상부터에서는 사라짐)

 

일반적인 Rendering 동작

  • 여기서 우리가 기억해야할 중요한 것이 있습니다.
  • React의 기본적인 동작은 부모 Component가 Rendering되면 React는 모든 자식 Component를 순차적으로 re-rendering 한다는 것입니다.
  • 예시 보기(A > B > C > D 순서의 Component Tree가 있고, B에 state를 변경하는 버튼이 있다고 가정)
    1. 'B'에 있는 'setState()'가 호출되어, 'B'의 re-rendering이 Rendering queue로 들어갑니다.
    2. 'A'는 업데이트가 필요하다고 체크 되어 있지 않으므로 통과합니다.
    3. 'B'는 업데이트가 필요하다고 체크되어 있으므로 'B'를 re-rendering합니다.
    4. 'C'는 업데이트가 필요한 것으로 체크되어 있지 않지만 부모인 'B'가 rendering 되었으므로 React는 'C'또한 re-rendering 합니다.
    5. 'D'도 똑같이 업데이트가 필요한 것으로 체크되어 있지 않지만 부모인 'C'가 rendering 되었으므로 React는 자식인 'D' 또한 re-rendering 합니다.
  • 즉, Component를 rendering하는 작업은 기본적으로 하위에 있는 모든 Component 또한 rendering하게 됩니다.
  • 또한 일반적인 rendering의 경우에는 'props'가 변경되었는지는 신경쓰지 않습니다. 부모 Component가 rendering되었기 때문에 자식 Component도 무조건 re-rendering 됩니다.
  • 만약 최상위 Component에서 'setState()'를 호출한다는 것은 기본적으로 Component Tree에 있는 모든 Component를 re-rendering 한다는 것을 의미합니다.
  • 그러나 한번 rendering이 되면 Tree의 대부분의 Component가 동일한 rendering 결과물을 반환할 가능성이 높기 때문에 React는 DOM을 변경할 필요가 없습니다. 그러나 React는 여전히 Component에게 rendering을 요청하고, 이 rendering 결과물을 비교하는 작업을 요구합니다. 이는 두가지 모두 시간과 노력이 필요합니다.
  • 한가지 기억해둬야 할 것은 rendering이 나쁜 것만은 아니라는 것입니다. 단지 React가 실제로 DOM을 변경해야 하는지 여부를 확인하는 것일 뿐입니다.

 

Rendering 규칙

  • Rendering의 중요한 규칙 중 하나는 Rendering은 '순수' 해야하고 '부수작용'이 없어야 한다는 것 입니다. 근데 이는 매우 복잡하고 어렵습니다. 왜냐하면 대다수의 부수작용이 왜 일어났는지 뚜렷하지 못하고, 어떤 것도 망가뜨리지 않기 때문입니다.
  • 예를 들어'console.log()'도 부수작업을 야기하지만, 그 어떤 것도 망가뜨리지 않습니다. 'props'가 변경되는 것은 명백한 부수효과이며, 이는 무언가를 망가뜨릴 수 있습니다. rendering 중간에 ajax 호출 또한 부수효과를 일으키고, 이는 요청의 종류에 따라서 명백하게 예기치 못한 결과를 야기할 수 있습니다.
  • Rules of React라는 글이 있습니다. 이 글에서는 rendering을 포함한 다양한 react의 life cycle method의 동작과 어떠한 동작이 '순수'한지, 혹은 안전한지를 나타내고 있습니다.
  • Rules of React 요약
    • rendering 로직이 할 수 없는 것(하면 안되는 것)
      • 존재하는 변수나 객체를 변경해서는 안된다.
      • 'Math.random()''Date.now()'와 같은 랜덤 값을 생성할 수 없다.
      • 네트워크 요청을 할 수 없다.
      • 'state'를 업데이트 할 수 없다.
    • rendering 로직이 할 수 있는 것
      • rendering 도중에 새롭게 만들어진 객체를 변경할 수 있다.
      • Error를 던질 수 있다.
      • 아직 만들어지지 않은 데이터를 lazy 초기화 하는일을 할 수 있다.(캐시 등)

 

Component Meta data와 Fiber

 

Component 타입과 재조정('Reconciliation')

  • 재조정 페이지에 언급되어 있는 것 처럼 React는 기존 Component Tree와 DOM 구조를 가능한 많이 재사용함으로써 re-rendering의 효율성을 추구합니다. 동일한 유형의 Component, 또는 HTML Node를 Tree의 동일한 위치에 rendering하도록 React에 요청하게 되면, React는 해당 Component 또는 HTML Node를 만드는 대신에 해당 업데이트만 적용합니다.
  • 즉, React에 해당 Component 타입을 같은 위치에 rendering 하도록 계속 요청이 있다면, React는 계속 Component의 Instance를 유지한다는 뜻입니다.
    • Class Component의 경우, 실제 Component의 실제 Instance와 동일한 Instance를 사용합니다.
    • Function Component는 Class와 같은 느낌의 Instance는 없지만'<MyFunctionComponent />' 가 보여지고 활성화 상태로 유지되고 있다는 관점에서 Instance를 나타내는 것으로 볼수도 있습니다.
  • 그렇다면 React는 어떻게 결과물이 실제로 변경된 시기와 방법을 알 수 있을까요? 🤔
  • React rendering 로직은 elements를 그들의 'type' 필드를 기준으로 먼저 비교하는데, 이 때 '==='를 사용합니다. 만약 지정된 element가 '<div>'에서 '<span>'으로, 또는 '<ComponentA />'에서 '<ComponentB />'로 변경된 경우에는 전체 Tree가 변경되었다고 가정하여 비교 프로세스의 속도를 높입니다. 결과적으로 React는 모든 DOM Node를 포함한 기존 Component Tree를 삭제하고 새로운 Component Instance를 처음부터 다시 만들게 됩니다.
  • 예시 보기
// 좋지 못한 예시
function ParentComponent() {
	// 이렇게 선언하면 매번 새로운 Component 참조를 만들게 됩니다.
	function ChildCompoonent() {}

	return <ChildComponent />;
}

// 좋은 예시
function ChildComponent() {}

function ParentComponent() {
	// 이렇게 하면 Component 타입 참조가 딱 한번 만들어집니다.
	return <ChildComponent />;
}

 

 

'key'와 'Reconciliation'

  • React가 Component Instance를 식별하는 또 한가지 방법으로는 'key' prop이 있습니다. 'key'는 실제 Component로 전달되는 요소는 아닙니다. React는 이를 활용해 Component 타입의 특정 Instance를 구별하는데 사용할 수 있는 고유한 식별자로 사용합니다.
  • 아마도 'key'를 가장 많이 사용하는 경우는 'map()'함수를 사용해서 리스트를 rendering할 때 일 것입니다. 'key'는 목록의 순서변경, 추가, 삭제와 같은 방식으로 변경될 수 있는 데이터를 rendering하는 경우 매우 중요합니다. 가장 중요한 점은 반드시 고유한 값을 사용해야 한다는 것입니다. 고유한 값을 사용할 수 없는 경우, 최후의 수단으로 배열의 인덱스를 사용할 수 있겠지만 권장하지 않습니다.
  • 예시 보기
    • 만약 '<Item />' Component를 10개 rendering하고 'key'값으로 배열의 index를 사용해서 '0~9'를 할당했다고 해봅시다. 그 상태에서 '6'과 '7'을 지우고, 새롭게 3개를 추가하면 'key' 값은 '0~10'이 됩니다. React는 이 때 단순히 하나만 추가하게 되는데, 그 이유는 React가 보기에는 10개에서 11개로 늘어난 차이밖에 없기 때문입니다.
    • React는 이제 기존에 있던 Component와 DOM Node를 재활용할 것입니다. 재활용한다는 의미는 '<Item key={6} />'가 8로 넘겨받은 props를 사용하여 rendering하게 되어진다는 것입니다. Component Instance는 살아있지만, 이전과 다른 데이터 객체를 기반으로 하고 있습니다. 이것은 효과가 있을 수도 있지만, 예기치 못한 문제가 발생할 수도 있습니다. 또한 기존 목록의 아이템이 이전과 다른 데이터를 표시해야 하기 때문에, React는 Text와 다른 DOM의 내용을 변경하기 위해 목록의 아이템중에서 몇개의 업데이트를 적용해야만 합니다. 그러나 목록의 아이템이 사실상 변한 것이 아니므로 업데이트가 필요하지 않은 것으로 간주되어집니다.
    • 이럴 때 배열의 'index' 대신에 'key={item.idx}'와 같은 고유한 값으로 처리했다면, React는 올바르게 2개의 아이템을 지우고 3개를 추가할 것입니다. 이는 두개의 Component Instance와 DOM Node를 지우고 새롭게 3개의 Component Instance와 DOM Node를 만드는 것을 의미합니다.
  • 'key'는 리스트에 있는 Component의 Instance를 식별하는데 유용합니다. 어떤 React Component에든 'key'를 추가하여 식별자를 부여할 수 있고, 'key'를 변경하는 것은 React가 오래된 Component Instance를 없애고 새로운 DOM을 만든다는 것을 의미합니다. 일반적으로 사용되는 케이스는 위의 예시처럼 리스트를 rendering하는 경우 입니다. '<Form key={selectedItem.idx} />'을 rendering하면 선택한 항목이 변경될 때 React가 form을 삭제하고 다시 생성하므로, form의 오래된 상태 문제를 방지할 수도 있습니다.

 

Rendering 배치와 타이밍

  • 기본적으로 'setState()'를 호출하는 것은 React가 새로운 rendering pass를 시작한다는 뜻이고, 이는 동기적으로 실행되어 return 됩니다. 이에 추가적으로 React는 rendering 배치 형태의 최적화를 자동으로 실행합니다. 여기서 말하는 rendering 배치란 'setState()'에 대한 여러 호출로 인해 하나의 render pass가 대기열에 저장되어 실행되는 것을 말하며, 일반적으로 약간의 지연이 발생합니다.
  • React 문서에서 언급하는 것 중 하나는 'state'의 업데이트는 비동기 적일 수 있다는 사실입니다. 특히 React는 React Event Handler에서 발생하는 상태 업데이트를 자동으로 일괄적으로 처리합니다. React Event Handler는 일반적인 React 애플리케이션에서 매우 큰 부분을 차지하기 때문에, 이는 주어진 앱의 대부분의 상태 업데이트가 실제로 일괄적으로 처리된다는 것을 의미합니다.
  • React는 Event Handler를 'instability_batchedUpdates'라고 하는 내부 함수로 래핑하여 Event Handler를 rendering합니다. React는 'instability_batchedUpdates'가 실행중일 때 대기중인 모든 상태 업데이트를 추적한 다음, 단일 rendering 경로로 적용합니다. 또한 지정된 Event에 대해서 어떤 Handler를 호출해야하는지 이미 정확하게 알고 있기 때문에 Event Handler에서 사용하는 이 방법은 매우 잘 먹힙니다.
  • 예시 보기
    • 개념적으로 React가 내부적으로 하는 일을 다음과 같은 의사 코드로 상상해볼 수 있습니다.
    function internalHandleEvent(e) {
    	const userProvidedEventHandler = findEventHandler(e);
    
    	let batchedUpdates = [];
    
    	unstable_batchedUpdates(() => {
    		// 이 안에 대기중인 모든 업데이트가 일괄 처리된 업데이트로 푸시될 것입니다.
    		userProvidedEventHandler(e);
    	});
    
    	renderWithQueuedStateUpdates(batchedUpdates);
    }
    
    • 그러나 이는 실제 즉시 콜스택 외부에 대기중인 상태 업데이트와 함께 배치되지 않는 다는 것을 의미합니다.
    const [counter, setCounter] = useState(0);
    
    const onClick = async () => {
    	setCounter(0);
    	setCounter(1);
    
    	const data = await fetchSomeData();
    
    	setCounter(2);
    	setCounter(3);
    };
    
    • 이는 세개의 rendering pass를 실행할 것입니다. 먼저 'setCounter(0)'와 'setCounter(1)'를 함께 배치할 것입니다. 이는 둘 다 원래 Event Handler의 콜 스택 중에 발생하므로, 둘 다 'unstable_batchUpdates'의 호출 내에서 발생할 것이기 때문입니다.
    • 그러나 'setCounter(2)'는 'await'이후에 실행됩니다. 그 때문에 React는 전체 rendering pass를 'setCounter(2)'호출의 마지막 단계로 동기적으로 실행하고, rendering pass를 완료한 이후에 'setCounter(2)'에서 return 할 것입니다. 이와 유사한 동작이 'setCounter(3)'에서도 동일하게 일어나게 될 것입니다.
  • commit단계의 lifecycle method에는 'componentDidMount', 'componentDidUpdate', 'useLayoutEffecr'와 같은 몇가지 추가적인 엣지 케이스가 존재합니다. 이는 주로 브라우저가 페인팅을 하기전에 rendering 후 추가 로직을 수행할 수 있도록 하기 위해 존재합니다.
  • 사용사례 보기
    • 불완전한 일부 데이터로 Component를 최초 rendering
    • commit 단계 lifecycle에서, DOM Node의 실제 크기를 'ref'를 통해 측정하고자 할 때
    • 해당 측정을 기준으로 일부 Component의 상태 설정
    • 업데이트된 데이터를 기준으로 즉시 re-rendering
  • 이러한 사용사례에서 초기의 부분 rendering된 UI가 사용자에게 절대로 표시되지 않도록 하고, 최종 UI만 나타날 수 있게 합니다. 브라우저는 수정중인 DOM 구조를 다시 계산하지만 Javascript는 여전히 실행중이고, Event 루프를 차단하는 동안에는 실제로 화면에 아무것도 페인팅하지 않습니다. 그러므로 'div.innerHTML = “a”', 'div.innerHTML = “b”'와 같은 작업을 수행하면 'a'는 나타나지 않고 'b'만 나타나게 될 것입니다.
  • 이 때문에 React는 항상 commit 단계 lifecycle에서 rendering을 동기로 실행합니다. 이렇게 하면 부분적인 rendering을 무시하고 최종 단계의 내용만 화면에 표시할 수 있습니다.
  • 마지막으로 모든 'useEffect' 콜백이 완료되면 'useEffect' 콜백의 상태 업데이트가 대기열에 저장되고, 'Passive Effects' 단계가 끝나면 플러시 됩니다.
  • 'unstable_batchedUpdates' API가 public하게 export 되는 것에 주목할 필요가 있습니다.
    • 이름에서부터 알 수 있듯이 '불안정'으로 표시되고, React API에서 공식으로 지원하는 부분은 아닙니다.
    • 그러나 React팀은 '불안정'한 API 치고는 가장 안정적이며, 페이스북의 코드 절반이 이에 의존하고 있다고 이야기 했습니다.
    • 'react' 패키지에서 export 되는 다른 React의 핵심 API와는 다르게, 'unstable_batchedUpdates'는 reconciler에 특화된 API로 React 패키지의 일부가 아닙니다. 대신에 이는 'react-dom' 'react-native'에서 export됩니다. 즉, 'react-three-fiber'나 'ink'와 같은 다른 reconciler와는 다르게 'unstable_batchedUpdates'를 export 하지지 않을 가능성이 큽니다.
  • React 18에서 소개된 Concurrent 모드에서는 React는 모든 업데이트를 배치로 실행합니다. Automatic Batching에 대하여 알아보기

 

Render 동작과 엣지 케이스

  • React에서 개발중인 '<StrictMode />' 태그 태부에서는 Component를 이중으로 rendering합니다. 즉, rendering 로직이 실행되는 횟수가 commit된 rendering pass의 횟수와 동일하지 않으며, rendering을 수행하는 동안 'console.log()'문에 의존하여 발생한 rendering의 수를 셀 수 없습니다. 대신 'React DevTools Profiler'를 사용하여 추적을 캡쳐하고, 전체적으로 commit된 rendering의 개수를 세거나, 'useEffect' 또는 'componentDidMount', 'componentDidUpdate' lifecycle에서 로깅을 추가하는 방법을 사용해야 합니다. 이렇게 하면 실제로 rendering pass를 완료하고 이를 commit한 경우에만 로그가 찍힙니다.
  • 정상적인 상황에서는 절대로 실제 rendering 로직에서 상태 업데이트를 대기열에 넣어서는 안됩니다. 즉, 클릭이 발생할 때 'setState()'를 호출하는 콜백을 사용하는 것은 괜찮지만, 실제 rendering 동작의 일부로 'setState()'를 호출하는 것은 안됩니다.
  • 그러나 여기에는 한가지 예외가 있습니다. Function Component는 rendering하는 동안 'setState()'를 직접 호출할 수 있지만, 이는 조건부로 수행되고 Component가 re-rendering될 때 마다 실행되지는 않습니다. 이것은 Class Component의 'getDerivedStateFromProps'와 동등하게 작동합니다. rendering 하는 동안 Function Component가 상태 업데이트를 대기열에 밀어 넣어두면, React는 즉시 상태 업데이트를 적용하고 해당 Component 중 하나를 동기화 하여 다시 rendering 한 후 계속 진행합니다. Component가 상태 업데이트를 무한하게 queueing하고 React가 다시 rendering을 하도록 강제하는 경우, React는 최대 50회까지만 실행할 후에 이 무한반복을 끊어버리고 오류를 발생 시킵니다. 이 기법은 'useEffect' 내부에 'setState()' 호출과 re-rendering을 하지 않고 props 값을 기준으로 state의 값을 강제로 업데이트 할 때 사용할 수 있습니다.
  • 예시 보기
function ScrollView({ row }) {
	const [isScrollingDown, setIsScrollingDown] = useState(false);
	const [prevRow, setPrevRow] = useState(null);

	// 조건부로 props 값을 기준으로 바로 state를 업데이트 시킬 수 있습니다.
	if (row !== prevRow) {
		setIsScrollingDown(prevRow !== null && row > prevRow);
		setPrevRow(row);
	}

	return `Scrolling down: ${isScrollingDown}`;
}

 

Reference

https://yceffort.kr/2022/04/deep-dive-in-react-rendering