Progressive JSON

TMT

https://overreacted.io/progressive-json/ - Dan Abramov

rogressive JPEG에 대해 알고 계신가요? Progressive JPEG가 무엇인지에 대한 좋은 설명이 있습니다. 이 아이디어는 이미지를 위에서 아래로 로드하는 대신, 처음에는 흐릿하게 보이다가 점차 선명해진다는 것입니다.

이 아이디어를 JSON 전송에 적용하면 어떨까요?

예를 들어, 다음과 같은 데이터가 담긴 JSON 트리가 있다고 가정해봅시다:

{
  header: 'Welcome to my blog',
  post: {
    content: 'This is my article',
    comments: [
      'First comment',
      'Second comment',
      // ...
    ]
  },
  footer: 'Hope you like it'
}

이제 이 데이터를 네트워크를 통해 전송한다고 상상해보세요. 포맷이 JSON이기 때문에, 마지막 바이트가 로드될 때까지 유효한 객체 트리가 만들어지지 않습니다. 전체가 다 로드된 후에야 ‎JSON.parse를 호출하고, 그 다음에 처리할 수 있습니다.

이제 이 데이터를 네트워크를 통해 전송한다고 상상해보세요. 포맷이 JSON이기 때문에, 마지막 바이트가 로드될 때까지 유효한 객체 트리가 만들어지지 않습니다. 전체가 다 로드된 후에야 ‎JSON.parse를 호출하고, 그 다음에 처리할 수 있습니다.

클라이언트는 서버가 마지막 바이트를 보낼 때까지 아무것도 할 수 없습니다. 만약 JSON의 일부(예: ‎comments를 불러오는 과정)가 서버에서 느리게 생성된다면, 클라이언트는 서버가 모든 작업을 끝낼 때까지 어떤 작업도 시작할 수 없습니다.

이게 좋은 엔지니어링이라고 생각하시나요? 하지만 이게 현상태입니다—99.9999%1의 앱이 JSON을 이렇게 보내고 처리합니다. 우리가 이걸 개선할 수 있을까요?

Streaming 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'
      // (나머지 댓글은 없음)
    ]
  }
  // (footer 속성 없음)
}

하지만, 이것도 그다지 좋지 않습니다.

이 접근법의 단점 중 하나는 객체가 일종의 불완전한 형태라는 점입니다. 예를 들어, 최상위 객체는 세 개의 속성(‎header, ‎post, ‎footer)을 가져야 하지만, 아직 스트림에 나타나지 않았기 때문에 ‎footer가 없습니다. ‎post는 세 개의 ‎comments를 가져야 하지만, 더 댓글이 올지, 이게 마지막인지 알 수 없습니다.

어찌 보면, 이건 스트리밍의 본질이기도 합니다—불완전한 데이터를 받고 싶었던 거잖아요?—하지만 이렇게 되면 클라이언트에서 이 데이터를 _실제로 활용하기_가 매우 어렵습니다. 필드가 빠져 있어서 타입이 맞지 않습니다. 무엇이 완성됐고, 무엇이 아직인지 알 수 없습니다. 그래서 스트리밍 JSON은 일부 특수한 경우를 제외하면 인기가 없습니다. 애플리케이션 로직은 일반적으로 타입이 맞고, “준비됨”이 “완성됨”을 의미한다고 가정하기 때문입니다.

JPEG와의 비유에서, 이 단순한 스트리밍 방식은 기본 “위에서 아래로” 로딩 메커니즘과 같습니다. 보이는 그림은 선명하지만, 전체의 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)은 서버에서 이미 준비됐더라도, 아직 보내지지 않았기 때문에 잘려 나갑니다. 데이터를 순서대로 스트리밍하면, 하나의 느린 부분이 _모든 것_을 지연시킵니다.

다시 강조하자면: 데이터를 순서대로 스트리밍하면, 하나의 느린 부분이 _이후 모든 것_을 지연시킵니다. 이걸 해결할 방법이 있을까요?

Progressive JSON

스트리밍에 접근하는 또 다른 방법이 있습니다.

지금까지는 데이터를 _깊이 우선(depth-first)_으로 보냈습니다. 최상위 객체의 속성부터 시작해서, 그 객체의 ‎post 속성으로 들어가고, 다시 그 객체의 ‎comments 속성으로 들어가는 식입니다. 무언가가 느리면, 그 이후의 모든 것이 지연됩니다.

하지만, 데이터를 _너비 우선(breadth-first)_으로 보낼 수도 있습니다.

예를 들어, 최상위 객체를 이렇게 보낸다고 해봅시다:

{
  header: "$1",
  post: "$2",
  footer: "$3"
}

여기서 ‎"$1", ‎"$2", ‎"$3"는 아직 보내지지 않은 정보의 자리표시자입니다. 이 자리표시자들은 나중에 스트림에서 점진적으로 채워질 수 있습니다.

예를 들어, 서버가 스트림에 몇 줄의 데이터를 더 보냈다고 해봅시다:

{
  header: "$1",
  post: "$2",
  footer: "$3"
}
/* $1 */
"Welcome to my blog"
/* $3 */
"Hope you like it"

여기서 데이터를 보내는 순서에 구애받지 않는다는 점에 주목하세요. 위 예시에서는 ‎$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는 이제 객체로 해결됩니다. 하지만, ‎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"

여기서는 스트리밍을 조금 과하게 한 것 같습니다. 일부가 실제로 느리게 생성되는 경우가 아니라면, 별도의 줄로 보내는 것은 이득이 없습니다.

예를 들어, 두 가지 느린 작업(게시글 로딩, 댓글 로딩)이 있다고 가정해봅시다. 이 경우, 총 세 개의 청크로 보내는 것이 합리적입니다.

먼저, 외부 껍데기를 보냅니다:

{
  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"
}

이 방식이 더 간결하면서도 같은 목적을 달성합니다.

일반적으로, 이 포맷은 언제 하나의 청크로 보낼지, 여러 청크로 나눌지 결정할 수 있는 유연성을 제공합니다. 클라이언트가 청크가 순서 없이 도착해도 견딜 수 있다면, 서버는 다양한 배치 및 청크화 전략을 선택할 수 있습니다.

아웃라이닝(Outlining)

이 접근법의 흥미로운 결과 중 하나는, 출력 스트림에서 반복을 자연스럽게 줄일 수 있다는 점입니다. 이미 본 객체를 직렬화할 때, 별도의 줄로 아웃라인해서 재사용할 수 있습니다.

예를 들어, 다음과 같은 객체 트리가 있다고 해봅시다:

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과 달리, 순환 참조 객체도 직렬화할 수 있습니다. 순환 객체는 자신의 스트림 “줄”을 가리키는 속성을 갖게 됩니다.

Streaming Data vs Streaming UI

위에서 설명한 방식은 본질적으로 React Server Components(RSC)가 동작하는 방식입니다.

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>;
}

React는 ‎Page의 출력을 Progressive JSON 스트림으로 제공합니다. 클라이언트에서는 점진적으로 로드되는 React 트리로 재구성됩니다.

처음에는 클라이언트의 React 트리가 이렇게 보일 것입니다:

<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>

하지만, 여기서 중요한 점이 있습니다.

데이터가 스트림으로 들어온다고 해서, 페이지가 임의로 “점프”하듯이 보이길 원하지는 않습니다. 예를 들어, 게시글 내용 없이 페이지가 표시되는 일은 원하지 않을 수 있습니다.

이 때문에 React는 대기 중인 Promise에 “구멍”을 표시하지 않습니다. 대신, 가장 가까운 선언적 로딩 상태(‎<Suspense>)를 표시합니다.

위 예시에서는 트리에 ‎<Suspense> 경계가 없습니다. 즉, React는 _데이터_를 스트림으로 받지만, 사용자에게 “점프하는” 페이지를 실제로 보여주지는 않습니다. 전체 페이지가 준비될 때까지 기다립니다.

하지만, UI 트리의 일부를 ‎<Suspense>로 감싸면, 점진적으로 드러나는 로딩 상태를 선택적으로 사용할 수 있습니다. 데이터가 전송되는 방식은 변하지 않지만(여전히 가능한 한 “스트리밍”됨), React가 사용자에게 언제 보여줄지 제어할 수 있습니다.

예를 들어:

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>;
}

이제 사용자는 두 단계로 로딩 시퀀스를 인식하게 됩니다:

  • 먼저, 게시글이 헤더, 푸터, 그리고 댓글의 글리머(like 스켈레톤)와 함께 “팝인”됩니다. 헤더와 푸터만 따로 보이지는 않습니다.
  • 그 다음, 댓글이 따로 “팝인”됩니다.

즉, UI가 드러나는 단계는 데이터가 도착하는 순서와 분리되어 있습니다. 데이터는 준비되는 대로 스트림으로 전송되지만, 우리는 의도적으로 설계한 로딩 상태에 따라 사용자에게 보여주고 싶습니다.

어떤 면에서, React 트리의 Promise는 거의 ‎throw처럼 동작하고, ‎<Suspense>는 거의 ‎catch처럼 동작합니다. 데이터는 서버가 준비되는 대로 어떤 순서로든 최대한 빨리 도착하지만, React는 로딩 시퀀스를 우아하게 제어하고, 개발자가 시각적 공개 시점을 통제할 수 있게 해줍니다.

지금까지 설명한 내용은 “SSR”이나 HTML과는 무관합니다. 저는 JSON으로 표현된 UI 트리를 스트리밍하는 일반적인 메커니즘을 설명한 것입니다. 이 JSON 트리를 점진적으로 드러나는 HTML로 _변환_할 수도 있고(React가 할 수 있음), 이 아이디어는 HTML보다 더 넓은 범위, SPA와 같은 내비게이션에도 적용됩니다.

결론

이 글에서는 RSC의 핵심 혁신 중 하나를 간략히 설명했습니다. 데이터를 하나의 큰 덩어리로 보내는 대신, 컴포넌트 트리의 props를 바깥에서 안으로 보냅니다. 그 결과, 의도적으로 설계된 로딩 상태가 표시될 수 있을 때마다 React가 이를 표시할 수 있고, 페이지의 나머지 데이터는 계속 스트림으로 받아올 수 있습니다.

더 많은 도구들이 점진적 데이터 스트리밍을 도입하길 바랍니다. 클라이언트에서 무언가를 시작할 수 없는 상황이 서버가 모든 작업을 끝낼 때까지 기다려야 한다면, 스트리밍이 도움이 될 수 있다는 명확한 신호입니다. _하나의 느린 작업_이 _이후 모든 것_을 느리게 만든다면, 그것도 경고 신호입니다.

이 글에서 보여드린 것처럼, 스트리밍 _자체만으로_는 충분하지 않습니다—스트리밍을 활용하고 불완전한 정보를 우아하게 처리할 수 있는 프로그래밍 모델도 필요합니다. React는 의도적인 ‎<Suspense> 로딩 상태로 이 문제를 해결합니다. 이와 다르게 해결하는 시스템을 알고 계시다면, 꼭 알려주세요!

Footnotes

  1. 제가 그냥 지어낸 숫자입니다

Edit this page

On this Page