React Deep Dive Study - 2.1, 2.2

_sw_·7일 전
0

2.1 JSX

페이스북이 임의로 만든 언어로서 다양한 속성을 가진 트리 구조를 토큰화해 ECMA Script로 변환하는데 초점을 두고 있다.

→ 표현하기 까다로웠던 XML 스타일의 트리 구분을 작성하는 데 많은 도움을 주는 새로운 문법

2.1.1 JSX의 정의

JSXElement

JSX를 구성하는 기본 요소로 HTML의 요소와 비슷한 역할을 한다.

  • JSXOpeningElement - <JSXElement JSXAttributes(optional)>
  • JSXClosingElement - </JSXElement>
  • JSXSelfClosingElement - <JSXElement JSXAttributes(optional) />
  • JSXFragment - <>JSXChildren(optional)</>

JSXElementName

JSXIdentifier

function Valid1() {
	return <_></_>
}

JSX 내부에서 사용할 수 있는 식별자. 함수명, 변수명과 같이 JSXElement 사이에 구분할 수 있는 이름을 말한다. 숫자 또는 $_ 이외에 다른 특수문자로 시작할 수 없다.

JSXNameSpacedName

function Valid() {
	return <foo:bar></foo:bar>
}

JSXIdentifier:JSXIdentifier 의 조합을 의미하며, : 로 묶을 수 있는 것은 한 번 뿐이다.

JSXMemberExpression

function Valid() {
	return <foo.bar></foo.bar>
}

JSXIdentifier.JSXIdentifier의 조합을 의미하며, . 여러 개를 활용하여 묶을 수 있다. 대신 JSXNameSpacedName와 혼합하여 사용하는 것은 불가능하다.

JSXAttributes

JSXSpreadAttributes

{…AssignmentExpression} : 객체 뿐만 아니라 AssignmentExpression에서 취급할 수 있는 모든 표현식이 존재할 수 있다.

JSXAttribute

속성을 나타내는 키(JSXAttributeName)와 값(JSXAttributeValue)의 짝으로 표현한다.

  • JSXAttributeName - 속성의 키 값을 의미, JSXIdentifier(<$></$>)와 JSXNamespacedName(foo:bar) 를 통해 키를 나타낼 수 있다.
  • JSXAttributeValue - 속성의 키에 할당하는 값
    • "" (큰 따옴표로 구성된 문자열)
    • '' (작은 따옴표로 구성된 문자열)
    • { AssignmentExpression }
    • JSXElement
    • JSXFragment ( 별도의 속성을 갖지 않는 형태의 JSX, <></> )

JSXChildren

JSXElement의 자식 값을 나타낸다. JSX가 트리 구조를 효과적으로 보여주기 위한 목적이 있기 때문에 JSX로 부모/자식에 대한 표현이 가능하고 이 자식을 JSXChildren이라고 한다.

JSXChild

→ JSXChildren을 구성하는 기본 단위 JSXChildren은 0개 이상의 JSXChild가 필요하다. 0개 이상의 의미처럼 JSXElement는 JSXChildren은 JSXChild가 필요하다.

  • JSXText - { , < , > , } 를 제외한 문자열
  • JSXElement
  • JSXFragment
  • { JSXChildExpression (optional) } - JSXAttributes의 AssignmentExpression을 의미.

JSXString

HTML에서 사용 가능한 문자열을 JSXStrings에서 사용 가능하다. 여기서의 문자열은 '큰 따옴표', '작은 따옴표' 또는 JSXText를 의미한다.


JSX String Characters 관련 최근 논의

JSX 스펙에서 문자열 처리와 관련해서 최근에는 논의가 있었는지 궁금해서 타임라인과 함께 간단히 정리해봤습니다.

현재 JSX의 문자 처리 방식

JSX는 HTML4의 252개 문자 엔티티(&nbsp;, &amp; 등)만 지원하고, HTML5의 2,231개 확장 문자는 의도적으로 제외하고 있습니다. HTML과의 복사-붙여넣기 호환성을 위해서죠.

주요 논의 내용

HTML Entities vs JavaScript Escaping (2014~)

  • HTML entities를 제거하고 JavaScript 이스케이프(\u1234)를 사용하자는 제안
  • JSX가 HTML이 아닌 ECMAScript 기능이므로 JS 문법이 더 적절하다는 의견
  • 하지만 HTML 복사-붙여넣기 편의성 때문에 채택되지 않음

JSX 2.0 제안 무산 (2016)

  • 여러 breaking changes를 모아 한번에 처리하려 했으나 실패
  • 203개 찬성에도 불구하고 생태계 안정성을 위해 보류

보안 이슈 (2022)

  • &#0; 같은 특수 문자 참조의 동작 논의
  • HTML 스펙상 보안을 위해 특정 문자는 U+FFFD로 대체되는데, JSX 스펙과의 불일치 문제 제기

Babel 8 변경사항

  • JSX 텍스트에서 }, > 문자 금지 예정
  • 마이그레이션: {'}'}{'>'}로 대체

현재 상황

JSX는 안정성을 최우선으로 하여 의도적으로 변경을 최소화하고 있습니다. 향후 TC39 제안으로 진행될 가능성이 언급되고 있지만, 2022년 이후로는 활발한 논의가 없는 상태입니다.

참고


2.1.3 JSX는 어떻게 자바스크립트에서 변환될까?

아래 JSX 코드는

const ComponentA = <A required={true}>Hello World</A>
const ComponentB = <>Hello World</>

const ComponentC = (
	<div>
		<span>hello world</span>
	</div>
)

@babel/plugin-transform-react-jsx 로 변환하면 아래와 같이 바뀐다.

'use strict'
var ComponentA = React.createElement(
	A,
	{
		required: true,
	},
	'Hello World',
)
var ComponentB = React.createElement(
	React.Fragment,
	null,
	'Hello World',
)
var ComponentC = React.createElement(
	'div',
	null,
	React.createElement('span', null, 'hello world')
)

JSXElement(A, React.Fragment, 'div')가 먼저 인자로 들어가고 이후 옵셔널 요소인 JSXChildren( React.createElement('span', null, 'hello world'), JSXAttributes({ required: true }), JSXStrings('Hello World') 들이 다음 인자들로 들어간다.

💡 A 는 저대로 두는건가..? A는 중첩된 컴포넌트일텐데 변환될 때도 중첩된 구조로 바뀌는 건가?

  1. Babel은 JSX 문법만 변환합니다
    • <A> → React.createElement(A, ...)
    • A 자체는 건드리지 않음
  2. 런타임에 실행됩니다
    • React.createElement(A, ...)가 호출되면
    • React가 A 함수를 실행하고
    • 그 안의 JSX도 이미 변환된 상태
  3. 중첩 구조는 함수 호출로 해결
    • A 컴포넌트 내부의 JSX는 A 함수 안에서 변환됨
    • 각 컴포넌트는 독립적으로 변환됨

위 코드를 트랜스파일하면 아래와 같이 결과가 나온다.

'use strict'

var _jsxRuntime = require('custom-jsx-library/jsx-runtime')

var ComponentA = (0, _jsxRuntime.jsx)(A, {
	required: true,
	children: 'Hello World',
})

var ComponentB = (0, _jsxRuntime.jsx)(_jsxElement.Fragment, {
	children: 'Hello World',
});

var ComponentC = (0, _jsxRuntime.jsx)( 'div', {
	children: (0, _jsxRuntime.jsx)( 'span', {
		children: 'Hello World',
	}),
})

그래서 동일한 props를 갖지만 children 요소만 달라지는 경우, 두 개의 컴포넌트를 만들고 삼항연산자를 할 필요 없고, 아래와 같이 createElement의 옵션을 통해서 간결하게 처리할 수 있다.

function TextOrHeading({ 
	isHeading,
	children
}: PropsWithChildren<{isHeading: boolean}>) {
 return isHeading ? <h1 {...args}>{children}</h1> : <span {...args}>{children}</span>
}

function TextOrHeading({ 
	isHeading,
	children
}: PropsWithChildren<{isHeading: boolean}>) {
 return React.createElement(
	 isHeading ? 'h1' : 'span',
	 { className: 'text' },
	 children,
 )
}

2.2 가상 DOM과 리액트 파이버

DOM과 렌더링 과정

DOM: 웹 페이지에 대한 인터페이스로 브라우저가 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보를 담고 있다.

렌더링 과정

  1. 요청한 주소에 대한 HTML 파일을 다운로드
  2. 브라우저 렌더링 엔진이 HTML을 파싱해서 DOM 트리를 구성
  3. CSS 파일을 만나면 CSS 파일 다운로드
  4. 브라우저 렌더링 엔진이 CSS를 파싱해 CSSOM 트리를 구성
  5. DOM을 순회하면서 트리를 분석한다. 이때 display: none 같이 화면에 보이지 않는 요소에 대해서는 작업하지 않는다. (렌더 트리 생성)

❓ display:none 말고 렌더 트리 형성에 영향을 주는 옵션은 어떤 것이 있을까.

렌더 트리에서 완전히 제외되는 속성

  • display: none

    • DOM 트리에는 존재하지만 렌더 트리에서 완전히 제외
    • 레이아웃 계산 및 페인팅 과정에서 제외
  • <head>, <script>, <meta>

    • 시각적으로 표시되지 않는 요소들

렌더 트리에는 포함되지만 특별하게 처리되는 속성

  • visibility: hidden

    • 렌더 트리에 포함됨 (display: none과 다름)
    • 레이아웃 공간은 차지하지만 화면에 그려지지 않음
    • 자식 요소가 visibility: visible이면 보임
  • opacity: 0

    • 렌더 트리에 포함됨
    • 레이아웃 공간 차지, 투명하게 렌더링
      - 이벤트는 여전히 받을 수 있음
  • position: absolute/fixed + left: -9999px

    • 렌더 트리에는 포함
    • 화면 밖으로 이동시킴
  • clip-path, transform: scale(0)

    • 렌더 트리에 포함
    • 시각적으로만 숨김
  1. 눈에 보이는 노드에 대해서는 해당 노드에 대한 CSSOM 정보를 찾고 노드에 적용한다.
    • 레이아웃(layout, reflow): 브라우저 화면 어디에 나타나야하는지 계산하는 과정, 레이아웃 과정을 거치면 페인팅 과정을 반드시 수행
    • 페인팅(painting): 레이아웃 단계 이후에 색과 같은 실제 유효한 모습을 그리는 과정

가상 DOM 등장 배경

브라우저의 관점

추가 렌더링 과정은 SPA에서 많이 발생하게 된다. SPA는 페이지 전환시 새롭게 HTML 파일을 다운로드 받는 것이 아니라 하나의 HTML 파일 내부에서 리플로우, 리페인트 과정을 반복하면서 새로운 요소들을 보여주는 방식이다. 요소를 삭제, 삽입, 위치 계산 등의 작업이 반복되니 사용자 경험상으로는 뛰어나지만 DOM을 관리하는 비용이 커지고 있었다.

개발자의 관점

하나의 인터렉션을 통해서 내부 DOM의 수많은 요소가 변경이 일어난다. 개발자는 DOM 요소 하나하나의 변경점을 아는 것보다, 결과적으로 만들어지는 DOM 결과물에 더 포커싱이 되어있다. 그래서 인터렉션의 최종 결과물 DOM을 간편하게 제공하는 것은 개발자에게도 유용한 방법이다.


가상 DOM과 리액트 파이버

가상 DOM의 역할

가상 DOM은 실제 브라우저 DOM이 아닌 리액트가 관리하는 가상의 DOM을 의미한다. 상태나 props가 변경될 때마다 새로운 가상 DOM이 생성되고, 그 변화가 기존 가상 DOM과 비교(diff)된다. DOM 계산을 브라우저가 아닌 메모리에서 수행함으로써 실제 DOM의 렌더링 과정을 최소화할 수 있다.

리액트 파이버의 역할

리액트 파이버는 재조정자(reconciler)가 관리하며, 가상 DOM과 실제 DOM을 비교해서 변경사항을 수집한다. 파이버가 가지고 있는 둘 사이 차이점에 대해서 화면에 렌더링을 요청하는 역할을 한다.

여기서 재조정(reconciliation)은 가상 DOM을 활용한 렌더링 과정을 최적화하는데 도와주는 리액트의 아키텍처 또는 알고리즘을 말한다.

리액트 파이버가 하는 일

  • 작업을 작은 단위로 분할하고 쪼갠 다음, 우선순위를 매긴다.
  • 이러한 작업을 일시 중지하고 나중에 다시 시작할 수 있다.
  • 이전에 했던 작업을 다시 재사용하거나 필요하지 않은 경우에는 폐기할 수 있다.

⇒ 이런 과정이 비동기적으로 일어난다.

리액트 파이버가 작업을 수행하는 단계

파이버의 작업은 크게 두 단계로 나뉜다.

렌더 단계(Render Phase)

사용자에게 노출되지 않는 모든 비동기 작업을 수행한다. 이 단계에서 앞서 언급한 파이버의 작업, 즉 우선순위를 지정하거나 중지시키거나 버리는 등의 작업이 이루어진다.

이 단계의 특징:

  • 비동기적으로 실행
  • 작업을 중단하고 재개할 수 있음
  • 우선순위가 높은 작업이 들어오면 현재 작업을 중단하고 새로운 작업을 먼저 처리
  • beginWork(), completeWork() 등이 실행됨

커밋 단계(Commit Phase)

DOM에 실제 변경 사항을 반영하기 위한 작업으로, commitWork()가 실행된다. 이 과정은 동기적으로 수행되며 중단될 수도 없다.

이 단계의 특징:

  • 동기적으로 실행
  • 중단 불가능
  • 실제 DOM 업데이트, 라이프사이클 메서드 실행 등이 발생
  • 사용자에게 보이는 변경사항이 이 단계에서 반영됨

💡 왜 커밋 단계는 동기적일까?

렌더 단계에서는 여러 번 작업을 중단하고 재개해도 사용자에게 보이지 않기 때문에 문제가 없다. 하지만 커밋 단계에서 DOM 업데이트를 중단하면 사용자는 절반만 그려진 화면을 보게 되므로, 이 단계는 한 번에 동기적으로 완료되어야 한다.

리액트 파이버 구현체

리액트 파이버는 실제로 어떻게 구현되어 있을까?

자바스크립트 객체로 관리

리액트 파이버는 하나의 자바스크립트 객체로 관리된다. 각 파이버는 리액트 엘리먼트에 1:1로 대응되며, 해당 컴포넌트에 대한 정보를 문자열, 숫자, 배열과 같은 값으로 저장한다.

관계 정의를 통한 트리 구조

파이버는 child, sibling, return 과 같은 속성을 통해 다른 파이버와의 관계를 정의하고 있다.

  • child: 첫 번째 자식 파이버를 가리킴
  • sibling: 다음 형제 파이버를 가리킴
  • return: 부모 파이버를 가리킴 (작업 완료 후 돌아갈 곳)

이러한 구조를 통해 부모는 하나의 자식만 참조하고, sibling을 통해 형제들을 연결함으로써 1대다 관계를 효율적으로 표현한다.

// 파이버 객체의 간소화된 예시
const fiber = {
  type: 'div',              // 컴포넌트 타입
  key: null,
  props: { className: 'container' },
  
  // 트리 구조를 위한 포인터
  child: childFiber,        // 첫 번째 자식
  sibling: siblingFiber,    // 다음 형제
  return: parentFiber,      // 부모
  
  // 작업 관련
  alternate: currentFiber,  // current ↔ workInProgress
  effectTag: 'UPDATE',      // 어떤 작업을 해야 하는지
  // ...
}

이렇게 링크드 리스트 형태로 구현함으로써 재귀 없이 트리를 순회할 수 있고, 작업을 언제든 중단하고 재개할 수 있는 유연성을 확보했다.

가상 DOM과 파이버의 협력

  1. 상태나 props가 변경되면 새로운 가상 DOM이 생성되고, 기존 가상 DOM과 비교(diff)된다.
  2. React Fiber는 가상 DOM의 변경 사항을 렌더 단계에서 관리하며, 우선순위를 계산하고 중요한 작업부터 처리할 수 있도록 한다.
  3. 여러 변경 사항이 모이면, Fiber는 커밋 단계에서 그 변경 사항을 최종적으로 실제 DOM에 일괄적으로 반영한다. 이를 통해 불필요한 DOM 조작을 줄이고 성능을 최적화한다.

따라서 가상 DOM은 매번 모든 변경 사항을 저장하는 것이 아니라, 변경이 일어날 때마다 새롭게 생성되고, 이 변경 사항들을 React Fiber가 추적하고 관리하여, 최적화된 방식으로 실제 DOM에 업데이트하는 것이다.


리액트 파이버 트리

리액트 내에는 두 개의 파이버 트리가 존재한다.

  • 현재 모습을 담고 있는 파이버 트리 (current)
  • 작업 중인 상태를 나타내는 workInProgress 트리

리액트는 파이버의 작업이 완료되고 나면, 커밋 단계에서 workInProgress 트리의 포인터를 변경해 current 트리로 바꾼다. 이를 더블 버퍼링이라고 한다.

더블 버퍼링 - 주로 컴퓨터 그래픽에서 사용되는 용어로, 내부 버퍼에서 그림을 지웠다가 그리는 과정을 수행한 다음에 외부 버퍼로 보내 화면이 부드럽게 보이도록 하는 방법이다.


파이버의 작업 순서

  1. 리액트는 beginWork() 함수를 실행해서 파이버 작업을 수행한다. 더 이상 자식이 없는 파이버를 만날 때까지 트리 형식으로 시작한다.
  2. 1번 작업이 끝나면 completeWork()를 실행해 파이버 작업을 완료한다.
  3. 형제가 있으면 형제로 넘어간다.
  4. 앞선 작업이 모두 완료되면 return을 통해 부모로 돌아가며, 2~4번을 반복한다.

파이버의 트리 순회 방식

React Fiber는 재귀 대신 Singly Linked List를 사용한 Parent-first, Depth-first traversal을 수행한다.

Fiber 구조체 내에서 자식, 형제, 부모에 대한 정보를 구조체로 관리하고 있다.

// packages/react-reconciler/src/ReactInternalTypes.js
export type Fiber = {
  return: Fiber | null,    // 부모
  child: Fiber | null,     // 첫 자식
  sibling: Fiber | null,   // 형제
  alternate: Fiber | null, // current ↔ workInProgress
  // ...
};

리액트 내부의 performUnitOfWork에서는 재귀보다 Linked List로 순회하는 방식을 활용한다.

function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate;
  
  const next = beginWork(current, unitOfWork, renderLanes);
  
  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  
  if (next === null) {
    // 자식이 없으면 complete 단계로
    completeUnitOfWork(unitOfWork);
  } else {
    // 자식이 있으면 자식을 다음 작업으로
    workInProgress = next;
  }
}

과거 Stack Reconciler에서는 재귀 방식으로 동기적으로 업데이트를 수행했기 때문에 중단할 수 없었다. 현재는 Linked List 기반 DFS로 개선하여 작업을 중단/재개할 수 있고, 작업 단위와 우선순위를 정할 수 있게 되었다.


업데이트 처리

새롭게 업데이트가 생기면 workInProgress 트리를 다시 빌드한다. 최초 렌더링 시에는 모든 파이버를 새롭게 만들어야 했지만, 이후에는 파이버가 이미 존재하므로 되도록 새로 생성하지 않고 기존 파이버에 업데이트된 props를 받아 파이버 내부에서 처리한다. 파이버 객체를 계속 생성하는 것은 리소스 낭비이므로, 기존의 파이버 객체를 재사용해서 내부 속성값만 초기화하고 변경하여 트리를 업데이트한다.


파이버와 가상 DOM

  • 파이버는 리액트 컴포넌트의 정보를 1:1로 가지고 있으며, 파이버의 작업(재조정)은 리액트 아키텍처 내에서 비동기적으로 작동한다. 단, 실제 DOM으로 반영되는 커밋 단계는 동기적으로 수행된다.
  • 가상 DOM은 웹 애플리케이션에 대해서만 통용되는 개념이다. 리액트 네이티브에서도 파이버를 통한 재조정은 일어나지만 렌더링 엔진이 다르기 때문에, 가상 DOM은 브라우저 환경에 특화된 개념이라는 점을 알아둘 필요가 있다.
profile
나도 잘하고 싶다..!

0개의 댓글