[번역] 스트리밍 JSON
점진적으로 JSON 보내기
점진적 JPEG에 대해 알고 계신가요? 여기 점진적 JPEG가 무엇인지에 대한 좋은 설명이 있습니다. 점진적 JPEG는 이미지를 위에서 아래로 순차적으로 로딩하는 대신, 처음에는 흐릿하게 보이다가 점점 선명해지는 방식으로 로딩하는 방식입니다.
이러한 아이디어를 JSON 전송에 적용해보면 어떨까요?
예를 들어, 다음과 같은 JSON 트리가 있다고 가정해봅시다.
const syntax = 'highlight'
{
header: 'Welcome to my blog',
post: {
content: 'This is my article',
comments: [
'First comment',
'Second comment',
// ...
]
},
footer: 'Hope you like it'
}
이 JSON을 네트워크를 통해 전송한다고 상상해봅시다. JSON 형식의 특성상 마지막 바이트까지 모두 로드되어야 유효한 객체 트리를 얻을 수 있습니다. 즉, 전체 데이터를 받아야만 JSON.parse
를 호출할 수 있고, 그 이후에야 데이터를 처리할 수 있습니다.
클라이언트는 서버가 마지막 바이트를 보낼 때까지 아무 작업도 할 수 없습니다. 만약 JSON의 일부를 생성하는 데 시간이 오래 걸린다면 (예: comments
를 불러오기 위한 데이터베이스 접근이 느리다면), 서버가 모든 작업을 완료할 때까지 클라이언트는 어떤 작업도 시작할 수 없습니다.
이것을 좋은 엔지니어링이라고 할 수 있을까요? 하지만 이것이 현실입니다 - 99.9999%*의 앱이 이런 방식으로 JSON을 전송하고 처리합니다. 우리는 과연 이를 개선할 수 있을까요?
* 순전히 제 생각입니다.
스트리밍 JSON
이 문제를 개선하기 위해 스트리밍 JSON 파서를 구현해볼 수 있습니다. 스트리밍 JSON 파서는 불완전한 입력으로부터 객체 트리를 생성할 수 있습니다.
{
header: 'Welcome to my blog',
post: {
content: 'This is my article',
comments: [
'First comment',
'Second comment'
이 시점에서 결과를 요청하면, 스트리밍 파서는 다음과 같은 객체를 반환할 것입니다.
{
header: 'Welcome to my blog',
post: {
content: 'This is my article',
comments: [
'First comment',
'Second comment'
// (The rest of the comments are missing)
]
}
// (The footer property is missing)
}
하지만 이 방법도 썩 좋지는 않습니다
한 가지 단점은 객체들이 불완전하다는 것입니다. 예를 들어, 최상위 객체는 세 개의 속성(header
, post
, 그리고 footer
)을 가져야 하지만, footer
은 아직 스트림에 나타나지 않았기 때문에 누락되어 있습니다. post
는 세 개의 comments
를 가져야 하지만, 앞으로 더 많은 comments
가 올지 아니면 이것이 마지막인지 알 수 없습니다.
어떤 면에서는, 이건 스트리밍의 본질이기도 합니다--불완전한 데이터를 먼저 받기를 원한 것이 아닌가요?--하지만 이런 방식은 클라이언트에서 이 데이터를 실제로 활용하는 걸 굉장히 어렵게 만듭니다. 필드들이 누락되어 있어서 타입이 전혀 “일치하지” 않기 때문입니다. 어떤 데이터가 완전한지 아닌지조차 알 수 없습니다. 이런 이유로 스트리밍 JSON은 몇몇 특수한 경우를 제외하곤 잘 사용되지 않습니다. 일반적으로 타입이 올바르고, “준비됐다”는 것은 곧 “완전하다”는 걸 전제로 하는 애플리케이션 로직에서 이를 제대로 활용하기가 너무 어렵습니다.
점진적 JPEG와의 비유에서, 이런 단순한 스트리밍 접근 방식은 기본적인 "하향식 (top-down)" 로딩 메커니즘과 일치합니다. 이미지의 상단 10%는 선명하게 보이지만, 높은 해상도에도 불구하고 전체 그림을 이해하기에는 부족합니다.
흥미롭게도, HTML 스트리밍 역시 기본적으로 이와 유사하게 작동합니다. 느린 연결에서 HTML 페이지를 로드하면, 문서 순서대로 스트리밍됩니다.
<html>
<body>
<header>Welcome to my blog</header>
<article>
<p>This is my article</p>
<ul class="comments">
<li>First comment</li>
<li>Second comment</li>
이 방식은 브라우저가 페이지를 부분적으로 표시할 수 있다는 장점이 있지만, 여전히 동일한 문제점이 존재합니다. 중간에서 임의로 끊길 수 있기 때문에 시각적으로 거슬릴 수 있고, 페이지 레이아웃이 깨질 수도 있습니다. 또, 더 많은 콘텐츠가 이어질지 여부도 불확실합니다. 예를 들어, 하단에 있는 footer 같은 요소는 서버에서 미리 준비돼 먼저 전송될 수 있었더라도, 그 이전 내용이 끝나지 않았기에 잘려나갑니다. 즉, 순서대로 데이터를 스트리밍하면, 하나의 느린 부분으로 인해 전체 로딩이 지연됩니다.
재차 강조하겠습니다. 데이터를 표시되는 순서대로 스트리밍하면, 그 중 하나라도 느리면 이후의 모든 것이 지연됩니다. 이 문제를 해결할 수 있는 방법이 있을까요?
점진적 JSON
스트리밍에 접근하는 또 다른 방법이 있습니다.
지금까지 우리는 깊이 우선(depth-first) 방식으로 데이터를 전송했습니다. 최상위 객체의 속성부터 시작하여, post
속성으로 들어가고, 그 안의 comments
속성으로 들어가는 방식입니다. 이 방식에서는 어떤 부분이 느리면, 모든 것이 지연됩니다.
하지만 너비 우선(breadth-first) 방식으로 데이터를 전송할 수도 있습니다.
예를 들어, 최상위 객체를 다음과 같이 전송한다고 가정해봅시다.
{
header: "$1",
post: "$2",
footer: "$3"
}
여기서 "$1"
, "$2"
, "$3"
은 아직 전송되지 않은 정보를 나타냅니다. 이러한 플레이스홀더는 이후 스트림에서 점진적으로 채워질 수 있습니다.
예를 들어, 서버에서 스트림으로 몇 개의 추가 행(row)을 전송한다고 생각해봅시다.
{
header: "$1",
post: "$2",
footer: "$3"
}
/*$1*/
"Welcome to my blog"
/*$3*/
"Hope you like it"
우리가 반드시 행(row)들을 특정한 순서로 전송해야 하는 건 아니라는 점에 주목하세요. 위 예시에서도 우리는 $1과 $3을 먼저 보냈지만, $2는 아직 대기 중입니다!
이 시점에서 클라이언트가 트리를 재구성하려고 하면, 다음과 같은 모습이 될 것입니다.
{
header: "Welcome to my blog",
post: new Promise(/* ... 아직 이행되지 않음 ... */),
footer: "Hope you like it"
}
아직 로드되지 않은 부분들은 Promise로 표현하겠습니다.
그런 다음 서버가 몇 줄을 더 스트리밍 한다고 가정해봅시다.
{
header: "$1",
post: "$2",
footer: "$3"
}
/*$1*/
"Welcome to my blog"
/*$3*/
"Hope you like it"
/*$2*/
{
content: "$4",
comments: "$5"
}
/*$4*/
"This is my article"
이것은 클라이언트 관점에서 누락된 부분들을 "채워넣을" 것입니다.
{
header: "Welcome to my blog",
post: {
content: "This is my article",
comments: new Promise(/* ... 아직 이행되지 않음 ... */),
},
footer: "Hope you like it"
}
post
에 대한 Promise는 이제 객체로 이행(resolve)됩니다. 하지만 아직 comments
안에 무엇이 있는지 모르기 때문에, 이제 그것들이 Promise로 표현됩니다.
마지막으로, 댓글들이 스트리밍될 수 있습니다.
{
header: "$1",
post: "$2",
footer: "$3"
}
/*$1*/
"Welcome to my blog"
/*$3*/
"Hope you like it"
/*$2*/
{
content: "$4",
comments: "$5"
}
/*$4*/
"This is my article"
/*$5*/
["$6", "$7", "$8"]
/*$6*/
"This is the first comment"
/*$7*/
"This is the second comment"
/*$8*/
"This is the third comment"
이제 클라이언트 관점에서 전체 트리가 완성됩니다.
{
header: "Welcome to my blog",
post: {
content: "This is my article",
comments: [
"This is the first comment",
"This is the second comment",
"This is the third comment"
]
},
footer: "Hope you like it"
}
데이터를 너비 우선 방식으로 청크 단위로 전송함으로써, 우리는 클라이언트에서 이를 점진적으로 처리할 수 있는 능력을 얻었습니다. 클라이언트가 일부 “준비되지 않음”(Promise로 표현되는) 상태의 데이터들을 잘 다루면서 나머지 데이터를 처리할 수만 있다면, 이건 확실한 개선입니다!
인라이닝 (Inlining)
기본적인 메커니즘을 이해했으니, 이제 출력을 더 효율적으로 만들기 위해 조정해보겠습니다. 앞선 예제에서 본 전체 스트리밍 시퀀스를 다시 한 번 살펴봅시다.
{
header: "$1",
post: "$2",
footer: "$3"
}
/*$1*/
"Welcome to my blog"
/*$3*/
"Hope you like it"
/*$2*/
{
content: "$4",
comments: "$5"
}
/*$4*/
"This is my article"
/*$5*/
["$6", "$7", "$8"]
/*$6*/
"This is the first comment"
/*$7*/
"This is the second comment"
/*$8*/
"This is the third comment"
여기서는 스트리밍을 약간 과도하게 사용한 것 같네요. 일부 콘텐츠 생성이 실제로 느리지 않다면, 이렇게 각기 다른 행(row)으로 나눠 보내는 것은 이점이 없습니다.
예를 들어, 포스트 본문을 불러오는 작업과 댓글을 불러오는 두개의 작업이 느린 상황을 가정해봅시다. 이 경우에는 전체를 세 개의 청크(chunk)로 나눠 보내는 것이 더 합리적입니다.
우선 첫 번째로, 다음과 같은 바깥 껍데기를 먼저 보냅니다.
{
header: "Welcome to my blog",
post: "$1",
footer: "Hope you like it"
}
이 데이터를 받은 클라이언트는 즉시 다음처럼 해석할 수 있습니다.
{
header: "Welcome to my blog",
post: new Promise(/* ... 아직 이행되지 않음 ... */),
footer: "Hope you like it"
}
그리고 다음으로는 post 데이터를 보냅니다. (comments는 아직 제외하고)
{
header: "Welcome to my blog",
post: "$1",
footer: "Hope you like it"
}
/*$1*/
{
content: "This is my article",
comments: "$2"
}
클라이언트 입장에서 보면 다음과 같습니다.
{
header: "Welcome to my blog",
post: {
content: "This is my article",
comments: new Promise(/* ... 아직 이행되지 않음 ... */),
},
footer: "Hope you like it"
}
마지막으로, 댓글을 한 번에 묶어서 전송합니다.
{
header: "Welcome to my blog",
post: "$1",
footer: "Hope you like it"
}
/*$1*/
{
content: "This is my article",
comments: "$2"
}
/*$2*/
[
"This is the first comment",
"This is the second comment",
"This is the third comment"
]
이제 클라이언트는 완전한 트리를 조립할 수 있습니다.
{
header: "Welcome to my blog",
post: {
content: "This is my article",
comments: [
"This is the first comment",
"This is the second comment",
"This is the third comment"
]
},
footer: "Hope you like it"
}
이 방식은 더 간결하면서도 동일한 목적을 달성합니다.
일반적으로 이 포맷은 어떤 데이터를 하나의 청크로 보낼지, 또는 여러 청크로 나눌지 선택할 수 있는 유연성을 제공합니다. 클라이언트가 서버로부터 청크를 순서대로 받지 않아도 문제없이 처리할 수 있다면, 서버는 상황에 따라 다양한 방식의 배치(batch) 및 청킹(chunking) 전략을 적용할 수 있습니다.
아웃라이닝 (Outlining)
이 접근 방식이 흥미로운 이유 중 하나는, 출력 스트림에서 중복을 줄이는 자연스러운 방법 또한 제공한다는 점입니다. 이미 직렬화한 객체를 다시 마주쳤을 때, 해당 객체를 별도의 행(row)으로 분리(outline) 해서 재사용할 수 있습니다.
예를 들어, 다음과 같은 객체 트리가 있다고 해봅시다.
const userInfo = { name: 'Dan' };
[
{ type: 'header', user: userInfo },
{ type: 'sidebar', user: userInfo },
{ type: 'footer', user: userInfo }
]
이걸 일반 JSON으로 직렬화하면 { name: 'Dan' }
이 반복됩니다.
[
{ type: 'header', user: { name: 'Dan' } },
{ type: 'sidebar', user: { name: 'Dan' } },
{ type: 'footer', user: { name: 'Dan' } }
]
하지만 JSON을 점진적으로 스트리밍한다면, 다음처럼 아웃라인으로 빼내는 것을 고려할 수 있습니다.
[
{ type: 'header', user: "$1" },
{ type: 'sidebar', user: "$1" },
{ type: 'footer', user: "$1" }
]
/* $1 */
{ name: "Dan" }
또한 우리는 더 균형 잡힌 전략을 취할 수도 있습니다. 예를 들어, 기본적으로는 객체를 인라인으로 직렬화하다가 (더 간결하므로), 같은 객체가 두 번 이상 등장하면 그때부터 별도로 분리해서 중복을 제거하는 방식이죠.
이 방식의 또 다른 장점은, 일반 JSON과 달리 순환 참조(cyclic object) 도 직렬화할 수 있다는 점입니다. 순환 객체는 자기 자신을 참조하는 필드를 해당 스트림의 "행(row)"으로 가리키기만 하면 되기 때문입니다.
스트리밍 데이터 vs 스트리밍 UI
지금까지 설명한 방식은 사실상 리액트 서버 컴포넌트 (React Server Components) 가 작동하는 방식과 동일합니다.
예를 들어, 리액트 서버 컴포넌트로 다음과 같은 페이지를 만든다고 해봅시다.
function Page() {
return (
<html>
<body>
<header>Welcome to my blog</header>
<Post />
<footer>Hope you like it</footer>
</body>
</html>
);
}
async function Post() {
const post = await loadPost();
return (
<article>
<p>{post.text}</p>
<Comments />
</article>
);
}
async function Comments() {
const comments = await loadComments();
return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}
리액트는 Page의 결과를 점진적 JSON 스트림으로 클라이언트에 제공합니다. 클라이언트에서는 이 스트림이 점진적으로 리액트 트리로 복원됩니다.
처음엔 클라이언트에 리액트 트리가 이렇게 나타날 겁니다.
<html>
<body>
<header>Welcome to my blog</header>
{new Promise(/* ... 아직 이행되지 않음 */)}
<footer>Hope you like it</footer>
</body>
</html>
그 후, 서버에서 loadPost()
가 이행되면 더 많은 스트림이 들어옵니다.
<html>
<body>
<header>Welcome to my blog</header>
<article>
<p>This is my post</p>
{new Promise(/* ... 아직 이행되지 않음 */)}
</article>
<footer>Hope you like it</footer>
</body>
</html>
마지막으로 loadComments()
까지 서버에서 이행되면, 클라이언트는 남은 데이터들을 모두 받습니다.
<html>
<body>
<header>Welcome to my blog</header>
<article>
<p>This is my post</p>
<ul>
<li key="1">This is the first comment</li>
<li key="2">This is the second comment</li>
<li key="3">This is the third comment</li>
</ul>
</article>
<footer>Hope you like it</footer>
</body>
</html>
그런데 여기서 중요한 포인트가 있습니다.
데이터가 점진적으로 들어옴에 따라 페이지가 임의로 점프하듯 렌더링되길 원하지는 않을 겁니다. 예를 들어, 포스트 본문이 없이 헤더와 푸터만 먼저 보이는 것은 절대 원하지 않겠죠.
이것이 리액트가 pending 상태인 Promise에 대해 “구멍”을 표시하지 않는 이유입니다. 리액트는 대신, <Suspense>
로 지정된 가장 가까운 명시적인 로딩 상태만 보여줍니다.
위 예시에서는 <Suspense>
가 트리에 없습니다. 그렇기에 리액트는 데이터를 스트리밍으로 받아도, 전체 페이지가 완전히 준비되기 전까지는 "점프하는" 페이지를 유저에게 렌더링하지 않습니다. 완전한 페이지가 준비될 때까지 기다릴 것입니다.
하지만 <Suspense>
로 UI 트리의 일부를 감싸면, 점진적으로 드러나는 로딩 상태를 선택적으로 (opt-in) 적용할 수 있습니다. 이것은 데이터가 전송되는 방식에는 영향을 주지 않고 (여전히 가능한 한 "스트리밍" 방식으로 전송될 것입니다), 리액트가 그 데이터를 사용자에게 언제 보여줄지를 변경합니다.
예를 들어,
import { Suspense } from 'react';
function Page() {
return (
<html>
<body>
<header>Welcome to my blog</header>
<Post />
<footer>Hope you like it</footer>
</body>
</html>
);
}
async function Post() {
const post = await loadPost();
return (
<article>
<p>{post.text}</p>
<Suspense fallback={<CommentsGlimmer />}>
<Comments />
</Suspense>
</article>
);
}
async function Comments() {
const comments = await loadComments();
return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}
이제 사용자는 두 단계에 걸친 로딩 시퀀스를 경험하게 됩니다.
-
첫 번째 단계: 포스트 본문, 헤더, 푸터, 댓글의 플레이스홀더가 함께 나타남
-
두 번째 단계: 댓글이 “툭” 하고 나중에 들어옴
다시 말하자면, UI가 사용자에게 드러나는 순서는, 데이터가 도착하는 순서와 분리됩니다. 데이터는 가능한 한 빠르게 스트리밍되지만, UI는 의도적으로 설계된 로딩 상태에 따라 공개되는 거죠.
비유하자면, 리액트 트리 안의 Promise는 마치 throw
와 같은 역할을, <Suspense>
는 거의 catch와 같은 역할을 합니다. 데이터는 서버가 준비되는 대로 가능한 한 빨리 도착하지만, 리액트는 이를 우아하게 보여주는 흐름을 관리해주고, 개발자가 시각적인 공개를 제어할 수 있도록 합니다.
지금까지 설명한 것이 “SSR”이나 HTML에 국한된 게 아님을 주목해주세요. 제가 설명한 것은 JSON으로 표현되는 UI 트리를 스트리밍하는 일반적인 메커니즘입니다. 그 JSON 트리를 점진적으로 렌더링되는 HTML로 변환할 수도 있지만 (리액트가 하고 있는 것처럼), 이 개념은 HTML을 넘어 SPA 스타일의 페이지 전환에도 그대로 적용할 수 있습니다.
마무리하며
이 글에서는 리액트 서버 컴포넌트의 핵심적인 혁신들 중 하나를 간략히 살펴보았습니다. 데이터를 하나의 큰 청크로 보내는 것이 아닌, 컴포넌트 트리 밖에서 안으로 프로퍼티를 보내는 방식이죠. 그 결과, 보여주고자 하는 의도적인 로딩 상태가 있다면, 리액트는 페이지의 나머지 데이터가 스트리밍되는 동안에도 그 로딩 상태를 즉시 보여줄 수 있습니다.
저는 더 많은 도구들이 점진적 데이터 스트리밍 방식을 도입할 것을 촉구합니다. 만약 어떤 작업을 클라이언트에서 시작하기 위해 서버의 작업이 끝나기를 기다려야 하는 상황이 있다면, 그건 스트리밍이 도움이 될 수 있다는 분명한 신호입니다. 하나의 느린 작업 때문에 이후의 모든 작업이 지연되는 상황 역시 마찬가지로 스트리밍이 필요하다는 경고 신호입니다.
이 글에서 보여드렸듯이, 스트리밍만으로는 충분하지 않습니다 -- 스트리밍의 장점을 활용하고, 불완전한 정보를 우아하게 처리할 수 있는 프로그래밍 모델 역시 필요합니다. 리액트는 이를 의도적인 <Suspense>
로딩 상태로 해결합니다. 만약 이 문제를 다른 방식으로 해결하는 시스템을 알고 계신다면, 꼭 알려주세요!