목차
1. 서론: 로컬에선 되는데 배포만 하면 죽어버리는 데이터
안녕하세요, 10년 차 웹 퍼블리셔이자 프론트엔드 실무를 굴러먹고 있는 오토 애드프레스입니다. 다들 Next.js 14 App Router 많이들 쓰고 계시죠? 처음 도입할 때는 참 감동이었습니다. 혁신적인 서버 컴포넌트(Server Components) 덕분에 무거운 자바스크립트 번들을 클라이언트로 보내지 않아도 되고, 렌더링 속도는 비약적으로 상승했으니까요. 게다가 SEO까지 기본적으로 챙겨주니 안 쓸 이유가 없었습니다.
하지만 막상 실무 프로덕션(Production) 환경에 배포하고 나면 겪게 되는 무시무시한 지옥이 하나 있습니다. 바로 '캐싱(Caching)'입니다. 지난주 금요일, 퇴근을 불과 30분 앞두고 고객사 담당자에게서 다급한 전화가 걸려왔습니다. "어제 관리자 페이지에서 메인 상품 배너 이미지를 바꿨는데, 실제 쇼핑몰 메인 페이지에서는 여전히 옛날 이미지가 나와요. 캐시 지우고 새로고침을 백 번 해도 똑같습니다. 지금 이벤트 시작해야 하는데 어떡하죠?"
순간 등골이 서늘해졌습니다. 제 로컬 개발 환경(npm run dev)에서는 기가 막히게 데이터가 잘 불러와졌거든요. 그런데 Vercel에 빌드해서 올리기만 하면 데이터가 마치 찰흙처럼 굳어버려서 꿈쩍도 하지 않는 겁니다. 공식 문서의 "간편한 캐싱"이라는 말만 덜컥 믿고 도입했다가 저처럼 주말 반납하고 에러 로그와 씨름하신 분들, 분명 적지 않을 거라고 생각합니다.
오늘은 제가 3시간 동안 온갖 구글링과 삽질을 반복하며 겪었던 App Router 캐싱의 진짜 모습과, 공식 매뉴얼에서는 절대 속 시원하게 알려주지 않는 트러블슈팅 과정을 아주 리얼하게 풀어보려고 합니다. 딱딱한 이론보다는 '진짜 실무'에서 부딪히는 문제에 집중했으니, 지금 당장 배포 후 데이터 갱신 에러로 고통받고 계신 분들께 단비 같은 글이 되길 바랍니다.
2. 문제의 발단: Next.js 14의 맹목적인 fetch 캐싱
이 사단의 근본적인 원인은 Next.js 14가 기본적으로 fetch 요청을 극단적일 정도로 캐싱한다는 점에 있습니다. 과거 Pages Router 시절에는 getServerSideProps나 getStaticProps를 통해 명시적으로 서버 렌더링을 할지, 정적 생성을 할지 우리가 직접 통제했습니다. 그런데 App Router로 넘어오면서 이 모든 게 fetch API 하나로 통합되었죠.
Next.js는 성능 최적화를 위해 기본 옵션으로 { cache: 'force-cache' }를 사용합니다. 즉, 빌드 시점이나 최초 요청 시점에 한 번 데이터를 받아오면, 그 데이터를 영구적으로 메모리에 박아버립니다. 백엔드 DB값이 아무리 바뀌어도, 브라우저에서 캐시를 비워도 소용없습니다. 이건 브라우저 캐시가 아니라 Next.js 서버(Data Cache)에 남아있는 거니까요.
💡 실무자의 시선: 공식 문서의 맹점
공식 문서에서는 단순히 데이터가 자주 바뀌면 { cache: 'no-store' }를 쓰라고 안내합니다. 참 쉽죠? 그런데 실무는 다릅니다. 메인 페이지의 수많은 컴포넌트 중 딱 하나(예: 실시간 공지사항)만 no-store를 줘도, Next.js는 그 페이지 전체를 동적 렌더링(Dynamic Rendering)으로 판단해 버립니다. CDN 캐싱의 이점을 다 날려버리고 매번 서버에서 페이지를 다시 그리게 되죠. 트래픽이 몰리는 쇼핑몰 메인 페이지에서 이건 서버 부하로 이어지는 자살 행위나 다름없습니다. 정적 페이지의 장점을 살리면서 필요한 데이터만 갱신하는 '부분 갱신(On-Demand Revalidation)'이 절실했습니다.
결국 저는 no-store라는 쉬운 길을 버리고, 특정 이벤트(예: 관리자가 배너를 수정했을 때)가 발생하면 해당 캐시만 날려버리는 revalidateTag를 적용하기로 마음먹었습니다.
3. 실전 트러블슈팅: revalidateTag의 배신과 극복 과정
본격적인 트러블슈팅에 들어갔습니다. 관리자 페이지에서 배너 API를 수정할 때마다 웹훅(Webhook)을 쏴서 프론트엔드의 캐시를 지우는 구조를 짰죠. 코드는 대략 아래와 같았습니다.
// 1. 데이터를 가져오는 서버 컴포넌트 (데이터 패칭)
async function getBanners() {
const res = await fetch('https://api.example.com/banners', {
next: { tags: ['banners'] } // 'banners'라는 태그로 캐싱
});
return res.json();
}
// 2. 캐시를 지우는 API 라우트 (app/api/revalidate/route.ts)
import { revalidateTag } from 'next/cache';
export async function POST(request) {
revalidateTag('banners');
return Response.json({ revalidated: true });
}
로직상으로는 완벽했습니다. API 서버에서 웹훅을 성공적으로 날렸고, Next.js 라우트에서도 revalidated: true라는 응답을 정상적으로 뱉었으니까요. 그런데 화면은? 여전히 그대로였습니다. 이때부터 멘탈이 흔들리기 시작하더라고요.
도대체 왜 캐시가 안 날아갈까? Vercel 대시보드 로그를 뒤지고, 로컬에서 빌드한 뒤 프로덕션 모드(npm run build && npm run start)로 띄워서 콘솔을 수십 번 찍어본 결과, 원인은 너무나도 허무한 곳에 있었습니다.
🚨 극복 과정: 내가 놓쳤던 2가지 에러 포인트
- 첫째, 클라이언트 컴포넌트와의 혼용 문제: 부모 컴포넌트가 서버 컴포넌트라 하더라도, 데이터를 Props로 넘겨받는 자식 컴포넌트에
'use client'가 선언되어 있고 그 안에서 상태(State)로 한 번 더 관리하고 있다면 화면 갱신이 즉시 일어나지 않습니다. Next.js의 라우터 캐시(Router Cache)가 브라우저 단에서 약 30초(동적 페이지)에서 5분(정적 페이지)간 화면을 유지해 버리기 때문입니다. 이를 해결하기 위해 클라이언트 측에서router.refresh()를 강제로 호출해주거나, 아예 불필요한 클라이언트 상태 관리를 걷어냈습니다. - 둘째, 태그 오타 및 환경 변수 불일치: 이건 제 실수이기도 한데요,
fetch에 달아둔 태그 이름과revalidateTag에서 호출하는 문자열이 미세하게 달랐습니다. 실무에서는 태그 이름을 하드코딩하지 말고 반드시 상수로 관리(Constants)해야 한다는 기본을 다시 한번 뼈저리게 느꼈습니다.
추가로, Next.js 14에서는 <Suspense> 경계(Boundary)를 잘 활용해야 합니다. 캐시가 무효화된 후 새로운 데이터를 렌더링하는 동안 화면이 멈춰 있는 현상을 방지하기 위해, 데이터 패칭 컴포넌트를 <Suspense fallback={<Skeleton />}> 로 감싸주니 사용자 경험이 훨씬 매끄러워졌습니다.
4. 프로젝트 타겟별 맞춤형 캐시 전략
이 엄청난 삽질을 끝내고 나니, Next.js의 캐시 시스템을 프로젝트 성격에 따라 어떻게 아키텍처링해야 할지 감이 오더군요. 무조건 서버 컴포넌트가 답은 아닙니다. 여러분의 직무나 프로젝트 타겟에 맞춰 전략을 다르게 가져가야 합니다.
| 프로젝트 타입 | 추천 캐시 전략 | 실무 적용 코멘트 |
|---|---|---|
| B2B 기업 소개 사이트 / 블로그 | SSG (기본 force-cache 유지) + revalidatePath |
데이터 갱신이 하루 1~2회 미만입니다. 전체 페이지 빌드에 의존해도 좋으며, CMS에서 글을 작성할 때 웹훅으로 revalidatePath만 쏴주면 완벽합니다. SEO 점수를 극대화할 수 있습니다. |
| 이커머스 메인 / 프로모션 뷰 | ISR (Time-based Revalidation) + revalidateTag |
트래픽이 많아 매번 렌더링하면 서버가 터집니다. { next: { revalidate: 60 } }로 1분마다 자동 갱신되게 하거나, 품절/가격 변동 시 핵심 태그만 날리는 하이브리드 방식이 필수입니다. |
| 실시간 관리자 대시보드 / 주식 차트 | SSR (no-store) 또는 클라이언트 패칭 (React Query) |
여긴 애초에 SEO가 필요 없고 데이터의 '최신화'가 생명입니다. App Router의 캐싱 기능과 싸우지 마세요. 과감히 'use client'와 React Query(또는 SWR)를 조합해 브라우저 단에서 실시간 통신을 하는 것이 정신 건강에 이롭습니다. |
최근 진행한 B2C 커머스 플랫폼 프로젝트에서는 메인 골격은 정적으로 생성(SSG)해 두고, 사용자 개인화 데이터(장바구니 개수, 추천 상품)만 클라이언트 사이드(CSR)에서 페칭하는 '하이브리드 렌더링' 방식을 채택했습니다. <html>과 <body> 같은 큰 레이아웃은 Next.js 서버에 맡기고, 변동성이 심한 녀석들만 브라우저에게 책임을 넘기니 그 지긋지긋한 캐시 충돌에서 해방될 수 있었죠.
5. 총평 및 실무자만 아는 꿀팁 (로깅 세팅)
결과적으로 그날 고객사의 클레임은 revalidateTag와 캐시 상수를 리팩토링하여 깔끔하게 해결했습니다. 이번 경험을 통해 Next.js 14의 App Router는 강력한 무기지만, 총 쏘는 법(캐시 정책)을 정확히 모르면 언제든 내 발등을 쏠 수 있다는 사실을 뼈저리게 배웠습니다.
🔥 검색으로는 알기 힘든 효율화 꿀팁: Fetch 캐시 로깅 켜기
실무에서 캐시가 문제일 때, 이게 캐시 적중(HIT)인지 실패(MISS)인지 눈으로 확인이 안 되면 답답해 미칩니다. next.config.js 파일에 다음과 같이 로깅 옵션을 추가해 보세요.
/** @type {import('next').NextConfig} */
const nextConfig = {
logging: {
fetches: {
fullUrl: true, // 터미널에 fetch 요청의 전체 URL과 캐시 상태(HIT/MISS/SKIP)를 출력
},
},
};
module.exports = nextConfig;
이 옵션을 켜고 개발 서버를 돌리면, 터미널 창에 GET /api/banners 200 OK (HIT) 이런 식으로 캐시 작동 여부가 명확하게 찍힙니다. 디버깅 시간을 절반 이상으로 줄여주는 실전 최고의 팁입니다. 공식 문서 구석에 처박혀 있어서 은근히 모르는 프론트엔드 개발자들이 많더라고요.
새로운 기술 스택을 실무에 도입하는 건 늘 고통과 환희의 연속인 것 같습니다. 완벽한 도구는 없듯, App Router의 캐싱 역시 그 의도를 정확히 파악하고 우리 프로젝트의 성격에 맞게 '길들여야' 하는 녀석이라는 점, 잊지 마시기 바랍니다.
🤔 자주 묻는 질문 (FAQ)
npm run build 후 npm run start로 프로덕션 환경을 띄워서 확인하셔야 합니다. 저도 이것 때문에 시간을 많이 날렸습니다.revalidatePath('/board')가 직관적입니다. 하지만 여러 페이지에 동일한 컴포넌트(예: 헤더의 유저 프로필)가 공통으로 쓰인다면, 해당 fetch 요청에 tags: ['user-profile']을 달고 revalidateTag를 사용하는 것이 훨씬 경제적이고 사이드 이펙트가 적습니다.useRouter()의 router.refresh()를 호출하여 브라우저에게 화면을 다시 그려달라고 요청해야 합니다.참고 자료: Next.js Official Documentation - Data Fetching, Caching, and Revalidating
