NextJS API Routes Caching
2024. 9. 13. 14:21

API Routes란?

NextJS의 Page Router 방식에서는 API Routes(링크)를 사용해 Next 서버에 손쉽게 엔드포인트를 만들 수 있지만 api/ 폴더에 따로 파일을 만들어줘야 한다는 번거로움이 있었다. 하지만 App Router에서는 Server Actions(링크)이 추가되어 컴포넌트 내에서도 서버함수를 수행할 수 있도록 코드를 작성하는 편리성을 제공한다.

 

API Routes 응답 캐싱 방법

  1. HTTP Cache-Control 헤더를 사용한 캐싱
    • 가장 일반적인 방법은 HTTP 헤더 사용하여 캐시 제어를 하는 것이다. 브라우저나 CDN에서 응답을 캐시할 있도록 Cache-Control 헤더를 설정한다.
  2. 서버 메모리 캐싱
    • 서버의 메모리에 데이터를 캐시하는 방법. 이를 통해 백엔드에서 동일한 데이터를 다시 계산하거나 가져오는 것을 피할 있습니다. 하지만 서버가 재시작되면 캐시가 사라지며, 인스턴스가 여러 개 일 때 값이 서로 다를 수 있고, 대규모 애플리케이션에서는 메모리 사용량에 주의해야한다.
  3. Redis 같은 외부 캐시 사용
    • 서버 메모리 캐싱의 한계를 극복하기 위해, Redis 같은 외부 캐시 시스템을 사용할 있다. Redis 인메모리 데이터베이스로, 서버가 재시작되더라도 캐시 데이터를 유지할 있다. 또한 여러 서버 간에 공유 캐시를 사용할 있기 때문에, 스케일링이 필요한 애플리케이션에 적합하다.
  4. Increamental Static Regeneration (ISR) (Next docs 링크)
    • 정적으로 생성된 페이지를 일정 주기마다 다시 생성하면서 최신 데이터를 캐시할 있다. 이를 사용하면 정적 페이지와 동적 데이터를 결합할 있다.
  5. Edge Caching
    • Next.js Vercel 같은 플랫폼에서 Edge Network 사용하여 API 응답을 Edge 서버에 캐시할 있다. Edge Functions 활용하면 사용자에게 가까운 Edge 서버에서 데이터를 제공하여 성능을 극대화할 있다.
  6. SWR (Stale-While-Revalidate) 라이브러리 사용
    1. SWR 라이브러리는 Next.js에서 클라이언트 데이터 캐싱에 매우 유용한 라이브러리이다. stale-while-revalidate 전략을 사용하여, 캐시된 데이터를 즉시 반환하고 백그라운드에서 데이터를 갱신하는 방식으로 동작한다.

 

캐싱 방법 선정 과정

진행 중인 NextJS(Page Router) 프로젝트에서 거래소의 토큰 가격 정보를 불러오는 API Hooks를 만드는 과정에서 거래소 응답을 받아 전처리 후 반환하는 API Routes에 대한 캐싱작업이 필요하였다. 그런데 1분 정도의 상대적으로 짧은 시간의 캐싱이 필요한 데이터로 클라이언트로 캐싱할 경우 만료 후 수많은 사용자들로부터 다시 요청이 들어와 서버에 부하가 걸릴 것으로 예상되었다.

 

따라서 서버나 CDN 쪽의 캐싱방법을 선택해야 했는데, 해당 프로젝트 배포환경이 CDN이 없어 "2. 서버 메모리 캐싱"으로 선정하여 API Routes 노드 환경에 캐싱을 구현하는 것으로 선정하였다.

 

아래는 해당 선정과정에서 고려되었던 방법들에 대한 일부 상세 설명을 나열하였다.

 

HTTP Cache-Control 상세

// pages/api/hello.js
export default function handler(req, res) {
  res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=30');
  res.status(200).json({ message: 'Hello, World!' });
}
  • 응답 헤더에 "max-age"를 set 하면 브라우저(클라이언트)에 캐싱이 적용된다.
  • "max-age"대신 "s-maxage"를 사용하면 프록시 서버, CDN에서 캐싱이 가능한데, Next Vercel에서 배포 시 해당기능을 지원하기 때문에 Next Docs(링크)에서도 해당 옵션에 대한 설명을 제공함.
  • public, private 옵션
    • public : 모든 종류의 캐시(: 브라우저, CDN, 프록시 서버 ) 해당 응답을 캐싱할 있음을 나타냄. 일반적으로 모든 사용자 동일한 데이터를 사용할 있는 경우, 지시자를 사용. 예를 들어, 공개 웹사이트의 정적 자원(이미지, CSS 파일 )이나 공용 API 응답 같은 경우에 적합.
    • private : 개별 클라이언트(브라우저) 캐시할 있으며, 공유 캐시(CDN, 프록시 서버)에서는 캐싱되지 않음. 이는 개인화된 데이터 사용자별로 다른 응답 있을 유용함. 예를 들어, 로그인된 사용자 정보나 개인화된 대시보드 데이터를 캐싱할 때는 지시자를 사용하여 다른 사용자와 공유되지 않도록 함.
  • stale-while-revalidate
    • 캐시 만료 후 요청이 들어왔을 때 우선 만료된 값(stale value)을 전달해 주고 캐시를 새로 업데이트함.
    • 설정 시간 내에라도 캐시 갱신이 완료되면 그 다음요청부터는 갱신된 새로운 캐시값을 응답으로 제공.
    • 만약 설정 시간내에 캐시 갱신이 안 되었다면, 그 이후 요청에서는 백엔드에서 직접 값을 가져옴.

 

서버 메모리 캐싱 상세

ttl(Time To Live, 캐시 데이터 유효시간) 등 기본적인 캐시 개념들을 적용하여 직접 코드로 구현이 가능하지만 완성도, 간결성, 추후 확작성 등을 고려해 node-cache(링크) 패키지를 사용해 캐싱을 구현하였다.

 

...
import { NextApiRequest, NextApiResponse } from "next";
import NodeCache from "node-cache";

const ttl = 60; // 60초 동안 데이터가 캐시됨
const cacheKey = "token-cache";
let isUpdatingCache = false; // 캐시 갱신 중 여부를 나타내는 플래그

// stdTTL 전체 기본 ttl 설정
// deleteOnExpire: false > stale상태에서 요청들어오면 기존값 먼저 응답 주기 위해서
const cache = new NodeCache({ stdTTL: ttl, deleteOnExpire: false });

// 캐시 만료시
cache.on("expired", async function (key, value) {
  // 사용하는 key 값이 1개라 key값 검증은 생략

  if (!isUpdatingCache) {
    isUpdatingCache = true; // 캐시 갱신 시작

    try {
      // 캐시값 업데이트
      const data = await fetchData();
      cache.set(key, data);
    } catch (err) {
      // 에러시 캐시값 비우기
      cache.set(key, undefined);
    } finally {
      isUpdatingCache = false; // 캐시 갱신 완료 후 상태 초기화
    }
  }
});

// 빗썸 토큰 정보 불러오기
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  try {
    const tokenInfo = cache.get(cacheKey);

    if (tokenInfo) {
      // 캐시값 있음
      res.status(200).json(tokenInfo);
    } else {
      // (첫 요청) 캐시가 없을 때
      const data = await fetchData();
      cache.set(cacheKey, data);

      return res.status(200).json(data);
    }
  } catch (error) {
    console.error("토큰 정보를 가져오는 중 오류 발생:", error);
    return res
      .status(500)
      .json({ error: "토큰 정보를 가져오는 중 오류가 발생했습니다." });
  }
}

// 거래소 데이터를 가져오는 비동기 함수
async function fetchData() {
  const targetString = Object.values(TARGET_TOKENS)
    .map((token) => `KRW-${token}`)
    .join(",");

  const response = await fetch(
    `https://api.bithumb.com/v1/ticker/?markets=${targetString}`,
    {
      headers: {
        accept: "application/json"
      }
    }
  );

  const result = await response.json();

  // 필요데이터만 따로 전처리
  const data = result... (생략)

  return data;
}



  • 기본 동작 방식
    • 첫 요청 시 데이터 fetch 하여 받아온 결과를 cache에 set 하고 응답으로도 전달.
    • 캐싱된 후 ttl 시간 이내에 요청이 오면 캐싱된 데이터를 응답으로 전달
    • ttl이후(캐싱 만료) 요청이 오면 기존에 만료된 데이터를 응답으로 넘겨주고 백그라운드 콜백 함수(cache.on)로 캐싱 데이터 업데이트
    • 캐싱데이터 업데이트 중 다시 요청이 들어오면 콜백 함수(cache.on)가 또 실행되므로, isUpdatingCache 플래그를 추가하여 중복으로 업데이트되지 않도록 구현
  • cache.on을 통해 캐시만료("expired") 콜백함수 설정 가능
  • deleteOnExpire 설정값을 false로 설정해야 만료된 값을 바로 폐기하지 않고 ttl 이후 요청이 왔을 때 Stale(만료)된 값으로 바로 응답이 가능. (응답 데이터 종류에 따라 만료된 값을 전달해도 크게 이상이 없을 경우 사용해야 함. 캐시를 업데이트하는 동안 만료된 데이터를 제공하지만 빠른 응답이 가능)
  • 캐싱데이터 업데이트 중 다시 요청이 들어오면 콜백 함수이 중복으로 실행되므로, isUpdatingCache 플래그를 추가하여 중복 업데이트 방지
  • NodeCache 인스턴스 생성 시 stdTTL을 설정하면 각 캐시 값에 TTL을 지정하지 않아도 해당값이 default로 적용됨