Web: SSR에 대한 React의 고민, Suspense, 그리고 Server Component

Heechan
HcleeDev
Published in
20 min readAug 18, 2023

--

Photo by Markus Spiske on Unsplash

과거부터 Web 개발자들은 더 좋은 유저 사용성을 위해서 발전을 거듭해왔다. 유저가 더 빨리 화면을 볼 수 있게 하고, 더 빨리 상호작용할 수 있게 만들기 위해 많은 기술들이 만들어졌다. 애당초 서버에서 HTML을 매번 만들어서 보내주던 고대 웹에서, 클라이언트에서 JS로 화면을 만들던 클라이언트 사이드 랜더링, 그것도 처음에 유저가 볼 수 있는게 느려진다고 서버에서 대충 HTML을 만들어서 보내주기로 한 서버 사이드 랜더링까지 지금까지 꾸준한 발전이 있었다.

그 와중 꽤나 생태계 친화적인 React 팀에서도 당연히 유저 사용성을 높이기 위한 고민을 했었고, 그에 컴포넌트를 어떻게 해야 효과적으로 랜더링할 수 있을지, SSR 시대에 어떻게 잘 맞추어 갈 수 있을지에 대한 대답을 내놓기 시작했다. (한 2년 전부터)

이번주는 React가 SSR에서 느낀 문제점과 고민, 그리고 이를 해결하기 위한 Suspense 와 Server Component에 대해 알아보자.

SSR도 아쉽다

이 시대에는 Next.js로 대표되는 SSR, 서버 사이드 랜더링은 클라이언트 사이드 랜더링의 초기 로딩 등의 문제를 해결하기 위해 대두되었다. 기존의 CSR 방식으로 만들어진 웹 페이지는, 빈 HTML을 하나 받고, JavaScript 번들을 전부 다운로드한 다음에, 그 JavaScript가 실행되며 랜더링까지 다 한 후에야 유저가 화면을 볼 수 있었다.

그 전에는 그냥 하얀 화면

웹 개발자들은 이 하얀 화면이 그닥 맘에 들지 않았고, 지난 주에 다룬 webpack 등의 번들러를 개조해서 번들을 쪼개는 식으로 최대한 로딩을 빠르게 해보려는 시도도 했었다. 하지만 처음에 보이는 하얀 화면으로 인한 일종의 ‘깜빡임’을 해결하기는 굉장히 쉽지 않았다.

따라서 서버 사이드 랜더링은 최초로 보내는 HTML을 빈 HTML이 아닌, 뭐라도 채운 HTML을 보내서 유저가 처음부터 뭔가 채워진 화면을 볼 수 있게 하는 것이 목표였다.

이 뭐라도 채운 HTML을 빌드 타임에 만들어서 가지고 있거나, 요청이 왔을 때 서버에서 만들어서 보내주기 때문에 유저는 화면을 비교적 빠른 시간에 확인할 수 있게 되었다.

하지만 뭔가 JavaScript가 붙어서 오는 것이 아니라, 초기 HTML은 그저 화면 구성만 덜렁 되어있기 때문에 실질적으로 사용자가 상호작용을 하려면 JavaScript를 다 다운로드하고 ‘Hydrate’하는 과정까지 거쳐야 한다.

정리하면 SSR 방식으로 만들더라도 아래와 같은 문제가 있었다고 볼 수 있다.

  • 뭐라도 보기 위해서는 일단 모든 데이터를 다 가지고 와야 했다.

클라이언트에서 만들든 서버에서 만들든, 어차피 DB나 API를 통해 필요한 데이터를 가지고 오는 것이 수반되어야 한다. 특히 SSR 방식에서 유저의 요청에 따라 서버에서 HTML을 만들 때는 모든 데이터를 서버가 모을 때까지 유저는 HTML의 생성을 기다려야 한다.

Next는 페이지 단위로 필요한 데이터를 위에서 다 받아와서 Props로 내려주는 방식으로 구성되어있으니…

  • 뭐라도 Hydrate 하기 위해서는 일단 모든 컴포넌트에 대한 JS를 로드해야 했다.

Hydrate 할 때 React는 내부 Tree를 따라 로드한 JS를 각 컴포넌트에 붙이는 작업을 거친다. 그런데 만약 아직 JS가 다 로드 안된 상태라면 React 입장에서는 뭔가 이상하다고 에러를 내게 된다. 따라서 굳이 사용하지 않는 컴포넌트의 로직까지도 일단 다 로드한 후에야 Hydrate를 시작할 수 있다.

  • 뭐라도 상호작용하기 위해서는 일단 모든 컴포넌트에 대한 Hydration이 완료되어야 했다.

React가 Hydration 작업을 하고 있는 동안에는 다른 JavaScript 동작이 멈춰있기 때문에 중간에 특정 컴포넌트와 상호작용할 수는 없다. 따라서 첫 상호작용을 위해서는 모든 컴포넌트에 대한 Hydration이 수반되어야 한다.

SSR이 유저에게 처음으로 화면이 보이는 시간은 줄여줬겠지만, 첫 상호작용이 가능한 시간을 줄여줬다고는 말하기 힘들다. 오히려 화면은 보이는데 상호작용이 안되면 좀 더 답답할 수도 있다는 생각이 든다. (그런걸 UI로 해결하긴 해야겠지만…)

HTML Streaming과 Selective Hydration

이런 일종의 Waterfall 때문에 생기는 문제를 해결하기 위해 React는 Suspense 를 공개했다. 이 Suspense를 이용해 두 가지 방향으로 문제를 해결할 수 있다.

첫 번째는 HTML의 스트리밍이다. 기존에는 서버 내에서 랜더링이 오래 걸리는 경우에는 다른 컴포넌트의 랜더링이 완료되더라도 HTML을 미리 보내주지 못하고 기다려야 했다.

하지만 Suspense를 이용하면, Suspense 안 쪽에 있는 컴포넌트가 다 랜더링되지 않더라도 그 밖에 있는 HTML을 유저에게 먼저 보내준다.

<Layout>
<NavBar />
<Sidebar />
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>

이 코드가 그 예시다. <Suspense> 안에는 Comments 컴포넌트가 들어있는데, 컨셉상 이 컴포넌트는 댓글들을 쭉 불러오는데 오래 걸리는걸로 생각하면 된다. 아직 서버 내에서 Comments 컴포넌트에 대한 랜더링이 완료되지 않았다면, 서버는 그 자리에 Spinner 를 담아서 HTML로 보내준다.

<main>
<nav>
<!--NavBar -->
<a href="/">Home</a>
</nav>
<aside>
<!-- Sidebar -->
<a href="/profile">Profile</a>
</aside>
<article>
<!-- Post -->
<p>Hello world</p>
</article>
<section id="comments-spinner">
<!-- Spinner -->
<img width=400 src="spinner.gif" alt="Loading..." />
</section>
</main>

이후 서버에서 Comments의 랜더링을 완료하면, 그때 Comments에 대한 HTML을 보내준다. 아래와 같이 script도 포함해서 Spinner를 대체해 그 자리에 쏙 들어가도록 말이다.

<div hidden id="comments">
<!-- Comments -->
<p>First comment</p>
<p>Second comment</p>
</div>
<script>
// This implementation is slightly simplified
document.getElementById('sections-spinner').replaceChildren(
document.getElementById('comments')
);
</script>

이 HTML 스트리밍으로 서버에서 HTML을 보내기 전에 모든 데이터를 일단 모아야 한다는 첫 번째 문제점은 해결할 수 있다.

다른 문제점은 Hydration을 하려면 모든 JS 코드가 로드되어야 한다는 점과, 상호작용을 하려면 모든 Hydration이 끝나야 한다는 문제점이었다. 이 문제도 Suspense 를 활용하면 React에서 적절히 처리해준다.

Selective Hydration은 몇 가지 상황에 따라 나뉜다.

일단 HTML은 왔고, 코드가 다 로딩되지 않은 상태일 때다. 이때 Suspense 로 감싸져 있는 컴포넌트는 코드가 다 오지 않더라도 다른 녀석들을 먼저 Hydration할 수 있다.

이 그림이 그런 상황이다. 이러면 2번째, 3번째 문제도 대강 해결할 수 있다.

그리고 아예 HTML이 다 오지 않았지만, Suspense 에 묶이지 않은 녀석들은 JS 코드까지 준비가 다 되었을 경우에도 먼저 Hydration이 진행된다.

이런 그림이 된다.

React는 Suspense를 제공함으로써 개발자가 랜더링이나 데이터 fetching이 오래 걸릴 것 같은 녀석들을 Wrapping하도록 해 SSR에서의 성능, 유저 경험을 높이고자 했다.

하지만 여전히 남아있는 문제들이 있었다.

일단 JavaScript 전체 번들의 크기는 그닥 줄어들지 않았다. JavaScript 의존성이 많아 받아오는 번들 크기가 커짐으로써 생기는 딜레이도 큰 편인데, 그런 면에서는 전반적인 로딩 속도는 드라마틱한 변화가 없다.

모든 컴포넌트와 상호작용하기 위해서는 전체 JavaScript를 다 받아와서 Hydration까지 마쳐야 한다는 것은 변하지 않는다. 유저가 화면을 빠르게 인식할 수 있다는 점에서 UX가 좋아지긴 했지만, 보고나서 바로 상호작용할 수 있으면 더 좋지 않을까?

React Server Component, RSC

React는 이 Suspense의 특징을 활용해서 위의 문제점을 해결한 Server Component를 공개하게 된다.

기존의 SSR에서 컴포넌트는 단순히 데이터 처리 전의 HTML을 만들어서 유저한테 일단 보이게 하고, 그 후 데이터 fetching 및 JavaScript Hydration까지 완료되어야 제 구실을 했다.

하지만 Server Component는 서버에서 데이터 fetching, 랜더링까지 다 마친 데이터를 JSON으로 직렬화하여 클라이언트한테 보내준다. 구실을 다 할 수 있는 컴포넌트로 서버에서 만든 다음에 유저에게 보내주기 때문에 유저 입장에서는 빠른 노출, 빠른 반응을 경험할 수 있다.

대강의 사용 예시는 아래와 같다.

// Note.js - Server Component

import db from 'db';
// (A1) We import from NoteEditor.js - a Client Component.
import NoteEditor from 'NoteEditor';

async function Note(props) {
const {id, isEditing} = props;
// (B) Can directly access server data sources during render, e.g. databases
const note = await db.posts.get(id);

return (
<div>
<h1>{note.title}</h1>
<section>{note.body}</section>
{/* (A2) Dynamically render the editor only if necessary */}
{isEditing
? <NoteEditor note={note} />
: null
}
</div>
);
}

위에 보이는 Note 컴포넌트는 RSC다. 대충 보면 일반적인 React 컴포넌트와 큰 차이가 없어보인다. 실제로 큰 틀에서는 컴포넌트를 구성하는 방법에 큰 차이가 없으나, 디테일한 부분에서 문제가 있다.

일단 흔히 보이는 useState나 useEffect가 없다. React Server Component는 서버에서 단 한 번 랜더링된다. 따라서 상태나 그로 인한 영향이 있을 수가 없다. RSC에서는 useState , useEffect , useReducer 등 상태 및 Life cycle을 관리하는 Hook을 사용할 수 없다.

하지만 Hook을 사용할 수 없다고 해서 Class Component를 사용할 수 있는 것은 아니다. 애초에 Class Component는 Hook 없이 상태나 Life cycle을 관리할 수 있는 컴포넌트기도 하고, 단 한 번의 랜더링만 발생하는 서버 컴포넌트에서 Class Component는 그 취지에 맞지 않다. 따라서 Functional Component를 사용한다.

보면 async/await 문을 사용하고 있음을 알 수 있다. 대부분 클라이언트에서 랜더링하는 컴포넌트가 API 요청을 useEffect에서 진행하지만, 여기서는 그런 Hook을 사용할 수 없고 컴포넌트를 만들어내는 함수가 단 한 번 굴러가는 상황에서 async/await 문을 이용하게 된 것이다. 위에서는 DB에 접근해서 데이터를 가져오는 과정에서 확인할 수 있다.

SSR 개발을 해봤으면 다들 알고 있는 사실이겠지만 서버 컴포넌트에서는 DOM에 직접 접근하는 행동도 할 수 없다.

여기서 사용하고 있는 <NoteEditor> 는 클라이언트 사이드에서 랜더링되는 컴포넌트다.

// NoteEditor.js - Client Component

'use client';

import { useState } from 'react';

export default function NoteEditor(props) {
const note = props.note;
const [title, setTitle] = useState(note.title);
const [body, setBody] = useState(note.body);
const updateTitle = event => {
setTitle(event.target.value);
};
const updateBody = event => {
setBody(event.target.value);
};
const submit = () => {
// ...save note...
};
return (
<form action="..." method="..." onSubmit={submit}>
<input name="title" onChange={updateTitle} value={title} />
<textarea name="body" onChange={updateBody}>{body}</textarea>
</form>
);
}

클라이언트 컴포넌트는 이제 파일 상단에 'use client' 로 표시해야 한다. 기존에는 .server.js , .client.js 이런 식으로 표기한 것 같은데, 이렇게 변경되었다. 여기서는 서버 컴포넌트에서는 볼 수 없었던 useState 를 볼 수 있다.

서버 컴포넌트에서 클라이언트 컴포넌트가 자연스럽게 쓰이고 있는 것도 확인할 수 있는데, 서버에서 랜더링하는 시점에서는 이 클라이언트 컴포넌트 부분은 비워놓고 나머지 부분을 랜더링한다. 물론 Reference를 남겨둔다. 브라우저가 JS를 다 로딩한 후에야 <NoteEditor> 는 랜더링될 것이다. 내부에서 이 Reference를 바탕으로 랜더링이 완료된 컴포넌트를 끼워넣는다고 생각하면 된다.

반면 클라이언트 컴포넌트 내에서는 서버 컴포넌트를 부를 수 없다. 어차피 클라이언트에서 랜더링되는데 그 안에 서버 컴포넌트가 있어봤자 그 의미를 크게 살릴 수 없을 것이다.

다만 클라이언트 컴포넌트에 Props로 서버 컴포넌트를 넘겨주는 것은 된다.

const ClientComponent = ({children}) => {
return <div>{children}</div>;
}

...

const ServerComponent = () => {
return (
<ClientComponent>
<ServerComponent2 />
</ClientComponent>
);
}

const ServerComponent2 = () => {...}

이런 식으로는 넣을 수 있다.

React Server Component가 가져오는 장점이 여러가지가 있다.

첫 번째는 Zero size bundle component다. 서버 컴포넌트는 서버에서 이미 모든 랜더링을 끝나고 우리에게 전달되기 때문에, import 로 가져오는 수많은 번들은 서버에서만 쓰고 브라우저까지 다운받을 필요가 없어진다. 예전부터 문제가 되었던 큰 사이즈의 번들 문제를 해결할 수 있어진다.

두 번째는 Backend와 가까움으로써 얻는 이점이다. DB에 직접 접근한다거나, API 요청을 하는 것이 애플리케이션 서버와 같은 — 혹은 가까운 — 곳에 있어 딜레이가 줄어든다는 점이다. 요즘 네트워크 요청이 그렇게 느리지 않다고 생각할 수도 있겠지만, 브라우저에서 서버 사이의 round trip이 없어지는 것은 꽤나 의미가 있다. 뭐 상황에 따라서는 Backend, DB 자체를 직접 수정 및 그 역할을 할 수도 있겠다.

세 번째는 Code Splitting의 용이함이다. 번들러를 통해서 코드 쪼개기를 할 수도 있겠지만, 아무래도 문맥에 따라 컴포넌트의 이용 여부가 달라지기 때문에 개발자들이 React.lazy 같은 기능을 이용해 직접 표시해주곤 했다. 하지만 지금은 서버 컴포넌트, 클라이언트 컴포넌트로 나뉘었기 때문에 React는 모든 클라이언트 컴포넌트가 import 되는 쪽을 Code splitting 대상으로 여긴다. 어차피 클라이언트 컴포넌트는 서버에서 랜더링되지 않으니 따로 관리해도 괜찮을 것이다.

또한 서버 랜더링 결과에 따라 if , switch 등의 조건문을 기반으로 어떤 클라이언트 컴포넌트가 먼저 사용될지 확인할 수 있다. 따라서 어떤 컴포넌트를 먼저 다운받게 할지 같은 최적화도 나름 적용할 수 있게 된다.

RSC와 SSR 프레임워크

RSC가 SSR과 똑같은 것이 아닌가? 라고 생각할 수 있고, 나도 문서들을 충분히 찾아보기 전까지는 헷갈렸었다.

정리하자면 SSR과 RSC는 애초에 비교할 수 있는 대상이 아니고, RSC은 SSR에 큰 도움을 주는 존재라고 생각하면 된다. SSR 프레임워크 개발자들이 좀 더 이상적인 SSR을 만들기 위해 도와주는 도구가 되는 것이다.

만약 SSR 프레임워크를 사용하지 않고 그냥 React를 사용한다면 RSC는 의미가 없다. 어차피 순수 React 빌드로 앱을 만들면 무조건 클라이언트 사이드 랜더링이 되기 때문에…

다만 Next.js 등의 SSR 프레임워크를 사용한다면 다르다. 이 프레임워크들은 RSC를 이미 열심히 적용해두었다. 실제로 React 문서에서 RSC에 대한 정보를 얻기보다는 Next.js 문서에서 서버 컴포넌트에 대한 정보를 훨씬 많이 얻을 수 있다.

그럼 실제로 RSC가 SSR 프레임워크에서 어떤 식으로 그 동작을 도와주고 있을까? RFC 문서에 있는 내용을 가지고 와봤다.

서버에서는

[프레임워크 단] URL로 요청을 받으면 그에 맞추어 React에 해당 페이지(컴포넌트)의 랜더링을 요청한다.

[React 단] Root Server Component와 하위에 들어갈 Server Component들을 랜더링한다. 만약 Native Component(ex. div)나 Client Component를 만나면 JSON 데이터나 번들에 대한 참조를 집어넣고 나중에 브라우저에서 알아서 하길 기대한다.

[프레임워크 단] 프레임워크는 React가 랜더링해 만들어낸 결과물을 유저에게 Streaming해야 한다. React가 랜더링한 것은 HTML이 아니라, UI에 대한 설명을 담은 데이터다. 이 데이터를 스트리밍하는 동시에, 프레임워크는 이 데이터를 기반으로 유저에게 보내주기 위한 초기 HTML을 구성할 수도 있다. Next.js의 경우에는 이때 Client Component도 (기존 Next.js SSR에서 늘 하던 것처럼) pre-render해서 HTML에 포함하고 차후 Hydration되도록 한다.

브라우저에서는

[프레임워크 단] Streaming된 데이터를 받아 React에게 서버가 만들어낸 결과물을 만들어달라고 요청한다.

[React 단] 받아온 결과 값을 해석하여 화면을 만든다. 이는 Progressively하게 진행되기 때문에 전체 스트리밍이 완료될 때까지 기다릴 필요는 없다. React가 이전에 만들었던 Suspense 를 이용한다면 이 과정 중에서 Client Component의 랜더링, 그리고 Server Component가 남은 스트리밍을 다 가져올 때까지 로딩 상태를 보여줄 수 있다.

(이 부분은 헷갈리는 것이 Suspense 를 React에서 처리 중에 알아서 자연스럽게 넣어준다는건지 개발자가 직접 Suspense 를 필요한 부분에 fallback 과 함께 넣어준다면 그 효과를 볼 수 있다는 뜻인건지 헷갈린다. 어지간 하면 후자일 것 같긴 하다)

이런 과정을 거쳐서 최종 UI 결과물이 만들어지게 된다. 이런건 SSR 프레임워크 개발자들이 처리해두었을 부분이라 우리가 직접 확인해야 할 일까지는 없겠지만, 대강이나마 알아두면 도움이 되지 않을까 싶다.

RSC는 Next.js가 가지고 있던 특징에도 큰 영향을 끼쳤다. 원래 Next.js스러운 메서드였던 getStaticProps 같은 함수들이 사라졌다. 그리고 기존 Next.js에서는 가장 상위 페이지에서 Data fetching을 한 후 하위 컴포넌트로 내려줘야 하는 방식이었는데, RSC를 사용함으로써 각 서버 컴포넌트에서 이를 처리할 수 있게 되었다.

결론

이번에 쭉 공부해본 결과, 일반적인 개발자 입장에서는 굳이 자세히 알 필요 없다는 생각이 들었다. 어차피 요즘 권장되는 Next.js 등의 프레임워크에서 이 RSC를 이용해서 좋은 API를 만들어두었기 때문에 그냥 간단한 컨셉만 알고 따라가도 괜찮지 않나 라는 생각이 든다.

그래도 웹 사용성을 더 좋게 만들기 위한 도전을 알아본 것은 굉장히 흥미로웠다. 앞으로는 또 어떻게 발전할지 조금은 두려워진다.

아직 우리 회사에서는 Next.js를 쓸 일이 거의 없었다. 그냥 React로만 애플리케이션을 만들고 있는데, 차후에는 이런 것에 대한 고민도 많이 하면서 개발해보는 기회가 오면 좋을 것 같다.

그리고 Suspense도 잘 써볼 일이 없었는데, 오늘 친구랑 얘기하다 보니까 그냥 CSR에서도 네트워킹 처리와 관련해서는 Suspense를 잘 활용할 수 있는 방법이 있었다. 지금까지는 그냥 react-query 가 제공하는 isLoading을 사용했는데, suspense 옵션을 이용하면 좀 더 fallback을 일관적으로 만들 수 있을지도 모르겠다.

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science