
Vercel 외 플랫폼에서 ISR 안 되는 문제
Vercel에서는 잘 되던 ISR이 AWS나 Docker 환경에서는 왜 작동하지 않을까요? 파일 시스템 캐시의 함정과 해결책을 파헤쳐봤습니다.

Vercel에서는 잘 되던 ISR이 AWS나 Docker 환경에서는 왜 작동하지 않을까요? 파일 시스템 캐시의 함정과 해결책을 파헤쳐봤습니다.
서버를 끄지 않고 배포하는 법. 롤링, 카나리, 블루-그린의 차이점과 실제 구축 전략. DB 마이그레이션의 난제(팽창-수축 패턴)와 AWS CodeDeploy 활용법까지 심층 분석합니다.

새벽엔 낭비하고 점심엔 터지는 서버 문제 해결기. '택시 배차'와 '피자 배달' 비유로 알아보는 오토 스케일링과 서버리스의 차이, 그리고 Spot Instance를 활용한 비용 절감 꿀팁.

내 서버가 해킹당하지 않는 이유. 포트와 IP를 검사하는 '패킷 필터링'부터 AWS Security Group까지, 방화벽의 진화 과정.

왜 넷플릭스는 멀쩡한 서버를 랜덤하게 꺼버릴까요? 시스템의 약점을 찾기 위해 고의로 장애를 주입하는 카오스 엔지니어링의 철학과 실천 방법(GameDay)을 소개합니다.

모든 개발자가 듣기 싫어하는 말 1위가 "로컬에서는 잘 되는데요?"일 겁니다. 하지만 그보다 더 무서운 말이 있습니다. 바로 "Vercel에서는 잘 되는데요?"입니다.
제가 이 문제를 겪은 건 Next.js 서비스를 Vercel에서 AWS로 이관하던 때였습니다. Vercel 초기 시절에는 개발이 아주 편했습니다. git push만 하면 배포가 되고, 도메인이 연결되고, SSL이 붙고, CDN이 적용됩니다. 인프라 엔지니어가 따로 필요 없었죠.
특히 Next.js의 필살기인 ISR (Incremental Static Regeneration)은 그야말로 마법이었습니다. 정적 사이트(Static)의 빠른 속도를 유지하면서도, 데이터가 바뀌면 알아서 최신 페이지를 그려주니까요(Dynamic).
export async function getStaticProps() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return {
props: { posts },
revalidate: 60, // 60초마다 갱신!
};
}
이 revalidate: 60 한 줄이면 충분했습니다. "와, Next.js 진짜 잘 만들었다"고 감탄하며 비즈니스 로직에만 집중할 수 있었죠.
하지만 서비스가 성장하고 트래픽이 늘어나면서 Cloud Bill(청구서)이 부담스러워지기 시작했습니다. AWS Activate 같은 스타트업 지원 프로그램을 통해 크레딧을 확보할 수 있다면, "돈도 아낄 겸, 인프라 내재화도 할 겸 AWS로 가자!"라는 결정을 내리게 됩니다.
EC2 인스턴스를 띄우고, Docker 이미지를 빌드하고, 로드 밸런서(ALB)를 붙였습니다. 배포 파이프라인(CI/CD)도 구축했죠. 모든 게 완벽해 보였습니다.
서비스 오픈 3시간 전, 긴급 리포트가 올라오기 전까지는요."관리자 페이지에서 공지사항을 수정했는데, 메인 홈페이지에는 옛날 내용이 그대로 나옵니다. 새로고침을 계속 하다 보면 가끔 새 내용이 나오다가, 다시 옛날 내용이 나옵니다."
네? 가끔 나오고 가끔 안 나온다고요? 이건 DB 문제도 아니고, 프론트엔드 버그도 아니었습니다. 전형적인 "유령(Ghost)" 버그였습니다.
처음에는 브라우저 캐시 문제인 줄 알았습니다. 개발자 도구를 켜고 'Disable cache'를 체크해봐도 똑같았습니다. 그다음엔 CDN(CloudFront) 문제인 줄 알고 캐시 무효화(Invalidation)를 해봤습니다. 여전히 똑같았습니다.
서버 로그를 봤습니다. 분명히 ISR 재생성이 트리거되었다는 로그(Generating static pages...)가 찍혀 있었습니다. DB 조회도 정상적으로 이루어졌고요. 그런데 왜 사용자에게는 3일 전 데이터가 보이는 걸까요?
이 현상을 이해하기 위해선 Next.js가 ISR을 어떻게 처리하는지, 그 "물리적 실체"를 파헤쳐야 했습니다.
우리가 Vercel을 쓸 때, ISR은 "Global Cache"라는 중앙 저장소를 사용합니다.
revalidate)이 지났다면, Background Serverless Function이 돕니다.즉, Vercel 환경에서는 "상태(State)가 중앙화"되어 있습니다. 개발자는 이걸 신경 쓸 필요가 없죠.
하지만 AWS EC2 위에 Docker 컨테이너를 띄우면 이야기가 다릅니다. Docker 안에서 Next.js 서버를 실행(npm start)하면, 기본적으로 로컬 파일 시스템을 캐시 저장소로 사용합니다.
구체적으로는 .next/cache/ 디렉터리와 .next/server/pages/ 디렉터리입니다.
ISR이 발동하면, Node.js 프로세스는 DB에서 데이터를 가져와 새로운 HTML 파일을 만들고, 자기 자신의 디스크에 씁니다.
여기서 우리가 간과한 결정적 차이가 발생합니다.
우리는 고가용성(High Availability)을 위해 서버를 2대 띄우고 로드 밸런서(ALB)로 묶어놨습니다. (Auto Scaling Group)
상황을 재구성해봤다.
index.html (제목: "점검 완료") -> 업데이트됨 ✅index.html (제목: "점검 안내") -> 옛날 그대로 ❌사용자 A가 새로고침을 하면 어떻게 될까요?
사용자 입장에서는 타임루프에 갇힌 것처럼, 과거와 현재를 왔다 갔다 하게 됩니다. 이것이 QA 팀이 리포트한 "가끔 나오고 가끔 안 나오는" 현상의 정체였습니다.
더 끔찍한 시나리오도 있습니다. 배포(Deployment)입니다.
Docker 기반 배포는 보통 Immutable Infrastructure 전략을 따릅니다. 새 버전이 나오면 기존 컨테이너를 죽이고, 새 이미지를 기반으로 새 컨테이너를 띄웁니다.
컨테이너가 죽으면? 그 안에 있던 파일 시스템(.next/cache)도 함께 증발합니다.
열심히 사용자들이 접속해서 ISR로 최신 데이터를 만들어놨는데, 배포 한 번 하면 싹 다 초기화되고 다시 맨 처음 빌드 시점(Build Time)의 데이터로 돌아가는 겁니다.
문제의 핵심은 "서버들이 각자 따로 캐시(일기장)를 쓴다"는 것입니다. 해결책은 간단합니다. "공용 일기장(Shared Storage)을 쓰자."
모든 서버가 읽고 쓸 수 있는 외부 저장소가 필요했습니다.
가장 먼저 떠오른 건 네트워크 파일 시스템인 EFS였습니다. 여러 EC2가 하나의 폴더를 공유할 수 있으니까요. .next/cache 폴더를 EFS에 마운트하면 되지 않을까?
해봤습니다.
결과는? 너무 느립니다. EFS는 네트워크를 타기 때문에 로컬 디스크보다 I/O가 느립니다. Next.js 빌드나 실행 속도가 눈에 띄게 저하되었습니다. 그리고 설정이 복잡합니다.
로드 밸런서에서 "한 번 서버 1번에 붙은 애는 계속 서버 1번으로 보내줘"라고 설정하는 방법입니다. 이러면 사용자 A는 계속 최신 버전을 보겠죠. 하지만 근본적인 해결책이 아닙니다. 서버 2번은 영원히 갱신되지 않을 수도 있고, 서버 1번이 죽으면 사용자 A는 갑자기 과거로 가게 됩니다.
결국 답은 인메모리 데이터 저장소인 Redis였습니다. 빠르고, 모든 서버가 접근 가능하며, Key-Value 구조가 캐싱에 딱 맞습니다.
다행히 Next.js(13.4 이상)부터는 CacheHandler API를 통해 내부 캐시 로직을 커스터마이징할 수 있는 공식적인 방법을 제공합니다. 우리는 이것을 이용해 "파일 시스템 대신 Redis에 쓰고 읽어라"라고 Next.js에게 알려주면 됩니다.
바닥부터 짤 필요는 없습니다. 훌륭한 오픈소스 라이브러리 @neshca/cache-handler가 있습니다.
npm install @neshca/cache-handler redis
cache-handler.mjs)프로젝트 루트에 이 파일을 만듭니다.
import { CacheHandler } from '@neshca/cache-handler';
import { createClient } from 'redis';
CacheHandler.onCreation(async () => {
// 환경변수에서 Redis URL을 가져옵니다. 없으면 로컬호스트(개발용)
const redisUrl = process.env.REDIS_URL ?? 'redis://localhost:6379';
const client = createClient({
url: redisUrl,
});
client.on('error', (err) => {
console.error('Redis connection error:', err);
});
// Redis 연결
await client.connect();
console.log('Redis connected for Next.js ISR Cache');
// Redis용 핸들러 생성
const redisHandler = await createRedisHandler({
client,
keyPrefix: 'nextjs-isr-cache:', // 키 충돌 방지용 접두사
timeoutMs: 5000, // 타임아웃 설정
});
return {
handlers: [redisHandler],
// 메모려(L1) 캐시를 쓸지 말지 결정할 수 있습니다.
// 분산 환경에서는 메모리 캐시를 끄거나 아주 짧게 잡아야 정합성이 맞습니다.
};
});
export default CacheHandler;
next.config.js)이제 Next.js가 이 핸들러를 쓰도록 설정합니다. 여기서 가장 중요한 옵션은 cacheMaxMemorySize: 0입니다.
/** @type {import('next').NextConfig} */
const nextConfig = {
// ... 기존 설정들
// 우리가 만든 핸들러 파일 경로 지정
cacheHandler: require.resolve('./cache-handler.mjs'),
// 중요!! 메모리 캐시 끄기 (0 바이트)
// 이걸 끄지 않으면, Redis에 최신 데이터가 있어도
// 각 서버가 자기 메모리에 있는 옛날 데이터를 먼저 줄 수 있습니다.
cacheMaxMemorySize: 0,
};
module.exports = nextConfig;
이렇게 설정하고 배포하면, 아키텍처가 다음과 같이 바뀝니다.
배포(재시작)를 해도 Redis는 살아있기 때문에, 캐시 데이터가 날아가지 않습니다. (Redis 설정을 통해 영속성 보장 가능)
여기서 끝이 아닙니다. Next.js에는 시간 기반 갱신 말고, API를 호출해서 즉시 갱신하는 On-Demand Revalidation(res.revalidate('/path')) 기능이 있습니다.
만약 우리가 CMS(관리자 도구)에서 "저장" 버튼을 누르면 웹훅(Webhook)을 쏴서 페이지를 갱신한다고 치자.
웹훅 요청이 서버 1번으로 들어갔습니다.
서버 1번은 res.revalidate('/products/1')을 실행합니다. Redis에 새 데이터를 씁니다.
그런데 만약 cacheMaxMemorySize: 0을 안 했다면?
서버 2번은 여전히 자기 메모리에 옛날 데이터를 들고 있습니다. Redis에 새 데이터가 들어왔는지 모릅니다.
이 문제를 완벽하게 해결하려면 Pub/Sub 모델이 필요합니다.
/products/1 갱신했어!"라고 메시지 발행(Publish)다행히 @neshca/cache-handler는 실험적 기능으로 이것도 지원하지만, 설정이 꽤 복잡합니다.
가장 현실적인 대안은 그냥 메모리 캐시를 끄는 것(cacheMaxMemorySize: 0)입니다. Redis는 충분히 빠르기 때문에(보통 1ms 미만 응답), 굳이 서버 메모리에 또 캐싱할 필요가 없습니다. 네트워크 왕복 비용(Network RTT)만 감수하면, 정합성(Consistency) 문제는 완벽하게 해결됩니다.
Vercel을 쓸 때는 이런 고민을 1초도 할 필요가 없었습니다. 월 $20만 내면 Vercel 엔지니어들이 갈아 넣은 최적화된 인프라를 누릴 수 있었죠.
AWS로 오면서 우리는 "인프라 통제권"을 얻었지만, 동시에 "인프라를 유지보수해야 하는 책임"도 얻었습니다.
Redis 비용(ElastiCache t4g.micro 기준 월 $15 정도)도 추가되었고, 무엇보다 CacheHandler 설정하고 디버깅하는 데 쓴 제 인건비가 꽤 들어갔습니다.
하지만 이 과정을 통해 Next.js가 어떻게 동작하는지 뼛속까지 이해하게 되었습니다. "마법"이라고 생각했던 기능들이 사실은 파일 입출력(I/O)과 캐시 전략의 정교한 조합이었음을 깨달았습니다.
Vercel 밖에서 Next.js를 운영하려는 분들에게 조언합니다. "ISR을 쓸 거면 Redis를 준비하세요. 아니면 타임루프를 각오하든지요."