본문 바로가기

개발/React

React 18 - 동시성을 다루기 위한 여정

 

서론

 

 이번에 회사에서 프로젝트를 진행하며 공통 컴포넌트를 개선하는 과정에서 문득 사용자 이벤트 발생과 re-render의 동작 순서를 생각하다가 명확하게 답이 나오지 않아 React의 동작을 다시한번 정리하고 가자는 생각이 들어 글을 작성하게 되었습니다.

 

 

코드는 첨부하지 않았습니다. 이미 코드도 첨부되어있고 잘 정리된 글이 너무 많습니다. 따라서 이해한 바를 토대로 서사와 설명을 작성합니다. 여러가지 좋은 글을 읽고 스스로 이해하기 위해 재구성한 글입니다. 모든 출처를 적어놓으니 혹여 문제가 된다면 수정하도록 하겠습니다. :)

 

개요

 

React 17, 18에 걸쳐 여러가지 변화가 있었는데요, 먼저 간단하게 생각해보면 기존에도 완벽했다면 크게 개선의 니즈가 없었을 것입니다. 그렇다면 어떤 것이 문제였기때문에 이러한 변화를 만들 수 밖에 없었는지를 생각해보고 이를 토대로 사고를 확장해나가면 좋을것 같습니다.

 

먼저 위에서 언급한 문제는 대규모 전환 작업에서는 Blocking Rendering 이 발생한다는 점이었습니다.

 

 이는 기존의 구조가 Call Stack을 기반으로 한 재귀호출 방식을 가지고 있었기 때문입니다. 이러한 방식으로 인해 렌더링 과정에서 오랜 시간이 소요된다면 이후에 작업이 실행되는 시점 역시도 같이 늦어지기 때문에 좋지 않은 경험을 주게 됩니다.

 React 는 Breaking Change를 만들기 위해 17 버전은 16버전에 비해 Gradual Change를 만들었습니다. 이는 모두 대규모 전환 작업을 동시성을 활용해서 해결한다 는 취지를 가지고 진행한 작업인데요, 정확하게는 React 17에서는 이 중에서 동시성을 해결하기 위한 기반을 다졌다고 볼 수 있겠습니다.

 

동시성 (Concurrency)

 

 

먼저 계속해서 언급해야할 동시성에 대해서 정확하게 이해하고 가면 좋습니다.

 

동시성을 가진다는 의미는 기본적으로 병렬성(Parellelism)과는 다릅니다. 동시성은 독립적으로 실행되는 프로세스들의 조합으로 실제로는 매번 하나의 태스크를 처리하면서 이를 빠르게 스위칭하여 동시에 작업하는 것처럼 보이게 하는 것이고 병렬성은 실제로 여러 가지 태스크를 동시에 처리하는 것을 의미합니다. 더 잘 와닿는 예시를 들자면 이는 JS 의 Event Loop 가 동작하는 방식과 같습니다. Event Loop 역시도 Single Thread인 JS 에서 여러가지 작업을 처리하기 위해 만든 동시성을 가진 방식입니다.

 

추가적으로 둘의 차이를 간단하게 테이블 구조로 정리해보면서 동시성에 대한 설명은 마무리해보겠습니다.

 

 

  동시성 병렬성
코어 싱글 코어에서도 작동 멀티 코어에서 작동
동작 동시에 실행되는 것처럼 보임 실제로 동시에 실행 됨
흐름 최소 두 개의 흐름 한 개의 흐름

 

문제 해결을 위한 접근

 

핵심 키워드 : Call Stack 기반의 재귀호출 방식 자체를 개선한다.

 

 

이제 위에서 마주한 문제를 해결하기 위한 첫번째 접근을 살펴보겠습니다.

 

React가 개선하고자했던 포인트는 먼저 Reconciliation 입니다.

 

Re-render가 일어나고 이에 대한 반영이 실제 UI에 나타나는 과정을 세부적으로는 두가지 단계로 나누어져 있는데, 이는 업데이트에 대한 내용을 찾아서 적용하는 Render Phase 와 이를 실제 UI 에 Paint 하는 Commit Phase 단계로 나누어져 있습니다. 또한 이러한 과정이 동기적으로 발생하기 때문에 대규모 작업에서는 병목을 일으킬 수 있다고 했고, 이러한 결과가 나타나는 이유는 Call Stack 기반의 재귀호출 방식을 사용하고 있기 때문이라고 했습니다.

 

 따라서 문제 해결을 위해서는 기존의 Call Stack 기반의 재귀호출 방식을 개선해야했는데, 그의 대안으로 나온 것이 Fiber Architecture 입니다. Fiber Architecture 의 핵심은 다음과 같습니다.

 

Virtual DOM을 통해 각 Node 를 Fiber 라는 JS 객체로 바라볼 수 있도록 하고, 이 객체로부터 여러 정보를 받아 렌더링 작업을 분할하고 우선순위를 부여하여 중단 및 재개가 가능하도록 합니다. Fiber는 각 컴포넌트의 렌더링 상태 및 업데이트에 대한 정보를 포함하고 있어서, 이를 활용하여 React의 렌더링 작업을 효율적으로 관리할 수 있도록 합니다.

 

 

이로부터 정보를 다시 정리해볼 수 있습니다.

 

  • 각 Node 는 Fiber 라는 JS 객체로 치환되고, 이 객체는 컴포넌트의 렌더링 상태 및 업데이트에 대한 정보를 가지고 있다.
  • Fiber Architecture 의 도입으로 작업을 분할하고, 우선순위를 부여하여 중단 및 재개가 가능해진다.

 

 첫번째 정보에 대한 부연 설명은 이와 같습니다. Fiber Architecture에서는 Call Stack Frame 하나를 함수로 대체하고 Fiber 객체를 활용하여 객체 내에 존재하는 child, sibling, return 값을 통해 child가 있다면 child를 다음 작업으로, 아니라면 sibiling, 이 역시도 없다면 parent로 돌아가는 방식으로 동작을 통해 stack 구조가 아니지만 순회를 할 수 있도록 하여 재귀 호출방식이 아닌 참조 포인터를 기반으로 트리를 탐색하도록 했습니다.

 

 

 두 번째 정보는 어떤 기준으로 작업을 분할하고, 우선순위를 부여했는지에 대해서는 짚고 넘어가야 합니다. React는 각 Event의 성격을 기준으로 작업을 나누었고, 이들이 처리되는 방식에 대한 우선순위를 지정해 두었습니다. 대표적인 종류로는 다음과 같습니다.

 

 

  • Discrete Event (개별 이벤트)
  • User Blocking Event
  • Continuous Event (연속 이벤트)

 

이 우선순위 개념은 17 버전에서는 프로토타입이었고, 18 버전에서 Lane Model이라는 개념과 함께 완성되었습니다. 다만 React 17 버전까지는 아직 동시성의 개념이 도입되지 않아 모든 작업이 동기적으로 이루어졌고, React 18 버전부터 위의 문제를 본격적으로 해결하기 위한 개념들이 도입되었습니다.

 

React 18

 

위의 내용까지는 기존에 가지고 있던 문제를 해결하기 위한 초석이었고, 이제부터 본격적으로 변화가 생깁니다.

시작하기 전 다시 한번 기존에 가지고 있던 문제를 확인해 보겠습니다.

 

대규모 전환 작업에서는 Blocking Rendering 이 발생한다

 

 

그리고 이를 해결하기 위한 방향성은 대규모 전환 작업을 동시성을 활용해서 해결한다. 였습니다.

 

React 17에서는 동시성이 가능할 수 있도록 Call Stack 구조를 통한 재귀호출 방식을 Fiber Architecture로 개선했었습니다, 또한 프로토타입이긴 하지만 각 이벤트별 우선순위를 정하기도 했었습니다. ( Discrete Event, User Blocking Event, Continuous Event... )

 

 그렇다면 이제 React 18에서는 이러한 우선순위가 얼만큼 고도화되었는지를 먼저 확인해봐야합니다. React 18 에서는 우선순위에 대한 것을 Lane Model이라는 개념으로 해결하려고 했습니다.

 

 Lane Model 이란, 성격에 따라 정의한 여러 가지 이벤트들을 각각 Lane으로 정의하여 Lane 별로 우선순위를 정해 작업을 수행할 수 있도록 하는 모델입니다. Lane Model에서 다루는 Lane 들을 살펴보겠습니다.

 

Lane이라는 개념이 나오면서 각각은

 

  1. 개별로 처리해야 하는지? 일괄로 처리할 수 있는지?
  2. 바로 UI에 반영되어야 하는지, 나중에 반영되어도 괜찮은지?

 이렇게 두 가지 기준으로 렌더 방식이 나뉘게 됩니다. 역할은 크게 두 가지로 나누어져 있는데 기존의 React 17에서 진행하던 작업방식과 동일하게 동기적으로 동작하는 Sync Render와 대규모 전환 작업을 해결할 수 있도록 나온 전환 방식이 적용되어 비동기적으로 동작하는 Concurrent Render로 나뉩니다.

그렇다면 Lane Model 에는 어떤 Lane들이 있는지 알아보고, 각각의 Lane 들이 어떤 렌더 방식을 가지고 있는지 알아보겠습니다.

 

Lane Model

Sync Lane

 첫 번째는 Sync Lane입니다. 기존의 React 17에서 정의한 이벤트 중에서는 Discrete Event (개별 이벤트)에 대응합니다. 일반적으로 Discrete Event의 경우 위의 2가지 기준을 생각해 보면, 개별로 처리해야 하고, 유저가 일으킨 작업이기 때문에 바로 UI에 반영되어야 합니다. 따라서 렌더링은 Sync Render 방식으로 진행됩니다.

Continuous Lane

 두 번째는 Continuous Lane입니다. 기존의 React 17에서 정의한 이벤트 중에서는 Continuous Event (연속 이벤트)에 대응합니다. 일반적으로 Continuous Lane의 경우 위의 2가지 기준을 생각해 보면, 개별처리가 아닌 연속적인 동작으로 인한 일괄처리가 가능하고, 이 역시도 유저가 일으킨 작업이기 때문에 바로 UI에 반영되어야 합니다. 이 역시도 Sync Render 방식으로 진행됩니다.

Default Lane

 세 번째는 Default Lane입니다. 기존의 React 17에서 정의한 이벤트에는 없던 내용이고, 이는 React 외부에서 발생하는 이벤트로 대부분은 비동기 함수에서 생성되는 setTimeout, Promise와 같은 이벤트들에 대응합니다. 이들 역시도 비동기라 할지라도 유저가 요청한 것에 대한 응답을 기다릴 것이기 때문에 바로 UI에 반영되어야 합니다. 따라서 이 역시도 Sync Render 방식으로 진행됩니다.

Transition Lane

마지막으로는 React 18에서 주요하게 해결하려고 했던 문제를 다루는 Transition Lane입니다. 이는 전환 이벤트를 다루는 Lane으로 일반적으로는 React 18에서 새로 도입된 startTransition API 나 useTransition으로부터 반환되는 transition setter를 사용하는 경우 해당 Lane에 할당됩니다. 이는 일괄처리가 가능한 이벤트이고 이 방식은 동시성을 가지고 작업을 진행하기 때문에 Concurrent Render 방식으로 진행됩니다.

 

Lane이 해결하려고 했던 문제들

 

그렇다면 Lane Model 이 도입되면서 정확히 어떤 문제들을 해결했는지를 보겠습니다.

 

  • 업데이트의 개념 분리
    • 기존에는 업데이트를 발생시킨 이벤트를 기준으로 우선순위를 결정하고 업데이트 간 우선순위는 대소 비교를 통해 판단했지만 순서와 상관없이 먼저 처리해야 하는 이벤트 혹은 유저 경험을 위해 비교적 나중에 작업해도 괜찮은 전환 작업 (Transition)의 등장으로 인해 일반적인 형태의 렌더링으로 CPU에 제한을 가하는 CPU-Bound Rendering과 Suspense와 네트워크 요청이 결합된 형태에서 IO에 제한을 가하는 IO-Bound Rendering으로 나누어 각 이벤트별로 우선순위를 정하기 위해 업데이트 개념을 분리했습니다.
  • 우선순위가 더 높은 업데이트를 먼저 처리하기
    • Concurrent Render를 진행하는 과정에서 우선순위가 더 높은 업데이트가 들어온다면해당 작업을 멈추고 먼저 처리해야 하는 작업을 진행합니다. 이때 중단된 작업은 Fiber의 참조 포인터를 갱신하고 해당 작업이 남아있음을 Scheduler에게 알려 이후 해당 작업이 종료된 후에는 다시 시작할 수 있도록 합니다. 또한 진행해야 하는 전환 작업이 여러 종류로 들어오더라도 복수의 Transition Lane을 두어 각 전환 이벤트의 성격에 따라 처리할 수 있도록 합니다.
  • 불필요한 렌더링 건너뛰기
    • 동일한 전환 작업이 여러 개 쌓인 상태에서 더 먼저 처리해야 하는 작업이 들어와 단계별로 전환 이벤트가 쌓이게 된다면, 실제로 유저가 관심 있어하는 포인트는 가장 마지막에 한 전환 작업이고 중간 작업은 비교적 중요도가 떨어지기 때문에 중간 상태의 UI는 불필요해집니다. 따라서 이러한 상황에서는 이벤트를 일괄처리하여 최종 상태에 대한 렌더링만을 진행하여 중간 상태의 불필요한 렌더링을 건너뛸 수 있게 되었습니다.

그렇다면 이제 Lane Model을 바탕으로 동시성을 가지기 위해 보장해야 하는 것을 어떻게 React가 해결했는지 확인해 보겠습니다.

 

동시성을 가지기 위해 보장해야 하는 사항

 

1. React는 작업을 중단 및 재실행할 수 있어야 합니다. 상황에 따라서는 작업중이던 것을 버릴 수 있어야합니다.

 

 동시성을 보장하기 위해서는 이전에 언급한 것과 같이 작업을 중단하고 우선순위가 더 높은 작업을 수행해야 하며 이후 진행하던 작업을 다시 실행할 수 있어야 합니다. 이러한 작업은 Fiber Architecture를 도입했기 때문에 해결할 수 있습니다. Concurrent Render에서는 Call Stack의 첫 번째 Frame을 실행함수로 두고, 해당 실행함수에서 매번 중단해야 하는 상황인지를 체크 (yield) 하는 방식을 채택하여 Transition 작업을 진행하고 있는 도중에 해당 작업을 멈추고 더 우선순위가 높은 작업을 수행할 수 있습니다.

 기본적으로 작업은 reconciliation을 담당하는 Reconciler에서 업데이트를 확인하고 진행해야하는 작업을 Scheduler에게 넘겨주는 방식으로 진행됩니다. 진행중이던 작업을 멈추는 경우에도 Reconciler 에서 해당 작업을 다시 실행할 수 있는 콜백을 Scheduler 에게 넘겨주고, 작업 중이었다는 것을 Root에 기록하여 이후 참조 포인터가 작업 중이었던 상태임을 인지할 수 있도록 합니다.

2. 매 렌더링은 서로에게 영향을 주고받지 않으며, 항상 동일한 상황에서는 동일한 결과가 나와야 합니다.

 

 기존의 구조에서는 각 작업은 중단되지 않고 동기적으로 이루어지기 때문에 문제가 없었습니다. 하지만 동시성을 보장하는 Concurrent Render 가 등장한 이후부터는 중간에 중단된 이후 다른 작업이 선행될 수 있기 때문에 일부만 진행된 작업 결과가 선행된 작업과 같이 나가서는 안됩니다.

 따라서 React는 Fiber Root에 직접적인 반영을 담당하는 Virtual DOM Tree를 두고, 이 작업을 위한 Virtual DOM Tree를 하나 더 만들어 해당 DOM Tree 에서 모든 작업이 완료될때만 Root 가 해당 Virtual DOM Tree 를 가리켜 최신화할 수 있도록 하는 Double Buffering 방식을 사용하도록 했습니다.

 이러한 방식을 채택함으로써 Transition 작업을 진행하는 도중 우선순위가 더 높은 작업이 들어와 yield 가 발생한다면 기존에 작업 중이던 DOM Tree를 파기하고 새로운 작업용 DOM Tree 를 만들어 이곳에 새로운 작업에 대한 업데이트를 진행합니다. 이후 해당 작업이 완료된다면 결과물인 DOM Tree로 Fiber Root 가 바라보는 Current를 변경하고 기존의 Virtual DOM 은 파기되어 다음 렌더링을 위한 작업 Tree를 새로 만들어 진행하게 됩니다. 이후 기존에 멈추었던 작업을 다시 수행하게 됩니다. 따라서 각 작업은 서로에게 영향을 받지 않고, 동일 입력값에 대해서는 동일한 결과가 나올 수 있도록 합니다.

3. 상황에 맞게 브라우저에게 메인 스레드를 넘겨줄 수 있어야 합니다.

 

 React는 Fiber Architecture의 도입으로 호출 스택의 첫 프레임에 존재하는 실행함수를 통해 계속해서 작업을 진행할 수 있는지 여부를 체크한다고 했습니다. 여기서는 실제로 Message Channel을 활용하여 주기적으로 실행시간을 체크하고, 유저 인풋이 발생하거나 페인트가 필요한 경우에는 프레임을 그려주어야 하는 주기를 넘어가면 작업을 중단하도록 되어있습니다. (세부적으로는 작업의 실행 및 중단은 Reconciler 가 작업을 Scheduler에게 등록하는 방식으로 진행됩니다.) 따라서 상황에 맞게 브라우저에 메인 스레드를 넘겨줄 수 있게 되었습니다.

 

정리

 

너무 많은 내용을 다루었기에 간단하게 내용들을 정리하면서 마무리해 보겠습니다.

 

  1. React는 기존 구조에서는 대규모 전환 작업을 처리함에 있어서 문제가 존재했었고, 이러한 문제를 개선하기 위해서 동시성을 구현하려고 했습니다.
  2. React 17 버전에서는 동시성을 구현하기 위해 기존의 재귀호출 기반의 Stack reconciliation을 개선해야겠다 생각했고 Fiber Architecture를 도입, 각 DOM Node를 Fiber라는 객체에 대응하도록 하여 Fiber 객체에 있는 값들을 기반으로 Stack 구조와 유사하게 재귀와 같은 형태를 띨 수 있지만 중단은 가능한 reconciliation을 만들었습니다. 다만 이 시기에는 전환작업에 대한 API 가 나오지 않았기 때문에 중단이 가능한 경우는 없었습니다.

  3. 이후 React 18 버전에서는 동시성을 다루기 위한 전환작업에 대한 API를 만들고 이들과 기존의 작업들 간의 우선순위를 정할 수 있는 Lane Model을 만들었습니다. Lane Model의 도입으로 각 작업 간의 성격에 따라 분리할 수 있게 되었고 이러한 과정 속에서 불필요한 업데이트는 진행하지 않을 수 있도록 하고, 업데이트 별 우선순위가 나누어져 어떤 작업이 먼저 선행되어야 하는지 조정할 수 있도록 만들었습니다.

  4. 이렇게 Lane Model과 Fiber Architecture를 바탕으로 작업을 진행하는 도중 계속해서 브라우저에 메인 스레드를 넘겨주어야 하는지 여부를 확인하고 필요에 따라 진행하던 작업을 중단하고 브라우저에게 메인 스레드를 넘겨줄 수 있도록 하여 동시성을 구현했습니다.

 

많은 히스토리가 있던 React 18 변화과정을 어느 정도 덜어내면서 정리하려다 보니 중간중간 틀린 부분이 있거나 일부 도약이 있을 수 있습니다. 또한 개인적인 이해도 역시 완벽하게 올라오지 않았다고 생각하여 아쉬운 부분이 있어 계속해서 보완해서 더 좋은 자료로 만들 수 있도록 하겠습니다.

 

Reference