본문 바로가기
SPA/Next

[Next] Data Fetching에 대해 알아보기 (3) - ISR

by J4J 2023. 2. 20.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 Data Fetching의 세 번째인 ISR에 대해 적어보는 시간을 가져보려고 합니다.

 

 

 

이전 글

 

[Next] Data Fetching에 대해 알아보기 (1) - CSR / SSR

[Next] Data Fetching에 대해 알아보기 (2) - SSG / Dynamic Routing

 

 

 

들어가기에 앞서

 

Next 공식문서에 따르면 Next의 Data Fetching의 종류에는 다음과 같이 있습니다.

 

  • SSR
  • SSG
  • CSR
  • Dynamic Routing
  • ISR

 

 

 

그리고 이번 글에서는 ISR에 대해 알아보도록 하겠습니다.

 

 

 

ISR - 기본

 

ISR은 Incremental Static Regeneration의 약자로 Dynamic Routing의 이점을 가져가며 단점을 보완하기 위해 사용됩니다.

 

위에 링크를 걸어둔 Dynamic Routing에 대한 이전 글을 확인해 보면 아시겠지만 Dynamic Routing의 특징은 다음과 같습니다.

 

  • build 단계 때 fetching data를 가져와 페이지를 prerendering
  • build 이후 호출되는 데이터가 변경되더라도 prerendering된 페이지는 값이 변경되지 않음

 

 

 

즉, Dynamic Routing을 사용하면 prerendering을 하기 때문에 많은 수의 페이지들을 미리 구성하여 사용자들이 더 빠른 속도로 화면을 사용할 수 있게 도와주지만 build 이후로 변경된 데이터를 확인할 수 없다는 단점이 존재합니다.

 

ISR은 이런 단점을 보완하여 build 이후에도 rebuild를 하지 않고 변경된 데이터들로 구성된 페이지를 다시 생성하여 사용자에게 노출시킬 수 있도록 도와줍니다.

 

 

 

ISR을 사용하기 위해서는 2가지 속성값을 필수적으로 사용해야 합니다.

  • getStaticPaths의 fallback
  • getStaticProps의 revalidate

 

 

 

먼저 fallback은 이전 글에도 나와 있지만 getStaticProps가 언제 실행될지를 정해주는 역할을 수행합니다.

 

그리고 revalidate는 getStaticProps로 인해 fetching된 데이터가 stale 되는 시간을 의미하며 단위는 초(seconds)입니다.

 

 

 

fallback(= blocking)과 revalidate(= 30s)를 사용하여 ISR 방식으로 페이지가 만들어지는 과정을 예시를 들면 다음과 같습니다.

 

  • 처음 build를 통해 prerendering 페이지를 생성하고 캐싱 처리
  • client가 페이지에 접속하면 revalidate 타이머가 동작하고, 30초 동안 모든 사용자에게 캐싱된 페이지를 노출
  • 30초가 지난 뒤 페이지에 접속한 첫 번째 client는 캐싱되어 있던 이전 페이지를 동일하게 확인하게 되고, 해당 client가 페이지에 접근한 순간 데이터를 다시 fetching 하여 prerendering을 진행
  • 데이터가 fetching 된 이후에 접속한 client는 초기에 만들어진 stale 된 페이지 대신 새롭게 prerendering 된 페이지를 확인하게 되고 다시 revalidate 타이머가 동작하며 반복 (두 번째 이후의 client가 접속하더라도 fetching이 이루어지지 않으면 기존에 prerendering된 페이지를 확인)
  • build 단계 때 prerendering 되지 않은 페이지에 접근하는 경우 fallback의 특징에 따라 동작

 

 

 

ISR의 간단한 예시로 다음과 같이 API와 코드를 작성해 볼 수 있습니다.

 

기본적인 틀은 Dynamic Routing과 동일하지만 위와 얘기한 것처럼 fallback과 revalidate가 필수적으로 사용된 것을 볼 수 있습니다.

 

/product/no api

 

/product/detail api

 

import axios from 'axios';
import { GetStaticProps } from 'next';

interface Product {
    name: string;
    price: number;
    stock: number;
}

export const getStaticPaths = async () => {
    try {
        const res = await axios.get('http://localhost:8080/product/no');
        const productNos: number[] = res.data;

        return {
            paths: productNos.map((productNo) => {
                return {
                    params: {
                        // 페이지가 구성되기 위해 사용될 데이터를 params로 저장 (getStaticProps에서 가져와 사용)
                        // /isr/1, /isr/2 → params: [{ params: { productNo: '1' }}, { params: { productNo: '2' }}]
                        productNo: String(productNo),
                    },
                };
            }),
            fallback: 'blocking', // prerendering되지 않은 페이지가 호출되면 초기 렌더링이 되기전에 데이터를 fetching한 뒤 렌더링 진행
        };
    } catch (error) {
        return {
            paths: [],
            fallback: false,
        };
    }
};

export const getStaticProps: GetStaticProps = async (context) => {
    if (context.params) {
        const productNo = Number(context.params.productNo); // getStaticPaths에서 전달한 params를 이용하여 data fetching
        const [res] = await Promise.all([
            await axios.get('http://localhost:8080/product/detail', {
                params: {
                    productNo,
                },
            }),
        ]);

        const product: Product = res.data;
        if (product) {
            return {
                props: {
                    product,
                },
                revalidate: 30, // getStaticPaths에서 전달받은 params를 이용하여 fetching된 데이터의 유효시간 설정 (30초)
            };
        }
    }

    return {
        notFound: true,
        revalidate: 30,
    };
};

interface Props {
    product: Product;
}

const Isr = (props: Props) => {
    return (
        <div>
            <h2>데이터 Fetch로 가져온 상품</h2>
            <p>상품이름: {props.product?.name}</p>
            <p>가격: {props.product?.price}</p>
            <p>재고: {props.product?.stock}</p>
        </div>
    );
};

export default Isr;

 

 

반응형

 

 

코드를 위와 같이 구성하고 다음 명령어를 이용하여 build를 해줍니다.

 

$ npm run build

 

 

 

build를 한 뒤 .next → server → pages를 확인하면 prerendering이 된 html과 json 파일들을 확인할 수 있습니다.

 

build 파일 경로

 

 

 

그리고 만들어진 1.json 파일을 확인해 보면 다음과 같이 fetching 된 데이터가 props로 사용되는 것을 확인할 수 있습니다.

 

{"pageProps":{"product":{"price":3200,"name":"첫번째 상품","stock":58}},"__N_SSG":true}

 

 

 

여기까지는 Dynamic Routing과 동일합니다.

 

이번 글에서는 ISR을 구현한 것이고 revalidate 타이머가 동작되지 않게 하기 위해 url에 접근하기 전에 API를 다음과 같이 수정해 보도록 하겠습니다.

 

수정된 /product/detail api

 

 

 

이후로 다음 명령어를 이용하여 서버를 실행한 뒤 url에 접근해 보면 build 할 때 prerendering 된 데이터가 그대로 유지되어 있는 것을 확인할 수 있으며 Preview에도 fetching 되었던 데이터가 정상적으로 보이는 것을 볼 수 있습니다.

 

$ npm run start

 

build 후 페이지

 

 

 

그리고 30초 뒤에 새로고침을 해보면 다음과 같이 변경된 API가 적용되지 않고 기존 페이지가 그대로 보이는 것을 확인할 수 있습니다.

 

30초 후 새로고침한 페이지

 

 

 

하지만 해당 시간대에 API로그를 확인해 보면 Next에서 API를 호출했던 로그가 남아있는 것을 확인할 수 있습니다.

 

2023-02-20 16:26:29.010  INFO 19340 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2023-02-20 16:26:29.018  INFO 19340 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2023-02-20 16:26:29.019  INFO 19340 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 1 ms
2023-02-20 16:26:29.171  INFO 19340 --- [nio-8080-exec-1] c.q.o.controller.ProductController       : servletPath: /product/detail
2023-02-20 16:26:29.171  INFO 19340 --- [nio-8080-exec-1] c.q.o.controller.ProductController       : returnData: {price=3200, name=변경된 첫번째 상품, stock=58}

 

 

 

이 상태에서 url을 다시 새로고침해보면 변경된 API에 맞게 페이지가 재 구성된 것을 볼 수 있습니다.

 

30초 후 fetching된 뒤 새로고침한 페이지

 

 

 

또한 위에서 봤었던 1.json 파일을 다시 확인해 보면 props가 변경된 API에 맞게 수정된 것도 확인해 볼 수 있습니다.

 

{"pageProps":{"product":{"price":3200,"name":"변경된 첫번째 상품","stock":58}},"__N_SSG":true}

 

 

 

ISR - On-Demand Revalidation

 

ISR에서는 On-Demand Revalidation을 추가적으로 적용할 수 있습니다.

 

여기서 말하는 On-Demand Revalidation은 ISR의 revalidation을 더 효율적으로 사용할 수 있게 도와줍니다.

 

위에서 확인한 바와 같이 ISR은 Dynamic Routnig과 달리 rebuild를 하지 않고 revalidate에서 설정한 시간이 지났을 때 자동으로 변경된 데이터를 이용하여 prerendering을 진행해 줍니다.

 

하지만 여기서도 발생될 수 있는 문제점은 데이터가 변경되었더라도 사용자가 페이지를 방문해야만 revalidate 타이머가 동작하고 심지어 설정한 시간이 지나더라도 적어도 한 번 이상 사용자가 더 접근을 해야 변경된 데이터를 확인할 수 있습니다.

 

이런 문제점을 해결할 수 있게 도와주는 것이 On-Demand Revalidation이고 On-Demand Revalidation을 사용할 경우 데이터가 갱신되는 시점이 사용자의 페이지 방문이 아니라 Next에서 revalidation을 위한 trigger가 발생될 때로 변경됩니다.



 

 

먼저 On-demand Revalidation을 적용하기 위해서 Next 버전 확인이 필요합니다.

 

12.2.0 버전 이후로 정상적으로 사용 가능하다고 하니 만약 버전이 이보다 낮은 경우에는 적용할 수 없습니다.

 

그리고 적용하기 위해서는 Next의 API Routes를 사용해줘야 합니다.

 

간단한 예시로 다음과 같이 코드를 작성해 볼 수 있습니다.

 

 

 

먼저 API Routes를 위한 코드를 /pages/api 하위 경로에 다음과 같은 코드를 작성해 줍니다.

 

// /pages/api/product/detail/[productNo].ts

import { NextApiRequest, NextApiResponse } from 'next';

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
    try {
        await res.revalidate(`/isr/${req.query.productNo}`); // /isr/{productNo}에 해당되는 페이지에 revalidate 트리거 적용
        return res.json({ revalidated: true });
    } catch (err) {
        return res.status(500).send('Error revalidating');
    }
};

export default handler;

 

 

 

그리고 ISR이 적용된 코드는 다음과 같이 revalidation 트리거가 동작되는 API Routes를 호출할 수 있게 이벤트로 추가해 주며, revalidate는 따로 적용하지 않게 하여 트리거 없이는 revalidate가 되지 않도록 해보겠습니다.

 

// /pages/isr/[productNo].tsx

import axios from 'axios';
import { GetStaticProps } from 'next';

interface Product {
    name: string;
    price: number;
    stock: number;
}

export const getStaticPaths = async () => {
    try {
        const res = await axios.get('http://localhost:8080/product/no');
        const productNos: number[] = res.data;

        return {
            paths: productNos.map((productNo) => {
                return {
                    params: {
                        // 페이지가 구성되기 위해 사용될 데이터를 params로 저장 (getStaticProps에서 가져와 사용)
                        // /isr/1, /isr/2 → params: [{ params: { productNo: '1' }}, { params: { productNo: '2' }}]
                        productNo: String(productNo),
                    },
                };
            }),
            fallback: 'blocking', // prerendering되지 않은 페이지가 호출되면 초기 렌더링이 되기전에 데이터를 fetching한 뒤 렌더링 진행
        };
    } catch (error) {
        return {
            paths: [],
            fallback: false,
        };
    }
};

export const getStaticProps: GetStaticProps = async (context) => {
    if (context.params) {
        const productNo = Number(context.params.productNo); // getStaticPaths에서 전달한 params를 이용하여 data fetching
        const [res] = await Promise.all([
            await axios.get('http://localhost:8080/product/detail', {
                params: {
                    productNo,
                },
            }),
        ]);

        const product: Product = res.data;
        if (product) {
            return {
                props: {
                    productNo,
                    product,
                },
                // revalidate: 30, // On-demand Revalidation 처리를 위해 설정 제거
            };
        }
    }

    return {
        notFound: true,
    };
};

interface Props {
    productNo: number;
    product: Product;
}

const Isr = (props: Props) => {
    /**
     * handle
     */
    const handle = {
        clickRevalidation: async () => {
            await axios.get(`/api/product/detail/${props.productNo}`);
        },
    };

    return (
        <div>
            <h2>데이터 Fetch로 가져온 상품</h2>
            <p>상품이름: {props.product?.name}</p>
            <p>가격: {props.product?.price}</p>
            <p>재고: {props.product?.stock}</p>

            <button onClick={handle.clickRevalidation}>revalidation 트리거</button>
        </div>
    );
};

export default Isr;

 

 

 

 

코드를 작성하고 build를 한 뒤 서버를 실행하면 다음과 같은 결과를 확인해 볼 수 있습니다.

 

On-demand Revalidation 결과

 

 

 

추가적으로 Next에서는 다음과 같이 revalidation이 무분별하게 처리되는 것을 방지하기 위해 secretToken을 사용하는 것을 가이드하고 있습니다.

 

불필요한 API 호출을 방지하기 위해 인증 과정을 추가한다면 On-Demand Validation이 더 효과적으로 사용될 것이라고 생각합니다.

 

https://<your-site.com>/api/revalidate?secret=<token>

 

// pages/api/revalidate.js

export default async function handler(req, res) {
  // Check for secret to confirm this is a valid request
  if (req.query.secret !== process.env.MY_SECRET_TOKEN) {
    return res.status(401).json({ message: 'Invalid token' })
  }

  try {
    // this should be the actual path not a rewritten path
    // e.g. for "/blog/[slug]" this should be "/blog/post-1"
    await res.revalidate('/path-to-revalidate')
    return res.json({ revalidated: true })
  } catch (err) {
    // If there was an error, Next.js will continue
    // to show the last successfully generated page
    return res.status(500).send('Error revalidating')
  }
}

 

 

 

 

 

 

 

이상으로 Data Fetching의 세 번째인 ISR에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글