새로운 어플리케이션을 개발할때, 렌더링을 언제 어디서 어떻게 할지 결정하는 것은 매우 중요합니다. 웹 서버, 빌드 서버, 클라이언트의 환경에서 렌더링을 할 것인지 그리고 렌더링을 한 번에 할 것인지, 부분적으로 할 것인지, 점진적으로 할 것인지 결정해야합니다.
이러한 결정은 매우 중요합니다. 적절한 렌더링 패턴을 고르는 것은 DX(Developer Experience), UX(User Experience) 측면 모두에게 중요합니다.
최근에는 렌더링 패턴중 CSR(Client Side Rendering), SSR(Server Side Rendering)이 주로 이야기되고 있으며 이 두 렌더링 패턴은 각각 장단점을 가지고 있으며 같은 웹페이지에서 특정 패턴이 유익할 수도 해로울 수도 있습니다.
Static Rendering
CSR, SSR과 같은 렌더링 패턴들을 소개하기 앞서 Static Rendering을 소개합니다.
Static Rendering은 즉각적인 페이지 로드를 통해 빠른 웹사이트를 만들 수 있는 간단하지만 강력한 패턴입니다. Static Rendering을 통해 빌드할 때 페이지 전체를 위한 HTML을 생성합니다. 그리고 이는 다음 빌드까지 변화하지 않습니다. 생성한 HTML 콘텐츠는 정적이여서 CDN이나 Edge network에서 쉽게 캐싱할 수 있습니다.
Static Rendering 방식은 잘 변화하지 않는 페이지에 적합합니다. 오늘날 웹에서는 동적으로 데이터를 제공해야 하기 때문에 다양한 방식을 사용하고 있습니다.
Basic/Plain Static Rendering
Baisc/Plain Static Rendering은 동적 콘텐츠가 거의 없는 페이지에 사용합니다. 이러한 방식은 서버에 렌더링된 HTML을 사용할 수 있기때문에 TTFB(Time to First Byte)가 매우 빠릅니다. 브라우저에서는 빠르게 응답을 받고 렌더링할 수 있으므로 FCP(First Contentful Paint), LCP(Largest Contentful Paint)를 빠르게 처리합니다. 정적인 콘텐츠를 렌더링하는 동안 레이아웃이 변하지 않습니다.
하지만 최근의 거의 모든 웹사이트들은 사용자와의 상호작용을 위해 동적인 콘텐츠들을 필요로합니다.
Static Rendering with Client-Side fetch
Basic/Plain Static Rendering을 사용하면서 동적 데이터를 가진 콘텐츠를 보여주기 위해서는 Static Rendering with Client-Side fetch 패턴을 사용해야 합니다.
Skeleton component를 사용하면 Static Rendering을 유지하면서 동적 데이터를 가져올 수 있습니다. 페이지가 로드된 후에 SWR과 같은 Fetching 도구를 사용하여 클라이언트에 동적 데이터를 포함한 콘텐츠를 보여줍니다. Custom API route는 CMS로 부터 데이터를 가져오고 반환하는데 사용됩니다.
사용자가 페이지를 요청했을 때, 이미 생성되어있던 HTML파일이 보내지고 사용자는 데이터를 가지지 않은 Skeleton UI를 먼저 봅니다. 클라이언트가 API route를 통해 데이터를 요청하고 응답을 받았을 때 데이터를 볼 수 있습니다.
이러한 방법은 TTFB와 FCP에 유리하지만 LCP에는 조금 떨어집니다. 그리고 Skeleton UI와 데이터 콘텐츠가 차이가 클 수록 레이아웃의 변화가 있을 수 있습니다.
Basic/Plain Static Rendering에 비해 Static Rendering with Client-Side fetch 방식은 동적 데이터를 위해 API route를 한번 더 호출하기 때문에 더 높은 서버 비용을 초래합니다.
Next.js
Next.js는 동적 데이터로 작업할때의 성능을 위해 몇가지 해결책을 제공합니다.
- getStaticProps
- Incremental Static Regeneration
getStaticProps
첫 번째로 서버에서 빌드할 때 데이터를 서버로부터 받아올 수 있는 getStaticProps가 있습니다. getStaticProps 는 서버로 받아온 데이터를 포함한 HTML을 만들어 클라이언트에서 데이터를 요청하지 않도록 합니다. 따라서 Skeleton component가 데이터 요청을 기다리지 않고 클라이언트에서 렌더링됩니다.
getStaticProps는 Basic/Plain Static Rendering과 네트워크, 메인 스레드 작업을 동일하게 가져가므로 우수한 성능을 보입니다.
하지만 페이지가 커질수록 DX측면에서 getStaticProps를 사용하는 것은 좋지 않습니다. 블로그와 같이 수백 개의 페이지가 정적으로 구축된 사이트의 경우 getStaticProps를 지속적으로 호출하는 것은 빌드시간을 더 길게합니다. 또한 외부 API를 사용하는 경우 사용량이 많이늘 수 있습니다.
따라서 이 방법은 빌드 시 드물게 데이터를 갱신하는 경우에 적합합니다. 데이터를 자주 업데이트해야하면 사이트를 다시 빌드하고 재배포해야 합니다.
getStaticProps 예시
https://nextjs.org/docs/basic-features/data-fetching/get-static-props
// posts will be populated at build time by getStaticProps()
function Blog({ posts }) {
return (
<ul>
{posts.map((post) => (
<li>{post.title}</li>
))}
</ul>
)
}
// This function gets called at build time on server-side.
// It won't be called on client-side, so you can even do
// direct database queries.
export async function getStaticProps() {
// Call an external API endpoint to get posts.
// You can use any data fetching library
const res = await fetch('https://.../posts')
const posts = await res.json()
// By returning { props: { posts } }, the Blog component
// will receive `posts` as a prop at build time
return {
props: {
posts,
},
}
}
export default Blog
Incremental Static Regeneration
앞서 getStaticProps가 새로운 데이터를 위해 잦은 배포가 필요한 점을 해결하기 위해 ISR(Incremental Static Regeneration)을 사용할 수 있습니다. ISR은 페이지의 정적인 부분만 미리 렌더링한 후, 사용자가 필요로하는 동적 데이터를 렌더링할 수 있습니다. ISR은 빌드시간 더 빠르게하고 특정 주기마다 재생성하는 페이지와 캐시를 자동으로 무효화할 수 있게합니다.
Next.js에서 ISR을 사용할 수 있도록 getStaticPaths를 제공합니다. getStaticPaths는 동적 경로(dynamic path)를 생성합니다.
사용자가 아직 서버에서 생성되지 않은 페이지를 요청하면 필요에 따라 생성되고 캐싱됩니다. 따라서 첫 번째 사용자만 사전 렌더링되지 않은 페이지에 대해 좋지 않은 경험을 하게 됩니다. 그 후 다른 사용자는 빠르고 캐싱된 응답을 받을 수 있습니다. getStaticPaths를 사용하는 것은 긴 빌드 시간 문제를 해결하지만 여전히 새 목록이 있을 때마다 다시 배포해야 하는 랜딩 페이지가 있습니다.
랜딩 페이지를 새로고침하기 위해서 반환하는 객체에 revalidate 속성을 추가하여 일정한 주기로 캐시 무효화를 하고 새 페이지를 만드는 방법이 있습니다. 하지만 콘텐츠는 revalidate 속성을 통해 정의한 시간만큼 자주 업데이트 되지 않을 수 있습니다. 이로 인해 불필요한 페이지 재생성 및 캐시 무효화가 발생합니다. 이런 일이 발생할 때마다 서버리스 기능을 호출하므로 서버 비용이 높아질 수 있습니다.
getStaticPaths 예시
https://nextjs.org/docs/basic-features/data-fetching/get-static-paths
https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration
// pages/posts/[id].js
// Generates `/posts/1` and `/posts/2`
export async function getStaticPaths() {
return {
paths: [{ params: { id: '1' } }, { params: { id: '2' } }],
fallback: false, // can also be true or 'blocking'
}
}
// `getStaticPaths` requires using `getStaticProps`
export async function getStaticProps(context) {
return {
// Passed to the page component as props
props: { post: {} },
// Automatically invalidate the cache and regenerate the page in the background at a specific interval.
revalidate: 60
}
}
export default function Post({ post }) {
// Render post...
}
- getStaticPaths 도 빌드할때의 경로들만 사용 가능합니다.
- getStaticPaths 는 getStaticProps 와 함께 사용해야합니다.
On-demand Incremental Static Regeneration
앞서 ISR을 사용할때 불필요한 재생성 및 캐시무효화가 발생하는 것을 해결하기 위해 On-demand Incremental Static Regeneration을 사용합니다. 이것은 재생성이 주기마다가 아니라 특정 이벤트에 의해 발생하도록 합니다. revalidate속성을 사용하는 대신 API routes를 통해 새 데이터를 받아옵니다.
export default async function handler(req, res) {
const { data } = JSON.parse(req.body)
if (data.event === "listening:added") {
await res.unstable_revalidate('/')
}
return res.status(2000).json({ revalidated: true })
}
On-demand ISR은 Edge 네트워크에서 페이지를 다시 생성하고 재배포하여 전 세계 사용자가 오래된 콘텐츠를 보지 않고 Edge 캐시에서 페이지의 최신 버전을 자동으로 볼 수 있도록 합니다. 또한 불필요한 재생성 및 서버리스 함수 호출을 방지하여 일반 ISR에 비해 운영 비용을 절감합니다.
Server-Side Rendering
SSR을 사용하면 모든 요청에 대해 HTML을 생성합니다. 이 방법은 사용자의 쿠키 기반 데이터 혹은 사용자 요청에서 얻은 데이터를 포함한 페이지에 적합합니다. 또한 인증 상태에 따라 렌더링을 차단해야 하는 페이지에도 적합합니다.
Next.js를 사용하면 getServerSideProps 메서드를 사용하여 서버에서 페이지를 렌더링할 수 있습니다. 이 메서드는 모든 요청을 서버에서 실행하며 응답값을 가지고 HTML을 생성합니다.
사용자가 페이지를 요청하면 getServerSideProps 메서드가 실행되고 페이지를 생성하는데 사옹한 데이터를 반환하고 클라이언트에 응답을 보냅니다. 그런 다음 클라이언트는 이 HTML을 렌더링하고 Element를 hydrates 하기위한 JS bundle을 가져오기 위해 다른 요청을 보낼 수 있습니다.
생성된 HTML는 모든 요청에 대해 고유하며 CDN에 캐시하면 안됩니다.
클라이언트의 네트워크 및 기본 쓰레드는 Static Rendering과 SSR이 유사합니다. FCP와 LCP는 거의 동일하며 초기 페이지 로드 후 동적 콘텐츠 로드가 없기 때문에 레이아웃 변경을 피할 수 있습니다. 하지만 SSR의 경우 TTFB는 모든 요청에서 페이지가 처음부터 생성되기 때문에 Static Rendering보다 훨씬 더 깁니다.
개인화된 데이터를 사용하는데는 SSR이 좋은 방법이지만, UX와 서버비용을 줄이기 위해서 고려해야할 것들이 있습니다. 이것들은 매 요청마다 Serverless function을 호출하기 때문에 높은 확률로 발생합니다.
1. Excution time of getServerSideProps
페이지는 getServerSideProps가 종료된 후에 생성되므로 너무 길게 실행되지 않도록 합니다.
2. Deploy databases in the same region as your serverless function
데이터베이스가 쿼리하는데 걸리는 시간을 줄여야합니다. 또한 데이터베이스의 region도 고려해야 합니다.
3. 응답에 Cache-control 헤더 추가
getServerSideProps 내부에서 Cache-Control을 사용하여 다양한 응답에 대해 캐싱할 수 있습니다.
// This value is considered fresh for ten seconds (s-maxage=10).
// If a request is repeated within the next 10 seconds, the previously
// cached value will still be fresh. If the request is repeated before 59 seconds,
// the cached value will be stale but still render (stale-while-revalidate=59).
//
// In the background, a revalidation request will be made to populate the cache
// with a fresh value. If you refresh the page, you will see the new value.
export async function getServerSideProps({ req, res }) {
res.setHeader(
'Cache-Control',
'public, s-maxage=10, stale-while-revalidate=59'
)
return {
props: {},
}
}
4. Upgrade server hardware
서버 하드웨어를 업데이트하는 것도 빠른 응답을 도와줍니다.
Client-Side Rendering
CSR을 사용한다면 페이지를 렌더링하기 위해 서버에서는 barebones HTML만 내려준다. 페이지에서 콘텐츠를 보여주기 위해 필요한 로직, 데이터 페칭, 라우팅은 모두 클라이언트(ex. 브라우저)에서 실행하는 JS 코드로 이루어진다. CSR은 Single-page applications(SPA)를 만드는 가장 유명한 방법이다. CSR을 통해 우리는 설치된 어플리케이션과 웹사이트의 경계를 흐리게할 수 있었다.
CSR - Basic structure
React를 사용하여 현재시각을 업데이트하여 보여주는 간단한 예제입니다.
[ HTML ]
<div id="root"></div>
[ JS ]
function tick() {
const element = (
<div>
<h1>Hello, world!</h1>
<h2>It is {new Date().toLocaleTimeString()}.</h2>
</div>
);
ReactDOM.render(element,document.getElementById('root'));
}
setInterval(tick, 1000);
HTML은 root 라는 id를 가진 div 태그로만 이루어져 있습니다. 콘텐츠가 보여지거나 업데이트되는 것은 완전히 JS에 의해 좌우됩니다. 서버로부터 주고받는 렌더링된 HTML이 없으며 HTML은 내부에서 업데이트됩니다. 여기서 시간은 환율 혹은 주기와 같은 다른 실시간 데이터로 대체될 수 있습니다. 우리는 이를 페이지를 새로 고치거나 서버를 왕복하지 않고도 표시할 수 있습니다.
Javascript bundles and Performance
페이지의 복잡성이 증가할 수록 페이지를 렌더링하는데 필요한 JS 코드의 복잡성과 크기도 증가합니다. CSR은 큰 JS bundle을 가지고 있어 FCP, TTI(Time to Interactive)를 증가시킵니다.
Client-side React - Pros and Cons
React를 사용하는 어플리케이션의 대부분은 클라이언트에서 API를 통해 서버와 상호작용하여 데이터를 가져오고 저장합니다. 따라서 거의 모든 UI가 클라이언트에서 생성됩니다. 전체 웹 어플리케이션이 첫 요청에 의해 로드되고 페이지를 렌더링 하기 위해 서버에 요청을 하지 않습니다. 클라이언트에서 실행되는 코드가 화면과 데이터를 변화시킬뿐입니다.
Pros
CSR은 페이지 새로고침 없이 페이지 이동과 같은 Navigation을 지원하여 훌륭한 UX를 가집니다. 페이지내 제한적인 데이터로 화면을 바꾸기 때문에 페이지 라우팅이 일반적으로 빨라져 CSR 어플리케이션의 응답성이 더 좋다고 느껴집니다. 또한 CSR을 통해 개발자는 클라이언트와 서버 코드를 명확하게 구분할 수 있습니다.
Cons
CSR의 이러한 장점에도 불구하고 몇가지 단점이 있습니다.
- SEO considerations
- Performance
- Code Maintainability: API로 인해 Server코드와 Client 코드가 다른 언어로 작성됨에따라 데이터 필드의 validation이나 formatting을 위한 로직이 따로 필요해졌습니다.
- Data Fetching: 이벤트 발생 시 가져온 데이터의 크기에 따라 애플리케이션의 로드, 상호작용 시간이 추가될 수 있습니다.
Improving CSR performance
CSR의 성능은 결국 JS bundle 크기와 반비례합니다. 따라서 최선의 방법은 최적의 성능을 위해 JS 코드를 구성하는 것입니다.
- Budgeting Javasript: gzip으로 압축된 100-170KB미만의 minify된 번들을 사용하는 것은 좋은 시작점입니다.
- Preloading: 중요한 리소스를 미리 로드하는 데 사용할 수 있습니다. HTML의 <head> 에 미리 로드 할 수 있는 JS를 포함시킬 수 있습니다.
- Lazy loading: 중요하지 않은 리소스를 식별하고 필요할 때만 로드할 수 있게합니다.
- Code Splitting: 큰 번들 크기를 줄이기 위해 분할합니다.
- Application shell caching with service workers: 어플리케이션 Shell을 이용해 최소한의 HTML, CSS, JS를 캐싱합니다. Service worker는 어플리케이션 Shell을 오프라인으로 캐시할 수 있게합니다. 이는 나머지 콘텐츠가 필요에 따라 점진적으로 로드되는 SPA를 제공할 수 있게합니다.
Conclusion
CSR로 진행하던 프로젝트에서 기존 SEO를 지원하던 다른 서비스를 포함해야하는 경우가 생겨 SSR로의 검토를 하던 중 이론적 학습을 위해 patterns.dev 페이지를 읽으며 번역한 글입니다. Static Rendering 부터 SSR, CSR까지 다양한 Rendering Pattern들이 있고 각 패턴들은 컨텐츠를 언제 어디에서 어떻게 렌더링하는가에 따라 장단점을 가지고 있습니다.
아직까지 React-Helmet을 통해 커버할 수 있는 정도의 SEO를 지원하는 경우라면 CSR을 사용하여 어플리케이션을 만드는게 사용자와 동적 데이터로 상호작용하는 웹페이지들에서 유리합니다. 하지만 데이터가 정적이라면 사용자에게 빠르게 첫 화면을 보여줄 수 있도록 Next.js 를 사용해 Static Rendering, ISR, SSR을 고려할만합니다.
참고문서
'FrontEnd' 카테고리의 다른 글
의식의 흐름에 따라 보는 <img> preload (0) | 2023.09.14 |
---|---|
Pagination 알아보기 (0) | 2023.04.02 |
Deno Fresh 알아보기 (0) | 2022.11.20 |
자바스크립트 번들러 비교 (0) | 2020.07.18 |
Webpack Core Concepts (0) | 2020.02.18 |