Next.js 배포 시 빈 캐시 문제 해결: 런타임 워밍에서 빌드 타임 정적 생성으로
배포할 때마다 반복되는 불안함
Coolify로 Next.js 블로그를 운영하면서 배포할 때마다 불안한 순간이 있었습니다. 배포 직후 사이트에 접속하면 블로그 글 목록이 비어있거나, 심한 경우 internal server error, no available server 에러가 뜨는 현상이었습니다.
5분 정도 기다리면 정상으로 돌아왔지만, 그 사이에 방문한 사용자에게는 빈 페이지가 보였을 것입니다. 이 문제를 해결하기 위해 여러 시도를 했고, 결국 근본적인 접근 방식을 바꾸게 되었습니다.
문제의 원인: ISR과 빈 캐시
Next.js의 ISR(Incremental Static Regeneration)은 첫 요청 시 페이지를 생성하고 캐시합니다. 문제는 새 컨테이너가 시작되면 캐시가 비어있다는 점입니다.
배포 직후 홈페이지에 접속하면 Next.js가 백엔드 API를 호출해서 블로그 목록을 가져옵니다. 이때 백엔드가 아직 완전히 준비되지 않았거나 응답이 느리면, 빈 배열이 반환되고 그 상태가 5분간 캐시됩니다. revalidate 간격이 300초였기 때문입니다.
1차 시도: 런타임 캐시 워밍
처음에는 런타임에서 해결하려 했습니다. 컨테이너가 시작되면 서버가 준비될 때까지 기다렸다가, 주요 페이지들을 미리 fetch해서 캐시를 채우는 방식이었습니다.
# start-with-warmup.sh 스크립트
1. 서버 시작 (백그라운드)
2. /api/health가 응답할 때까지 대기
3. ISR 캐시 무효화 (/api/revalidate 호출)
4. /ko, /en 페이지 fetch로 캐시 워밍
5. /tmp/app-ready 파일 생성
6. 헬스체크가 200 반환 시작
Readiness Gate 패턴을 적용해서, 캐시 워밍이 완료되기 전에는 헬스체크가 503을 반환하도록 했습니다. 이렇게 하면 Coolify가 새 컨테이너로 트래픽을 전환하지 않을 것이라 생각했습니다.
no available server 에러의 원인
그런데 예상과 다르게 internal server error, no available server 에러가 발생했습니다. 원인을 분석해보니, Coolify의 롤링 업데이트 과정에서 타이밍 문제가 있었습니다.
새 컨테이너가 503을 반환하는 동안, 기존 컨테이너는 이미 종료 준비를 시작합니다. 헬스체크 start-period(45초) 동안 새 컨테이너가 준비되지 않으면, 잠시 동안 트래픽을 받을 수 있는 컨테이너가 없는 상태가 됩니다.
캐시 워밍에 시간이 오래 걸리거나, 백엔드 응답이 느린 경우에 이 문제가 발생했습니다.
Deep Health Check 시도와 한계
단순히 서버가 응답하는지만 확인하는 것이 아니라, 실제로 콘텐츠가 제대로 렌더링되는지까지 확인하는 Deep Health Check를 구현했습니다.
/api/health/deep 엔드포인트
1. 백엔드 API 연결 확인
2. 홈페이지 렌더링 확인 (블로그 글 목록 있는지)
3. 블로그 상세 페이지 렌더링 확인
이론적으로는 완벽해 보였지만, 실제로는 false negative 문제가 있었습니다. 캐시 워밍 중에 Deep Health Check가 실행되면, 아직 캐시가 채워지지 않은 상태에서 검사해서 실패로 판정되었습니다. 결국 이 기능은 revert 해야 했습니다.
근본적인 질문: 왜 빌드 타임에 안 했을까?
어느 순간 의문이 들었습니다. Next.js는 빌드 시점에 정적 페이지를 생성할 수 있는데, 왜 런타임에 복잡한 워밍 로직을 돌리고 있는 걸까?
코드를 확인해보니 SKIP_STATIC_GENERATION=true 환경변수로 빌드 시 정적 생성을 건너뛰고 있었습니다. 주석에는 "Coolify 빌드 환경: API 호출 스킵 (SSL 인증서 문제 회피)"라고 적혀 있었습니다.
과거에 Coolify 빌드 환경에서 외부 HTTPS 호출이 안 되어서 이렇게 우회한 것으로 추측됩니다. 하지만 정말 안 되는 건지 테스트해본 적은 없었습니다.
빌드 환경 API 접근 테스트
Dockerfile에 테스트 코드를 추가해서 실제로 확인해보기로 했습니다.
# Dockerfile에 추가
RUN curl -v https://api.kichang.info/health
RUN curl -sf "https://api.kichang.info/blog-posts?limit=3&language=ko"
빌드 로그를 확인해보니, 예상과 달리 모든 API 호출이 성공했습니다. DNS 해석, SSL 인증서 검증, 데이터 fetch까지 전부 정상이었습니다.
📡 Testing API health endpoint...
* SSL certificate verified via OpenSSL.
< HTTP/2 200
{"status":"ok","timestamp":"2026-01-06T05:34:51.116Z"}
📡 Testing blog posts API (ko)...
{"posts":[{"title":"OCI HeatWave MySQL + NestJS 연동..."
SSL 인증서 문제는 과거에 있었거나, 아니면 처음부터 오해였을 수 있습니다. 어쨌든 지금은 빌드 환경에서 API 접근이 가능하다는 것을 확인했습니다.
해결책: 빌드 타임 정적 생성
이제 접근 방식을 완전히 바꿨습니다. 빌드 시점에 데이터를 가져와서 정적 페이지를 생성하는 것입니다.
새로운 빌드 흐름
Stage 0: Data Fetcher
├─ API에서 블로그 데이터 fetch
└─ posts-ko.json, posts-en.json 저장
Stage 2: Builder
├─ pre-fetched JSON 파일 복사
├─ USE_PREFETCHED_DATA=true pnpm build
└─ 모든 블로그 페이지 정적 생성
Stage 3: Runner
├─ node server.js (직접 실행)
└─ 헬스체크 즉시 200 반환
핵심은 빌드 시점에 이미 모든 페이지가 생성되어 있으므로, 런타임에 캐시 워밍이 필요 없다는 점입니다.
확장성 고려
글이 많아지면 빌드 시간이 길어질 수 있습니다. 이를 대비해서 최신 50개 글만 정적 생성하고, 나머지는 ISR로 처리하도록 제한을 두었습니다.
const limit = parseInt(process.env.STATIC_GENERATION_LIMIT || '50', 10);
const recentPosts = posts.slice(0, limit);
대부분의 트래픽은 최신 글에 집중되므로, 사용자 경험에 큰 영향은 없습니다.
결과: 단순해진 구조
변경 후 삭제할 수 있었던 것들:
start-with-warmup.sh- 복잡한 워밍 스크립트/api/health/deep- Deep Health Check- Readiness Gate 패턴 - /tmp/app-ready 파일 체크
헬스체크는 단순히 200을 반환하는 것으로 충분해졌고, start-period도 45초에서 15초로 줄일 수 있었습니다.
| 항목 | 이전 | 이후 |
|---|---|---|
| 시작 시간 | 5-10초 (워밍 대기) | 즉시 |
| start-period | 45초 | 15초 |
| 빈 캐시 문제 | 가능성 있음 | 해결됨 |
| no available server | 간헐적 발생 | 해결됨 |
돌이켜보며
처음에 SSL 인증서 문제라고 추측하고 런타임 워밍으로 우회했던 것이 문제의 시작이었습니다. 추측 대신 실제로 테스트해봤다면 더 일찍 해결할 수 있었을 것입니다.
복잡한 문제에 복잡한 해결책을 적용하다 보면, 원래 문제보다 해결책이 더 많은 문제를 만들 때가 있습니다. Deep Health Check의 false negative, Readiness Gate의 타이밍 이슈가 그랬습니다.
결국 가장 좋은 해결책은 문제 자체를 없애는 것이었습니다. 런타임에 캐시를 채우는 대신, 빌드 시점에 이미 채워진 상태로 배포하면 됩니다. 단순한 접근이 복잡한 우회보다 낫다는 것을 다시 한번 느꼈습니다.
관련 글
내가 Next.js ISR을 선택한 이유: 블로그 SEO, 그 고민의 시작과 해결
Next.js ISR을 선택하여 블로그 SEO 문제를 해결하는 방법을 알아보세요. React CSR의 한계를 극복하고, 검색 엔진 최적화와 소셜 미리보기를 완벽 지원하는 ISR의 핵심 원리를 소개합니다.
Liveness와 Readiness: 컨테이너 헬스체크의 두 가지 관점
Docker나 Kubernetes 환경에서 헬스체크를 구현할 때 Liveness와 Readiness의 차이를 이해하고, NestJS Backend와 Next.js Web에서 실제로 구현한 경험을 정리했습니다.
PM2 vs Coolify: 상황에 맞는 Node.js 배포 전략 선택하기
Node.js 배포 도구인 PM2와 Coolify의 차이점을 분석하고, 프로젝트 특성에 따른 선택 기준을 제시합니다.