(React) 리액트 뒤에서 일어나는 일들 (Rendering, Batching, Events)

한중일 개발자·2024년 2월 11일
0

React Basics

목록 보기
4/11

The Ultimate React Course 2024: React, Redux & More 의 필기 위주로 작성되었습니다. 해당 강의는 강의 내용 기반으로 블로그 글 작성이 허용된 강의입니다.

리액트의 코어, 핵심을 파헤쳐볼 시간이다. 우리가 쓴 코드를 가지고 리액트가 어떻게 작동하는지 알아보자.

컴포넌트, 인스턴스와 Element

컴포넌트는 우리가 알듯, 리액트 element를 반환하는 자바스크립트 함수다.

컴포넌트 인스턴스의 예시를 보자.

<div className="tabs">
        <Tab num={0} activeTab={activeTab} onClick={setActiveTab} />
        <Tab num={1} activeTab={activeTab} onClick={setActiveTab} />
        <Tab num={2} activeTab={activeTab} onClick={setActiveTab} />
        <Tab num={3} activeTab={activeTab} onClick={setActiveTab} />
</div>

위의 Tab은 3번 쓰이는데, 각 Tab 태그들이 인스턴스다. 컴포넌트를 사용할때 생성되고, 컴포넌트의 물리적 실현체라 보면 된다. 객체지향에서 클래스와 객체의 관계를 생각하면 될듯 하다. 인스턴스는 자기만의 state와 prop을 가진다.

인스턴스는 라이프사이클, 즉 생명주기를 가진다. 보통 컴포넌트와 컴포넌트 인스턴스를 섞어 말해서 보통 컴포넌트 생명주기라고 하지만, 엄밀히는 인스턴스의 생명주기다.

Element, 원소들은 JSX가 React.createElement() 함수 콜을 통해 변환되어 나온 결과들이라 보면 된다. 컴포넌트 인스턴스를 위한 DOM 원소를 만들 정보를 가지고 있다. 최종적으로 이는 HTML DOM 원소가 되어, 실제 브라우저에 보일 표현이 된다. 리액트 Element는 리액트 프로그램에 있을 뿐, 실제로 렌더링 되는 HTML DOM 원소와는 차이가 있다.

렌더링 과정

  • 프로그램 내부에서 state가 업데이트되고, 렌더가 trigger된다.
  • Render phase는 사실 DOM 업데이트와 화면에 페인팅이 이루어지지 않는다. 렌더링은 리액트에서 내부적으로만 이루어지고, 시각적인 변화는 이루어지지 않는다.
  • Commit phase에서 실질적인 DOM 변화가 이루어진다.
  • 이후 브라우저에 페인팅이 이루어진다.

Render Trigger

렌더가 트리거되는 경우는 두가지다.

  • 어플리케이션이 최초로 렌더된다.
  • 하나 이상의 컴포넌트의 state가 업데이트 되었다. (리렌더링)

새로운 가상 DOM은 현재 상태의, state 업데이트 전의 Fiber tree와 함께 리액트의 reconciler인 Fiber를 통해 재조정 (reconciliation)되어 업데이트된 Fiber tree가 만들어진다.

렌더 프로세스는 어플리케이션 전체에 대해 트리거된다. 상태가 업데이트된 컴포넌트에 대해서만 리렌더링이 이루어지는것같지만, 사실 그렇지 않다. 또한 렌더는 바로 트리거되는게 아니고, JS 엔진에 여유가 있을때를 위해 스케쥴만 되어있는다.

Render Phase

  • 우선 리렌더링을 트리거한 컴포넌트 인스턴스를 렌더한다. 여기서의 렌더는 해당 컴포넌트 함수를 호출한단 뜻이다.
  • 이는 업데이트된 리액트 element들을 만들고, 새로운 가상 DOM을 구성한다.

가상 DOM


가상 DOM은 컴포넌트 트리의 모든 인스턴스의 React element를 트리로 만든 자바스크립트 오브젝트다. 여러 트리를 구축하는데 대가가 낮다. 특징으로 부모 컴포넌트가 렌더링되면 자식 컴포넌트들도 prop의 변경 여부에 상관없이 렌더링된다.

Reconciliation (재조정)

재조정은 왜 필요한걸까? 애초에 프로그램의 state가 변경될때마다 DOM트리를 다 업데이트하면 되지 않을까?

사실 DOM 작성은 비교적 느리고, 업데이트되는건 DOM 트리의 일부일 뿐이라, 리액트는 최대한 현재 있는 DOM을 재사용하려 한다. 그걸 어케하는데라고 묻는다면 여기서 재조정이 등정한다. Reconciliation은 최신 상태를 반영하기 위해선 어떤 DOM 원소들이 삽입, 제거, 업데이트되어야 하는지 결정하는 과정이고, 리액트의 Reconciler인 Fiber 에 의해 진행된다. 리액트의 핵심이라고 할 수 있겠다.

Fiber


Fiber tree는 위 사진처럼 생겨먹은 트리다. 가상 DOM처럼 매 렌더때마다 재생성되지 않고, 컴포넌트별 state, prop등 정보를 포함한다.

Fiber의 작업은 비동기로 진행 가능하고, 렌더링 과정은 유연하게 작업들의 우선순위 지정, 일시정지, 재사용, 폐기가 가능하여 Suspesne, transition 등 동시성 기능들을 가능하게 하고, 렌더가 길어진다고 JS 엔진을 block하지 않는다.

재조정 진행 과정

  • 사진 우측의 코드를 예시로 한다. showModal이 false가 되면서 새로운 Virtual DOM 이 만들어진다. State의 변화가 App에서 이루어졌으니 자식 컴포넌트들이 모두 재렌더링된다.
  • 재조정이 시작된다. 트리 위치에 기반해서 현재 fiber tree와 비교하여 무엇이 바뀌어야 할지 분석하는 Diffing 과정을 거쳐 업데이트된 fiber tree (workInProgress 트리) 가 만들어진다. 새 트리를 보면 빨간색은 DOM에서 삭제되고, 노란색은 업데이트 되야함을 알 수 있다.
  • 가상 DOM과 비교했을때, 가상 DOM에서 Video 컴포넌트는 App의 자식 컴포넌트라 재 렌더링되었지만 사실 바뀐게 없어서, 재조정 이후 실제 DOM에서 변경이 이루어지지 않는다.
  • 변경 사항들은 list of effects라는 리스트에 들어가 commit phase에서 DOM이 실제로 변경된다.

Diffing

Diffing 과정은 2가지 중요한 전제를 가지고 진행된다.

  • 서로 다른 타입의 두개의 element는 다른 트리를 생성한다.
  • 안정적인 key prop을 가진 element는 렌더 과정동안 똑같다.

Diffing 과정에서 두개의 렌더간 아래의 두가지 상황을 주로 핸들링한다.
1. 같은 위치에, element가 바뀌는 경우.
2. 같은 위치에, 같은 element가 있는 경우.


첫번째 경우다. 첫번째 전제에 따라 두개의 다른 element들은 다른 트리를 생성할 것이므로, 위 예시처럼 요소가 바뀌면 모든 sub-tree가 더이상 유효하지 않다 판단해 이전 상태의 컴포넌트들은 상태를 포함하여 DOM에서 삭제된다. 만약 자식들이 똑같이 있는다면 트리는 재구축되고, 상태도 리셋된다.

2번째 경우는 조금 간단해진다. 같은 원소의 속성만 위치 변경 없이 바뀌는 경우라, DOM에서 그대로 자식 원소들, 상태와 함께 유지되고 새로운 prop/속성들이 pass된다. 이런 상황을 원하지 않는다면 key prop을 사용하면 된다.

Key prop

Key를 내가 진행하는 프로젝트에서도 IDE에서 제발 좀 쓰래서 이유를 딱히 모르고 많이 사용했었는데, 이제 이게 뭔지 알아보자.

Key prop은 diffing 알고리즘에게 해당 원소가 고유함을 알려주는 특별한 prop이다. 리액트가 같은 타입의 여러 인스턴스들을 구별할 수 있도록 하고, key가 렌더 간 같다면 DOM에 원소가 유지되고, 바뀐다면 트리상의 위치가 동일하더라도, 원소가 파괴되고 새롭게 생성된다. 이경우 state가 리셋되고, 이런 결과를 원한다면 key를 사용해야겠다.

key가 있고 없고의 경우의 비교다. 키가 없는 경우 원래 2개 원소들은 변화가 전혀 없더라도 새 원소가 추가되었으니 (즉 트리상 위치가 바뀌었으니) 리액트가 DOM에서 없애고 재생성해버린다. 성능상의 낭비다.

렌더간 바뀌지 않는, 즉 stable한 키가 있는 경우 위치가 바뀌어도 원소가 지워지는 일 없이 유지된다.

위 예시에선 같은 원소의 prop이 바뀌었음에도 diffing의 원칙에 따라 같은 위치, 같은 원소기에 state가 변화하지 않는다. 위 경우 우리가 원하는 행동이 아니기에 key를 사용하면 해결된다!

이제 list.map 조지고 key를 안쓰면 IDE에서 왜 발악하는지 제대로 알게 되었다.

Commit Phase

  • 리스트의 업데이트들에 따라 Commit phase에서 DOM의 변경이 일어나는데, 이 변경은 동기적이어서 중지없이 한번에 이루어진다. 덕분에 부분 결과만 나올 일 없이 일관적인 UI가 유지된다.
  • 커밋 페이즈 이후 workInProgress fiber tree는 다음 렌더 사이클의 current 트리가 되어 순환한다.
  • Browser Paint단계에서 드디어 업데이트된 UI가 유저에게 보여진다.
  • 사실 커밋 페이즈는 리액트가 하지 않고, 렌더만 한다. 읭? 대신 ReactDOM이라는 renderer 라이브러리가 커밋을 해준다. 이름일 Renderer지만 사실 렌더를 하지 않는건 함정이다. 커밋만 한다. React Native, Remotion등도 렌더러의 일종이다.

정리

  • 최초 렌더시, 그리고 컴포넌트 인스턴스의 state 업데이트 시 렌더링이 trigger된다.
  • 이는 render phase로 이어진다. 이 단계에선 아무런 시각적 아웃풋도 나오지 않고, 재렌더링이 필요한 인스턴스들을 렌더링한다. 리액트의 렌더링은 단순히 컴포넌트 함수 호출을 뜻한다. 이는 하나 이상의 업데이트된 React element를 생성하고, 리액트 원소들의 트리인 가상 DOM에 들어간다. prop의 변화에 상관 없이 렌더링된 컴포넌트의 자식 컴포넌트들도 모두 재렌더링된다.
  • 새 가상 DOM은 현재 fiber 트리와 reconcile된다. 과정에서 가장 적은 DOM 업데이트 수를 찾는다. 이는 Fiber라는 reconciler로 진행되고, fiber tree라는 불변 자료 구조로 작동한다.
  • Fiber 트리는 모든 리액트 원소와 DOM 원소에 대해 fiber를 가지고 있고, 이 fiber는 컴포넌트의 state, prop, queue of work를 가지고 있다.
  • Reconcilation 이후 queue of work는 해당 원소에 필요한 DOM 업데이트를 포함한다. 이 업데이트의 계산은 diffing 알고리즘으로 이루어진다. Diffing 알고리즘은 새 가상 DOM의 원소와 현재 fiber tree의 원소를 비교하여 변경 사항을 계산한다.
  • 결과로 새로 업데이트된 fiber tree가 만들어지고, 필요한 DOM 업데이트의 리스트도 만들어진다. 위의 렌더 과정은 모두 비동기적이다.
  • 커밋 페이즈에 리스트를 사용해 이름만 렌더러인 ReactDOM이 새로운 상태를 반영할 수 있게 DOM 원소의 업데이트를 실행한다. 해당 과정은 동기적이고, DOM 업데이트는 한방에 이루어진다.
  • 최종적으로 브라우저 리페인트가 이루어진다.

렌더 로직의 규칙

렌더 로직은 컴포넌트가 어떻게 보일지 묘사하는 과정에 참여하는 로직들이다. usestate 변수와 JSX에 들어갈 변수들이 여기 속하겠다. 이 렌더 로직에는 몇가지 규칙이 있다.

  • 컴포넌트는 무조건 Pure, 순수해야 한다. 같은 prop이 주어지면 항상 같은 JSX가 반환되어야 한다.
  • 렌더 로직은 side effect, 즉 부작용을 만들어선 안된다. 즉, 네트워크 통신을 하거나, 타이머를 시작하거나, 바로 DOM API를 사용하거나, 함수 스코프 외의 객체/변수와 상호작용해선 안된다. 그래서 prop을 마음대로 변경할 수 없다.
  • state/ref를 업데이트해선 안된다- 무한 루프를 야기한다!

부작용이 있는 기능들은 event handler 함수에서 사용하면 되고, 이를 register하기 위한 useEffect 후크도 있다.

Batching

const reset = function() {
  setAnswer('');
  console.log(answer); // ''이 아닌 현 상태 프린트
  setBest(true);
  setSolved(false);
}

위와 같은 함수가 있다고 하자. state가 3번 업데이트되니 재렌더링과 커밋도 세번 일어날것 같지만, 사실 리액트는 batching을 진행하여 단 한번만의 렌더링과 커밋을 진행한다. 여러 API 호출을 하나로 합치는 것이고, 성능적으로 훨씬 합리적인것으로 보인다.

두번째 줄을 보면 setAnswer('')가 실행되었음에도 원래 상태가 프린트되는데, 이는 아직 그 시점에서 재렌더링이 일어나지 않았기 때문이고, answer는 stale한 상태로 원래 상태가 프린트된다. 이는 리액트의 상태 업데이트는 비동기적이기 때문이다.

그래서 의문점 하나가 해결된다: 현 상태에 기반하여 state를 업데이트 하려면 콜백 함수를 써야한다는 점.

function handleTripleLikes() {
  setLikes(likes+1);
  console.log(likes); // 0
  setLikes(likes+1);
  setLikes(likes+1);
}

이미 위처럼 해봤자 0에서 시작해도 3이 아닌 1이 나올 것을 알고있다. 이제 왠지 안다! Batching때문에 첫줄이 실행되도 likes는 여전히 0이기 때문이다. 즉 setLikes(0+1=1)만 3번 실행되는 셈이다.

function handleTripleLikes() {
  setLikes((likes) => likes+1);
  setLikes((likes) => likes+1);
  setLikes((likes) => likes+1);
}

이제 문제 없다!

사실 이벤트 핸들러에 대한 자동 batching은 17버전에도 지원됐지만, 아래 3개같은 경우에는 18에 와서야 지원되고 있다.

Events

Event propagation


위 DOM 트리를 보며 얘기해보자. 하단 버튼중 하나가 클릭되어 이벤트가 발생했다고 하자.

우선 새로운 event 오브젝트가 트리의 루트에 생성된다. Capturing phase동안 이 이벤트는 밑으로 쭉 내려가서 이벤트가 처음 trigger된 target element에 도달한다. 이후 해당 원소에 handler function이 있으면 실행된다. 이후 event 객체는 다시 bubbling phase를 통해 document 최상단으로 올라간다.

Capturing과 bubbling 페이즈때 event는 트리의 모든 자식과 부모 원소를 전부 한바퀴 돈다. 그리고 event handler는 타겟 원소의 이벤트만 listen하지 않고 bubbling phase때도 listen한다. 무슨 말이냐면 같은 타입의 이벤트를 listening하고 있다면 부모 원소의 모든 이벤트 핸들러들도 실행되어버린다. 위의 경우 header에도 버튼과 같은 event handler가 있다면 이 프로세스동안 두곳에 있는 핸들러 모두 실행된다.

Event delegation

위의 propagation 특성 덕에 Event delegation이라는 기술? 을 사용할 수 있다. 이는 여러 원소의 이벤트 핸들링을 하나의 부모 원소에서 중앙적으로 실행하는 것이다. 위 예시에서 버튼이 1000개가 된다면 핸들러도 각각 달아줘야 하고 성능과 메모리를 부숴버릴게 분명하니, 첫 부모 요소인 options에 추가하면 된다.

이 방법을 사용하면 e.target이 확인되고, 타겟이 버튼중 하나라면 options에서 이벤트를 핸들링해주면 된다.

리액트의 이벤트 핸들링

위의 경우는 리액트가 아닌 브라우저에서의 이벤트 진행 과정이었다. 이제 리액트의 핸들링 과정을 보자.

위 사진처럼 버튼에 event handler를 달고, header에도 달면 바닐라 js에서 하던것처럼 해당 원소에 querySelector를 지정해 리스너를 추가할것 같지만, 사실 리액트는 root 원소에 이벤트 타입 하나당 하나의 핸들러 함수를 등록해두고, root 노드의 fiber tree에도 렌더 과정에 적용된다.

결론은 우리가 안보이는 곳에서 리액트는 모든 이벤트에 대해 event delegation을 수행한다.

Synthetic event


리액트의 이벤트는 Synthetic event라는 이름으로, DOM의 네이티브 event object를 래핑하여 제공된다. 네이티브 이벤트 객체와 같은 인터페이스를 가지지만, 이벤트가 모든 브라우저에서 같은 방식으로 작동하도록, consistent하도록 되어있다. 대부분의 synthetic events는 bubble한다.

profile
한국에서 태어나고, 중국 베이징에서 대학을 졸업하여, 일본 도쿄에서 개발자로 일하고 있습니다. 유창한 한국어, 영어, 중국어, 일본어와 약간의 자바스크립트를 구사합니다.

0개의 댓글