React

 

다음은 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 기반 실시간 갱신으로도 확장 가능

 

 

 

 

 

about author

PHRASE

Level 60  라이트

어린이에게는 과학을 가르치는 것이 아니다. 단지 과학의 취미를 주면 족하다. - J.J. 루소

댓글 ( 0)

댓글 남기기

작성