
데이터가 캐시돼서 최신 값이 안 나올 때 (Next.js 캐싱의 모든 것)
데이터를 수정했는데 페이지에 계속 예전 값이 나오는 유령 같은 현상. Next.js 13+의 강력한(그리고 사악한) 캐싱 메커니즘을 4계층으로 분석하고, React Query와의 차이점, 그리고 실제 디버깅 전략을 공유합니다.

데이터를 수정했는데 페이지에 계속 예전 값이 나오는 유령 같은 현상. Next.js 13+의 강력한(그리고 사악한) 캐싱 메커니즘을 4계층으로 분석하고, React Query와의 차이점, 그리고 실제 디버깅 전략을 공유합니다.
분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

안드로이드는 Xcode보다 낫다고요? Gradle 지옥에 빠져보면 그 말이 쏙 들어갈 겁니다. minSdkVersion 충돌, Multidex 에러, Namespace 변경(Gradle 8.0), JDK 버전 문제, 그리고 의존성 트리 분석까지 완벽하게 해결해 봅니다.

서버에서 잘 오던 데이터가 갑자기 앱을 죽입니다. 'type Null is not a subtype of type String' 에러의 원인과, 안전한 JSON 파싱을 위한 Null Safety 전략을 정리해봤습니다.

게시판에 달린 댓글 하나 때문에 관리자 계정이 탈취당했습니다. XSS(Cross-Site Scripting)의 3가지 유형(Stored, Reflected, DOM)과 React/Next.js 환경에서의 구체적인 방어법(HTML 이스케이프, CSP, 쿠키 보안)을 예제와 함께 깊이 있게 다룹니다.

어느 날 운영팀에서 연락이 왔습니다. "관리자 페이지에서 배너 순서를 바꿨는데, 메인 페이지에 반영이 안 돼요."
저는 자신만만하게 대답했습니다. "브라우저 캐시 지우고 다시 해보세요." 하지만 돌아온 대답은 충격적이었습니다. "시크릿 모드에서도 안 되고, 제 핸드폰에서도 안 되고, 심지어 옆자리 동료 PC에서도 안 바뀌는데요?"
그제서야 깨달았습니다. 이건 브라우저 캐시(Client) 문제가 아니라, 서버가 과거에 갇혀 있다는 것을요. 문제의 코드는 너무나 평범했습니다.
export default async function Page() {
const data = await fetch('https://api.example.com/banners');
const banners = await data.json();
return (
<div>
{banners.map(b => <Banner key={b.id} {...b} />)}
</div>
);
}
제가 가진 오개념은 이거였습니다: "fetch는 함수니까, 호출할 때마다 매번 서버에 요청을 보내겠지?"
우리가 아는 axios.get()이나 window.fetch()는 항상 그랬으니까요.
하지만 Next.js 13(App Router)부터 fetch는 더 이상 단순한 네트워크 요청 함수가 아닙니다.
Next.js가 이 함수를 몽키패치(Monkey Patch)해서, 요청 결과를 서버 파일 시스템에 영구 저장해버립니다.
즉, 여러분이 서버를 재배포하기 전까지, 저 코드는 단 한 번만 실행되고 그 뒤로는 저장된 JSON 파일만 읽어옵니다. 데이터베이스가 바뀌든 말든 상관없이요.
해결 방법은 3가지가 있습니다. 상황에 맞춰 골라 써야 합니다.
주식 시세나 채팅처럼 1초라도 옛날 데이터면 안 되는 경우입니다.
// ✅ 캐시 완전 비활성화 (SSR과 동일)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
});
블로그 글이나 상품 목록처럼, 약간의 딜레이는 허용되지만 서버 부하를 줄이고 싶을 때입니다.
// ✅ 60초마다 재검증 (ISR)
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
});
이러면 60초 동안은 캐시된 걸 보여주고, 61초에 접속한 사람은 (구버전을 보지만) 백그라운드에서 캐시 업데이트를 트리거합니다.
가장 추천하는 방법입니다. 데이터가 실제로 변경되었을 때만 캐시를 날립니다.
// 1. 태그를 붙여서 조회
fetch(url, { next: { tags: ['banners'] } });
// 2. 데이터 수정 후 태그 무효화 (Server Action이나 Route Handler에서)
revalidateTag('banners');
이게 진짜 헷갈리는 부분입니다. "캐시를 껐는데 왜 안 바뀌지?" 싶다면 이 4계층 중 어디에 걸렸는지 확인해야 합니다.
Layout에서도 User를 부르고, Page에서도 User를 불렀다면? 실제 요청은 1번만 갑니다.fetch 결과를 JSON 파일로 저장. 이게 N+1 문제나 stale data의 주범입니다.revalidate나 no-store로 제어.fetch 결과뿐만 아니라, HTML과 RSC Payload 자체를 저장합니다.fetch를 안 쓰고 DB를 직접 조회해도, 페이지 자체가 정적(Static)으로 빌드되면 이 캐시에 걸립니다.export const dynamic = 'force-dynamic'을 쓰거나 cookies() 같은 동적 함수를 사용.revalidatePath), 사용자가 새로고침 안 하면 여전히 옛날 화면을 봄.router.refresh()를 호출해야 함.많이 받는 질문입니다. "Next.js 캐시가 있는데 React Query(TanStack Query)는 이제 필요 없나요?"
결론부터 말하면 여전히 필요할 수 있습니다. 역할이 다르거든요.
| 특징 | Next.js Cache (Fetch) | React Query |
|---|---|---|
| 실행 위치 | Server Side | Client Side |
| 주 목적 | 초기 로딩 속도, 서버 부하 감소, SEO | 무한 스크롤, 낙관적 업데이트, 실시간 폴링 |
| 저장소 | 서버 파일 시스템 / 메모리 | 브라우저 메모리 |
| 데이터 | HTML에 구워져서 내려옴 | JS가 실행된 후 받아옴 |
fetch (SEO 중요하니까)Next.js 팀은 "공격적인 캐싱(Aggressive Caching)"을 기본 철학으로 삼고 있습니다. "변경되지 않았음이 증명되기 전까진, 모든 것은 정적이다"라고 가정하죠.
성능면에서는 훌륭하지만, 개발자에게는 지뢰밭입니다. 데이터가 안 바뀐다면 1. 브라우저 캐시, 2. 데이터 캐시, 3. 풀 라우트 캐시 순서로 의심해보세요.
그리고 디버깅할 때는 반드시 next.config.js에서 로깅을 켜세요.
module.exports = {
logging: {
fetches: {
fullUrl: true,
},
},
}
여러분의 터미널에 HIT, MISS, SKIP이 찍히는 걸 눈으로 확인해야 평화가 찾아옵니다.
One day, I got a call from the operations team. "I changed the banner order in the admin panel, but the main page still shows the old order."
I answered confidently, "Please clear your browser cache and try again." But the reply was shocking. "I tried Incognito mode, my phone, and even my colleague's PC. It's all the same old data."
That's when I realized. It wasn't a browser cache issue (Client). The Server itself was stuck in the past. The code looked perfectly innocent:
/* Standard fetch code */
export default async function Page() {
const data = await fetch('https://api.example.com/banners');
const banners = await data.json();
return (
<div>
{banners.map(b => <Banner key={b.id} {...b} />)}
</div>
);
}
My misconception was simple: "fetch is a function, so it should send a request to the server every time I call it, right?"
That's how axios.get() or window.fetch() always worked.
But starting from Next.js 13 (App Router), fetch is no longer just a network request function.
Next.js monkey-patches this global function. It intercepts the call and permanently stores the result in the server's file system.
Basically, unless you redeploy the server, that code runs only once, saves the JSON, and then serves that static file forever. It doesn't care if your database has changed.
There are three main ways to handle this. Choose wisely based on your scenario.
Use this for stock prices, chat messages, or user-specific data where "stale" is unacceptable.
// ✅ Disable cache completely (Same as SSR)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
});
Use this for blog posts or product lists. A slight delay is acceptable in exchange for faster loading and reduced server load.
// ✅ Revalidate every 60 seconds (ISR)
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
});
This serves the cached version for 60 seconds. The first person to visit at 61 seconds will see the old version, but trigger a background update for the next person.
The most recommended approach. You invalidate the cache only when data actually changes (e.g., via CMS webhook).
// 1. Tag your fetch
fetch(url, { next: { tags: ['banners'] } });
// 2. Invalidate tag when data is modified (Server Action / Route Handler)
revalidateTag('banners');
This is where developers get lost. "I disabled the cache, why is it still old?" You need to identify which layer is holding onto your data.
fetchUser() in Layout, and fetchUser() in Page, the actual network request only happens ONCE.fetch results as JSON files. This is the main culprit of stale data.revalidate or no-store or tags.fetch (e.g., direct DB query), if Next.js decides your page is "Static", it bakes the HTML at build time and never calls your DB again.export const dynamic = 'force-dynamic' or use dynamic functions like cookies().revalidatePath on the server, but the user hasn't refreshed the browser, so they see the old memory cache.router.refresh() in your client component after mutation.Common question: "Do I still need React Query (TanStack Query) if Next.js has caching?"
Yes, you might still need it. They serve different purposes.
| Feature | Next.js Cache (Fetch) | React Query |
|---|---|---|
| Where it runs | Server Side | Client Side |
| Main Goal | First Load Speed, SEO, Server Load | Infinite Scroll, Optimistic Updates, Polling |
| Storage | Server File System / RAM | Browser RAM |
| Data Flow | Baked into HTML | Fetched via JS after load |
fetch.Don't guess. Enable logging to see exactly what Next.js is doing.
Add this to your next.config.js:
module.exports = {
logging: {
fetches: {
fullUrl: true,
},
},
}
Now watch your terminal when you refresh the page.
CACHE: Hit the Data Cache (No request sent).MISS: Cache missing, fetched from API.SKIP: Cache skipped (no-store or revalidate: 0).Seeing SKIP when you expect a fresh request brings inner peace.
Next.js adheres to a philosophy of "Aggressive Caching." It assumes "Everything is static unless proved otherwise." This is great for performance benchmarks, but often a trap for developers building dynamic apps.
If your data isn't updating, investigate in this order:
fetch execute?).Mastering these 4 layers is the only way to tame the beast that is the App Router.