👩🏻🌾 React 파먹기 - Rendering
최근들어 개념적인 부분, 원리적인 부분들을 공부하다보니 계속 ‘왜?’라는 생각을 하게 되었다. 지금까지 호기심이 많이 없었던게 문제였기도 하겠지만 말이다. 그래서 늦었지만 내가 주로 사용하는 React 를 조금 더 자세히 이해해야겠다는 욕심이 들어서 공부를 시작했다.
한 번에 너무 많은 것을 하면 금방 질릴 것을 알기에, 조금씩 깊게 파보려고 한다.
🥲 나는 렌더링의 개념을 설명할 줄 아는가?
누군가가 나에게 “리액트에서 렌더링이 뭔가요?”라고 질문한다면 나는 자신 있게 설명할 수 있을까? ← 라는 생각을 문득 고향 가는 기차에서 했었는데, 고개를 푹 숙였다. 리액트에서 말하는 렌더링은 단순히 Painting 을 의미하는 것은 아니기 때문일 것이기에.
그래서 이번에는 렌더링에 대해 제대로 이해를 해보기로 했다. 리액트에서 다루는 렌더링은 그 개념이 방대한데, 우선은 전반적인 기초 개념과 순서적인 부분만 다루면서 친해져보자.
🤔 rendering 이란?
리액트에서 이야기하는 렌더링이란 컴포넌트가 현재의 props
와 state
의 상태에 기초해서 UI 를 어떻게 구성하면 되는지 컴포넌트에게 요청하는 작업을 의미한다.
아래의 예시를 보면, Components
라는 함수형 컴포넌트가 선언이 되어있고, props
와 state
의 값을 이용해 View
를 구성하고 있다. 아래의 컴포넌트를 그려내기 위해서는 컴포넌트에게 “난 화면을 그릴테니, 네가 가지고 있는 props 와 state 를 알려줘!” 라는 단계가 필요하다는 의미이다.
import { useState } from "react";
const Component = (props) => {
const [age, setAge] = useState<number>(20);
return <div>{`Hello, my nams is ${props.name} and i'm ${age} years old.`}</div>
};
export default Component;
♻️ React 에서의 렌더링
기본적으로 리액트는 root DOM(<div id=”root”></div>
) 에서부터 시작해서 업데이트가 필요한 플래그가 지정된 모든 컴포넌트를 찾는다.
React 프로젝트를 생성하면 index.tsx
파일 또는 main.tsx
파일에 아래와 같이 생성된 코드를 확인할 수 있을 것이다. 아래 코드를 보면 "root"
라는 id 를 가진 HTMLElement
를 ReactDOM.createRoot()
에 전달한 다음, 해당하는 Element 를 .render()
메서드에 전달한다.
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
리액트가 모든 컴포넌트 가지들을 훑으며 업데이트가 필요한 플래그가 지정된 컴포넌트를 찾으면, 클래스 컴포넌트는 classComponentInstance.render()
를 호출하고 함수형 컴포넌트라면 FunctionComponent()
를 호출해 렌더링된( 👉🏻 props
와 state
에 기초해 UI를 구성하는 방법에 대해 질문해서 받아온!) 결과를 저장하며 렌더링을 진행한다.
컴포넌트의 렌더링 결과는 일반적으로 JSX 문법으로 구성되어 있으며, 이것은 javaScript가 컴파일이 되고 배포 준비가 되는 순간 React.createElement()
를 호출해 변환된다. createElement
는 UI 구조를 설명하는 일반적인 javaScript 객체인 React Element 를 반환한다.
🧀 Element : DOM Element, Component Element
Element 는 무엇일까? Element 는 화면에 렌더링 할 DOM 노드들의 정보를 React 에 알려주기 위한 하나의 수단이다. Element 는 DOM node 또는 컴포넌트를 표현하는 JavaScript 의 일반 불변 객체이며, type
과 props
를 가진다.
type and props
React Element 의 type
은 문자열 혹은 함수형/클래스형 컴포넌트이며, props
는 하나의 객체이다.
Let’s create Element
React Element 는 React.createElement()
함수 또는 JSX의 태그 문법으로 작성된다.
createElement()
를 이용해서 생성하기
React.createElement(
'div',
{ className: 'pokemon' },
'Pikachu'
);
위와 같은 방법은 사실 사용이 쉽지않고 직관적이지 못한 단점이 있긴 하다. (그렇다고 해서 몰라도 되는건 아니지만!) 그래서 보통은 이 방법 보다는, 아래의 JSX
문법을 많이 사용한다.
JSX
문법을 이용해서 생성하기
<div className='pokemon'>Pikachu</div>
위 두 가지 방법을 이용해서 React Element 를 생성하면, 아래와 같은 객체가 형성된다. 이러한 element 들이 모여 트리를 만들면 이를 element tree 라 부르며, 이는 곧 메모리상에만 존재하게 되는 virtual DOM 이 된다.
{
type: 'div',
props: {
className: 'pokemon',
children: 'Pikachu'
}
}
간단하게 정리하면 element 는 컴포넌트를 JSON 으로 표현한 것이다.
<div class="leetrue">
<b>leetrue</b>
</div>
{
"type": "div",
"props": {
"className": "leetrue",
"children": {
"type": "b",
"children": "leetrue"
}
}
}
만약 type
이 string 인 경우라면 type
은 해당 컴포넌트가 어떤 HTML 태그인지를 표현한다. 그리고 이 경우, props
는 해당 HTML 태그의 속성을 명시한다.
DOM Element
element 의 type 이 태그 이름에 해당하는 문자열인 경우(소문자로 시작)를 말한다.
해당 태그를 가진 DOM 노드를 표현하며, props 정보를 통해서 해당 노드의 attribute 들을 표현한다. React 가 실제로 화면에 렌더링 하는 대상에 해당한다.
Component Element
element 의 type 이 클래스형/함수형 컴포넌트인 경우를 의미한다.
사용자가 직접 정의한 컴포넌트를 표현하며, 입력으로 props
를 받으면 렌더링할 element tree 를 반환한다. 이 element tree 는 어떠한 element tree 를 반환하는지 묻는 것을 반복한다. 클래스형 컴포넌트의 경우 당연히 컴포넌트 인스턴스의 생성이 선행될 것이다.
// 🤔 React: Form이 뭔데..?
{
type: SignUpForm
props: {
isSubmitted: false,
buttonText: 'OK!'
}
}
// 🤔 React: Form에 있는 이 Button은 또 뭐야?
{
type: Button,
props: {
children: 'OK!',
color: 'blue'
}
}
// 😜 React: Form에 있는 Button을 보니 Dom Node 였구나! ㅇㅋㅇㅋ 그만!
type: 'button',
props: {
className: 'button button-blue',
children: {
type: 'b',
props: {
children: 'OK!'
}
}
}
}
클래스형 컴포넌트
지역 상태를 가질 수 있고, 해당 컴포넌트 인스턴스에 대응하는 DOM 노드가 생성, 수정, 삭제될 때의 동작을 제어할 수 있다.(생명 주기)
함수형 컴포넌트
render()
함수만 가지는 클래스형 컴포넌트와 동일하며, 지역 상태를 가질 수 없지만 구현이 단순하다.
🥔 Component Instance
클래스로 선언된 컴포넌트들만 인스턴스를 가지며, 이것을 컴포넌트 인스턴스라고 부른다. 컴포넌트 클래스 내부에서 this 키워드를 통해 참조하는 대상에 해당한다. 지역 상태를 저장하고 생명 주기 이벤트들에 대한 반응을 제어할 때 매우 유용하다. 함수형 컴포넌트는 인스턴스를 가지지 않는다.
🐟 인스턴스가 뭔데?
비슷한 성질을 가진 여러 개의 객체를 생성하기 위해 사용되는, 생성자 함수(constructor)를 하나의 붕어빵 틀이라고 생각한다면 이렇게 찍어낸 붕어빵들을 인스턴스라고 한다.
function FishBread(anggo, price) {
this.anggo = anggo;
this.price = price;
this.desciption = function () {
console.log(`이 붕어빵 앙꼬는 ${this.anggo}이고 ${price}원입니다!`);
};
}
const creamFishBread = new FishBread("슈크림", 2000);
console.log(creamFishBread); // FishBread { anggo: '슈크림', price: 2000, is: f }
creamFishBread.desciption(); // 이 붕어빵 앙꼬는 슈크림이고 2000원입니다!
🐟 그럼 React 에서 인스턴스는 뭔데
함수형 컴포넌트와 클래스형 컴포넌트부터 다시 들어가보자. 두 가지 타입의 컴포넌트는 모두 props 객체 인자를 받고 React element 를 반환하는 컴포넌트이지만 유형이 다르다.
class ClassComponent extends React.Component {
render() {
return <h1>Hello, {this.props.name}<h1>
}
}
위 코드의 경우는 클래스형 컴포넌트로 React Component Class 또는 React Component type 이라고 한다. 각각의 컴포넌트는 props 라는 매개변수를 받고 render
함수를 통해 표시할 뷰 계층 구조를 반환한다.
function FuntionalComponent(props) {
return <h1>Hello, {props.name}</h1>;
}
위 코드 같은 경우는 인스턴스가 아니다. 이 함수는 팩토리 형태이며, 실제 DOM 에 렌더링 되는 컴포넌트의 인스턴스들을 생성한다.
다시 렌더링으로 돌아와서,
어쨌든, element 를 다루며 하고 싶었던 이야기는, 어떤식으로 DOM 노드를 반환하는지에 대해 이야기하고 싶었다. 내가 리액트를 이해하는 데에 큰 도움이 되었기 때문에!
다시 돌아와서- 렌더링이 일어나면 프로젝트의 전체 컴포넌트에서 렌더링 결과물을 수집하고, 리액트는 새로운 Object tree 와 비교하며 실제 DOM 을 의도한 출력처럼 보이게 적용해야하는 모든 변경사항을 수집한다. 이렇게 DOM 의 변경사항을 비교하고 계산하는 과정을 리액트에서는 reconciliation
(재조정)이라고 부른다. 계산이 끝나면 리액트는 모든 변경 사항을 하나의 동기 시퀀스로 DOM 에 적용하게 된다.
🌍 Virtual DOM
React 의 대표적 특징 중 하나인 Virtual DOM 은 실제 DOM 구조와 비슷한 React 객체의 트리를 의미한다.
브라우저에서는 유저와의 다양한 인터랙션을 통해 DOM 구조의 빈번한 변화가 일어나는데, 이 때 마다 DOM 수정으로 인한 Render Tree 생성, Reflow, Repaint 과정이 일어난다면 브라우저의 성능은 ㅈㅓ.. 심해 속으로 쳐박힐 것잉ㄹ.ㄹ..다.
React 에서는 이 경우 Virtual DOM 을 실제 DOM 에 필요한 부분만 적절히 반영해 불필요한 수정이 일어나지 않도록 돕는다. 이 Virtual DOM 의 가장 큰 장점은 개발자가 직접 DOM 을 조작하지 않아도 된다는 점이고 이 과정이 모두 자동화가 된다는 점이다.
또한 DOM 수정에 대한 것은 batch 로 한 번에 수행이 되므로 불필요한 리렌더링을 최소화 할 수 있다.
♻️ Reconciliation
Reconciliation(이하 재조정) 은 다룰 내용이 많기 때문에, 이번에는 조금 간단하게만 정리하려한다. 👉🏻 재조정은 간단히 생각하면 기존의 Virtual DOM 과 새로운 Virtual DOM 을 비교하는 과정이다.
하지만 모든 트리를 순회하며 탐색하고 비교한다면 최첨단의 알고리즘을 이용해도 O(n^3) 의 시간복잡도를 가진다고 한다. 물론 리액트는 그런식으로 비교하지 않겠지만..
리액트에서는 브라우저 렌더링 시 기존 컴포넌트와 어떤 점이 변경되었는지 비교하기 위해 diffing 알고리즘을 사용해 컴포넌트를 갱신한다. 리액트는 아래의 2가지 가정을 기반으로 O(n) 의 시간 복잡도를 가지는 휴리스틱 알고리즘을 구현했다.
서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 생성해낸다.
- 같은 타입인 경우는 변경된 속성들에 대해서만 갱신한다.
개발자가 key prop 을 통해 컴포넌트 인스턴스를 식별해 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 말아야할 것인지 표시해줄 수 있다.
위의 재조정 단계를 거쳐 이전 elements 와 새롭게 생성된 elements 를 비교해서 elements 가 변경되었다면 렌더링을 수행한다.
🖼️ Re-rendering
리액트에서는 초기 렌더링이 한 번 진행되고, 이후에는 특정 조건 발생 시 리렌더링을 진행한다. 그렇다면 리렌더링을 진행하는 조건은 무엇이 있을까?
리렌더링을 유발하는 조건
state
변경 시부모 요소로부터 전달 받은
props
변경 시중앙 상태값 (
Context value
또는redux store
) 변경 시부모 컴포넌트의 리렌더링이 일어날 시
리렌더링 과정 간단 정리
위 조건 충족 시 리렌더링이 큐에 들어감
구현부의 실행 →
props
취득,hooks
실행, 내부 변수 및 함수 재생성return
실행, 렌더링 시작Render Phase(렌더 단계) : 새로운 Virtual DOM 생성 후 이전 Virtual DOM 과 비교해 달라진 부분 확인
Commit Phase(커밋 단계) : 달라진 부분만 실제 DOM 에 반영
useLayoutEffect
: 브라우저가 화면에 Paint 하기 전useLayoutEffect
에 등록해둔 effect 가 동기적으로 실행되며 이 과정에서state
,redux store
등의 변경이 있다면 리렌더링이 일어남Paint : 브라우저가 실제 DOM 을 화면에 그림 ( →
didUpdate
완료 )useEffect
: update 되어 화면에 그려진 직후,useEffect
에 등록해둔 effect 가 비동기적으로 실행됨
Render Phase and Commit Phase
🧮
Render Phase
: 컴포넌트를 렌더링하고 변경사항을 계산하는 모든 작업♻️
Commit Phase
: DOM 에 변경사항을 적용하는 과정
👩🏻🌾 마무리
이렇게 리액트 한 스푼 떠먹었다. 이제는 렌더링이 무엇인가요? 라고 물으면 “어느 정도는” 대답할 수 있을 것 같다. 100% 자신있게 대답할 수 있기 위해서는 계속 뜯어봐야겠지만! 그래도 어느정도 렌더링 된다 ← 라는 이야기를 들었을 때 어떤 것들이 일어나는지에 대한 그림이 그려지는 것 같다.
위 내용을 공부하는 과정에서 Reconciliation 과 Fiber 에 대해서도 좀 자세히 다뤄봐야겠다는 생각이 들었다. 리스트에 올려두는걸로-! 구럼 오늘도 수고했다 잍을우.