솝트 28기에 키릭스 를 개발하며 next.js+typescript를 처음 도입해 사용해봤었는데, 이번에 회사에서 온라인 코딩파티를 개발하며 SSR에서 고려해야 하는데 고려하지 못한 지점들을 새롭게 알게 되었습니다. 그래서 이번엔 SSR을 구현하는 데 있어서 next.js를 어떻게 활용해야하는지 좀 더 구체적인 방법들을 정리해보았습니다.
기본적인 next.js의 초기세팅에 관한 내용들은 next.js+typescript 초기세팅 하기 이 포스팅을 참고해주시고, 여기선 이미 기본적인 초기세팅(next 설치, eslint+prettier설정)은 되어있다는 가정 하에 추가적인 설정들을 어떻게 하면 좋은지, 왜 그렇게 해야하는지를 중심으로 정리해보았습니다.
CSR : 첫 렌더시 그냥 페이지 로드, 다시 렌더링함으로써 데이터를 불러옴. 그래서 데이터가 검색엔진에 안 걸림. 그러나 한 번에 데이터를 와랄라 불러오기 때문에 페이지 이동할 때 빠름
SSR : 첫 렌더시 데이터도 서버측에서 함께 로드. 렌더 한 번이라 초기 로딩속도 빠르고, 검색엔진에 데이터들이 걸림. 그러나 페이지 불러올 때마다 중복 데이터 불러와야 해서 페이지 이동시 느림.
일반적으로 CSR의 경우, 초기에 페이지가 일단 렌더가 된 이후, 클라이언트에서 데이터를 불러오며 다시 한 번 렌더링이 됩니다. 한편, SSR을 수행하는 경우, 처음 렌더가 될 때 서버 측에서 데이터도 함께 가져와서 그려줍니다. 그렇기 때문에 SSR의 경우, 한 번에 렌더링이 되기 때문에 초기 로딩속도가 빠르지만 페이지를 넘길 때마다 중복되는 데이터일지도 서버측에서 다시 불러와줘야하기 때문에 페이지 과부하가 걸릴 위험성이 CSR에 비해 큽니다. 그럼에도 SSR을 사용하는 이유는 페이지가 로딩될 때 데이터도 동시에 로드되기 때문에 검색엔진에 해당 데이터들이 걸리기 때문입니다. 따라서 첫 로드시 빈 상태인 CSR과 달리 검색엔진 최적화에 효과적입니다.
Next.js는 SSR을 기반으로 하지만, 페이지가 로드된 이후엔 React에서 CSR을 이용하는 방식을 차용합니다.
1. 페이지는 서버가 그립니다. pages/안에 폴더를 만들면, 해당 라우팅의 페이지들은 서버측에서 먼저 로드해줍니다.
2. 페이지가 그려진 이후에 페이지 내부에서 동적인 데이터를 패치하는 과정(axios,swr 등을 이용)은 CSR의 방식을 따릅니다. 이때의 데이터들은 일단 페이지가 로드된 이후에 클라이언트 측에서 다시 렌더되며 불러와집니다. 그렇기 때문에 SEO에 걸리지 않습니다.
그렇기 때문에 만약 페이지가 로드될 때 함께 데이터가 패칭되어야 하는 상황이라면(pre-rendering) next.js의 데이터 패칭 방식 (getInitialProps, getStaticProps, getStaticPath, getServerSideProps)을 이용해 첫 렌더에 데이터가 패칭될 수 있도록 처리를 해주어야 합니다.
이 흐름을 보았을 때, 모든 페이지에 공통적인 데이터 패칭이 필요하다면 _app.tsx에서 미리 데이터 패칭을 해주면 되고, 페이지마다 다른 데이터가 필요하다면 페이지마다 데이터 패칭을 해주면 됩니다. 그 구체적인 방법에 대해 알아보겠습니다.
Next 9.3버전 이전엔 getInitialProps만으로 데이터 패치를 전부 해결했지만, 9.3버전부터는 getInitialProps가 getStaticProps, getStaticPath (Static Generation) / getServerSideProps (Server-side Rendering) 로 분화되었습니다.
If you're using Next.js 9.3 or newer, we recommend that you use getStaticProps or getServerSideProps instead of getInitialProps.
출처: 공식문서
그러나 공식문서에 따르면,
When you add getInitialProps in your custom app, you must import App from "next/app", call App.getInitialProps(appContext) inside getInitialProps and merge the returned object into the return value.
App currently does not support Next.js Data Fetching methods like getStaticProps or getServerSideProps.
_app.js에서 전역적으로 데이터 패칭을 할 경우, getStaticProps나 getServerSideProps를 지원하지 않기 때문에 getInitialProps를 이용해야 합니다. 사실 지원하지 않는 것에는 그럴 만한 이유가 있기 때문이지만, 혹시나 사용할 수도 있으니 두 가지 방법을 모두 정리해보았습니다.
⚠️ 주의 ⚠️ : _app에서 getInitialProps를 사용해 모든 페이지에서 사용할 공통 속성값을 지정할 수 있으나, 이럴 경우 자동 정적 최적화(Automatic Static Optimization)이 비활성화되어 모든 페이지가 서버 사이드 렌더링을 통해 제공됩니다.
출처: 공식문서
Next는 기본적으로 데이터 요구 사항이 없는 경우 페이지가 정적인지 자동으로 확인하고, getInitialProps나 getServerSideProps를 사용하지 않는다면 페이지를 정적 HTML로 사전에 렌더링하여 자동으로 페이지를 최적화합니다.
자동 정적 최적화 : getInitialProps가 없으면, 페이지를 정적 HTML 으로 사전렌더링 해서 정적 최적화를한다. SSR 계산이 없기 때문에 사용자에게 즉시 뿌려지는 ultra fast 로딩이다.
그러나 전역적으로 getInitialProps를 사용하게 되면, 이러한 최적화과정이 일어나지 않습니다. 때문에 Next.js 9.3버전 이후엔 이런 것을 방지하고자 SSR과 SSG를 분리해 getStaticProps / getServerSideProps로 나눠졌으며, 전역적인 데이터 패치 기능을 지원하지 않습니다. 따라서 전역적으로 SSR의 데이터 패칭을 해야만 하는 경우라면 getInitialProps를 써야만 전역적인 패치가 가능합니다. (Next에서 추천하는 방식은 아니라는 이야기.. )
// 타입지정을 위해 import
import { NextPageContext } from 'next'
function MyApp({ Component, pageProps }) {
return (
<>
<Head>
<title>Next!</title>
</Head>
<AppLayout>
<Component {...pageProps} />
</AppLayout>
</>
);
}
MyApp.getInitialProps = async (context:NextPageContext) => {
const { ctx, Component } = context;
let pageProps = {};
if (Component.getInitialProps) {
// Component의 context로 ctx를 넣어주자
pageProps = await Component.getInitialProps(ctx);
}
// return한 값은 해당 컴포넌트의 props로 들어가게 됩니다.
return { pageProps };
};
기본적으로 getInitialProps를 비롯한 데이터 패칭 메소드들은 리턴한 값을 해당 컴포넌트의 props로 보냅니다. getinitialProps는 context를 기본 props로 받는데, 그 내부엔 ctx객체와 Component가 존재합니다. Component는 해당 컴포넌트를 의미하며, Component로 보내는 ctx 객체의 구성은 다음과 같습니다.
pathname - 현재 pathname /user?type=normal page 접속 시에는 /user
query - 현재 query를 object형태로 출력 /user?type=normal page 접속 시에는 {type: 'normal'}
asPath - 전체 path /user?type=normal page 접속 시에는 /user?type=normal
req - HTTP request object (server only)
res - HTTP response object (server only)
err - Error object if any error is encountered during the rendering
⚠️ 그러나 여기서 주의해야할 사항이 있습니다.⚠️
1. getInitialProps 내부 로직은 서버에서 실행되기 때문에 Client에서만 가능한 로직은 피해야 합니다. (Window, document 등)
2. 한 페이지를 로드할 때, 하나의 getInitialProps 로직만 실행됩니다. 예를 들어 _app.js에 getInitialProps를 달아서 사용한다면 그 하부 페이지의 getInitialProps는 실행되지 않습니다. -> 커스터마이징을 통해 따로 처리해줘야 합니다.
이런 것들을 고려해보았을 때, 이렇게 _app.tsx를 건들여서 데이터 패칭을 하는 것은 모든 페이지에서 전역적으로 가져와야 하는 데이터가 일괄적일 때 이외엔 지양하는 편이 좋다는 것을 알 수 있습니다.
사실 페이지 별로 데이터 패칭하는 것도 getInitialProps로 가능합니다. 그러나 그것이 정적데이터인지, 페이지 요청마다 렌더되는 데이터인지에 따라 그 방식을 분리한 것이 getStaticProps / getStaticPath / getServerSideProps 이며, 9.3버전 이후 Next에선 이 두 가지를 이용하는 것을 권고하고 있습니다. 따라서 getInitialProps가 아닌, 이 셋을 이용하는 방식을 정리해보았습니다.
Fetch data at build time, pre-render for Static Generation
공식문서
getStaticProps는 빌드시 고정되는 값으로 빌드 이후에는 수정이 불가능합니다.
// 타입지정을 위해 import
import { GetStaticProps } from 'next'
interface PostInterface {
userId: number
id: number
title: string
body: string
}
//getStaticProps()에서 받은 데이터값을 props로 받음
function Blog({ posts } : { posts:PostInterface }) {
return (
<ul>
{posts.map((post) => (
<li>{post.title}</li>
))}
</ul>
)
}
// getStaticProps 받는 부분
export const getStaticProps: GetStaticProps = async (context) => {
const res = await fetch('https://.../posts')
const posts = await res.json()
// 데이터가 없으면 notFound를 보낸다
if (!data) {
return {
notFound: true,
}
}
//{ props: posts } 빌드타임에 받아서 Blog component로 보낸다
return {
props: {
posts,
},
}
}
export default Blog
getStaticProps가 기본 props로 받는 context 객체의 구성은 다음과 같습니다
params: 다이나믹 라우트 페이지라면, params를 라우트 파라미터 정보를 가지고 있다.
req: HTTP request object
res: HTTP response object
query: 쿼리스트링
preview: preview 모드 여부 >공식문서
previewData: setPreviewData로 설정된 데이터
getStaticProps가 리턴할 수 있는 값은 다음과 같습니다
props : 해당 컴포넌트로 리턴할 값 (선택적)
revalidate : 페이지 재생성이 발생할 수 있는 시간(초). 기본값은 false이며, 이게 거짓이면 다음 빌드때까지 페이지가 빌드된 상태로 캐시됨. (선택적)
notFound : Boolean값, 404status를 보내는 것을 허용한다. (선택적)
그렇지만 data를 빌드시에 미리 땡겨와서 static하게 제공하기 때문에 굉장히 빠른 속도로 페이지가 렌더됩니다. 따라서 매 유저의 요청마다 fetch할 필요가 없는 데이터를 가진 페이지를 렌더링 할때 매우 유리합니다.
If a page has dynamic routes and uses getStaticProps it needs to define a list of paths that have to be rendered to HTML at build time.
공식문서
동적라우팅 + getStaticProps를 원할 때 사용합니다.
페이지가 동적 라우팅을 쓰고 있고, getStaticProps를 쓰는 경우, getStaticPaths을 통해 빌드 타임 때 정적으로 렌더링할 경로를 설정해야합니다. 여기서 정의하지 않은 하위 경로는 접근해도 화면이 뜨지 않습니다. 동적라우팅 할 때, 라우팅 되는 경우의 수를 하나하나 집어넣어야 합니다.
// 타입지정을 위해 import
import { GetStaticProps } from 'next'
interface PostInterface {
userId: number
id: number
title: string
body: string
}
// 이 페이지에서 렌더될 컴포넌트
function Post({ posts } : { posts:PostInterface }) {
return (
<ul>
{posts.map((post) => (
<li>{post.title}</li>
))}
</ul>
)
}
// 빌드될 때 실행
export const getStaticPaths = async () => {
// posts를 받기 위해 fetch
const res = await fetch('https://.../posts')
const posts = await res.json()
// pre-render할 Path를 얻음 (posts를 통해서)
const paths = posts.map((post) => ({
params: { id: post.id },
}))
// 우리는 오로지 이 path들만 빌드타임에 프리렌더 함
// { fallback: false } 는 다른 routes들은 404임을 의미
// true이면 만들어지지 않은 것도 추후 요청이 들어오면 만들어 줄 거라는 뜻
return { paths, fallback: false }
}
// 빌드될 때 실행
export const getStaticProps = async ({ params }) => {
// params는 post `id`를 포함하고 있다
const res = await fetch(`https://.../posts/${params.id}`)
const post = await res.json()
// 해당 페이지에 props로 보냄
return { props: { post } }
}
export default Post
getStaticPaths가 리턴할 수 있는 값은 다음과 같습니다
paths : 빌드타임에 pre-rendering할 경로들
fallback : paths 이외의 경로들에 대해 추후 요청이 들어오면 만들어 줄지 말지. 만다면 404를 리턴함.
Fetch data on each request. pre-render for Server-side Rendering
공식문서
getServerSideProps는 빌드와 상관없이, 매 페이지 요청마다 데이터를 서버로부터 가져옵니다.
// 타입 지정을 위해 import
import { GetServerSideProps } from 'next'
function Page({ data }) {
console.log(this.props.data)
//res.json()이 찍힙니다
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const res = await fetch(`https://.../data`)
const data = await res.json()
// data 없을 땐 리턴값을 달리함
if (!data) {
return {
redirect: {
destination: '/',
permanent: false,
},
}
}
//pageProps로 넘길 데이터
return { props: { data: data } }
}
export default Page
getServerSideProps가 기본 props로 받는 context 객체의 구성은 다음과 같습니다
params: 다이나믹 라우트 페이지라면, params를 라우트 파라미터 정보를 가지고 있다.
req: HTTP request object
res: HTTP response object
query: 쿼리스트링
preview: preview 모드 여부 >공식문서
previewData: setPreviewData로 설정된 데이터
getServerSideProps가 리턴할 수 있는 값은 다음과 같습니다
props : 해당 컴포넌트로 리턴할 값 (선택적)
redirect : 값 내부와 외부 리소스 리디렉션 허용한다 (선택적) 무조건 { destination: string, permanent: boolean } 의 꼴이어야 한다. 몇몇 드문 케이스에서 오래된 HTTP클라이언트를 적절히 리디렉션하기 위해 커스텀 status코드가 필요할 수 있는데, 그땐 permanent property 대신에 statusCode property를 이용한다.
notFound : Boolean값, 404status를 보내는 것을 허용한다. (선택적)
getServerSideProps는 페이지를 렌더링하기전에 반드시 fetch해야할 데이터가 있을 때 사용합니다. 매 페이지 요청시마다 호출되므로 getStaticProps보다 느리지만, 빌드 이후에도 페이지 요청마다 실행된다는 특징이 있습니다.
Fetching data on the client side
If your page contains frequently updating data, and you don’t need to pre-render the data, you can fetch the data on the client side. An example of this is user-specific data. Here’s how it works:
First, immediately show the page without data. Parts of the page can be pre-rendered using Static Generation. You can show loading states for missing data.
Then, fetch the data on the client side and display it when ready.
출처: 공식문서
공식문서에 따르면,
1. 빈번하게 데이터가 update될 때
2. SEO가 굳이 필요하지 않은 데이터(user-specific data 등..)
는 일부 정적 pre-render를 자료 없이 보여주고 로딩 상태 보여주며, 클라 측에서 데이터가 패치되었을 때 보여주는 것이 좋다고 합니다.
꼭 styled-component를 써야하는 상황이 아니라면 emotion을 사용하세요! 그럼 아래의 설정이 필요하지 않습니다. 그렇지만 styled-component를 쓴다면 아래의 설정을 통해 사용자에게 더 좋은 경험을 선사할 수 있습니다.
기본적으로 next.js는 서버에서 페이지를 그려주기 때문에, 맨 처음 사용자가 웹페이지에 들어갔을 때 페이지만 그려지고, css는 아직 그려지지 않은 상태입니다. 그렇기 때문에 만약 styled-component를 사용한다면 아래와 같이 pages/_document.tsx 설정을 통해 미리 css가 로드될 수 있도록 해줘야 합니다.
import Document, { DocumentContext } from 'next/document';
import { ServerStyleSheet } from 'styled-components';
export default class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
const sheet = new ServerStyleSheet();
const originalRenderPage = ctx.renderPage;
try {
// sheet을 사용해 정의된 모든 스타일을 수집
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) => sheet.collectStyles(<App {...props} />),
});
// Documents의 initial props
const initialProps = await Document.getInitialProps(ctx);
// props와 styles를 반환
return {
...initialProps,
styles: (
<>
{' '}
{initialProps.styles} {sheet.getStyleElement()}{' '}
</>
),
};
} finally {
sheet.seal();
}
}
}
그러나 무조건 styled-component를 사용해야하는 것이 아니라면, emotion을 이용하시면 이러한 설정이 기본적으로 적용되어 있어서 스타일이 늦게 호출되는 현상이 일어나지 않습니다.
꼭 styled-component를 사용해야하는 상황이라면, 위와 같은 처리를 통해 스타일시트를 미리 불러옴으로써 스타일이 늦게 호출되는 현상을 방지할 수 있습니다.
next에서 이미지 import를 위해선 추가적인 모듈 설치나 웹팩 설정을 해주어야 하는데, next 내부에서 이미지를 최적화하여 불러올 수 있는 방법을 제공합니다.
import Image from "next/image";
export default function Header() {
return (
<>
<Image
src='/'
width=''
height=''
/>
</>
);
}
이 모듈을 이용하는 것의 장점은
이 부분은 CDD (Component Driven Development) 방식을 채택해 개발하시는 분들만 참고하셔도 좋을 것 같습니다
이번에 Youniverse 릴리즈 2.0 개발을 시작하며 CDD 방식의 개발방식을 채택했기 때문에 위의 세팅들에 storybook을 도입하게 되었습니다. 추후 storybook에 대한 자세한 내용을 담은 포스팅을 갈길 예정이며 지금은 간단하게 초기 세팅에 대해서만 정리하겠습니다!
npx sb init // 스토리북 설치
yarn add --dev react-docgen-typescript-loader // story북 실행했을 때 컴포넌트 테이블 만들기 위함
yarn path --dev
const path = require('path');
module.exports = {
stories: ['../stories/**/*.stories.mdx', '../stories/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'storybook-addon-styled-component-theme/dist/preset',
],
webpackFinal: async (config) => {
// node_mules폴더와 styles 폴더 안의 모듈을 인식할 수 있게 함
config.resolve.modules = [path.resolve(__dirname, '..'), 'node_modules', 'styles'];
// 절대 경로 설정
config.resolve.alias = {
...config.resolve.alias,
'@components': path.resolve(__dirname, '../components'),
'@assets': path.resolve(__dirname, '../public/assets'),
};
return config;
},
};
yarn storybook // 스토리북 실행
좋은글 감사합니다 ~~이후에 storybook을 활용하는 부분도 궁금하네요!