
하루에 12커밋. Cache Components, useCache, ISR — 세 가지 캐싱 전략을 연속으로 시도하고 두 번 실패한 뒤에야 답을 찾았다. Next.js 16의 새로운 캐싱 메커니즘이 아직 얼마나 불안정한지 몸으로 확인한 기록이다.
Next.js 16으로 업그레이드하면서 빌드 환경에 MongoDB가 없는 문제에 부딪혔다. Docker 빌드 시 DB 연결이 안 되니 프리렌더링이 실패한다. 이걸 해결하려고 캐싱 전략을 세 번 바꿨고, 그 과정이 꽤 교훈적이었다.
Next.js 16에서 'use cache' 디렉티브가 새로 나왔다. 기존 unstable_cache를 대체하는 깔끔한 API라서 바로 전환했다. cacheComponents: true를 next.config.mjs에 추가하면 된다고 했다.
// next.config.mjs — 넣었다가 바로 뺐다
cacheComponents: true, // PayloadCMS admin과 충돌!문제는 이 플래그가 dynamicIO를 전역으로 활성화한다는 점이다. dynamicIO가 켜지면 모든 데이터 접근에 Suspense 바운더리가 필요하다. PayloadCMS admin 패널은 그런 구조가 아니라서 빌드가 통째로 깨졌다. 넣은 지 30분 만에 롤백했다.
cacheComponents를 빼고 experimental: { useCache: true }로 바꿨다. 이번에는 빌드가 성공했다. 하지만 배포 후에 문제가 터졌다.
async function getSeriesWithCounts() {
'use cache'
cacheTag('series')
cacheLife('hours')
// 빌드 타임에 DB 없음 → 빈 배열 반환 → 이게 캐시됨
const payload = await getPayload({ config })
// ...
}Docker 빌드 시점에는 MongoDB가 없다. 'use cache' 함수가 빌드 타임에 실행되면서 빈 배열을 반환했고, 그 빈 결과가 캐시됐다. 문제는 ISR revalidation이 이 캐시를 갱신하지 않는다는 것이다. 빈 페이지가 영원히 서빙됐다.
이건 문서에서 찾을 수 없었던 함정이다. 'use cache'와 "빌드 타임에 DB가 없는 환경"의 조합은 캐시 지옥을 만든다.
두 번 실패하고 나서 ISR로 돌아왔다. 페이지 레벨에서 revalidate = 60을 설정하고, 빌드 타임에는 NEXT_PHASE 가드로 빈 값을 반환하되, 런타임에서 첫 요청 시 실제 데이터로 ISR 캐시를 생성하는 패턴이다.
// 페이지 레벨: ISR
export const revalidate = 60
async function getSeriesWithCounts() {
// 빌드 타임에는 빈 배열 반환 (DB 없음)
if (process.env.NEXT_PHASE === 'phase-production-build') return []
// 런타임에서 실제 DB 조회
const payload = await getPayload({ config })
return payload.find({ collection: 'posts', /* ... */ })
}'use cache'는 Header, Footer 같은 레이아웃 컴포넌트에만 유지했다. 이 컴포넌트들은 빌드 타임에 실패해도 런타임에서 try-catch로 복구할 수 있기 때문이다.
캐싱 삽질 중에 publishedAt 필드를 수동으로 설정하는 게 귀찮아서 훅을 추가했다. 포스트가 처음 publish될 때 자동으로 현재 시간이 들어간다.
// src/hooks/setPublishedAt.ts
export const setPublishedAt: CollectionBeforeChangeHook = async ({
data, originalDoc
}) => {
if (data._status === 'published' && !originalDoc?.publishedAt) {
data.publishedAt = new Date().toISOString()
}
return data
}여기서도 삽질이 있었다. 처음에 previousDoc를 썼는데, PayloadCMS에서 이미 deprecated된 프로퍼티였다. originalDoc으로 바꿔야 했다. 타입이 맞지 않아서 바로 알 수 있었지만, 런타임에서만 확인되는 프레임워크도 있으니 이런 사소한 API 변경은 주의해야 한다.
'use cache', cacheComponents, useCache 모두 실험적 기능이다. 프로덕션에서 안정적인 선택은 여전히 ISR이다.결국 하루에 12커밋을 쌓고 돌아온 곳은 ISR이었다. 새로운 API가 나오면 써보고 싶은 게 개발자 심리인데, "아직 실험적"이라는 경고를 가볍게 보면 안 된다는 걸 다시 배웠다. 검증된 방식이 지루하더라도, 프로덕션에서는 지루한 게 좋다.
빌드 타임에도 유효한 결과를 반환할 수 있는 함수에 쓰면 안전하다. Header, Footer처럼 실패해도 try-catch로 복구 가능한 레이아웃 컴포넌트가 좋은 대상이다. 페이지 레벨 데이터 함수에는 아직 위험하다.
페이지 레벨은 ISR로 두고, 레이아웃이나 공유 컴포넌트에서 'use cache'를 사용하는 조합은 가능하다. 다만 페이지 데이터 함수 자체에 'use cache'를 걸면 빌드 타임 빈 캐시 문제가 발생할 수 있으니 분리해서 사용해야 한다.
ISR + NEXT_PHASE 가드 조합이 현재로서는 가장 안정적이다. 빌드 타임에 빈 값을 반환하고, 런타임 첫 요청 시 실제 데이터로 ISR 캐시를 생성한다. warm-cache 스크립트로 배포 직후 캐시를 미리 생성해두면 첫 사용자도 빈 페이지를 보지 않는다.