2장 리액트 핵심 요소 깊게 살펴보기
React Deep Dive ·이번 장에서는 리액트를 이루는 핵심적인 개념을 깊게 샆펴보고, 이러한 기능이 자바스크립트를 토대로 어떻게 동작하는지 알아보자.
2.1 JSX란?
- 보통 리액트를 통해 JSX를 접하기 때문에 JSX가 리액트의 전유물이라고 오해하는 경우가 종종 있다. 페이스북에서 독자적으로 개발했다는 사실에서 미루어 알 수 있듯이 JSX는 이른바 ECMAScript라고 불리는 자바스크립트 표준의 일부는 아니다. 즉, V8이나 Deno와 같은 자바스크립트 엔진이나 크롬, 웨일, 파이어폭스 같은 브라우저에 의해서 실행되거나 표현되도록 만들어진 구문이 아니다.
- 앞서 언급했던 것처럼 JSX는 자바스크립트 표준 코드가 아닌 페이스북이 임의로 만든 새로운 문법이기 때문에 JSX는 반드시 트랜스파일러를 거쳐야 비로소 자바스크립트 런타임이 이해할 수 있는 의미 있는 자바스크립트 코드로 변환된다.
- JSX의 설계 목적은 JSX 내부에 트리 구조로 표현하고 싶은 다양한 것들을 작성해두고, 이 JSX를 트랜스파일이라는 과정을 거쳐 자바스크립트(ECMAScript)가 이해할 수 있는 코드로 변경하는 것이 목표라고 볼 수 있다.
- 요약하자면 JSX는 자바스크립트 내부에서 표현하기 까다로웠던 XML 스타일의 트리 구문을 작성하는 데 많은 도움을 주는 새로운 문법이라고 볼 수 있다. 이제 본격적으로 JSX가 어떻게 구성돼 있고 자바스크립트가 이를 이해하기 위해 어떤 과정을 거쳐야 하는지 살펴보자.
2.1.1 JSX의 정의
- JSX는 기본적으로 JSXElement, JSXAttributes, JSXChildren, JSXStrings라는 4가지 컴포넌트를 기반으로 구성돼 있다. 각 컴포넌트에 대해 살펴보자.
JSXElement
- JSX를 구성하는 가장 기본 요소로, HTML의 요소(element)와 비슷한 역할을 한다. JSXElement가 되기 위해서는 다음과 같은 형태 중 하나여야 한다.
- JSXOpeningElement: 일반적으로 볼 수 있는 요소다. JSXOpeningElement로 시작했다면 후술할 JSXClosingElement가 동일한 요소로 같은 단계에서 선언돼 있어야 올바른 JSX 문법으로 간주된다.
- 예:〈JSXElement JSXAttribiites(optional)>
- JSXClosingElement: JSXOpeningElement가 종료됐음을 알리는 요소로, 반드시 JSXOpeningElement와 쌍으로 사용돼야 한다.
- 예:〈JSXElement />
- JSXSelfClosingElement: 요소가 시작되고, 스스로 종료되는 형태를 의미한다. 와 동일한 모습을 띠고 있다. 이는 내부적으로 자식을 포함할 수 없는 형태를 의미한다.
- 예:〈JSXElement JSXAttributes(optional) />
- JSXFragment: 아무런 요소가 없는 형태로, JSXSelfClosingElement 형태를 띨 수는 없다. “</>”는 불가능하다. 단 “<></>” 는가능하다.
- JSXOpeningElement: 일반적으로 볼 수 있는 요소다. JSXOpeningElement로 시작했다면 후술할 JSXClosingElement가 동일한 요소로 같은 단계에서 선언돼 있어야 올바른 JSX 문법으로 간주된다.
JSXAttributes
-
JSXElement에 부여할 수 있는 속성을 의미한다. 단순히 속성을 의미하기 때문에 모든 경우에서 필수값이 아니고, 존재하지 않아도 에러가 나지 않는다.
- JSXSpreadAttributes: 자바스크립트의 전개 연산자와 동일한 역할을 한다고 볼 수 있다.
- {.. .AssignmentExpression}: 이 AssignmentExpression에는 단순히 객체뿐만 아니라 자바스크립트에서 AssignmentExpression으로 취급되는 모든 표현식이 존재할 수 있다. 여기에는 조건문 표현식, 화살표 함수, 할당식 등 다양한 것이 포함돼 있다.
- JSXAttribute: 속성을 나타내는 키와 값으로 짝을 이루어서 표현한다. 키는 JSXAttributeName, 값은 JSXAttribute Value로 불린다.
- JSXSpreadAttributes: 자바스크립트의 전개 연산자와 동일한 역할을 한다고 볼 수 있다.
JSXChildren
- JSXElement의 자식 값을 나타낸다. JSX는 앞서 언급했듯 속성을 가진 트리 구조를 나타내기 위해 만들어졌기 때문에 JSX로 부모와 자식 관계를 나타낼 수 있으며, 그 자식을 JSXChildren이라고 한다.
- JSXChild: JSXChildren을 이루는 기본 단위다. 단어의 차이에서 알 수 있듯이 JSXChildren은 JSXChild를 0개 이상 가질 수 있다.
- JSXText: {. <, >, }을 제외한 문자열
- JSXElement: 값으로 다른 JSX 요소가 들어갈 수 있다.
- JSXFragment: 값으로 빈 JSX 요소인 <></>가 들어갈 수 있다
- { JSXChildExpression (optional) }: 이 JSXChildExpression은 자바스크립트의 AssignmentExpression을 의미한다. 익숙하지 않겠지만 다음과 같은 코드두 올바른 JSX 표현식으로 볼 수 있다.
// 이 함수를 리액트에서 렌더링하면 "foo"라는 문자열이 출력된다. export default function App() { return <>{(() => 'foo')()}</> }
- JSXChild: JSXChildren을 이루는 기본 단위다. 단어의 차이에서 알 수 있듯이 JSXChildren은 JSXChild를 0개 이상 가질 수 있다.
JSXStrings
- HTML에서 사용 가능한 문자열은 모두 JSXStrings에서도 가능하다. 이는 개발자가 HTML의 내용을 손쉽게 JSX로 가져올 수 있도록 의도적으로 설계된 부분이다. 여기서 정의된 문자열이라 함은, “큰따옴표로 구성된 문자열”, ‘작은따옴표로 구성된 문자열’ 혹은 JSXText를 의미한다.
2.1.2 JSX 예제
- 백문불여일견!! 코드를 보며, 앞서 소개한 4가지 요소를 조합해 JSX를 만들어 보자. 다음 예제는 모두 유효한JSX 구조를 띠고 있다.
// 하나의 요소로 구성된 가장 단순한 형태
const ComponentA = <A>안녕하세요.</A>
// 자식이 없이 SelfClosingTag로 닫혀 있는 형태도 가능하다.
const ComponentB = <A />
// 옵션을 { }와 전개 연산자로 넣을 수 있다.
const ComponentC = <A {...{ required: true }} />
// 속성만 넣어도 가능하다.
const ComponentD = <A required />
// 속성과 속성을 넣을 수 있다.
const ComponentE = <A required={false} />
const ComponentF = (
<A>
{/* 문자열은 큰따옴표 및 작은따옴표 모두 가능하다. */}
<B text="리액트" />
</A>
)
const ComponentG = (
<A>
{/* 옵션의 값으로 JSXElement를 넣는 것 또한 올바론 문법이다. */}
<B optionalChildren={<>안녕하세요.</>} />
</A>
)
const ComponentH = (
<A>
{/* 여러 개의 자식도 포함할 수 있다. */}
안녕하세요
<B text="리액트" />
</A>
)
2.1.3 JSX는 어떻게 자바스크립트로 변환될까?
- 우선 자바스크립트에서 JSX가 변환되는 방식을 알려면 리액트에서 JSX를 변환하는 @babel/plugintransform-react-jsx 플러그인을 알아야 한다. 이 플러그인은 JSX 구문을 자바스크립트가 이해할 수 있는 형태로 변환한다.
- 다음과 같은JSX 코드가 있다고 가정해 보자.
const ComponentA = <A> required={true}>Hello World</A>
const ComponentB = <>Hello World</>
const ComponentC = (
<div>
<span>hello world</span>
</div>
)
- 이를 변환한 결과는 다음과 같다.
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, 'heUo world'),
)
- @babel/plugin-transform-react-jsx를 직접 써보고 싶다면 필요한 패키지를 설치하고 다음과 같이 코드를 작성하면 된다.
2.1.4 정리
- JSX는 자바스크립트 코드 내부에 HTML과 같은 트리 구조를 가진 컴포넌트를 표현할 수 있다는 점에서 각광받고 있다. 물론 인기만 있는 것은 아니다. JSX가 HTML 문법과 자바스크립트 문법이 뒤섞여서 코드 가독성을 해친다는 의견도 있다. JSX 내부에 자바스크립트 문법이 많아질수록 복잡성이 증대하면서 코드의 가독성도 해칠 것이므로 주의해서 사용해야 한다.
2.2 가상 DOM과 리액트 파이버
- 리액트의 특징으로 가장 많이 언급되는 것 중 하나는 바로 실제 DOM이 아닌 가상 DOM을 운영한다는 것이다. 그러나 가상 DOM이 왜 만들어졌는지, 실제 DOM과는 어떤 차이가 있는지, 그리고 정말로 실제 DOM 을 조작하는 것보다 빠른지에 대해서는 잘 모르는 경우가 많다. 이번 장에서는 리액트의 가상 DOM이 무엇인지, 그리고 실제 DOM에 비해 어떤 이점이 있는지 살펴보고 가상 DOM을 다룰 때 주의할 점에 대해서도 알아보겠다.
2.2.2 가상 DOM의 탄생 배경
- 가상 DOM은 말 그대로 실제 브라우저의 DOM이 아닌 리액트가 관리하는 가상의 DOM을 의미한다. 가상 DOM은 웹페이지가 표시해야 할 DOM을 일단 메모리에 저장하고 리액트가 실제 변경에 대한 준비가 완료됐을 때 실제 브라우저의 DOM에 반영한다. 이렇게 DOM 계산을 브라우저가 아닌 메모리에서 계산하는 과정을 한 번 거치게 된다면 실제로는 여러 번 발생했을 렌더링 과정을 최소화할 수 있고 브라우저와 개발자의 부담을 덜 수 있다.
2.2.3 가상 DOM을 위한 아키텍처, 리액트 파이버
- 그렇다면 이러한 가상 DOM을 만드는 과정을 리액트는 어떻게 처리하고 있을까? 리액트는 여러 번의 렌더링 과정을 압축해 어떻게 최소한의 렌더링 단위를 만들어 내는 것일까? 이러한 가상 DOM과 렌더링 과정 최적화를 가능하게 해주는 것이 바로 리액트 파이버(React Fiber)다.
리액트 파이버란?
- 리액트 파이버는 리액트에서 관리하는 평범한 자바스크립트 객체다. 파이버는 파이버 재조정자(fiber reconciler)가 관리하는데, 이는 앞서 이야기한 가상 DOM과 실제 DOM을 비교해 변경 사항을 수집하며, 만약 이 둘 사이에 차이가 있으면 변경에 관련된 정보를 가지고 있는 파이버를 기준으로 화면에 렌더링을 요청하는 역할을 한다.
- 리액트 파이버의 목표는 리액트 웹 애플리케이션에서 발생하는 애니메이션, 레이아웃, 그리고 사용자 인터랙션에 올바른 결과물을 만드는 반응성 문제를 해결하는 것이다. 이를 위해 파이버는 다음과 같은 일을 할 수 있다.
- 작업을 작은 단위로 분할하고 쪼갠 다음, 우선순위를 매긴다.
- 이러한 작업을 일시 중지하고 나중에 다시 시작할 수 있다.
- 이전에 했던 작업을 다시 재사용하거나 필요하지 않은 경우에는 폐기할 수 있다.
- 한 가지 중요한 것은 이러한 모든 과정이 비동기로 일어난다는 것이다. 과거 리액트 리액트의 조정 알고리즘은 스택 알고리즘으로 이뤄져 있었다. 스택이라는 이름에서 유추할 수 있듯이 과거에는 이 하나의 스택에 렌더링에 필요한 작업들이 쌓이면 이 스택이 빌 때까지 동기적으로 작업이 이루어졌다.
-
자바스크립트의 특징인 싱글 스레드라는 점으로 인해 이 동기 작업은 중단될 수 없고, 다른 작업이 수행되고 싶어도 중단할 수 없었으며, 결국 이는 리액트의 비효율성으로 이어졌다.
- 사용자 인터랙션에 따른 동시 다발적인 이벤트와 애니메이션은 다양한 작업을 처리하는 요즘의 웹 애플리케이션에서는 피할 수 없는 문제다. 이러한 기존 렌더링 스택의 비효율성을 타파하기 위해 리액트 팀은 이 스택 조정자 대신 파이버라는 개념을 탄생시킨다.
- 파이버는 일단 하나의 작업 단위로 구성돼 있다. 리액트는 이러한 작업 단위를 하나씩 처리하고 finishedWork()라는 작업으로 마무리한다. 그리고 이 작업을 커밋해 실제 브라우저 DOM에 가시적인 변경 사항을 만들어 낸다. 그리고 이러한 단계는 아래 두 단계로 나눌 수 있다.
- 1) 렌더 단계에서 리액트는 사용자에게 노출되지 않는 모든 비동기 작업을 수행한다. 그리고 이 단계에서 앞서 언급한 파이버의 작업, 우선순위를 지정하거나 중지시키거나 버리는 등의 작업이 일어난다.
- 2) 커밋 단계에서는 앞서 언급한 것처럼 DOM에 실제 변경 사항을 반영하기 위한 작업, commitWork()가 실행되는데. 이 과정은 앞서와 다르게 동기식으로 일어나고 중단될 수도 없다.
- 파이버가 실제 리액트 코드에서 어떻게 구현돼 있는지 살펴보자.
function FiberNode(tag, pendingProps, key, mode) {
// Instance
this.tag = tag
this.key = key
this.elementType = null
this.type = null
this.stateNode = null
// Fiber
this.return = null
this.child = null
this.sibling = null
this.index = 0
this.ref = null
this, refCleanup = null
this.pendingProps = pendingProps
this.memoizedProps = null
this.updateQueue = null
this.memoizedState = null
this.dependencies = null
this.mode = mode
// Effects
this.flags = NoFlags
this.subtreeFlags = NoFlags
this.deletions = null
this.lanes = NoLanes
this.childLanes = NoLanes
this.alternate = null
// 이하 프로파일러, _DEV__ 코드는 생략
}
-
보다시피 파이버가 단순한 자바스크립트 객체로 구성돼 있는 것을 볼 수 있다. 파이버는 리액트 요소와 유사하다고 느낄 수 있지만 한 가지 중요한 차이점은 리액트 요소는 렌더링이 발생 할 때마다 새롭게 생성되지만 파이버는 가급적이면 재사용된다는 사실이다. 파이버는 컴포넌트가 최초로 마운트되는 시점에 생성되어 이후에는 가급적이면 재사용된다.
-
리액트에 작성되어 있는 파이버를 생성하는 다양한 함수를 살펴보자.
var createFiber = function (tag, pendingProps, key, mode) {
return new FiberNode(tag, pendingProps, key, mode)
}
// 생략...
function createFiberFromElement(element, mode, lanes) {
var owner = null
{
owner = element._owner
}
var type = element.type
var key = element.key
var pendingProps = element.props
var fiber = createFiberFromTypeAndProps(
type,
key,
pendingProps,
owner,
mode,
lanes,
)
{
fiber._debugSource = element._source
fiber._debugOwner = element._owner
}
return fiber
}
-
리액트 파이버의 구현체를 봤으니 이제 여기서 선언된 주요 속성을 살펴보면서 어떠한 내용을 담고 있는지살펴보자.
- tag: 파이버를 만드는 함수 이름인 createFiberFromElement를 보면 유추할 수 있겠지만 파이버는 하나의 element에 하나가 생성되는 1:1 관계를 가지고 있다.
- stateNode: 이 속성에서는 파이버 자체에 대한 참조(reference) 정보를 가지고 있으며, 이 참조를 바탕으로 리액트는 파이버와 관련된 상태에 접근한다.
- child, sibling, return: 파이버 간의 관계 개념을 나타내는 속성이다. 리액트 컴포넌트 트리가 형성되는 것과 동일하게 파이버도 트리 형식을 갖게 되는데, 이 트리 형식을 구성하는 데 필요한 정보가 이 속성 내부에 정의된다. 한 가지 리액트 컴포넌트 트리와 다른 점은 children이 없다는 것, 즉 하나의 child만 존재한다는 점이다.
- index: 여러 형제들(sibling) 사이에서 자신의 위치가 몇 번째인지 숫자로 표현한다.
- pendingProps: 아직 작업을 미처 처리하지 못한 props
- memoizedProps: pendingProps를 기준으로 렌더링이 완료된 이후에 pendingProps를 memoizedProps로 저장해 관리한다.
- updateQueue: 상태 업데이트, 콜백 함수, DOM 업데이트 등 필요한 작업을 담아두는 큐. 이 큐는 대략 다음과 같은 구조를가지고 있다.
- memoizedState: 함수형 컴포넌트의 훅 목록이 저장된다. 여기에는 단순히 useState뿐만 아니라 모든 훅 리스트가 저장된다.
- alternate: 뒤이어 설명할 리액트 파이버 트리와 이어질 개념. 리액트의 트리는 두 개인데. 이 alternate는 반대편 트리파이버를 가리킨다.
- 생성된 파이버는 state가 변경되거나 생명주기 메서드가 실행되거나 DOM의 변경이 필요한 시점 등에 실행된다. 그리고 중요한 것은 리액트가 파이버를 처리할 때마다 이러한 작업을 직접 바로 처리하기도 하고 스케줄링하기도 한다는 것이다. 즉, 이러한 작업들은 작은 단위로 나눠서 처리할 수도, 애니메이션과 같이 우선순위가 높은 작업은 가능한 한 빠르게 처리하거나, 낮은 작업을 연기시키는 등 좀 더 유연하게 처리된다.
- 리액트 파이버의 가상 DOM이 생각보다 단순한 자바스크립트 객체로 관리되고 있다는 사실에 놀랄 수도 있다. 리액트 개발 팀은 사실 리액트는 가상 DOM이 아닌 Value UI, 즉 값을 가지고 있는 UI를 관리하는 라이브러리라는 내용을 피력한 바 있다. 파이버의 객체 값에서도 알 수 있듯이 리액트의 핵심 원칙은 UI를 문자열, 숫자, 배열과 같은 값으로 관리한다는 것이다. 변수에 이러한 UI 관련 값을 보관하고, 리액트의 자바스크립트 코드 흐름에 따라 이를 관리하고, 표현하는 것이 바로 리액트다.
리액트 파이버 트리
- 파이버의 개념에 대해 알아봤으니 그다음으로는 파이버 트리에 대해 알아보자. 파이버 트리는 사실 리액트 내부에서 두 개가 존재한다. 하나는 현재 모습을 담은 파이버 트리이고, 다른 하나는 작업 중인 상태를 나타내는 workInProgress 트리다. 리액트 파이버의 작업이 끝나면 리액트는 단순히 포인터만 변경해 worklnProgress 트리를 현재 트리로 바꿔버린다. 이러한 기술을 더블 버퍼링이라고 한다.

- 즉, 먼저 현재 UI 렌더링을 위해 존재하는 트리인 current를 기준으로 모든 작업이 시작된다. 여기에서 만약 업데이트가 발생하면 파이버는 리액트에서 새로 받은 데이터로 새로운 worklnProgress 트리를 빌드하기 시작한다. 이 worklnProgress 트리를 빌드하는 작업이 끝나면 다음 렌더링에 이 트리를 사용한다. 그리고 이 worklnProgress 트리가 이에 최종적으로 렌더링되어 반영이 완료되면 current가 이 worklnProgress로 변경된다.
파이버의 작업 순서
-
파이버와 파이버 트리에 대해 알아봤으니 이제 파이버 트리와 파이버가 어떤 식으로 작동하는지 흐름을 살펴보자. 먼저 일반적인 파이버 노드의 생성 흐름은 다음과 같다.
- 1) 리액트는 beginWork() 함수를 실행해 파이버 작업을 수행하는데, 더 이상 자식이 없는 파이버를 만날 때까지 트리 형식으로 시작된다.
- 2) 1 번에서 작업이 끝난다면 그다음 completeWorkO 함수를 실행해 파이버 작업을 완료한다.
- 3) 형제가 있다면 형제로 넘어간다.
- 4) 2번, 3번이 모두 끝났다면 return으로 돌아가 자신의 작업이 완료됐음을 알린다.
- 이렇게 트리가 생성됐다. 이제 여기서 setstate 등으로 업데이트가 발생하면 어떻게 될까?
- 이미 리액트는 앞서 만든 current 트리가 존재하고, setState로 인한 업데이트 요청을 받아 worklnProgress 트리를 다시 빌드하기 시작한다. 이 빌드 과정은 앞서 트리를 만드는 과정과 동일하다. 최초 렌더링 시에는 모든 파이버를 새롭게 만들어야 했지만 이제는 파이버가 이미 존재하므로 되도록 새로 생성하지 않고 기존 파이버에서 업데이트된 props를 받아 파이버 내부에서 처리한다.
2.2.4 파이버와 가상 DOM
- 앞서 언급했듯이 리액트 컴포넌트에 대한 정보를 1:1로 가지고 있는 것이 파이버이며, 이 파이버는 리액트 아키텍처 내부에서 비동기로 이뤄진다. 이러한 비동기 작업과 달리, 실제 브라우저 구조인 DOM에 반영하는 것은 동기적으로 일어나야 하고, 또 처리하는 작업이 많아 화면에 불완전하게 표시될 수 있는 가능성이 높으므로 이러한 작업을 가상에서, 즉 메모리상에서 먼저 수행해서 최종적인 결과물만 실제 브라우저 DOM에 적용하는 것이다.
2.2.5 정리
- 지금까지 리액트에서 가상 DOM이라는 개념이 무엇인지, 또 가상 DOM을 구현하기 위해 만들어진 리액트 파이버의 개념과 이를 조정하는 재조정자에 대해서 알아봤다.
- 가상 DOM과 파이버는 단순히 브라우저에 DOM을 변경하는 작업보다 빠르다는 이유로만 만들어진 것은 아니다. 만약 이러한 도움 없이 개발자가 직접 DOM을 수동으로 하나하나 변경해야 한다면 어떤 값이 바뀌었는지, 또 그 값에 따라 어떠한 값이 변경됐고 이와 관련된 것들이 무엇이었는지 파악하기가 매우 어려울 것이다. 이러한 어려움을 리액트 내부의 파이버와 재조정자가 내부적인 알고리즘을 통해 관리해 줌으로써 대규모 웹 애플리케이션을 효율적으로 유지보수하고 관리할 수 있게 된 것이다.
- 가상 DOM과 리액트의 핵심은 브라우저의 DOM을 더욱 빠르게 그리고 반영하는 것이 아니라 바로 값으로 UI를 표현하는 것이다. 화면에 표시되는 UI를 자바스크립트의 문자열, 배열 등과 마찬가지로 값으로 관리하고 이러한 흐름을 효율적으로 관리하기 위한 메커니즘이 바로 리액트의 핵심이다.
2.3 클래스형 컴포넌트와 함수형 컴포넌트
- 함수형 컴포넌트가 각광받기 시작한 것은 16.8 버전 에서 훅이 소개된 이후였다. 함수형 컴포넌트에 훅이 등장한 이후 함수형 컴포넌트에서 상태나 생명주기 메서드 비슷한 작업을 흉내 낼수 있게 되자 상대적으로 보일러플레이트가 복잡한 클래스형 컴포넌트보다 함수형 컴포넌트를 더 많이 쓰기 시작했다.
2.3.1 클래스형 컴포넌트
클래스형 컴포넌트의 한계
- 지금까지 클래스형 컴포넌트를 살펴봤다. 클래스형 컴포넌트에서 제공하는 메서드만으로도 완성도 있는 리액트 애플리케이션을 만드는 데는 충분해 보인다. 그런데 어떠한 문제점 때문에 리액트는 함수형 컴포넌트에 훅을 도입한 새로운 패러다임을 만든 것일까? 그 이유를 추측해보자면 다음과 같다
- 데이터의 흐름을 추적하기 어렵다: 앞서 생명주기 메서드를 잘 살펴봤다면 state의 흐름을 추적하기가 매우 어렵다는 사실을 알 수 있을 것이다. 서로 다른 여러 메서드에서 State의 업데이트가 일어날 수 있으며, 또 코드 작성 시 메서드의 순서가 강제돼 있는 것이 아니기 때문에 사람이 읽기가 매우 어렵다.
- 애플리케이션 내부 로직의 재사용이 어렵다: 컴포넌트 간에 중복되는 로직이 있고, 이를 재사용하고 싶다고 가정해 보자. 여기서 우리가 사용할 수 있는 것은 컴포넌트를 또 다른 고차 컴포넌트 (Higher Order Component)로 감싸거나, props를 넘겨주는 방식이 있을 것이다. 그러나 모두 심각한 단점이 있는데, 공통 로직이 많아질수록 이를 감싸는 고차 컴포넌트 내지는 props가 많아지는 래퍼 지옥(wrapper hell)에 빠져들 위험성이 커진다는 점이다.
- 기능이 많아질수록 컴포넌트의 크기가 커진다.
- 클래스는 함수에 비해 상대적으로 어렵다.
2.3.3 함수형컴포넌트 vs. 클래스형 컴포넌트
- render 내부에서 필요한 함수를 선언할때 this 바인딩을 조심할 필요도 없으며, state는 객체가 아닌 각각의 원시값으로 관리되어 훨씬 사용하기가 편해졌다. 물론 state는 객체도 관리할 수 있다. 렌더링하는 코드인 return에서도 굳이 this를 사용하지 않더라도 props와 state에 접근할 수 있게 됐다.
생명주기 메서드의 부재
- 가장 눈에 띄는 차이점이자 많은 개발자들이 적응하지 못하는 부분은 클래스형 컴포넌트의 생명주기 메서드가 함수형 컴포넌트에서는 존재하지 않는다는 것이다. 그 이유는 함수형 컴포넌트는 props를 받아 단순히 리액트 요소만 반환하는 함수인 반면, 클래스형 컴포넌트는 render 메서드가 있는 React.Component를 상속받아 구현하는 자바스크립트 클래스이기 때문이다.
함수형 컴포넌트와 렌더링된 값
- 리액트 개발자 댄 아브라모프가 블로그에서 이야기한 유명한 내용 중 하나로, 함수형 컴포넌트는 렌더링된 값을 고정하고, 클래스형 컴포넌트는 그렇지 못하다는 사실이다.
import React from 'react'
interface Props {
user: string
}
// 함수형 컴포넌트로 구현한 setTimeout 예제
export function FunctionalComponent(props: Props) {
const showMessage =()=>{
alert('Hello' + props.user)
}
const handleClick =()=>{
setTimeout(showMessage, 3000)
}
return <button onClick={handleClick}>Follow</button>
}
// 클래스형 컴포넌트로 구현한 setTimeout 예제
export class ClassComponent extends React.Component<Props, {}> {
private showMessage =()=>{
alert('Hello' + this.props.user)
}
private handleClick =()=>{
setTimeout(this.showMessage, 3000)
}
public render() {
return <button onClick={this.handleClick}>Follow</button>
}
}
- 여기서 FunctionalComponent와 ClassComponent는 같은 작업을 하고 있다. handleClick을 클릭하면 3초 뒤에 props에 있는 user를 alert로 띄워준다. 만약 3초 사이에 props를 변경하면 어떻게 될까? 두 코드가 모두 동일하게 작동할 것 같지만 차이가 있다.
- ClassComponent의 경우에는 3초 뒤에 변경된 props를 기준으로 메시지가 뜨고, FunctionalComponent는 클릭했던 시점의 props 값을 기준으로 메시지가 뜬다. 아무래도 FunctionalComponent 쪽이 일반적인 개발자가 의도한 방향일 것이다.
-
클래스형 컴포넌트는 props의 값을 항상 this로부터 가져온다. 클래스형 컴포넌트의 props는 외부에서 변경되지 않는 이상 불변 값이지만 this가 가리키는 객체, 즉 컴포넌트의 인스턴스의 멤버는 변경 가능한(mutable) 값이다. 따라서 render 메서드를 비롯한 리액트의 생명주기 메서드가 변경된 값을 읽을 수 있게된다.
- 다시 함수형 컴포넌트로 돌아와 보자. this에 바인딩된 props를 사용하는 클래스형 컴포넌트와 다르게, 함수형 컴포넌트는 props를 인수로 받는다. 그리고 this와 다르게, props는 인수로 받기 때문에 컴포넌트는 그 값을 변경할 수 없고, 해당 값을 그대로 사용하게 된다. 그리고 이러한 특성은 state도 마찬가지다.
- 함수형 컴포넌트는 렌더링이 일어날 때마다 그 순간의 값인 props와state를 기준으로 렌더링된다. props와 state가 변경된다면, 다시 한 번 그 값을 기준으로 함수가 호출된다고 볼 수 있다. 반면 클래스형 컴포넌트는 시간의 흐름에 따라 변화하는 this를 기준으로 렌더링이 일어난다.
2.4 렌더링은 어떻게 일어나는가?
- 리액트에도 렌더링이라는 과정이 존재한다. 리액트의 렌더링은 브라우저가 렌더링에 필요한 DOM 트리를 만드는 과정을 의미한다. 리액트도 브라우저와 마찬가지로 이 렌더링 작업을 위한 자체적인 렌더링 프로세스가 있으며, 이를 이해하는 것은 곧 리액트를 이해하는 첫걸음으로 볼 수 있다. 왜나하면 리액트의 렌더링은 시간과 리소스를 소비해 수행되는 과정으로, 이 비용은 모두 웹 애플리케이션을 방문하는 사용자에게 청구되며, 시간이 길어지고 복잡해질수록 유저의 사용자 경험 을 저해하기 때문이다. 따라서 리액트 개발자라면 렌더링이 어떻게, 왜, 어떤 순서로 일어나는지 알고 있어야하며 이러한 렌더링 과정을 최소한으로 줄여야 한다.
2.4.1 리액트의 렌더링이란?
- 리액트에서의 렌더링이란 리액트 애플리케이션 트리 안에 있는 모든 컴포넌트들이 현재 자신들이 가지고 있는 props와 state의 값을 기반으로 어떻게 UI를 구성하고 이를 바탕으로 어떤 DOM 결과를 브라우저에 제공할 것인지 계산하는 일련의 과정을 의미한다. 그럼 리액트에서는 언제 어떻게 렌더링이 일어나는지 본격적으로 살펴보자.
2.4.2 리액트의 렌더링이 일어나는 이유
- 렌더링 과정을 이해하는 것도 중요하지만 이보다 더 중요한 것은 렌더링이 언제 발생하느냐다. 리액트에서 렌더링이 발생하는 시 나리오는 다음과 같다.
- 1) 최초 렌더링: 사용자가 처음 애플리케이션에 진입하면 당연히 렌더링해야 할 결과물이 필요하다. 리액트는 브라우저에 이정보를 제공하기 위해 최초 렌더링을 수행한다.
- 2) 리렌더링: 리렌더링은 처음 애플리케이션에 진입했을 때 최초 렌더링이 발생한 이후로 발생하는 모든 렌더링을 의미한다. 리렌더링이 발생하는 경우는 다음과 같다
- 함수형 컴포넌트의 useState()의 두 번째 배열 요소인 setter가 실행되는 경우: useState가 반환하는 배열의 두 번째 인수는 클래스형 컴포넌트의 setstate와 마찬가지로 state를 업데이트하는 함수다. 이 함수가 실행되면 렌더링이 일어난다.
- 함수형 컴포넌트의 useReducer()의 두 번째 배열 요소인 dispatch가 실행되는 경우: useReducer도 useState와 마찬가지로 상태와 이 상태를 업데이트하는 함수를 배열로 제공한다. 이 두 번째 배열 요소를 실행하면 컴포넌트의 렌더링이 일어난다.
- 컴포넌트의 key props가 변경되는 경우: 리액트에서 key는 명시적으로 선언돼 있지 않더라도 모든 컴포넌트에서 사용할 수 있는 특수한 props다. 일반적으로 key는 다음과 같이 배열에서 하위 컴포넌트를 선언할 때 사용된다.
- 리액트에서 배열에 key를 쓰지 않으면 콘솔에 경고가 출력되기 때문에 key를 유일한 값으로 추가하는 것이 일반적이지만 정작 왜 추가해야 하는지는 모르는 경우가 많다. 왜 key가 필요할까?
- 리액트에서 key는 리렌더링이 발생하는 동안 형제 요소들 사이에서 동일한 요소를 식별하는 값이다. 동일한 자식 컴포넌트가 여러 개 있는 구조를 상상해 보자. 리렌더링이 발생하면 current 트리와 worklnProgress 트리 사이에서 어떠한 컴포넌트가 변경이 있었는지 구별해야 하는데, 이 두 트리 사이에서 같은 컴포넌트인지를 구별하는 값이 바로 key다. key가 존재한다면 두 트리 사이에서 통일한 key를 가지고 있는 컴포넌트는 이를 기준으로 구별할 수 있지만, 이 key가 없다면 단순히 파이버 내부의 sibling 인덱스만을 기준으로 판단하게 된다.
- props가 변경되는 경우 부모로부터 전달받는 값인 props가 달라지면 이를 사용하는 자식 컴포넌트에서도 변경이 필요하므로 리렌더링이 일어난다.
- 부모 컴포넌트가 렌더링될 경우: 한 가지 주의할 점은 부모 컴포넌트가 리렌더링된다면 자식 컴포넌트도 무조건 리렌더링이 일어난다는 것이다. 이에 대해서는 이후에 더 자세하게 다룬다.
2.4.3 리액트의렌더링프로세스
- 지금까지 리액트에서 언제 렌더링이 발생하는지 살펴봤다. 이제 본격적으로 렌더링이 어떤 과정을 거쳐 수행되는지 살펴보자.
- 렌더링 프로세스가 시작되면 리액트는 컴포넌트의 루트에서부터 차근차근 아래쪽으로 내려가면서 업데이트가 필요하다고 지정돼 있는 모든 컴포넌트를 찾는다. 만약 여기서 업데이트가 필요하다고 지정돼 있는 컴포넌트를 발견하면 클래스형 컴포넌트의 경우에는 클래스 내부의 render() 함수를 실행하게되고, 함수형 컴포넌트의 경우에는 Functioncomponent() 그 자체를 호출한 뒤에, 그 결과물을 저장한다.
- 앞서 언급한 바와 같이 일반적으로 렌더링 결과물은JSX 문법으로 구성돼 있고, 이것이 자바스크립트로 컴파일되면서 React.createElement()를 호출하는 구문으로 변환된다. 여기서 createElement는 브라우저의 UI 구조를 설명할 수 있는 일반적인 자바스크립트 객체를 반환한다. 다음 예제를 살펴보자.
function Hello() {
return (
<TestComponent a={35} b="yceffort">
안녕하세요
</TestComponent>
)
}
- 위 JSX 문법은 다음과 같은 React. createElement를 호출해서 변환된다.
function Hello() {
return React.createElement(
Testcomponent,
{ a: 35, b: 'yceffort' },
'안녕하세요',
)
}
- 결과물은 다음과 같다.
{type: Testcomponent, props: {a: 35, b: "yceffort", children: "안녕하세요"}}
- 렌더링 프로세스가 실행되면서 이런 과정을 거쳐 각 컴포넌트의 렌더링 결과물을 수집한 다음, 리액트의 새로운 트리인 가상 DOM과 비교해 실제 DOM에 반영하기 위한 모든 변경 사항을 차례차례 수집한다.
- 이렇게 계산하는 과정을 바로 2.2절 ‘가상 DOM과 리액트 파이버’에서 다뤘던 리액트의 재조정(Reconciliation)이라고 한다. 이러한 재조정 과정이 모두 끝나면 모든 변경 사항을 하나의 동기 시퀀스로 DOM에 적용해 변경된 결과물이 보이게 된다
2.4.4 렌더와 커밋
- 여기서 한 가지 주목해야 할 것은 리액트의 렌더링은 렌더 단계와 커밋 단계라는 총 두 단계로 분리되어 실행된다는 것이다. 이번에는 렌더 단계와 커밋 단계에서 어떠한 일이 벌어지는지 살펴보자.
- 렌더 단계(Render Phase)는 컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업을 말한다. 즉. 렌더링 프로세스에서 컴포넌트를 실행해(render() 또는 return) 이 결과와 이전 가상 DOM을 비교하는 과정을 거쳐 변경이 필요한 컴포넌트를 체크하는 단계다. 여기서 비교하는 것은 크게 세 가지로, type, props, key다. 이 세 가지 중 하나라도 변경된 것이 있으면 변경이 필요한 컴포넌트로 체크해둔다.
- 그 다음으로 커밋 단계(Commit Phase)는 렌더 단계의 변경 사항을 실제 DOM에 적용해 사용자에게 보여주는 과정을 말한다. 이 단계가 끝나야 비로소 브라우저의 렌더링이 발생한다.
- 리액트가 먼저 DOM을 커밋 단계에서 업데이트한다면 이렇게 만들어진 모든 DOM 노드 및 인스턴스를 가리키도록 리액트 내부의 참조를 업데이트한다. 그 다음, 생명주기 개념이 있는 클래스형 컴포넌트에서는 componentDidMount, componentDidUpdate 메서드를 호출하고, 함수형 컴포넌트에서는 useLayoutEffect 훅을 호출한다.
- 여기서 알 수 있는 중요한 사실은 리액트의 렌더링이 일어난다고 해서 무조건 DOM 업데이트가 일어나는 것은 아니라는 것이다. 렌더링을 수행했으나 커밋 단계까지 갈 필요가 없다면, 즉 변경 사항을 계산했는데 아무런 변경 사항이 감지되지 않는다면 이 커밋 단계는 생략될 수 있다.
- 이 두 가지 과정으로 이뤄진 리액트의 렌더링은 항상 동기식으로 작동했다. 따라서 렌더링 과정이 길어질수록 애플리케이션의 성능 저하로 이어지고, 결과적으로 그 시간만큼 브라우저의 다른 작업을 지연시킬 가능성이 있다.
2.4.6 정리
- 지금까지 리액트에서 렌더링이 언제 어떻게 일어나는지, 또 렌더링이 어떤 과정을 거쳐서 브라우저의 결과물에 영향을 미치는지 살펴봤다. 리액트에서 일어나는 렌더링 시나리오를 정확히 이해한다면 컴포넌트의 트 리 구조를 개선하거나 불필요한 렌더링 횟수를 줄임으로써 성능 좋은 리액트 웹 애플리케이션을 만드는 데 많은 도움이 될 것이다.
2.5 컴포넌트와 함수의 무거운 연산을 기억해 두는 메모이제이션
2.5.1 주장 1: 섣부른 최적화는 독이다, 꼭 필요한 곳에만 메모이제이션을 추가하자
- 먼저 꼭 필요한 곳을 신중히 골라서 메모이제이션해야 한다는 입장이다. 메모이제이션도 어디까지나 비용이 드는 작업이므로 최적화에 대한 비용을 지불 할 때는 항상 신중해야 한다고 주장한다.
- 대부분의 가벼운 작업 자체는 메모이제이션해서 자바스크립트 메모리 어딘가에 두었다가 그것을 다시 꺼내오는 것보다는 매번 이 작업을 수행해 결과를 반환하는 것이 더 빠를 수도 있다.
- 리액트와 자바스크립트의 입장에서 생각해 보자. 메모이제이션에도 비용이 든다. 값을 비교하고 렌더링 또는 재계산이 필요한지 확인하는 작업, 그리고 이전에 결과물을 저장해 두었다가 다시 꺼내와야 한다는 두 가지 비용이 있다. 항상 메모이제이션은 신중하게 접근해야 하며 섣부른 최적화는 항상 경계해야 한다.
- 따라서 메모이제이션은 항상 어느 정도의 트레이드 오프가 있는 기법이라고 보는 것이 옳다. 이전 결과를 캐시로 저장해 미래에 더 나은 성능을 위해 메모리를 차례대로 점유하게 된다. 렌더링도 비용이지만 메모리에 저장하는 것도 마찬가지로 비용이다. 메모이제이션으로 인한 성능 개선이 렌더링보다 낫지 않다면 결국 안하느니만 못하는 상황을 마주하게 되는 것이다.
2.5.2 주장 2: 렌더링 과정의 비용은 비싸다, 모조리 메모이제이션해 버리자
- 일부 컴포넌트에서는 메모이제이션을 하는 것이 성능에 도움이 된다. 섣부른 최적화인지 여부와는 관계없이, 만약 해당 컴포넌트가 렌더링이 자주 일어나며 그 렌더링 사이에 비싼 연산이 포함돼 있고, 심지어 그 컴포넌트가 자식 컴포넌트 또한 많이 가지고 있다면 memo나 다른 메모이제이션 방법을 사용하는 것이 이점이 있을 때가분명히 있다.
- 그렇다면 우리에게는 두 가지 선택권이 있다.
- memo를 컴포넌트의 사용에 따라 잘 살펴보고 일부에만 적용하는 방법
- memo를 일단 그냥 다 적용하는 방법
- 실무에 임하는 모든 개발자들은 생각보다 최적화나 성능 향상에 쏟을 시간이 많지 않다는 사실에 모두 공감할 것이다. 따라서 일단 memo로 감싼 뒤에 생각해 보는 건 어떨까? 이렇게 감싸는 것이 괜찮은지 생각해 보려면 잘못된 컴포넌트에 이뤄진 최적화, 즉 렌더링 비용이 저렴하거나 사실 별로 렌더링이 안 되는 컴포넌트에 memo를 썼을 때 역으로 지불해야 하는 비용을 생각해 보자.
- 잘못된 memo로 지불해야 하는 비용은 바로 props에 대한 얕은 비교가 발생하면서 지불해야 하는 비용이다. 물론 이 비용 또한 무시할 수 없다. props가 크고 복잡해진다면 이 비용 또한 커질 수 있다.
- 반면 memo를 하지 않았을 때 발생할 수 있는 문제는 다음과 같다.
- 렌더링을 함으로써 발생하는 비용
- 컴포넌트 내부의 복잡한 로직의 재실행
- 그리고 위 두 가지 모두가 모든 자식 컴포넌트에서 반복해서 일어남
- 리액트가 구 트리와 신규 트리를비교
- 얼핏 살펴보더라도 memo를 하지 않았을 때 치러야 할 잠재적인 위험 비용이 더 크다는 사실을 알수 있다.
- 정리하자면, 메모이제이션은 하지 않는 것보다 메모이제이션했을 때 더 많은 이점을 누릴 수 있다. 이것이 비록 섣부른 초기화라 할지라도 했을 때 누릴 수 있는 이점, 그리고 이를 실수로 빠트렸을 때 치러야 할 위험 비 용이 더 크기 때문에 최적화에 대한 확신이 없다면 가능한 한 모든 곳에 메모이제이션을 활용한 최적화를 하는 것이 좋다.
2.5.3 결론 및정리
- 두 의견 모두 메모이제이션이 리액트 애플리케이션에서 할 수 있는 성능 최적화라는 사실에 대해서는 이견이 없다. 먼저 아직 리액트를 배우고 있거나 혹은 리액트를 깊이 이해하고 싶고, 이를 위해 시간을 투자할 여유가 있다면 1번의 의견대로 섣부른 메모이제이션을 지양하는 자세를 견지하면서 실제 어느 지점에서 성능상 이점을 누릴 수 있는지 살펴보는 식으로 메모이제이션을 적용하는 것을 권장한다.
- 만약 현업에서 리액트를 사용하고 있거나 실제로 다룰 예정이지만 성능에 대해 깊게 연구해 볼 시간적 여유가 없는 상황이라면 일단 의심스러운 곳에는 먼저 다 적용해 볼 것을 권장한다. 앞서 리액트 파이버에서의 작동과 흐름을 살펴봐서 알겠지만 lodash나 간단한 함수의 경우와는 다르게 일반적으로는 props에 대한 얕은 비교를 수행하는 것보다 리액트 컴포넌트의 결과물을 다시 계산하고 실제 DOM까지 비교하는 작업이 더 무겁고 비싸다.