안녕하세요. IT 엘도라도 에 오신 것을 환영합니다.
글을 쓰는 것은 귀찮지만 다시 찾아보는 것은 더 귀찮습니다.
완전한 나만의 것으로 만들기 위해 지식을 차곡차곡 저장해 보아요.   포스팅 둘러보기 ▼

리액트 (React)

[Next.js] 핵심 개념 익히기 - ② 페이지 및 사전 렌더링

피그브라더 2022. 1. 17. 22:53
Next.js 공식 문서를 읽고, 필자가 생각하기에 Next.js에 대해 알고 있어야 하는 핵심 개념을 정리한 포스팅입니다. 실제로 Next.js 공식 문서를 살펴보면 생각보다 많은 내용이 있는데, 필자의 목적은 "Next.js가 무엇이냐"라는 질문에 대해 답을 할 수 있는 수준의 핵심 개념을 간추리는 것입니다. 더 자세한 내용은 개발하면서 그때그때 살펴보면 된다고 생각합니다.

 

1. 페이지 및 라우트

Next.js에서 각각의 페이지는 pages 디렉토리에 존재하는 .js, .jsx, .ts, .tsx 확장자의 파일에서 default export 되는 React 컴포넌트로 표현된다. 그리고 각 페이지의 라우트는 해당 파일의 이름에 의해 결정된다. 예를 들어, pages/about.js 파일은 /about 경로와 연결되며, pages/posts/[id].js 파일은 /posts/1, /posts/2 등의 경로와 연결된다. 이때 전자를 정적 라우트, 후자를 동적 라우트라고 부른다.

 

2. 사전 렌더링

Next.js는 기본적으로 모든 페이지를 (정확히는 SSG에 의해) 사전 렌더링 한다. 즉, Next.js는 클라이언트 사이드에서 JavaScript가 HTML을 만들어낼 필요 없이 각 페이지의 HTML을 서버 사이드에서 미리 만든다는 것이다. 이러한 방식은 더 좋은 성능과 검색 엔진 최적화(SEO)를 가져온다.

사전 렌더링에는 두 가지 방식이 있다. 하나는 Static Site Generation(SSG)이고, 다른 하나는 Server-Side Rendering(SSR)이다. 이 두 방식의 차이는 페이지의 HTML을 만들어내는 시점이다. 각 페이지마다 어떤 방식의 사전 렌더링을 사용할지 선택할 수 있다.

 

사전 렌더링 방식 설명
Static Site Generation (SSG) HTML이 빌드 타임에 만들어지고 각각의 요청 시에 활용된다.
Server-Side Rendering (SSR) HTML이 각각의 요청 시에 만들어지고 활용된다.
성능 상의 측면에서는 SSR보다 SSG가 권장된다. 한 번만 빌드될 뿐 아니라, 정적으로 만들어지는 HTML은 별도의 설정 없이도 CDN에 의해 캐싱되어 성능이 개선될 수 있기 때문이다. 물론 어떤 경우에는 SSR이 유일한 옵션인 경우도 있으니, 경우를 잘 따져봐야 한다.
위 두 가지 방식이 서버 사이드에서 렌더링이 이뤄지는 것이라면, 클라이언트 사이드에서 렌더링이 이뤄지는 Client-Side Rendering(CSR)도 존재한다. 즉, 페이지의 일부는 전적으로 클라이언트 사이드의 JavaScript에 의해 렌더링 되도록 할 수 있다는 것이다. 이는 순수한 React 앱에서 렌더링을 하는 방식과 동일하다.

 

3. Hydration

사전 렌더링에 의해 만들어진 각 HTML은 그 페이지에서 필요한 최소한의 JavaScript 코드와 연결된다. 따라서 어떤 페이지의 HTML이 브라우저에 의해 로드되면, 그 HTML에 연결된 JavaScript 코드가 실행되어 해당 페이지가 상호작용(Interactive) 가능해진다. 이러한 과정을 Hydration이라고 부른다.

 

 

4. Static Site Generation (SSG)

페이지의 HTML이 빌드 타임(next build)에 만들어지고, 각각의 요청 시에 재활용된다. 이는 별도의 설정 없이도 CDN에 의해 캐싱되어 성능이 개선될 수 있다. 기본적으로, 별도의 설정이 없다면 모든 페이지의 HTML은 SSG에 의해 미리 만들어진다. 그런데 만약 페이지의 내용이나 경로가 외부 데이터에 의존하면 다음 함수들을 사용한다. 이 함수들은 서버 사이드에서 빌드 타임에 호출된다.

외부 데이터에 의존하는 대상 사용해야 하는 함수
페이지의 내용 getStaticProps()
페이지의 경로 getStaticPaths()

 

4-1. getStaticProps()

페이지의 내용이 외부 데이터에 의존할 때 사용하는 함수이다.

// pages/posts.js

export default function BlogList({ posts }) {
    // 렌더링 로직
}

// 이 함수는 빌드 타임에 호출된다.
export async function getStaticProps({ params }) {
    // params: 라우트 파라미터들로 이뤄진 객체 (정적 라우트 페이지라면 undefined)

    // API 호출
    const response = await fetch('https://example.com/posts');
    const posts = await response.json();

    return {
        props: { posts },  // BlogList 컴포넌트가 전달받을 props 객체
        revalidate: false,  // 페이지의 Regeneration을 수행하기 위한 유예 기간
        notFound: false  // 404 페이지 반환 여부
    };
}
Next.js는 getStaticProps() 함수를 이용하여 HTML을 만들 뿐 아니라 JSON도 함께 만든다. 그 이유에 대한 자세한 내용은 아래의 Client-Side Navigation 섹션을 참조하자.
어떤 경우에는 특정 페이지의 SSG를 스킵하고 매 요청마다 해당 페이지를 렌더링 하고 싶을 수도 있다. 예를 들어, headless CMS를 사용하는데 그것이 완전히 출판되기 전에 초안에 대해 프리뷰를 해보고 싶을 수도 있다. Next.js는 이러한 경우를 처리하기 위해 프리뷰 모드라는 기능을 지원한다. 자세한 것은 여기를 참고하자.
개발 모드(next dev)에서는 매 요청마다 호출된다.
서버 사이드에서만 실행되고 클라이언트 사이드에서는 실행되지 않으므로 이 함수의 코드와 이 함수가 사용하는 모듈들은 브라우저에 전달되는 JS 번들에 포함되지 않는다.

 

4-2. getStaticPaths()

페이지의 경로가 외부 데이터에 의존할 때 사용하는 함수로, 동적 라우트인 페이지에서만 사용할 수 있다. 즉, 해당 동적 라우트와 매칭 되는 여러 경로 중 어떤 경로의 페이지를 SSG에 의해 사전 렌더링 할지 결정하는 함수이다.

// pages/posts/[id].js

export default function BlogDetail({ post }) {
    // 렌더링 로직
}

// 이 함수는 빌드 타임에 호출된다.
export async function getStaticPaths() {
    // API 호출
    const response = await fetch('https://.../posts');
    const posts = await response.json();

    // SSG에 의해 사전 렌더링할 페이지들의 경로
    const paths = posts.map((post) => ({
        params: { id: post.id }
    }));

    return {
        paths: paths,  // SSG에 의해 사전 렌더링할 페이지들의 경로
        fallback: false  // HTML이 만들어지지 않은 경로들은 404 페이지를 반환
    };
}

// 이 함수는 빌드 타임에 호출된다.
export async function getStaticProps({ params }) {
    // params: 라우트 파라미터들로 이뤄진 객체 (정적 라우트 페이지라면 undefined)

    // API 호출
    const response = await fetch(`https/example.com/posts/${params.id}`);
    const post = await response.json();

    return {
        props: { post },  // BlogDetail 컴포넌트가 전달받을 props 객체
        revalidate: false,  // 페이지의 Regeneration을 수행하기 위한 유예 기간
        notFound: false  // 404 페이지 반환 여부
    };
}
개발 모드(next dev)에서는 매 요청마다 호출된다.
서버 사이드에서만 실행되고 클라이언트 사이드에서는 실행되지 않으므로 이 함수의 코드와 이 함수가 사용하는 모듈들은 브라우저에 전달되는 JS 번들에 포함되지 않는다.

 

4-3. Incremental Static Regeneration (ISR)

Next.js는 이미 SSG에 의해 각 페이지의 HTML을 만들어둔 이후에도 특정 페이지의 생성 혹은 수정을 수행하는 방법을 제공하는데, 이를 Incremental Static Regeneration(ISR)이라고 한다. 이는 Next.js 앱 전체를 다시 빌드할 필요 없이 페이지 단위의 생성 혹은 수정을 가능하게 한다. 다음 예시를 보자.

// pages/posts/[id].js

export default function BlogDetail({ post }) {
    // 렌더링 로직
}

// 이 함수는 빌드 타임에 호출된다.
// 그러나 사전 렌더링되지 않은 경로라면 요청 시에 다시 호출된다.
export async function getStaticPaths() {
    // API 호출
    const response = await fetch('https://.../posts');
    const posts = await response.json();

    // SSG에 의해 사전 렌더링할 페이지들의 경로
    const paths = posts.map((post) => ({
        params: { id: post.id }
    }));

    return {
        paths: paths,  // SSG에 의해 사전 렌더링할 페이지들의 경로
        fallback: 'blocking'  // HTML이 만들어지지 않은 경로들은 요청 시에 사전 렌더링
    };
}

// 이 함수는 빌드 타임에 호출된다.
// 그러나 revalidation이 필요하다면 요청 시에 다시 호출된다.
export async function getStaticProps({ params }) {
    // params: 라우트 파라미터들로 이뤄진 객체 (정적 라우트 페이지라면 undefined)

    // API 호출
    const response = await fetch(`https/example.com/posts/${params.id}`);
    const post = await response.json();

    return {
        props: { post },  // BlogDetail 컴포넌트가 전달받을 props 객체
        revalidate: 10,  // 페이지의 Regeneration을 수행하기 위한 유예 기간
        notFound: false  // 404 페이지 반환 여부
    };
}

ISR의 동작 원리는 다음과 같다.

1. 빌드 타임에 SSG에 의해 사전 렌더링된 페이지를 최초로 요청하는 경우

캐시된 페이지를 보여준다.
2. 최초의 요청 이후 revalidate 초가 지나기 전에 다시 요청하는 경우

마찬가지로 캐시된 페이지를 보여준다.
3. 최초의 요청 이후 revalidate 초가 지난 후에 다시 요청하는 경우

여전히 캐시된 페이지를 보여준다.

백그라운드에서 해당 페이지의 Regeneration을 수행한다(getStaticProps() 호출).

페이지의 Regeneration이 완료되면 캐시를 갱신하고 업데이트된 페이지를 보여준다. 만약 백그라운드의 Regeneration이 실패하면 기존의 페이지가 그대로 보여질 것이다.
4. HTML이 만들어지지 않은 경로에 대해 요청하는 경우

fallback: false : 404 페이지를 보여준다.

fallback: 'blocking' : 페이지의 Generation을 수행하고(getStaticProps() 호출), 해당 페이지의 Generation이 완료되면 만들어진 HTML을 브라우저에게 전달한다. 이때부터 이 페이지는 빌드 타임에 SSG에 의해 사전 렌더링된 다른 페이지들과 마찬가지로 처리된다.

fallback : 'true' : 백그라운드에서 페이지의 Generation을 수행하고(getStaticProps() 함수 호출), 해당 페이지의 Generation이 완료되기 전에 우선 Fallback 버전의 HTML을 브라우저에게 전달한다. 그러다가 해당 페이지의 Generation이 완료되면 만들어진 JSON을 브라우저에게 전달하고, 브라우저는 해당 JSON을 컴포넌트의 props로 이용하여 페이지를 다시 렌더링한다. 이때부터 이 페이지는 빌드 타임에 SSG에 의해 사전 렌더링된 다른 페이지들과 마찬가지로 처리된다.
페이지의 fallback 버전이란?
props로 빈 객체가 전달되고 useRouter() 함수가 반환하는 routerisFallback 프로퍼티 값이 true인 상태의 페이지를 의미한다. 이후 서버 사이드에서 getStaticProps() 함수의 실행이 완료되어 JSON을 넘겨받으면 그 값이 false로 변하며 현재 페이지의 컴포넌트가 다시 렌더링 된다.

 

5. Server-Side Rendering (SSR)

페이지의 HTML이 각각의 요청 시에 만들어지고 활용된다. 이는 별도의 설정(Cache-Control 헤더)을 통해서만 CDN에 의해 캐싱될 수 있기 때문에, 기본적으로는 SSG보다 느린 성능을 보일 수밖에 없다. 하지만 매 요청마다 페이지의 내용이 업데이트되어야 하는 경우에는 SSR이 더 적합하다.

SSR을 사용하려면 getServerSideProps() 함수를 사용하면 된다. 이 함수는 매 요청마다 호출된다.

 

5-1. getServerSideProps()

export default function BlogDetail({ post }) {
    // 렌더링 로직
}

// 이 함수는 매 요청마다 호출된다.
export async function getServerSideProps({ params }) {
    // params: 라우트 파라미터들로 이뤄진 객체 (정적 라우트 페이지라면 undefined)

    // API 호출
    const response = await fetch(`https/example.com/posts/${params.id}`);
    const post = await response.json();

    return {
        props: { post },  // BlogDetail 컴포넌트가 전달받을 props 객체
        notFound: false  // 404 페이지 반환 여부
    };
}
서버 사이드에서만 실행되고 클라이언트 사이드에서는 실행되지 않으므로 이 함수의 코드와 이 함수가 사용하는 모듈들은 브라우저에 전달되는 JS 번들에 포함되지 않는다.

 

6. Client-Side Navigation

브라우저에서 next/link 혹은 next/router를 이용하여 다른 페이지로 이동하려는 경우를 말한다.

 

6-1. 이동하려는 페이지가 SSG인 경우

브라우저의 요청에 의해 서버가 해당 페이지에 대해 미리 만들어져 있는 JSON을 브라우저에게 전달하면 브라우저는 해당 JSON을 컴포넌트의 props로 이용하여 페이지를 렌더링 한다. 만약 해당 페이지의 HTML이 만료되었거나 아직 만들어지지 않았다면 ISR의 원리를 따른다.

 

6-2. 이동하려는 페이지가 SSR인 경우

브라우저의 요청에 의해 getServerSideProps() 함수가 서버 사이드에서 호출된다. 그리고 그 결과로 반환되는 JSON을 다시 브라우저에게 전달하면 브라우저는 해당 JSON을 컴포넌트의 props로 이용하여 페이지를 렌더링 한다.

 

7. Client-Side Rendering (CSR)

페이지의 일부는 전적으로 클라이언트 사이드의 JavaScript에 의해 렌더링 되도록 할 수 있다. 이는 순수한 React 앱에서 렌더링을 하는 방식과 동일하다. 이는 검색 엔진 최적화(SEO)가 필요 없거나, 데이터의 사전 렌더링이 필요 없거나, 페이지의 내용이 실시간으로 자주 업데이트되어야 할 때 유용하다.

 

흔한 구현 예시는 다음과 같다.

  1. 데이터 없이 페이지를 바로 보여준다. 데이터가 필요한 부분을 제외한 나머지는 SSG를 이용하여 사전 렌더링 한다. 이때는 데이터가 없는 로딩 상태를 보여주도록 한다.
  2. 이제 클라이언트 사이드에서 서버 사이드에게 데이터를 요청한다. 그리고 데이터가 클라이언트 사이드에 도착하면 해당 데이터를 가지고 페이지를 다시 렌더링 한다.
이 방식이 유용한 대표적인 페이지는 바로 대시보드이다. 대시보드는 유저별로 존재하는 사적인 페이지로, SEO가 중요하지 않아서 페이지가 사전 렌더링 될 필요가 없다. 또한 데이터도 빈번히 업데이트되기 때문에 매 요청마다 데이터를 가져오는 것이 중요하다.

 

7-1. useEffect()

function Profile() {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(false);

    useEffect(() => {
        setLoading(true);
        fetch('api/profile-data')
            .then((res) => res.json())
            .then((data) => {
                setData(data);
                setLoading(false);
            });
    }, []);

    if (loading) return <p>Loading...</p>;
    if (!data) return <p>No profile data</p>;

    return (
        <div>
            <h1>{data.name}</h1>
            <p>{data.bio}</p>
        </div>
    );
}

 

7-2. SWR

클라이언트 사이드에서 데이터를 쉽게 요청할 수 있도록 하는 React Hook 라이브러리이다. 만약 클라이언트 사이드에서 데이터를 요청해야 한다면 적극 권장한다. 이는 Caching, Revalidation, Focus Tracking, Refetching on Interval 등 많은 유용한 기능들을 지원한다. 이를 사용하여 위 예시를 다시 구현하면 다음과 같다. SWR이 자동으로 데이터를 캐싱할 것이고, 해당 캐시가 만료되면 자동으로 Revalidation까지 수행할 것이다.

import useSWR from 'swr';

const fetcher = (...args) => fetch(...args).then((res) => res.json());

function Profile() {
    const { data, error } = useSWR('/api/profile-data', fetcher);

    if (error) return <div>Failed to load</div>;
    if (!data) return <div>Loading...</div>;

    return (
    <div>
        <h1>{data.name}</h1>
        <p>{data.bio}</p>
    </div>
    );
}