다음은 NextJS 프레임워크를 풀스택이 아닌 프론트엔드로 개발시 최적화 방법론이다.
1. 목적
백엔드 서버에서 새로운 리뷰 데이터가 생길 때마다
Next.js 15 서버 캐시를 무효화 (revalidateTag())
클라이언트 페이지 이동 시 router.refresh()로 최신 데이터 반영
이렇게 하면 SEO에 강하면서도 실시간성이 있는 구조를 만들 수 있음
2.방법
1)백엔드 서버에서 웹훅으로 변경된 데이터 전송
2)프론엔드 넥스트프레임워크에서 웹훅 데이터를 받고, 해당 fetch 태그를 revalidateTag 로 무효화 시킨다.
3)페이지 이동간에 항상 router.refresh 를 추가해 준다.
소스 : https://github.com/braverokmc79/nextjs-review
1. revalidateTag를 사용하는 방법 (Next.js 13~15 서버 컴포넌트 방식)
import { revalidateTag } from "next/cache";
✅ 장점
Next.js 서버 캐시 최적화에 딱 맞는 방식입니다.
ISR(Incremental Static Regeneration)처럼 페이지를 정적으로 생성하되, 필요할 때만 invalidate 할 수 있어서 성능에 아주 유리합니다.
서버 컴포넌트와 fetch 함수에서 캐시 정책을 tags, revalidate 단위로 세밀하게 관리 가능.
Spring → Next.js 웹훅 호출로 변경 사항을 선제적으로 감지하여 invalidate 가능.
❌ 단점
클라이언트 상태 업데이트(로딩, 토스트 등)**를 직접적으로 연결하긴 어려움 → router.refresh() 같은 강제 새로고침 필요.
SSR/SSG 기반 프로젝트에 더 적합, CSR 기반이면 활용도가 떨어짐.
2. React Query로 CSR 방식 데이터 캐싱
✅ 장점
useQuery, useMutation, queryClient.invalidateQueries 같은 기능을 통해 클라이언트에서 아주 유연하게 캐시 관리 가능.
폼 제출 후 toast, 로딩, 에러 처리, 리다이렉션, 새로고침 없이 데이터 갱신 등 모든 UI 흐름을 부드럽게 컨트롤 가능.
WebSocket이나 SSE 연동 시에도 훨씬 다루기 쉬움.
❌ 단점
CSR 방식이므로 초기 페이지 로딩 속도는 느릴 수 있음 (특히 SEO 필요한 경우 불리).
데이터가 많아지면 클라이언트 메모리 부담 커질 수 있음.
서버 캐시가 아닌 브라우저 메모리 기반 캐시 → 서버 재시작 시 무의미.
추천
콘텐츠 중심의 정적인 페이지 (예: 리뷰, 블로그)
→ revalidateTag 방식이 좋습니다. Next.js 15에서 SSR/ISR에 최적화되어 있어요.사용자 인터랙션이 많고 실시간 데이터 연동이 필요한 경우 (예: 게시판, 채팅)
→ React Query를 추천합니다. UX 측면에서 훨씬 유연하고 반응성이 뛰어납니다.
결론
"정적 데이터는 revalidateTag, 사용자 입력이나 상호작용이 잦은 동적 데이터는 React Query."
이 두 가지를 혼용해서 사용하는 것도 아주 흔한 구조이다.
예를 들어:
리뷰 목록 페이지는 revalidateTag로 빠르게 렌더링하고,
리뷰 등록/수정/삭제는 React Query + mutation으로 처리하는 식.
✅ 왜 React Query 없이도 괜찮은가?
현재 구성 요약:
➡️ 이 구조는 이미 Next.js가 React Query의 주요 기능을 자체적으로 다 처리하고 있다는 의미예요.
하지만 넥스트프레임워크를 풀스택으로 개발시에는 백엔드에서 웹훅 전송이 없기 때문에 React Query 필요하다.
✅ React Query가 필요한 상황은 언제일까?
React Query는 일반적으로 아래 상황에서 빛나요:
필요설명
CSR : 중심클라이언트에서 데이터를 자주 갱신하고 조작해야 할 때
실시간 UI : 조작데이터가 자주 변경되며, 화면에서 곧바로 반영해야 할 때
수동 캐시 조작 : invalidateQueries, setQueryData 등으로 수동으로 다뤄야 할 때
오프라인 지원 : 브라우저 캐시와 상태 동기화 등
하지만 백엔드 프론트 엔드로 분리 개발해서 백엔드에서 웹훅으로 변경된 데이터를 전송하고, 넥스트 프레임워크를 프론트엔드 전용으로
사용시에는
서버 컴포넌트 기반 (RSC)
태그 캐시 + Webhook + router.refresh() 조합
페이지 단위로 데이터 패치
이런 경우엔 React Query 없이 더 깔끔하고 빠른 구조가 가능합니다.
✅ 결론
❌ React Query는 지금 구조에선 “필수가 아님”
오히려 사용하면 중복 관리, 복잡도 증가, 캐시 충돌 가능성까지 생길 수 있음
⭕revalidateTag 캐시 사용하는 방법 웹훅 개발 방법
1.백엔드 : 스프링부트
✅1. Spring Boot 웹훅 시스템 만들기
// WebhookNotifier.java import org.springframework.http.*; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import java.util.Map; @Component public class WebhookNotifier { private final RestTemplate restTemplate = new RestTemplate(); private final String WEBHOOK_URL = "http://localhost:3000/api/webhooks/event"; // Next.js 서버 주소 public void notifyChange(String modelName, String action) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("x-webhook-secret", "your-secret-token"); // 보안 인증용 Map<String, String> payload = Map.of( "model", modelName, "action", action // 예: create / update / delete ); HttpEntity<Map<String, String>> request = new HttpEntity<>(payload, headers); try { restTemplate.postForEntity(WEBHOOK_URL, request, Void.class); System.out.println("✅ Webhook 전송 완료: " + payload); } catch (Exception e) { System.err.println("❌ Webhook 전송 실패: " + e.getMessage()); } } }
2 다음과 같이 각 서비스 로직에서 호출 해야 하지만 ✅ AOP로 Webhook 자동 전송 처리하게
만들수 있다.
// 예: ReviewService.java public void createReview(ReviewDto dto) { // DB에 저장 reviewRepository.save(dto.toEntity()); // 웹훅 알림 전송 webhookNotifier.notifyChange("review", "create"); }
✅2. AOP로 Webhook 자동 전송 처리
1)WebhookAction.java (enum)
public enum WebhookAction { CREATE, UPDATE, DELETE }
2) ✅ 커스텀 어노테이션 정의 WebhookEntityEvent.java (custom annotation)
// ✅ WebhookEntityEvent.java (custom annotation) import java.lang.annotation.*; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface WebhookEntityEvent { String model(); }
3) WebhookEntityListener.java (JPA EntityListener)
import jakarta.persistence.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; @Component public class WebhookEntityListener { private static WebhookNotifier notifier; @Autowired public void init(WebhookNotifier webhookNotifier) { WebhookEntityListener.notifier = webhookNotifier; } @PostPersist public void postPersist(Object entity) { sendWebhookIfAnnotated(entity, WebhookAction.CREATE); } @PostUpdate public void postUpdate(Object entity) { sendWebhookIfAnnotated(entity, WebhookAction.UPDATE); } @PostRemove public void postRemove(Object entity) { sendWebhookIfAnnotated(entity, WebhookAction.DELETE); } private void sendWebhookIfAnnotated(Object entity, WebhookAction action) { Class<?> clazz = entity.getClass(); if (clazz.isAnnotationPresent(WebhookEntityEvent.class)) { WebhookEntityEvent annotation = clazz.getAnnotation(WebhookEntityEvent.class); String modelName = annotation.model(); // 트랜잭션 성공 후 실행 if (TransactionSynchronizationManager.isSynchronizationActive()) { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { notifier.notifyChange(modelName, action.name().toLowerCase()); } }); } else { notifier.notifyChange(modelName, action.name().toLowerCase()); } } } }
4) 엔티티에 적용 예
// ✅ Review.java (예시 Entity) import jakarta.persistence.*; @Entity @EntityListeners(WebhookEntityListener.class) @WebhookEntityEvent(model = "review") public class Review { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private String content; // ... 기타 필드와 getter/setter }
⭕✅위 코드는 완전 자동 감지형으로 구성한 JPA 기반 웹훅 시스템을 구현한 코드이다.
@WebhookEntityEvent: 엔티티에 model명을 지정해주는 커스텀 어노테이션
WebhookEntityListener: JPA 엔티티 이벤트 감지 후 트랜잭션 커밋 시점에만 웹훅 전송
WebhookNotifier: Next.js 등 외부 시스템으로 알림 전송
enum WebhookAction: 타입 안정성을 위한 액션 정의
이 구조면 각 서비스에서 직접 호출하지 않고도 자동으로 변경사항을 외부로 전파할 수 있어요.
2.백엔드 : strapi
Strapi CMS에서 Next.js 프론트엔드와 연동하여 웹훅을 통해 캐시를 무효화하고 싶을 때, 다음과 같은 방식으로 설정할 수 있습니다.
목표
Strapi에서 리뷰(review)가 생성/수정/삭제될 때마다
Next.js 서버의 /api/webhooks/event 엔드포인트로 요청을 보내
해당 태그(reviews)의 캐시를 무효화 (revalidateTag("reviews"))
✔️ 1. Strapi 관리자 페이지 접속
http://localhost:1337/admin 으로 접속
✔️ 2. 웹훅 설정 메뉴 이동
좌측 사이드바 > "Settings (설정)" 클릭
아래로 내려서 "Webhooks" 선택
✔️ 3. 새 웹훅 생성
➕ "Create new webhook" 클릭
설정 항목:
Name: Next.js 캐시 리프레시
URL: http://localhost:3000/api/webhooks/event
Events:
Entry create
Entry update
Entry delete
(선택적으로 Publish, Unpublish 도 추가 가능)
Trigger on:
Content type: review만 선택
⚡ 이 설정은 리뷰에 대한 변경사항만 캐시 무효화하도록 제한합니다.
✔ "Save" 클릭
✔️ 4. Next.js API Route 작성 예시 (/api/webhooks/event)
// app/api/webhooks/event/route.ts import { revalidateTag } from 'next/cache'; export async function POST(request: Request) { const payload = await request.json(); // review 모델에 대해서만 캐시 무효화 수행 if (payload && payload.model === "review") { revalidateTag("reviews"); console.log("Revalidated cache for reviews"); } return new Response(null, { status: 200 }); }
✅ 결과
이제 Strapi에서 리뷰를 작성/수정/삭제하면
Next.js가 해당 태그 캐시("reviews")를 무효화하여
/reviews 목록 페이지가 최신 상태로 유지됩니다.
추가 팁
실서버 배포 시에는 URL을 http://localhost:3000 대신 실제 도메인으로 바꿔야 합니다.
인증 토큰이 필요하면 Webhook 요청 헤더에 설정할 수 있으며, API에서도 검증 로직 추가 가능.
3.프론트: NextJS
1) Next.js 15(App Router 기준)의 데이터 캐싱과 관련된 동작 원리
개발시에는 새로고침을 통해 새롭게 갱신되나, 빌드후 배포시에는 다음과 같은 작동 된다.
✅ 기본 동작: 정적 캐싱(Default Static Rendering)
Next.js 15에서는 서버 컴포넌트(Server Components)에서 fetch()를 사용할 경우, 기본적으로 정적으로 캐싱됩니다.
이 말은 페이지를 처음 빌드할 때 가져온 데이터를 캐시에 저장하고, 그 이후부터는 같은 요청에 대해 캐시된 응답을 재사용한다는 뜻입니다.
그래서 브라우저를 새로고침하거나 닫았다가 다시 열어도 새 데이터를 가져오지 않고 기존 캐시된 데이터를 계속 사용합니다.
✅ 해결 방법
1. revalidate 설정하기 (ISR, Incremental Static Regeneration)
export const revalidate = 30; // 30초마다 백그라운드에서 캐시 갱신
컴포넌트나 라우트 레벨에서 설정 가능
일정 시간(예: 30초)마다 백그라운드에서 캐시된 데이터를 갱신함
2. dynamic = 'force-dynamic' 설정하기 (완전한 서버사이드 요청)
export const dynamic = 'force-dynamic';
해당 페이지는 매 요청마다 서버에서 새 데이터를 fetch합니다 (SSR처럼 동작)
캐시를 사용하지 않기 때문에 항상 최신 데이터를 보여줌
3. fetch()에서 next 옵션으로 직접 설정
await fetch("https://api.example.com/data", { next: { revalidate: 30, // ISR // 또는 // cache: "no-store", // 매번 요청 (force-dynamic과 유사) } });
???? 아무것도 설정하지 않은 경우?
fetch()는 기본적으로 build 시점에 실행되고 캐싱됨 (즉, 정적 데이터)
브라우저 새로고침해도 빌드 당시 캐시된 데이터를 그대로 사용하게 됨
✅ 결론
“Next.js 15에서 별도로 revalidate나 force-dynamic을 설정하지 않으면 새 데이터를 가져오지 못한다
단, fetch에 cache: 'no-store'나 revalidate, force-dynamic 등을 명시하면 동작을 변경할 수 있다
따라서, 여기에서는 백엔드에서 웹훅 전송을 받으면 해당 태그를 revalidate 처리하는 방법이다.
2)백엔드에서 전송된 웹훅을 받아와서 fetch 태그를 revalidateTag 처리하기
1) src/app/api/webhooks/event
import { CACHE_TAG_REVIEWS } from "@/lib/reviews"; import { revalidateTag } from "next/cache"; export async function POST( request: Request) { const payload = await request.json(); if(payload&& payload.model==="review"){ revalidateTag(CACHE_TAG_REVIEWS); console.log("서버에서 갱신 요청 옴 :revalidated: ",payload.model); } return new Response(null, {status: 200,}); }
cms 백엔드 관리자에서 직접 데이터를 넣을 경우 변경이 안되기 때문에, fetch 이 revalidate 를 추가 해준다.
또는, 넥스트 프레임워크에서 버튼을 만들어서 캐시를 삭제해 주는 기능을 구현해야 한다.
revalidate: 60*5, // 5분
export async function fetchReviews(parameters:object) { const url = `${CMS_URL}/api/reviews?` + qs.stringify(parameters, { encodeValuesOnly: true }); //console.log(" [fetchReviews] url: ", url); const response = await fetch(url, { next:{ tags: [CACHE_TAG_REVIEWS], } } ); if (!response.ok) { throw new Error(`CMS returned ${response.status} for ${url} `); } return await response.json(); }
2)페이지 이동시 router.refresh() 추가해야 한다.
1. smartPush 기능을 가진 커스텀 Link 컴포넌트 만들기
"use client" import { useRouter } from "next/navigation" import { startTransition } from "react" export function SmartLink({ href, children }: { href: string, children: React.ReactNode }) { const router = useRouter() const handleClick = (e: React.MouseEvent) => { e.preventDefault() // 기본 링크 동작을 방지 router.push(href) startTransition(() => { router.refresh() // 페이지 새로 고침 }) } return ( <a href={href} onClick={handleClick}> {children} </a> ) }
2)app/lib/navigation.ts 파일에 아래처럼 작성:
"use client" import { useRouter } from "next/navigation" import { startTransition } from "react" export function useSmartRouter() { const router = useRouter() const smartPush = (href: string) => { router.push(href) startTransition(() => { router.refresh() // 새로고침하여 최신 데이터 반영 }) } const smartReplace = (href: string) => { router.replace(href) // URL은 변경되지만, 히스토리에 기록은 안 남음 startTransition(() => { router.refresh(); // 페이지를 새로고침하여 최신 데이터 반영 }) } return { ...router, smartPush, smartReplace } }
✅ 사용 예시:
1)SmartLink
<SmartLink href={`/reviews/${review.slug}`}> <button>Review Detail</button> </SmartLink>
2)smartPush : 새로고침하여 최신 데이터 반영
"use client" import { useSmartRouter } from "@/lib/navigation" export default function Button() { const { smartPush} = useSmartRouter() return ( <button onClick={() => smartPush("/reviews")}> 리뷰 페이지 이동 & 데이터 최신화 </button> ) }
3)smartReplace : URL은 변경되지만, 히스토리에 기록은 안 남음
"use client" import { useSmartRouter } from "@/lib/navigation" export default function Button() { const { smartReplace} = useSmartRouter() return ( <button onClick={() => smartReplace("/reviews")}> 리뷰 페이지로 이동 (히스토리 기록 안 남음) </button> ) }
3. Link와 smartPush를 혼합한 방식 (SSR과 클라이언트 사이드 동작 모두 지원)
만약, <Link>와 smartPush를 함께 사용하려면, 클라이언트와 서버에서 동작을 분리해야 하기 때문에, 클라이언트에서만 smartPush를 사용하도록 해야 합니다.
import Link from "next/link" export default function ReviewLink({ review }) { const isClient = typeof window !== "undefined" const handleSmartPush = () => { if (isClient) { smartPush(`/reviews/${review.slug}`) } } return ( <Link href={`/reviews/${review.slug}`} onClick={handleSmartPush}> <button>Review Detail</button> </Link> ) }
✅ 요약 정리 :Next.js 15 + Webhook 기반 캐시 무효화 및 실시간 데이터 갱신 전략
1. 목적
백엔드 서버에서 새로운 리뷰 데이터가 생길 때마다
Next.js 15 서버 캐시를 무효화 (revalidateTag())
클라이언트 페이지 이동 시 router.refresh()로 최신 데이터 반영
이렇게 하면 SEO에 강하면서도 실시간성이 있는 구조를 만들 수 있음
2. 서버 데이터 fetch (태그 기반 캐시 적용)
export async function fetchReviews(parameters: object) { const url = `${CMS_URL}/api/reviews?` + qs.stringify(parameters, { encodeValuesOnly: true }); const response = await fetch(url, { next: { tags: [CACHE_TAG_REVIEWS], }, }); if (!response.ok) { throw new Error(`CMS returned ${response.status} for ${url}`); } return await response.json(); }
3. 백엔드 서버에서 보낸 Webhook 수신 처리 (캐시 무효화)
import { CACHE_TAG_REVIEWS } from "@/lib/reviews"; import { revalidateTag } from "next/cache"; export async function POST(request: Request) { const payload = await request.json(); if (payload && payload.model === "review") { revalidateTag(CACHE_TAG_REVIEWS); // 서버 캐시 무효화 console.log("서버에서 갱신 요청 옴 :revalidated: ", payload.model); } return new Response(null, { status: 200 }); }
4. 클라이언트에서 페이지 이동 시 최신화 (UI 자동 반영)
"use client" import { useSmartRouter } from "@/lib/navigation" export default function NavigateButton() { const { smartPush } = useSmartRouter() const handleMove = () => { smartPush("/somewhere") } return <button onClick={handleMove}>이동하기</button> }
✅ 정리
fetch(..., { next: { tags: [...] } }): 태그 기반 캐시 적용 (기본은 유지)
revalidateTag(...): 서버에서 해당 태그 캐시 무효화
router.refresh(): 클라이언트에서 이동 시 서버 컴포넌트 최신화
✔️ 장점
변경 없을 땐 캐시로 빠르게 응답 → 성능 향상 & SEO 최적화
변경 발생 시만 최소 갱신 → 실시간성 유지
필요 시 router.refresh()를 특정 액션(등록, 수정 등)에만 적용하거나, WebSocket 기반 실시간 갱신으로도 확장 가능
댓글 ( 0)
댓글 남기기