⚗️ 프로젝트#Web

웹 캐시로 API 요청 90% 줄이기

2025-08-26 | 🕒 읽는 데 0분 예상
작성자
u
카테고리
⚗️ 프로젝트
작성일
태그
Web
설명
상태
배포됨
최하위 정렬
최하위 정렬
forest_분류
forest_날짜
현재 개발 중인 라디오 프로젝트는 사용자가 현재 채널의 프로그램명과 선곡 정보를 실시간으로 확인할 수 있는 기능을 제공하고 있다.
이를 위해 60초마다 프로그램 정보를, 30초마다 선곡 정보를 서버에 요청하여 받아오는 폴링(Polling) 방식을 사용하고 있었다.
그런데 어느 날, Vercel의 Hobby Plan 사용량을 초과했다는 경고가 나를 맞이했다. 자세히 살펴보니 대부분의 요청이 라디오 서비스에서 발생하고 있는 것을 확인할 수 있었다.
사실 생각해보면 매우 비효율적인 구조임을 알 수 있다. 100명의 1초 동안 100회의 요청을 보낸다면, 서버는 100번의 요청을 처리해야 한다. 하지만 1초 동안 라디오에서 흘러나오는 프로그램명이나 선곡 정보가 바뀔 일은 없지 않은가? 설령 마침 바뀌는 시점이라고 해도 100번의 동일한 요청을 처리할 필요는 전혀 없다.
이러한 문제를 해결하기 위해 필요한 것이 바로 웹 캐시(Web Cache)이다.

웹 캐시, 뭐하는 녀석인가


웹 캐시는 한 번 요청했던 리소스(HTML, 이미지, API 응답 등)를 특정 위치에 미리 저장해두고, 동일한 요청이 다시 발생했을 때 서버에 또 요청하는 대신 저장해 둔 리소스를 사용하는 기술이다. 이는 웹 페이지의 로딩 속도를 획기적으로 개선하고 서버의 부하를 줄여 결과적으로 인프라 비용 절감에도 기여하는 매우 중요한 성능 최적화 기법이다.

캐시의 종류와 위치

HTTP 캐시는 저장되는 위치에 따라 크게 두 가지로 나뉜다.
  • 로컬 캐시 (브라우저 캐시): 사용자의 웹 브라우저에 직접 저장되는 캐시이다. 오직 한 명의 사용자만을 위한 것으로, 뒤로 가기나 재방문 시 매우 빠른 속도를 경험하게 해준다.
  • 공유 캐시 (프록시 캐시): 여러 사용자가 공유할 수 있는 캐시이다. 대표적으로 CDN(Content Delivery Network)이 여기에 해당한다. 사용자와 서버 사이의 중간 지점에서 자주 요청되는 리소스를 캐싱하여 여러 사용자에게 빠르게 응답을 전달한다.
결론을 먼저 말하자면, 이번 프로젝트에 도입한 방식은 후자인 공유 캐시라고 볼 수 있다.

캐시 제어를 위한 HTTP 헤더

서버는 HTTP 응답 헤더를 통해 브라우저나 CDN에게 리소스를 어떻게 캐싱할지 알려준다. 가장 핵심적인 헤더는 Cache-Control이다.
  • Cache-Control: max-age=<seconds>: 리소스가 유효하다고 간주되는 시간을 초 단위로 지정한다. 예를 들어 max-age=60이라면, 60초 동안은 서버에 재요청 없이 캐시된 데이터를 사용한다.
  • Cache-Control: no-store: 가장 강력한 설정으로, 어떤 경우에도 캐시를 저장하지 말라는 의미이다.
  • Cache-Control: no-cache: 이름 때문에 헷갈릴 수 있지만 캐시를 저장하지 않는다는 의미가 아니다. 캐시는 저장하지만, 사용할 때마다 서버에 이 캐시가 유효한지 **재검증(Revalidation)**하라는 의미이다.
  • Cache-Control: s-maxage=<seconds>: max-age와 유사하지만, CDN과 같은 공유 캐시에만 적용되는 유효 시간이다.
  • stale-while-revalidate=<seconds>: max-age로 지정된 유효 시간이 지나더라도, 지정된 시간 동안은 일단 오래된(stale) 캐시 데이터를 보여주면서 백그라운드에서 새로운 데이터로 재검증하라는 의미이다.

캐시 재검증

캐시의 유효 기간(max-age)이 만료되면 어떻게 될까? 캐시는 즉시 삭제되지 않는다. 대신 브라우저는 서버에 조건부 요청(Conditional Request)을 보내 저장된 캐시가 여전히 유효한지 확인한다. 이때 ETagLast-Modified 헤더가 사용된다.
서버는 이 값을 비교하여 리소스에 변경이 없으면, 전체 리소스를 다시 보내는 대신 304 Not Modified라는 상태 코드와 함께 빈 본문(Body)으로 응답한다. 클라이언트는 이 응답을 받고 캐시된 데이터를 계속 사용해도 좋다는 것을 알게 된다. 이는 불필요한 데이터 전송을 막아주는 매우 효율적인 방식이다.

Next.js에서 캐시 사용하기 (App Router)


Next.js 13 버전부터 도입된 App Router는 네이티브 fetch 함수을 확장하여 캐시와 관련된 다양한 기능을 기본으로 제공한다. 서버 성능과 직결되는 캐싱은 크게 세 종류이다.

Request Memoization

서버 렌더링 과정에서 동일한 fetch 요청이 여러 컴포넌트에서 발생하면, 실제로는 단 한 번만 요청을 보내고 그 결과를 기억(memoize)하여 재사용한다. 별도의 옵션 지정 없이도 fetch 함수에서 적용된다. 단, Request Memoization은 서버에서 호출되는 GET 요청에만 동작하며, 다른 HTTP 요청이나 클라이언트에서 호출되는 API에는 적용되지 않는다. 또, 한 번의 서버 렌더링 생명주기 동안만 유효하므로 revalidate는 불가능하다.
https://nextjs.org/docs/app/guides/caching#request-memoization

Data Cache

fetch 함수의 확장 기능으로, API 응답을 서버에 영구적으로 캐싱한다. 여러 사용자 요청과 배포에 걸쳐 데이터가 유지된다. revalidate 옵션을 통해 시간 기반 또는 온디맨드(On-demand) 방식으로 캐시를 갱신할 수 있다.
https://nextjs.org/docs/app/guides/caching#revalidating-1
쉽게 말해 가장 일반적인 형태의 캐시 동작이라고 볼 수 있다. fetch 함수에 next.revalidate 값을 지정하면 해당 시간동안 응답을 저장해두었다가 동일한 경로로 들어오는 요청에 대해 실제 API 호출 없이 저장된 응답을 반환한다.
// 1시간(3600초)마다 캐시를 갱신한다. fetch('https://...', { next: { revalidate: 3600 } });
만약 위 코드처럼 next.revalidate를 1시간으로 설정하면, 1시간 동안 100명의 사용자가 접속해도 실제 API 요청은 1회만 수행하게 된다.

Full Route Cache

서버 렌더링 결과(HTML과 RSC Payload) 자체를 캐싱한다.
https://nextjs.org/docs/app/guides/caching#static-and-dynamic-rendering
주로 상품 상세 페이지와 같이 내용 변경이 거의 없는 정적 페이지에 적용한다. 반대로 마이페이지처럼 정보가 수시로 갱신되는 페이지는 캐시를 적용하기에 적합하지 않다.

(Pages Router)


위에서 설명했던 내장 fetch 캐시 기능은 Next.js 13 이상의 App Router에서만 사용이 가능하다. 하지만 라디오 프로젝트는 Page Router를 사용해 개발하고 있던 것이다! 물론 App Router로 리팩토링하는 방법도 있지만, 여기서는 인메모리 캐시(In-memory Cache)를 직접 구현하기로 했다.

1. 인메모리 캐시 구현

서버 메모리에 데이터를 잠시 저장할 간단한 캐시 모듈을 /lib/cache.ts에 구현했다. JavaScript의 Map 객체를 사용하고, 각 데이터에 만료 시간(expireTime)을 함께 저장하여 TTL(Time-To-Live)을 관리하는 방식이다.
/lib/cache.ts
class MemoryCache { constructor() { this.cache = new Map(); } set(key, value, ttl = 60000) { // 기본 1분 TTL const expireTime = Date.now() + ttl; this.cache.set(key, {value, expireTime }); } get(key) { const item = this.cache.get(key); if (!item) return null; // 만료 시간을 체크하여 만료되었다면 삭제하고 null을 반환한다. if (Date.now() > item.expireTime) { this.cache.delete(key); return null; } return item.value; } } // 여러 요청에서 공유될 수 있도록 글로벌 인스턴스로 관리한다. const cache = new MemoryCache(); export default cache;

2. API Route에 캐싱 로직 적용

이제 API Route에 캐싱 로직을 통합한다. 핵심 흐름은 다음과 같다.
  1. 요청이 들어오면 방송사(stn), 채널(ch) 등의 매개변수로 고유한 cacheKey를 생성한다.
  1. cache.get(cacheKey)를 호출하여 유효한 캐시 데이터가 있는지 확인한다.
  1. 캐시 히트(Cache Hit): 데이터가 있다면 외부 API 요청 없이 즉시 캐시된 데이터를 반환한다.
  1. 캐시 미스(Cache Miss): 데이터가 없다면 외부 방송사 API에 데이터를 요청한다.
  1. 가져온 데이터를 가공한 후, cache.set(cacheKey, responseData, 30000)를 통해 30초의 TTL을 설정하여 캐시에 저장한다.
  1. 새로운 데이터를 클라이언트에 반환한다.
또한, 서버의 인메모리 캐시뿐만 아니라 CDN 캐시(Vercel Edge Computing에 배포하므로)도 활용하기 위해 HTTP 헤더를 설정했다.
import cache from '../../../lib/cache.js'; export default async function handler(req, res) { const { stn, ch, city } = req.query; // 1. CDN 및 브라우저 캐시를 위한 헤더 설정 // s-maxage: CDN에서 30초간 캐시 // stale-while-revalidate: 만료 후 60초간은 일단 오래된 캐시를 보여주면서 백그라운드에서 갱신 res.setHeader('Cache-Control', 's-maxage=30, stale-while-revalidate=60'); const cacheKey = `program_${stn}_${ch}_${city || 'default'}`; // 2. 인메모리 캐시 확인 const cachedResult = cache.get(cacheKey); if (cachedResult) { return res.status(200).json(cachedResult); // 캐시 히트 } // 3. 캐시 미스 시 외부 API에서 데이터 가져오기 (try-catch 블록 내부) try { // ... (외부 API 호출 및 데이터 처리 로직) ... const responseData = { title: title }; // 4. 인메모리 캐시에 결과 저장 (30초 TTL) cache.set(cacheKey, responseData, 30000); res.status(200).json(responseData); } catch (error) { // ... (에러 처리) ... } }

결과

이제 30초의 캐시 유효 시간 동안 수백, 수천 명의 사용자가 동일한 방송 채널 정보를 요청하더라도, 외부 API로의 실제 요청은 단 1회만 발생한다. Vercel 배포 환경에서는 s-maxage 헤더 덕분에 대부분의 요청이 CDN에서 처리되어 우리 서버(Serverless Function)의 실행 횟수 자체도 극적으로 감소했다.
notion image
실제 대시보드를 확인해보면, 최대 9분까지 찍히던 Edge Computing 사용 시간이 8월 13일 캐시 적용 후 1분 미만으로 감소한 것을 볼 수 있다.

댓글 0