Next.js로 워치리스트 기능을 구현하던 중 이상한 현상이 발생했습니다. 사용자가 관심종목을 추가/삭제하면 API 요청은 성공하는데, 페이지에 변경 사항이 반영되지 않는 문제였죠.
// WatchlistButton 클릭 → API 요청 성공 ✅
// 하지만 isInWatchlist prop은 변하지 않음 ❌
<WatchlistButton
isInWatchlist={false} // 여전히 false
symbol="AAPL"
/>처음엔 useOptimistic으로 UI는 즉시 업데이트되지만, 시간이 지나면 다시 원래대로 돌아가는 현상이 발생했습니다.
문제의 핵심은 Next.js의 캐싱 메커니즘을 제대로 이해하지 못한 것이었습니다.
Next.js는 성능 최적화를 위해 데이터를 자동으로 캐싱합니다. 하지만 데이터를 변경(추가/삭제)한 후에 캐시를 무효화하지 않으면 이전 데이터가 계속 보이게 됩니다.
Next.js에서 캐시를 무효화하는 방법은 크게 두 가지입니다:
revalidatePath vs revalidateTagrevalidatePath | revalidateTag | |
|---|---|---|
무효화 기준 | URL 경로 | 데이터 태그 |
사용 방법 |
|
|
장점 | 간단하고 직관적 | 한 번에 관련된 모든 데이터 무효화 |
단점 | 여러 페이지마다 호출 필요 | 사전에 태그 설정 필요 |
예시로 이해하기:
typescript
// ❌ revalidatePath - 모든 경로를 일일이 지정
export async function addToWatchlist(userId, symbol, company) {
// DB 작업
revalidatePath('/'); // 메인 페이지
revalidatePath('/watchlist'); // 워치리스트 페이지
revalidatePath(`/stocks/${symbol}`); // 종목 상세 페이지
revalidatePath('/dashboard'); // 대시보드
// ... 워치리스트가 보이는 모든 페이지
}
// ✅ revalidateTag - 한 번에 해결
export async function addToWatchlist(userId, symbol, company) {
// DB 작업
revalidateTag(`watchlist-${userId}`); // 이것만!
}워치리스트처럼 여러 페이지에 같은 데이터가 표시되는 경우 revalidateTag가 훨씬 효율적입니다.
처음에 이렇게 작성했습니다:
// ❌ 작동하지 않는 코드
export async function getWatchlist(userId: string) {
await connectToDatabase();
const items = await Watchlist.find({ userId });
return items;
}
export async function addToWatchlist(userId, symbol, company) {
// DB 작업
revalidateTag(`watchlist-${userId}`); // 🤔 이게 뭘 무효화하는 거지?
}문제점: getWatchlist 함수에는 태그가 붙어있지 않습니다. revalidateTag를 호출해도 무효화할 대상이 없는 것이죠!
💡 핵심 개념:
revalidateTag가 작동하려면 데이터를 조회할 때 태그를 붙여야 합니다.
// fetch에 태그 붙이기
const data = await fetch('https://api.example.com/watchlist', {
next: {
tags: ['watchlist', `watchlist-${userId}`],
revalidate: 3600 // 선택: 1시간마다 자동 갱신
}
});unstable_cache를 반드시 사용해야 합니다!
import { unstable_cache, revalidateTag } from 'next/cache';
// ✅ 올바른 방법: 조회 시 태그 붙이기
export async function getWatchlist(userId: string) {
if (!userId) return [];
return unstable_cache(
async () => {
await connectToDatabase();
const items = await Watchlist.find({ userId }).sort({ addedAt: -1 });
return items.map((item) => ({
_id: String(item._id),
userId: item.userId,
symbol: item.symbol,
company: item.company,
addedAt: item.addedAt,
}));
},
[`watchlist-${userId}`], // 캐시 키
{
tags: [`watchlist-${userId}`] // 🎯 태그 설정 - 필수!
}
)();
}
export async function checkWatchlistStatus(userId: string, symbol: string) {
if (!userId || !symbol) return false;
return unstable_cache(
async () => {
await connectToDatabase();
const item = await Watchlist.findOne({
userId,
symbol: symbol.toUpperCase().trim(),
});
return !!item;
},
[`watchlist-status-${userId}-${symbol}`],
{
tags: [`watchlist-${userId}`] // 같은 태그 사용
}
)();
}
// ✅ 변경 시 태그로 무효화
export async function addToWatchlist(userId: string, symbol: string, company: string) {
await connectToDatabase();
// 중복 확인, 제한 확인 등...
const newItem = await Watchlist.create({
userId,
symbol: symbol.toUpperCase().trim(),
company: company.trim(),
});
// 🎯 캐시 무효화 - 이제 작동함!
revalidateTag(`watchlist-${userId}`);
return newItem;
}
export async function removeFromWatchlist(userId: string, symbol: string) {
await connectToDatabase();
const result = await Watchlist.deleteOne({
userId,
symbol: symbol.toUpperCase().trim(),
});
// 🎯 캐시 무효화
revalidateTag(`watchlist-${userId}`);
return result.deletedCount > 0;
}처음엔 과하게 여러 태그를 사용했습니다:
// ❌ 과한 태그 사용
revalidateTag('watchlist'); // 모든 워치리스트
revalidateTag(`watchlist-${userId}`); // 사용자별
revalidateTag(`watchlist-${symbol}`); // 종목별하지만 워치리스트는 개인 데이터입니다. 다른 사용자와 공유되지 않으므로:
// ✅ 하나면 충분
revalidateTag(`watchlist-${userId}`);여러 태그가 필요한 경우:
공개 게시물처럼 여러 사용자가 같은 데이터를 보는 경우
다양한 필터링 조건이 있는 경우
revalidateTag는 캐시만 무효화하고 페이지를 리로드하지 않습니다. 만약 페이지가 리로드된다면:
원인 1: revalidatePath와 함께 사용
// ❌ 현재 페이지를 revalidate하면 리로드됨
revalidatePath('/current-page'); // 페이지 리로드 발생
revalidateTag('watchlist');원인 2: router.refresh() 호출
typescript
// Client Component에서
const router = useRouter();
router.refresh(); // 페이지 리렌더링해결책: revalidateTag만 사용하고, router.refresh()는 제거하세요.
DB 직접 조회에서 revalidateTag를 쓰려면 필수입니다.
데이터 소스 | 태그 붙이는 방법 | revalidateTag 작동? |
|---|---|---|
|
| ✅ |
DB 직접 조회 |
| ✅ |
DB 직접 조회 (태그 없이) | - | ❌ |
이름이 unstable_이지만, 실제로는 안정적이고 캐시 태깅에는 필수적인 도구입니다.
API 라우트 대신 Server Action을 직접 호출하는 것이 더 간단합니다:
// ✅ 권장: Server Action 직접 호출
'use client';
import { addToWatchlist, removeFromWatchlist } from '@/actions/watchlist';
const handleClick = () => {
startTransition(async () => {
if (isInWatchlist) {
await removeFromWatchlist(userId, symbol);
} else {
await addToWatchlist(userId, symbol, company);
}
// revalidateTag는 Server Action 안에서 자동 실행됨
});
};캐시 무효화가 작동하지 않는다면 다음을 확인하세요:
조회 함수에 태그가 붙어있는가? (unstable_cache 사용)
변경 함수에서 revalidateTag를 호출하는가?
조회와 변경에서 같은 태그를 사용하는가?
revalidatePath를 불필요하게 사용하고 있지 않은가?
router.refresh()를 호출하고 있지 않은가?
Next.js의 캐시 무효화는 다음 원칙만 기억하면 됩니다:
조회할 때: 태그 붙이기 (unstable_cache 또는 fetch)
변경할 때: 태그 무효화하기 (revalidateTag)
같은 태그 사용: 조회와 변경에서 일치시키기
typescript
// 📖 읽기: 태그 붙이기
unstable_cache(fn, key, { tags: ['my-tag'] })
// ✏️ 쓰기: 태그 무효화
revalidateTag('my-tag')이 패턴만 잘 따르면 Next.js의 강력한 캐싱 시스템을 완벽하게 활용할 수 있습니다!
참고 자료: