우연히 startTransition을 이용한 non-blocking update을 적용하고자 고민하던 와중에 JS는 싱글스레드인데 어떻게 백그라운드에서 UI를 그리며 유저 인터랙션을 받는걸까.. 라는 의문이 생겨 React 레포지토리를 들여다 봤고. 어쩌다보니 RSC영역까지 훑어보게 됐다. 100% 이해가 된건 아니지만 평소에 의문이었던 점들이 일부 풀렸다.
React19의 발표가 한참 지난 지금 v18의 메인인 동시성을 알아보는게 늦긴했지만 그래도 탐색하면서 알게된 사실들이 생각보다 많았어서 좋은시간이었다. 모든 hooks를 무작정 쓰는거보단 동작을 파악하는게 중요하다 생각해 포스트를 남겨보려고 한다.
동시성 Concurrent Render
18의 메인업데이트인 Automatic Batching 외에 가장 큰 변화는 동시성이다. 렌더링을 잠깐 멈추고 유저 인터랙션 등 우선순위가 높은 작업을 먼저 처리해 마치 두 작업이 동시에 일어나는것처럼 보인다해서 그렇게 붙여졌다고 하는데. 동시성이란 용어자체도 애매한 느낌이었고 싱글 스레드에서 어떻게 서로간의 작업을 멈추고 다시 이어가는지 이해가 되질않았기 때문에 와닿지 않았다.. 하지만 이를 이해하려면 Fiber 아키텍처의 등장을 살펴봐야만 했다.
Fiber. 렌더링 작업 단위
Fiber는 React가 렌더링할때 필요한 작업단위 1개에 대한 청사진이라고 보면 된다. 이 Fiber를 가지고 현재 렌더링작업에 대한 상황을 파악한다. JSX 1개와 1:1로 대응된다고 생각하면 된다. 소스를 보니 왜 class가 아닐까도 싶었지만 확장성도 필요없고 트랜스파일링시 추가적인 코드가 붙는 class대신 가벼운 생성자함수를 쓴걸 유추할 수 있었다. enableObjectFiber 라는 값에 따라 Object형태로 생성할지, 생성자 함수를 써서 생성할지를 나누는데 소스내부에는 false로 강제되어있어 일단 생성자함수로 생성하는구나 라고 파악했다.
// ReactFiber.js
function FiberNode(
this: $FlowFixMe,
tag: WorkTag,
pendingProps: mixed,
key: ReactKey,
mode: TypeOfMode,
) {
// Instance
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null;
{/* ... */}
}
const createFiber = enableObjectFiber ? createFiberImplObject : createFiberImplClass;
// ReactFiberLane.js
export const SyncHydrationLane: Lane = /* */ 0b0000000000000000000000000000001;
export const SyncLane: Lane = /* */ 0b0000000000000000000000000000010;
export const SyncLaneIndex: number = 1;
export const InputContinuousHydrationLane: Lane = /* */ 0b0000000000000000000000000000100;
{/* ... */}Fiber가 렌더링작업을 파악하기 위해 보관하는 값은 대략적으로 다음과 같다.
- sibling: 형제노드
- child: 내부 첫번째 자식노드
- return: 부모노드
- lane: 렌더링 우선순위
- alternate: WIP 포인터
여기서 lane과 alternate가 동시성을 가능하게 하는 핵심이다. lane을 살펴보면 비트마스크로 핸들링하는걸 볼 수 있는데 아마도 수~많은 렌더링 작업을 처리하다보니 속도에 관한 최적의 결과를 도출하기위해서가 아닐까 싶다.
실제 우선순위를 스위칭하는 등의 로직도 비트연산자가 사용되고 있다. (참고로 가장 우선순위가 높은건 Hydrate다. 왜인지는 Hydrate가 첫번째가 아니었을때를 생각해보면 알 수 있다.)
alternate에 대한 설명이 와닿지 않을 수 있다. React는 WIP(work in progress)라는 개념으로 현재 렌더트리의 카피본을 딴 이후 렌더링작업을 백그라운드에서 실행하고 완료되면 현재 트리를 alternate로 참조를 바꿔서 뷰를 전환한다. 흔히 React를 처음 접할때 가상DOM과 비교해서 변경점만 업데이트 시킨다의 개념이 이 개념이다. (React팀은 가상 DOM이라는 단어를 싫어한다고 한다..)
추가적으로 sibling, child 각각 형제,부모,자식의 첫번째 진입점이기 때문에 재귀를 통한 DFS가 이루어진다. 즉 부모 입장에서 하위의 자식까지 전부 렌더링 완료되어야 그제서야 부모도 완료처리가 된다. (= 부모의 useEffect가 가장 나중에 실행된다)
검색해보면 Fiber 아키텍처가 공식적으로 적용된건 놀랍게도 React16 부터다. DFS로 렌더트리를 그린다고 했는데 Fiber이전 아키텍처에서는 WIP개념이 없었기때문에 모든 노드를 순회하기 전에는 유저인터랙션을 받을 방법이 없었던것같다.. React팀이 얼마나 큰 그림을 가지고 예전부터 준비해왔는지 존경스럽다.
MessageChannel을 이용한 스케줄링
Fiber에 대략적으로 알아봤다. 우선순위도 있고 백그라운드에서 그려지는 작업에 대한 포인터도 존재한다. 하지만 JS는 싱글스레드다.. 그럼 어떻게 백그라운드에서 함수가 돌면서 유저 이벤트까지 받는걸까? React 레포를뒤져본 결과 MessageChannel을 이용해 백그라운드 - 메인간 특정시간마다 메세지를 주고받으며 긴급한 작업이 존재한다는걸 백그라운드가 감지를 하고 있었다. 즉 독자적인 스케줄러를 개발한것이다!
function workLoopConcurrentByScheduler() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
// $FlowFixMe[incompatible-call] flow doesn't know that shouldYield() is side-effect free
performUnitOfWork(workInProgress);
}
}
let frameInterval: number = frameYieldMs;
let startTime = -1;
function shouldYieldToHost(): boolean {
if (!enableAlwaysYieldScheduler && enableRequestPaint && needsPaint) {
// Yield now.
return true;
}
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// The main thread has only been blocked for a really short amount of time;
// smaller than a single frame. Don't yield yet.
return false;
}
// Yield now.
return true;
}위의 코드는 React의 엔진에 해당하는 ReactFiberWorkLoop.js에 존재하는 함수다. 주기적으로 shouldYield()를 실행해 특정 조건을 계속 확인하는 걸 볼 수 있다. frameYieldMs 역시 별도의 상수고 5라는 값을 가진다. 즉 5ms가 지나지 않았다면 메인에 제어권을 넘기지 않고 아니라면 제어권을 넘긴다. 이를 통해서 메인스레드는 정말 짧은시간동안만 차단되어 유저인터랙션을 거의 즉각적으로 받을 수 있게 느끼는 것이다.
동시성 사용 hooks
useTransition은 대표적인 동시성 사용 hooks다. 하지만 동시성의 원리를 알았어도 이해안되는 지점이 있었다. startTransition을 사용할때 콜백함수를 넣는데 하지만 정작 fiber에 lane에 transitionLane이 붙고 이를통한 메시지핑퐁을 통해 급한Lane이 있으면 해당작업을 진행하고 그리고 있던 WIP는 버린다.
즉 startTransition의 콜백에 태깅이 붙는것도 아니고, 컴포넌트 내부의 children 하나하나가 fiber라고 인식했는데 대체 어떻게 이 콜백이 급하지 않은 작업이다! 라는 걸 인식하냐는 것이 이해가 안되는 지점이었다.
function startTransition(scope: () => void, options?: StartTransitionOptions){
const prevTransition = ReactSharedInternals.T;
const currentTransition: Transition = ({}: any);
try{
const returnValue = scope();
...
}catch(){
...
}finally{
ReactSharedInternals.T = prevTransition;
}
}대략적인 코드를 보니 이해됐다. 즉 전역변수 ReactSharedInternals를 통해 Transition 상태임을 핸들링하고 마지막(finally)에 해제시킨다. 동기적으로 실행되기 때문에 finally동작이 보장된다. 그리하여 scope함수에 setState등 ReactSharedInternals 전역 변수의 상태에 의해 동작이 바뀌는 reactHooks를 쓰지않고 일반 함수를 넣는다면 사실상 별다른 동작을 안하는것이다.
useDeferredValue도 마찬가지로 동시성 hooks인데 내부적으로 DeferredLane이라는 우선순위로 주어진 값에 업데이트를 치기때문에 setState를 통한 업데이트보다 후순위여서 지연된 값을 통한 동시성을 사용할 수 있는거다. 즉 렌더링 + 동시성 동작을 대략적으로 요약하면 아래와 같다.
"렌더링 트리거인 setState는 실행 당시의 컨텍스트를 참조해 업데이트의 중요도인 Lane을 결정. 이후 Lane에 맞는 작업순서가 조정되어서 쌓인뒤 실행된다.
SSR환경에서 hooks는 어떻게 훑어지는걸까
서버라는 분리된 공간에서 번들링전에 렌더링되는 컴포넌트. 보통들 Next.js의 app router와 통합해서 접해봤을 확률이 높고 나역시도 그렇다. 하지만 많이들 간과하는점이 use client 지시어가 있어도 SSR 환경이라는거다. 즉 컴포넌트 내에 hooks가 서버에서 한번 훑어진다는거였다. 하지만 내가알고있는 지식으론 서버에는 hooks를 실행시킬 수 없었다.. 그래서 다시 레포를 뒤져봤다. 하지만 그전에 서버는 어떻게 렌더링한 컴포넌트를 브라우저에게 딱 알맞게 주는걸까? RSC등장 이후 컴포넌트 로드시 RSC Payload란걸 받는데 이녀석이 어떤 response를 주는지부터 살펴보자
RSC Payload
클라이언트에게 어떤 동작을 지시하는 명령이 담긴 payload다. 아래는 서버컴포넌트가 담긴 page로 navigate했을때 오는 rsc payload의 일부를 가져왔다.
// page.tsx
export function ServerComponent() {
console.log("I'm ServerComponent");∆
return <div style={{ color: "red" }}>ServerComponent</div>;
}
// Rsc payload
2:"$Sreact.fragment"
// ... 생략
3:[["Function.all","",0,0,0,0,true]]
5:[["Function.all","",0,0,0,0,true]]
// ... 생략
a:["$","div",null,{"style":{"color":"red"},"children":"ServerComponent"},"$e","$10",1] // 주목
e:{"name":"ServerComponent","key":null,"env":"Server","owner":"$b","stack":[["Page","PATH",51,263,50,1,false]],"props":{}}
10:[["ServerComponent","PATH",27,263,25,1,false]]
// .next/dev/server/chunks/ssr살펴보면 HTML 엘리먼트로 추정되는 라인을 발견할 수 있다. 여기에는 엘리먼트 식별자 $와 함께, 그 컴포넌트를 렌더링하기 위한 정보를 담은 참조값 $e $10가 포함되어 있다.
참조된 10번 라인을 따라가면 소스 코드의 경로(PATH)가 명시되어 있는데, 이는 서버에 저장된 변환된 파일을 가리킨다.
그럼 클라이언트 컴포넌트의 payload는 어떻게 보일까? 아래에서 보이듯 엘리먼트로 추정되는 payload가 살짝 달라진다.
// page.tsx
"use client";
export function ClientComponent() {
console.log("I'm ClientComponent");
return <div style={{ color: "blue" }}>ClientComponent</div>;
}
// Rsc payload
a:["$","$Lf",null,{},"$b","$e",1]
f:I["[project]/components/client-component.tsx [app-client] (ecmascript)",["/_next/static/chunks/app_layout_tsx_1cf6b850._.js","/_next/static/chunks/_7d2e2865._.js","/_next/static/chunks/app_client_page_tsx_a8c03fea._.js"],"ClientComponent"]서버 컴포넌트는 div와 같은 구체적인 태그와 props가 명시되어 있지만, 클라이언트 컴포넌트는 그렇지 않다. 이는 서버가 클라이언트 컴포넌트의 내부 렌더링 결과를 알지 못한다는 말이 되고 그 자리에는 '이 파일을 로드해서 실행하라'는 지침(Import)만이 남아있다.
추가로 Server Action도 클라이언트에서 서버에 있는 함수를 참조에 대한 요청으로 실행하는 마찬가지의 흐름이다. 아래를 살펴보자.
// page.tsx
import { testServerAction } from "@/actions/action";
export function ServerComponent() {
console.log("I'm ServerComponent");
return (
<form action={testServerAction} style={{ color: "red" }}>
ServerComponent
<button type="submit">Click</button>
</form>
);
}
// Rsc payload
a:["$","form",null,{"action":"$h11","style":{"color":"red"},"children":["ServerComponent",["$","button",null,{"type":"submit","children":"Click"},"$e","$12",1]]},"$e","$10",1]
11:{"id":"0089ecf928b170fd7b8f632a8e6f07cb8ce2dc6c5f","bound":null,"name":"testServerAction","env":"Server","location":["module evaluation","/Users/gimsinjae/Desktop/rsc-test/.next/dev/server/chunks/ssr/_da754466._.js",22,239]}
// bind with Server Action
11:{"id":"6089ecf928b170fd7b8f632a8e6f07cb8ce2dc6c5f","bound":"$@12","name":"bound testServerAction","env":"Server","location":["module evaluation","/Users/gimsinjae/Desktop/rsc-test/.next/dev/server/chunks/ssr/_da754466._.js",24,239]}
12:["qwe"]form의 action props는 $h11이라는 참조값을 가리키고 있다. 11번 라인을 확인해보면 해당 액션의 고유 ID와 서버 상의 실제 함수 경로가 명시되어 있다. 이 경로는 컴포넌트와 마찬가지로, 실행을 위해 변환된 코드가 서버에 보관된 위치를 뜻한다.
흔히 Server Action에 추가 매개변수를 전달할 때 bind나 hidden-input 방식을 사용한다. 이는 함수 실행문(ex: () => ) 자체가 네트워크를 통해 직렬화되어 전송될 수 없기 때문이다.
그렇다면 bind 역시 새로운 함수를 생성하는 메서드인데, 어떻게 전송이 가능한 걸까? 그 이유는 React RSC가 Server Action을 직렬화할 때, bind로 묶인 인자들을 추출해 RSC Payload에 데이터로서 함께 직렬화해주기때문이다.
실제로 11번 라인을 보면 null값을 가진 bound 필드가 bind가 추가되니 $@12 의 참조를 가진 값으로 변했고 12번 라인에 실제로 매개변수가 배열로 자리잡아 직렬화 된걸 볼 수 있다. 즉 bind 사용시, 코드가 아닌 "함수 ID와 인자 데이터의 조합"으로 변환되어 클라이언트에 전달되는 것이다.
RSC Payload까지 살펴 봤으면 이제 종착역인 서버에서 hooks가 실행되는데 어떻게 문제를 안터트릴까에 대한 궁금증을 해소하러 가보자...
서버에서 실행되는 Hooks의 실체: noop의 마법
결국 이 글의 시작점이된 "클라이언트 컴포넌트도 SSR 환경이다. 그렇다면 훅(Hooks) 코드가 실행될 텐데, 왜 브라우저 API가 없는데도 에러가 나지 않을까?"에 끝은 원피스를 찾고 웃기만 했던 로저처럼 생각보다 허무했다..
// packages/react-server/src/ReactFizzHooks.js
export const HooksDispatcher: Dispatcher = supportsClientAPIs
? {
// 1. 상태 관련 훅: 서버에서도 초기 HTML을 만들기 위해 실제로 실행됨
readContext,
use,
useState,
useReducer,
// 2. 이펙트 관련 훅: 서버에서는 실행될 필요가 없으므로 '빈 함수'로 대체
useEffect: noop,
useLayoutEffect: noop,
useInsertionEffect: noop,
useImperativeHandle: noop,
}
: {
// Hydration을 지원하지 않는 환경(Static)이라면 훅 사용 시 에러 발생
useEffect: clientHookNotSupported,
useState: clientHookNotSupported,
// ...
}
// shared/noop.js
export default function noop() {}React는 supportsClientAPIs라는 플래그를 통해 현재 환경이 나중에 Hydration이 될지를 판단하고, 렌더링 당시 DOM 조작 등 브라우저가 필요한 hooks는 noop라는 함수로, 나머지는 실행후 초기값을 계산한 레이아웃을 주는 거였다.
즉 서버에서 안전하게 처리가 들어가는 거였다.🥹 그래도 긴 여행을 떠나면서 얻어가는게 많은것같아 정리차원에서 오랜만에 새해기념 포스트를 작성한다.
참조