createFromFetch
함수는 서버 컴포넌트에서 서버로부터 데이터를 가져오고 처리하는 핵심 함수이다.
이 함수의 목적은 서버에서 생성된 컴포넌트 트리를 클라이언트에서 재구성하는 것이다.
function createFromFetch<T>(
promiseForResponse: Promise<Response>,
options?: Options,
): Thenable<T> {
// FlightResponse 객체 생성(서버 응답을 처리하고 저장)
const response: FlightResponse = createResponseFromOptions(options);
promiseForResponse.then(
function (r) {
// 응답 본문을 읽고 처리
// 서버에서 전송된 컴포넌트 트리 데이터가 파싱되고 FlightResponse 객체에 저장
startReadingFromStream(response, (r.body: any));
},
function (e) {
// 오류 처리
reportGlobalError(response, e);
},
);
// 루트 컴포넌트 추출(비동기 작업의 결과)
return getRoot(response);
}
서버 컴포넌트에서 서버와 클라이언트 간의 통신을 위해 사용되는 특별한 객체이다.
클라이언트 측에서 서버 응답을 처리하고 관리하는 데 사용된다.
function createResponseFromOptions(options: void | Options) {
return createResponse(
null,
null,
options && options.callServer ? options.callServer : undefined,
undefined, // encodeFormAction
undefined, // nonce
options && options.temporaryReferences
? options.temporaryReferences
: undefined,
__DEV__ && options && options.findSourceMapURL
? options.findSourceMapURL
: undefined,
__DEV__ ? (options ? options.replayConsoleLogs !== false : true) : false,
);
}
export function createResponse(
bundlerConfig: SSRModuleMap,
moduleLoading: ModuleLoading,
callServer: void | CallServerCallback,
encodeFormAction: void | EncodeFormActionCallback,
nonce: void | string,
temporaryReferences: void | TemporaryReferenceSet,
findSourceMapURL: void | FindSourceMapURLCallback,
replayConsole: boolean,
): Response {
return new ResponseInstance(
bundlerConfig,
moduleLoading,
callServer,
encodeFormAction,
nonce,
temporaryReferences,
findSourceMapURL,
replayConsole,
);
}
function ResponseInstance(
this: $FlowFixMe,
bundlerConfig: SSRModuleMap,
moduleLoading: ModuleLoading,
callServer: void | CallServerCallback,
encodeFormAction: void | EncodeFormActionCallback,
nonce: void | string,
temporaryReferences: void | TemporaryReferenceSet,
findSourceMapURL: void | FindSourceMapURLCallback,
replayConsole: boolean,
) {
const chunks: Map<number, SomeChunk<any>> = new Map();
this._bundlerConfig = bundlerConfig; // 번들러 설정
this._moduleLoading = moduleLoading; // 모듈 로딩 관련
this._callServer = callServer !== undefined ? callServer : missingCall; // 서버 호출
this._encodeFormAction = encodeFormAction; // 폼 액션 인코딩
this._nonce = nonce; // 보안을 위한 난수 값
this._chunks = chunks; // 청크 데이터 저장
this._stringDecoder = createStringDecoder(); // 문자열 디코더
this._fromJSON = (null: any);
// 데이터 파싱을 위한 상태 변수들
this._rowState = 0;
this._rowID = 0;
this._rowTag = 0;
this._rowLength = 0;
this._buffer = []; // 임시 데이터 저장 버퍼
this._tempRefs = temporaryReferences; // 임시 참조 저장소
this._fromJSON = createFromJSONCallback(this); // JSON 파싱 콜백
}
createFromJSONCallback
함수는 JSON.parse()의 reviver 함수를 반환한다.
이 reviver 함수는 스트리밍된 JSON 데이터를 파싱하는 데 사용된다.
function createFromJSONCallback(response: Response) {
return function (key: string, value: JSONValue) {
if (typeof value === 'string') {
return parseModelString(response, this, key, value);
}
if (typeof value === 'object' && value !== null) {
return parseModelTuple(response, value);
}
return value;
};
}
function parseModelString(
response: Response,
parentObject: Object,
key: string,
value: string,
): any {
if (value[0] === '$') {
if (value === '$') {
// 리액트 앨리먼트 처리
if (initializingHandler !== null && key === '0') {
initializingHandler = {
parent: initializingHandler,
chunk: null,
value: null,
deps: 0,
errored: false,
};
}
return REACT_ELEMENT_TYPE;
}
switch (value[1]) {
case '$': {
// 이스케이프된 문자열
return value.slice(1);
}
case 'L': {
// 지연 로딩 처리
const id = parseInt(value.slice(2), 16);
const chunk = getChunk(response, id);
return createLazyChunkWrapper(chunk);
}
case '@': {
// Promise 처리
if (value.length === 2) {
// 아직 결과가 없음을 나타냄
return new Promise(() => {});
}
const id = parseInt(value.slice(2), 16);
const chunk = getChunk(response, id);
return chunk;
}
case 'S': {
// Symbol 처리
return Symbol.for(value.slice(2));
}
case 'F': {
// 서버 참조(서버에서 실행되는 함수나 데이터)
const ref = value.slice(2);
return getOutlinedModel(
response,
ref,
parentObject,
key,
createServerReferenceProxy,
);
}
case 'T': {
// 임시 참조(서버와 클라이언트 간의 데이터 동기화)
const reference = '$' + value.slice(2);
const temporaryReferences = response._tempRefs;
if (temporaryReferences == null) {
throw new Error(
'Missing a temporary reference set but the RSC response returned a temporary reference. ' +
'Pass a temporaryReference option with the set that was used with the reply.',
);
}
return readTemporaryReference(temporaryReferences, reference);
}
case 'Q': {
// Map 처리
const ref = value.slice(2);
return getOutlinedModel(response, ref, parentObject, key, createMap);
}
case 'W': {
// Set 처리
const ref = value.slice(2);
return getOutlinedModel(response, ref, parentObject, key, createSet);
}
case 'B': {
// Blob 처리
if (enableBinaryFlight) {
const ref = value.slice(2);
return getOutlinedModel(response, ref, parentObject, key, createBlob);
}
return undefined;
}
case 'K': {
// FormData 처리
const ref = value.slice(2);
return getOutlinedModel(
response,
ref,
parentObject,
key,
createFormData,
);
}
case 'i': {
// Iterator 처리
const ref = value.slice(2);
return getOutlinedModel(
response,
ref,
parentObject,
key,
extractIterator,
);
}
case 'I': {
// $Infinity
return Infinity;
}
case '-': {
// 특수한 숫자 값 처리($-0 or $-Infinity)
if (value === '$-0') {
return -0;
} else {
return -Infinity;
}
}
case 'N': {
// NaN 처리
return NaN;
}
case 'u': {
// undefined 처리
return undefined;
}
case 'D': {
// Date 처리
return new Date(Date.parse(value.slice(2)));
}
case 'n': {
// BigInt 처리
return BigInt(value.slice(2));
}
case 'E': {
// Fallthrough
}
case 'Y': {
// Fallthrough
}
default: {
// 기본 모델 처리
const ref = value.slice(1);
return getOutlinedModel(response, ref, parentObject, key, createModel);
}
}
}
return value;
}
이전에 봤던 RSC Payload에서 1:"$Sreact.suspense"
이런 값이 있었는데,
이 값도 case "S"에 포함되어 Symbol.for("react.suspense")
로 역직렬화가 된다.
startReadingFromStream
함수는 비동기적으로 동작하며, 대량의 데이터를 효율적으로 처리할 수 있게 해준다.
서버 컴포넌트의 데이터를 점진적으로 받아 처리함으로써 초기 데이터가 준비되는 즉시 보여줄 수 있어 사용자 경험을 개선하고, 한번에 모든 데이터를 메모리에 로드하지 않고 청크 단위로 처리하여 메모리 사용을 최적화한다.
function startReadingFromStream(
response: FlightResponse, // 처리된 데이터를 저장하는 곳
stream: ReadableStream, // 서버로부터 받은 데이터 스트림
): void {
const reader = stream.getReader(); // 스트림에서 데이터를 읽기 위한 리더 객체 생성
function progress({
done, // 스트림을 다 읽었는지 체크하는 변수
value, // 스트림에서 읽은 데이터 청크
}: {
done: boolean,
value: ?any,
...
}): void | Promise<void> {
if (done) { // 스트림 읽기가 끝났다면
close(response); // 함수 종료
return;
}
const buffer: Uint8Array = (value: any);
processBinaryChunk(response, buffer); // 데이터 청크 처리
// 다음 청크가 준비되면 progress 함수 호출
return reader.read().then(progress).catch(error);
}
function error(e: any) {
// 스트림 읽기 중 발생한 오류 처리
reportGlobalError(response, e);
}
// 스트림 읽기 시작, 그 결과를 progress 함수로 전달
reader.read().then(progress).catch(error);
}
export function processBinaryChunk(
response: Response, // 처리 중인 응답 객체
chunk: Uint8Array, // 처리할 바이너리 데이터
): void {
// 여러 변수 상태 초기화
let i = 0;
let rowState = response._rowState;
let rowID = response._rowID;
let rowTag = response._rowTag;
let rowLength = response._rowLength;
const buffer = response._buffer;
const chunkLength = chunk.length;
while (i < chunkLength) { // 청크의 각 바이트를 순회하며 처리
let lastIdx = -1;
switch (rowState) {
// rowState 처리 로직(생략)
const offset = chunk.byteOffset + i;
if (lastIdx > -1) {
// 완성된 행 처리
const length = lastIdx - i;
const lastChunk = new Uint8Array(chunk.buffer, offset, length);
processFullBinaryRow(response, rowID, rowTag, buffer, lastChunk);
i = lastIdx;
if (rowState === ROW_CHUNK_BY_NEWLINE) {
i++;
}
rowState = ROW_ID;
rowTag = 0;
rowID = 0;
rowLength = 0;
buffer.length = 0;
} else {
// 다음 청크와 함께 처리 할 수 있도록 미완성 행을 버퍼에 저장
const length = chunk.byteLength - i;
const remainingSlice = new Uint8Array(chunk.buffer, offset, length);
buffer.push(remainingSlice);
rowLength -= remainingSlice.byteLength;
break;
}
}
// 모든 처리가 끝난 후 응답 객체의 상태 업데이트
response._rowState = rowState;
response._rowID = rowID;
response._rowTag = rowTag;
response._rowLength = rowLength;
}
이 함수는 바이너리 형식으로 인코딩된 서버 컴포넌트 데이터를 효율적으로 파싱하는 역할을 한다.
각 행은 ID, 태그, 길이, 데이터로 구성되며 이러한 구조를 파싱하여 적절히 처리한다.
function processFullBinaryRow(
response: Response,
id: number,
tag: number,
buffer: Array<Uint8Array>,
chunk: Uint8Array,
): void {
if (enableBinaryFlight) {
switch (tag) {
// ArrayBuffer, Int8Array 등등 처리(생략)
}
// 문자열 처리
const stringDecoder = response._stringDecoder;
let row = '';
for (let i = 0; i < buffer.length; i++) {
row += readPartialStringChunk(stringDecoder, buffer[i]);
}
row += readFinalStringChunk(stringDecoder, chunk);
processFullStringRow(response, id, tag, row); // 디코딩된 문자열 전달
}
이롤 통해 서버에서 전송된 다양한 형식의 데이터를 클라이언트에서 적절히 처리할 수 있다.
function processFullStringRow(
response: Response, // 처리 중인 응답 객체
id: number, // 행의 ID
tag: number, // 행의 타입을 나타내는 태그(ASCII 코드로 표현)
row: string, // 처리할 문자열 데이터
): void {
switch (tag) {
case 73 /* "I" */: {
// 모듈
resolveModule(response, id, row);
return;
}
case 72 /* "H" */: {
// 힌트
const code: HintCode = (row[0]: any);
resolveHint(response, code, row.slice(1));
return;
}
case 84 /* "T" */: {
// 텍스트
resolveText(response, id, row);
return;
}
// 에러, 디버그 정보, 개발/프로덕션 버전 불일치, 스트림 관련, Postpone 등을 처리하는 로직(생략)
default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
// JSON 데이터 처리
resolveModel(response, id, row);
return;
}
}
}
대부분의 청크 문자열은 모델 청크로 취급되는데 특별한 경우(모듈, 힌트 등)에는 다르게 처리된다.
function resolveModel(
response: Response,
id: number,
model: UninitializedModel,
): void {
const chunks = response._chunks; // 청크 가져오기
const chunk = chunks.get(id); // 주어진 id에 해당하는 청크가 있는지 체크
if (!chunk) {
// 청크가 없는 경우
// 새로운 해결된 모델 청크 생성 후 저장
chunks.set(id, createResolvedModelChunk(response, model));
} else {
// 기존 청크를 새로운 모델 데이터로 업데이트
resolveModelChunk(chunk, model);
}
}
export const enableFlightReadableStream = true;
function resolveModelChunk<T>(
chunk: SomeChunk<T>,
value: UninitializedModel,
): void {
if (chunk.status !== PENDING) {
// 청크 상태가 PENDING이 아니면 이미 처리된 청크로 간주
if (enableFlightReadableStream) {
// enableFlightReadableStream이 활성화되어 있는 경우
// 추가 데이터를 스트림 청크로 처리
const streamChunk: InitializedStreamChunk<any> = (chunk: any);
const controller = streamChunk.reason;
controller.enqueueModel(value); // 내부 데이터 큐에 추가
}
return;
}
// 리스너 및 상태 업데이트
const resolveListeners = chunk.value;
const rejectListeners = chunk.reason;
const resolvedChunk: ResolvedModelChunk<T> = (chunk: any);
resolvedChunk.status = RESOLVED_MODEL;
resolvedChunk.value = value;
if (resolveListeners !== null) {
initializeModelChunk(resolvedChunk);
wakeChunkIfInitialized(chunk, resolveListeners, rejectListeners);
}
}
이 과정들을 통해 비동기적으로 도착하는 컴포넌트 데이터를 효율적으로 처리하고 관련된 부분에 상태 변화를 알릴 수 있다.
export function getRoot<T>(response: Response): Thenable<T> {
const chunk = getChunk(response, 0); // 첫 번째 청크 가져오기
return (chunk: any);
}
function getChunk(response: Response, id: number): SomeChunk<any> {
const chunks = response._chunks; // 청크 맵 가져오기
let chunk = chunks.get(id); // 청크 조회
if (!chunk) {
// 청크가 존재하지 않는 경우
// 대기 중인 청크 생성, 추가
chunk = createPendingChunk(response);
chunks.set(id, chunk);
}
return chunk;
}
getRoot
함수는 서버 컴포넌트 응답의 루트 청크를 추출하여 클라이언트가 서버에서 생성된 컴포넌트 트리를 처리하기 시작할 수 있게 해준다.(반환된 청크는 Thenable 객체로 비동기적으로 해결될 수 있다)
getChunk
함수는 청크의 존재 여부에 관계없이 항상 청크 객체를 반환한다. 이 매커니즘을 통해 비동기적으로 도착하는 데이터를 효율적으로 관리할 수 있다.
function createPendingChunk<T>(response: Response): PendingChunk<T> {
// 청크 생성(PENDING 상태, value, reson은 null로 설정)
return new Chunk(PENDING, null, null, response);
}
// 상태, 값, 이유, 응답 객체를 속성을 가지는 객체를 생성하는 함수이다.
function Chunk(status: any, value: any, reason: any, response: Response) {
this.status = status;
this.value = value;
this.reason = reason;
this._response = response;
}
// prototype을 Promise 기반으로 설정하여 Promise와 유사하게 동작할 수 있게 설정
Chunk.prototype = (Object.create(Promise.prototype): any);
Chunk.prototype.then = function <T>(
this: SomeChunk<T>,
resolve: (value: T) => mixed,
reject?: (reason: mixed) => mixed,
) {
const chunk: SomeChunk<T> = this;
switch (chunk.status) {
// 청크 상태 처리
case RESOLVED_MODEL:
// 서버 컴포넌트의 렌더링 결과가 준비되었을 때
initializeModelChunk(chunk);
break;
case RESOLVED_MODULE:
// 클라이언트 컴포넌트에 대한 참조 정보가 준비되었을 때
initializeModuleChunk(chunk);
break;
}
switch (chunk.status) {
case INITIALIZED:
// 청크 데이터가 완전히 로드되고 초기화되었을 경우(위 상태 처리를 거친 경우)
resolve(chunk.value);
break;
case PENDING: // 아직 데이터가 도착하지 않은 경우
case BLOCKED: // 아직 완전히 로드되지 않은 경우
// 나중에 청크가 해결되거나 거부될 때 콜백을 호출하기 위해 배열에 추가
if (resolve) {
if (chunk.value === null) {
chunk.value = ([]: Array<(T) => mixed>);
}
chunk.value.push(resolve);
}
if (reject) {
if (chunk.reason === null) {
chunk.reason = ([]: Array<(mixed) => mixed>);
}
chunk.reason.push(reject);
}
break;
default:
// 오류 상태인 경우 reject 호출
if (reject) {
reject(chunk.reason);
}
break;
}
};
청크 객체는 Promise와 유사한 인터페이스를 제공하면서도 서버 컴포넌트의 특수한 요구사항을 처리할 수 있는 추가적인 기능을 가지고 있다.
클라이언트에서의 데이터 요청 및 수신
FlightResponse 객체 생성
스트리밍된 데이터 읽기 시작
바이너리 청크 처리
완성된 행 처리
문자열 행 처리
모델 데이터 처리
청크 상태 관리
청크 가져오기
루트 컴포넌트 추출
<서버>
1. renderToPipeableStream 함수를 통해 서버에서 React 트리를 렌더링한다.
2. 렌더링 된 결과를 가지고 RSC Payload라는 형태의 데이터 형식으로 직렬화한다.
3. RSC Payload와 함께 초기 HTML(Shell)을 생성한다.
4. 생성된 HTML, RSC Payload, 클라이언트 컴포넌트에 대한 JavaScript 코드를 클라이언트로 스트리밍 방식으로 전송한다.
💡 RSC Payload
JSON과 유사한 특별한 바이너리 형식이고 점진적으로 스트리밍되어 UI를 동적으로 업데이트한다. 클라이언트에서 React 트리를 재구성하는 데 사용된다.
- 서버 컴포넌트의 렌더링 결과
- 동적 데이터
- 클라이언트 컴포넌트에 전달될 props
💡 초기 HTML(Shell)
정적이며 상호작용이 불가능하지만 빠른 초기 렌더링(FCP)을 제공할 수 있다.
- 페이지의 기본 구조
- 레이아웃 컴포넌트
- 클라이언트 컴포넌트의 플레이스 홀더
- 메타 데이터(
<head>
,<meta>
등)
<클라이언트>
5. 클라이언트는 먼저 초기 HTML(Shell)을 받아 빠르게 표시한다.
6. 이후 서버로부터 스트리밍되는 RSC Payload를 받는다.
7. 받은 데이터를 처리하기 위해 FlightResponse 객체를 생성한다.
8. 스트리밍된 데이터를 청크 단위로 읽는다.
9. 각 바이너리 청크를 처리하여 문자열로 변환하고, 태그에 따라 다양한 유형의 데이터를 적절히 처리한다.
10. React 컴포넌트 트리의 일부인 모델 데이터를 처리한다.
11. 청크의 상태에 따라 적절한 처리를 한다.(초기화, 리스너 호출 등)
12. 서버에서 전송된 React 컴포넌트 트리의 루트를 추출한다.
13. 추출된 루트 컴포넌트를 기반으로 클라이언트에서 React 트리를 재구성한다.
14. 클라이언트 컴포넌트는 필요에 따라 Hydration 과정을 수행한다.
React Server Components - React 공식 문서(v18.3.1)
How do React Server Components(RSC) work internally in React?
Understanding React Server Components
AsyncLocalStorage - Node.js 공식 문서
WritableStream
Node.js Stream 당신이 알아야할 모든 것 2편
2024-01-27 Streaming SSR로 비동기 렌더링 하기
2024-02-23 [A는 B] 1. RSC란?
[Deep Dive] React Server Component를 정확히 아시나요? - 1편
How React server components work: an in-depth guide
(번역) React 18이 애플리케이션 성능을 개선하는 방법
React 서버 컴포넌트 작동원리를 아주 쉽게 알아보자
자바스크립트에서 데이터 스트림 읽기 (ReadableStream)
Nextjs 앱라우터 이해해보기