SSR(Server Side Rendering)의 한계
서버 사이드 렌더링(SSR)은 초기 로딩 성능과 SEO 측면에서 큰 장점을 제공하지만, 몇 가지 중요한 한계점을 가지고 있다. 이번 글에서는 전통적인 SSR의 문제점들을 살펴보고, React 18에서 이를 해결하기 위해 도입된 Suspense에 대해 알아보자.
1. 데이터 페칭의 블로킹 문제
SSR의 가장 큰 문제점 중 하나는 서버에서 모든 데이터를 한번에 가져와야 한다는 것이다. 컴포넌트 트리를 HTML로 렌더링하기 전에, 해당 페이지에 필요한 모든 데이터가 준비되어 있어야 한다.
예를 들어, 다음과 같은 컴포넌트로 구성되어 있는 블로그 포스트 페이지를 생각해보자.
- 네비게이션 바
- 사이드바
- 게시글 내용
- 댓글 섹션
만약 댓글 데이터를 가져오는데 시간이 오래 걸린다면, 개발자는 다음과 같은 선택을 해야 한다.
- 댓글을 서버 렌더링에서 제외하기
- 장점: 다른 컴포넌트들을 빠르게 보여줄 수 있음
- 단점: 사용자는 JavaScript가 로드될 때까지 댓글을 볼 수 없음
- 댓글을 포함해서 서버 렌더링하기
- 장점: 댓글이 초기 HTML에 포함됨
- 단점: 전체 페이지의 렌더링이 댓글 데이터를 기다리느라 지연됨
2. 하이드레이션 문제
SSR의 두 번째 큰 문제는 하이드레이션(hydration) 과정과 관련이 있다. React는 서버에서 생성된 HTML과 클라이언트의 컴포넌트 트리를 매칭하기 위해, 모든 컴포넌트의 JavaScript 코드가 로드될 때까지 기다려야 한다.
또한, React의 하이드레이션 과정이 가진 문제가 있다. 현재 React는 컴포넌트 트리 전체를 한번에 하이드레이션하고, 이는 다음과 같은 문제를 야기한다.
- 전체 트리가 하이드레이션될 때까지 어떤 컴포넌트와도 상호작용할 수 없음
- 특히 저사양 기기에서 성능 문제 발생
- 무거운 JavaScript 로직이 있는 경우 화면 멈춤 현상
- 사용자가 다른 페이지로 이동하고 싶어도 하이드레이션 완료를 기다려야 함
3. 폭포수(Waterfall) 프로세스의 한계
이러한 문제들의 근본 원인은 SSR이 다음과 같은 순차적인 "폭포수" 프로세스를 따르기 때문이다.
- 데이터 페칭 (서버)
- HTML 렌더링 (서버)
- JavaScript 코드 로딩 (클라이언트)
- 하이드레이션 (클라이언트)
각 단계는 이전 단계가 완전히 완료되어야만 시작될 수 있어, 전체적인 성능과 사용자 경험이 저하된다.
새로운 해결책 Suspense
이러한 문제들을 해결하기 위해 React 18에서는 Suspense를 도입했다. Suspense를 사용하면 앱의 다른 부분들이 개별적으로 로드되고 하이드레이션될 수 있어, 위에서 언급한 문제들을 효과적으로 해결할 수 있다.
사용법
사용법은 간단하다. 데이터를 전부 불러오기를 기다리고 싶지 않은 컴포넌트를 <Suspense>
로 감싸고, fallback
props에 로딩시 보여줄 컴포넌트를 넣어주면 된다.
function BlogPost() {
return (
<div>
<Nav />
<Sidebar />
<Article />
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</div>
);
}
초기 HTML은 다음과 같이 그려진다.
<main>
<!-- 생략... -->
<section id="comments-spinner">
<!-- Spinner -->
<img width=400 src="spinner.gif" alt="Loading..." />
</section>
</main>
그 후에, 서버에서 코멘트 컴포넌트를 위한 데이터가 준비되면 아래와 같은 과정을 거친다.
<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은 생성했지만, 여전히 자바스크립트를 전부 불러와야 하이드레이션을 시작할 수 있다. 이러한 경우 lazy
를 통한 code spliting으로 하이드레이션 과정도 분리할 수 있다.
const Comments = lazy(() => import('./Comments'));
function BlogPost() {
return (
<div>
<Nav />
<Article />
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</div>
);
}
코멘트 섹션을 제외한 나머지 자바스크립트 로드가 끝난 부분부터 하이드레이션을 진행한다. 후에, 코멘트 컴포넌트의 자바스크립트 까지 전부 로드 되면 코멘트도 하이드레이션이 진행된다.
지금 까지의 과정을 한번에 보면 다음과 같다.
Suspense를 발동 시킬 수 있는 조건
모든 종류의 데이터 로딩이 Suspense를 발동시키는 것은 아니다. 그렇다면 어떤 데이터 소스가 Suspense와 호환될까?
use
Hook을 사용한 데이터 페칭
import { use } from 'react';
// 데이터를 가져오는 함수
function fetchUserData(userId) {
return fetch(`/api/users/${userId}`)
.then(res => res.json());
}
function UserProfile({ userId }) {
const user = use(fetchUserData(userId));
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}
// Suspense로 래핑
function App() {
return (
<Suspense fallback={<Loading />}>
<UserProfile userId={1} />
</Suspense>
);
}
- Lazy 컴포넌트
import { lazy, Suspense } from 'react';
// 동적으로 로드되는 컴포넌트
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<div>
<h1>My App</h1>
<Suspense fallback={<Loading />}>
<HeavyComponent />
</Suspense>
</div>
);
}
- 서버 컴포넌트의 async
// 서버 컴포넌트
async function BlogPost({ id }) {
const post = await fetchBlogPost(id);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
function BlogPage({ id }) {
return (
<Suspense fallback={<LoadingPost />}>
<BlogPost id={id} />
</Suspense>
);
}
useDeferredValue
사용
function SearchResults({ query }) {
// 검색어 변경을 지연시켜 처리
const deferredQuery = useDeferredValue(query);
return (
<Suspense fallback={<Loading />}>
<SearchResultsList query={deferredQuery} />
</Suspense>
);
}
Suspense가 발동되지 않는 경우
반면에, 다음과 같은 일반적인 데이터 페칭 패턴들은 Suspense를 발동시키지 않는다.
useEffect
내부에서의 데이터 패칭
// ❌ Suspense를 발동시키지 않음
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUserData(userId).then(setUser);
}, [userId]);
if (!user) return <Loading />;
return <h1>{user.name}</h1>;
}
- 일반적인 Promise 처리
// ❌ Suspense를 발동시키지 않음
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
Promise.resolve(fetchUserData(userId))
.then(setUser)
.catch(setError);
if (error) return <Error error={error} />;
if (!user) return <Loading />;
return <h1>{user.name}</h1>;
}
Tanstack Query를 사용할 경우
기존에 사용하던 useQuery
가 아닌 useSuspenseQuery
를 사용해야 한다.
참조