streaming SSR, selective hydration 개념정리!
참고 :
hydration 정리
partial hydration, islands architecture 정리
한 마디로 한 번에 다 완성해서 보내는게 아니라 준비된 것부터 조금씩 흘려보내는 것.
기존 SSR은 서버가 페이지 html을 전부 다 만든 다음 한번에 응답함.
그래서 느린 데이터가 있으면 나머지 빠른 부분도 같이 기다려야 함
반면 Streaming SSR은 먼저 만들 수 있는 html부터 브라우저에 청크(chunk) 단위로 보내서 첫 화면이 더 빨리 보이고, 느린 데이터 때문에 페이지 전체가 막히는 문제가 없고, 사용자 체감 속도가 좋아진다는 장점이 있음.
이때 궁금한 점 :
헤더
본문
푸터
이렇게 페이지가 구성되어있을 때 헤더,푸터,본문 순으로 로드된다면
헤더
푸터
헤더
본문
푸터
이렇게 화면이 보이게 되는 것인가?
...보통은, 헤더,본문,푸터가 로드되는 순서대로 뒤죽박죽 튀어나온다기보다는 fallback을 많이 사용한다고 한다.
그래서
헤더
본문 로딩중…
푸터
헤더
본문
푸터
이렇게 보이게 해놓음.
또는 chunk 단위를 헤더+본문+푸터 이렇게 통으로 묶어놔서, 본문도 모두 로드가 되어야 모든 내용이 노출되도록 할 수도 있다.
Chunk 기준은 프레임워크에 따라 다른데 크게 suspense 경계, 라우트/세그먼트 단위, 프레임워크 내부 flush 타이밍에 따라 정해짐.
예를 들면 헤더/본문/푸터를 각각 청크로 묶어놨다면 이런 식 :
<>
<Header />
<Suspense fallback={<TitleSkeleton />}>
<Title />
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<Content />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
<Footer />
</>
혹은 헤더+본문+푸터를 통으로 청크로 묶어놨다면 이런 식이 됨 :
<>
<Header />
<Suspense fallback={<MainSkeleton />}>
<Title />
<Content />
<Comments />
</Suspense>
<Footer />
</>
라우트/세그먼트의 경우 Next.js의 loading.tsx, layout.tsx, page.tsx 구조가 큰 역할을 한다.
loading.tsx는 세그먼트가 스트리밍되는 동안 먼저 보여지는 요소임.
예를들어 Next App Router 구조가 이렇게 생겼다고 하자.
app/
layout.tsx
page.tsx
blog/
loading.tsx
page.tsx
App/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<header>공통 헤더</header>
{children}
<footer>공통 푸터</footer>
</body>
</html>
);
}
app/page.tsx
import Link from 'next/link';
export default function HomePage() {
return (
<main>
<h1>홈</h1>
<Link href="/blog">블로그로 이동</Link>
</main>
);
}
app/blog/loading.tsx
export default function Loading() {
return (
<main>
<h1>블로그</h1>
<div>글 목록 불러오는 중...</div>
</main>
);
}
app/blog/page.tsx
async function getPosts() {
await new Promise(function (resolve) {
setTimeout(resolve, 3000);
});
return ['글 A', '글 B', '글 C'];
}
export default async function BlogPage() {
const posts = await getPosts();
return (
<main>
<h1>블로그</h1>
<ul>
{posts.map(function (post) {
return <li key={post}>{post}</li>;
})}
</ul>
</main>
);
}
이 경우, /blog로 이동하면 사용자는 이런 페이지를 보게됨
처음 :
공통 헤더
loading.tsx
공통 푸터
나중에 :
공통 헤더
실제 blog/page.tsx
공통 푸터
예시에서는 loading.tsx 하나만 있지만, 세그먼트 단위별로 loading.tsx를 만들 수도 있다.
Flush 타이밍은 react 서버 api에서 onShellReady에서 바로 pipe() 하느냐, onAllReady까지 기다렸다가 pipe()하느냐가 대표적임.
헤더, 느린 본문, 푸터로 구성되어있는 페이지를 예시로 보자.
server.js
import express from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import App from './App.js';
const app = express();
app.get('/', function (req, res) {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady: function () {
res.setHeader('content-type', 'text/html');
pipe(res);
},
});
});
app.listen(3000);
App.js
import { Suspense } from 'react';
function Header() {
return <header>헤더</header>;
}
async function SlowContent() {
await new Promise(function (resolve) {
setTimeout(resolve, 3000);
});
return <div>느린 본문</div>;
}
function Footer() {
return <footer>푸터</footer>;
}
export default function App() {
return (
<>
<Header />
<Suspense fallback={<div>본문 로딩중...</div>}>
<SlowContent />
</Suspense>
<Footer />
</>
);
}
onShellReady을 쓴 이 경우엔 사용자는 이런 화면을 보게됨
처음 :
헤더
본문 로딩 중…
푸터
3초 뒤 :
헤더
느린 본문
푸터
반대로 server.js를 아래와 같이 (onAllReady) 바꾼다면 전부 기다렸다가 한 번에 보내지게 된다.
import express from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import App from './App.js';
const app = express();
app.get('/', function (req, res) {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onAllReady: function () {
res.setHeader('content-type', 'text/html');
pipe(res);
},
});
});
app.listen(3000);
즉 이렇게 한 경우, 사용자 입장에서는 3초 쯤 빈화면을 보다가 헤더, 느린 본문, 푸터를 한 번에 보게 됨.
여기서 생기는 의문은 onAllReady를 streaming SSR로 볼수있는가다.
기술적으로 stream API를 쓰지만, streaming SSR이라 하면 fallback 먼저 보여주다가 데이터가 준비되면 추가로 채워진다는 개념에서는 벗어나기 때문이다.
Is onAllReady streaming SSR ? 로 검색해봤는데 별다른 소득이 없었다…
나만 궁금했나봄 😅
Streaming SSR과 hydration을 같이 쓸 때 주의해야할 점이 있을까?
페이지에 이런 요소들이 있다고 해보자 :
헤더
상품설명
좋아요버튼
댓글
그리고 댓글을 불러오는데는 오래 걸리고, 좋아요 버튼은 js가 붙어야한다고 해보자.
export default function Page() {
return (
<>
<Header />
<ProductDescription /> //상품설명
<LikeButton /> //조아요버튼
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> //댓~~글
</Suspense>
</>
);
}
사용자가 접하는 초기 페이지는 이렇다 :
헤더
상품 설명
좋아요버튼 -> 아직 클릭 불가
댓글 fallback
잠시 후 좋아요버튼 hydration 완료 :
헤더
상품 설명
좋아요버튼 -> 동작 가능
댓글 fallback
댓글 준비됨 :
헤더
상품 설명
좋아요 버튼
댓글
여기서 주의할 점은, hydration이 붙기 전이라면 좋아요버튼이 보이긴 하지만 상태변경등 원하는 동작은 안된다는거다.
function LikeButton() {
const [liked, setLiked] = useState(false);
async function handleClick() {
await sendLikeApi();
setLiked(true);
}
return (
<button onClick={handleClick}>
{liked ? '좋아요 취소' : '좋아요'}
</button>
);
}
이렇게 생겼을 경우 api 요청도 안하고, ‘좋아요 취소’ 또는 ‘좋아요’로 워딩 변경도 안된다는 것.
이 경우을 대비해 hydration이 붙기전엔 disabled 처리를 한다든지, hydration 전이어도 작동되도록 form action을 붙여두는 방법을 쓴다고 한다.
이 문제는 SSR에 hydration을 붙일 수 있는 경우면 언제든 생길 수 있지만 streaming SSR일 경우 더 잘 드러나는 문제다.
Streaming SSR의 경우 페이지 일부를 더 빨리 보여주기때문에 ‘보이는데 아직 안눌리는 시간’이 체감되는 거임
이걸 보완하기 위해 selective hydration이라는게 있다
페이지 전체를 한 번에 hydrate하는게 아니라 여러 덩어리로 나눠서 필요한 부분부터 hydrate하는 방식.
직접 hydrate 하는 순서를 정해주는 것은 아니고, Suspense로 경계를 나눠주는 식이다.
import { Suspense } from 'react';
async function SearchBox() {
return <input placeholder="검색어 입력" />;
}
async function Comments() {
await new Promise(function (resolve) {
setTimeout(resolve, 3000);
});
return <section>댓글 목록</section>;
}
async function LikeButton() {
return <button>좋아요</button>;
}
export default function Page() {
return (
<main>
<h1>상품 상세</h1>
<Suspense fallback={<div>검색창 준비중...</div>}>
<SearchBox />
</Suspense>
<p>상품 설명</p>
<Suspense fallback={<div>좋아요 버튼 준비중...</div>}>
<LikeButton />
</Suspense>
<Suspense fallback={<div>댓글 불러오는 중...</div>}>
<Comments />
</Suspense>
</main>
);
}
위와 같은 경우, 검색창, 좋아요, 댓글을 우선적으로 hydration하게 된다.
그렇다면… selective hydration과 partial hydration의 차이가 뭐지?!🙃
이 둘의 결정적 차이점은 hydrate를 하는 범위이다.
Selective hydration은 전체를 hydrate하되 순서를 나누는 것,
Partial hydration은 아예 일부만 hydrate를 한다.
예를들어 식탁에 회, 볶음밥, 푸딩이 있다고 했을 때
Selective hydration : 손님 해산물 알러지 있으시죠? 먼저 볶음밥 숟가락과 푸딩 스푼을 준비해드리고, 나중에 젓가락을 놓아드릴게요.
Partial hydration : 손님 해산물 알러지 있으시죠? 볶음밥 숟가락이랑 푸딩 스푼만 드릴게요.
이런 느낌인 것이다~
전체적으로 정리하자면
회, 볶음밥, 푸딩이 오늘의 메뉴라고 할 때
SSR : 회, 볶음밥, 푸딩을 모두 만들면 식탁에 놓을게요.
Streaming SSR : 다 만든 순으로 내놓을거예요. 볶음밥 먼저 내왔습니다. 푸딩, 회는 기다려주세요.
Selective hydration : 해산물 알러지가 있으시니 볶음밥, 푸딩 스푼 먼저 준비해드리겠습니다. 젓가락은 나중에 드릴게요.
Partial hydration : 해산물 알러지가 있으시니 볶음밥, 푸딩 스푼만 드릴게요.
끝