인프런 커뮤니티 질문&답변

밍끼님의 프로필 이미지

작성한 질문수

Next + React Query로 SNS 서비스 만들기

ISR 테스트 중 궁금점

해결된 질문

24.10.21 12:11 작성

·

63

0

// src/components/TanstackQueryOption.ts

import {
  isServer,
  QueryClient,
  defaultShouldDehydrateQuery,
} from '@tanstack/react-query'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 15 * 1000,
      },
      dehydrate: {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

export function getQueryClient() {
  if (isServer) {
    return makeQueryClient()
  } else {
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}
// src/components/TanstackQueryProvider.tsx

'use client'


import { getQueryClient } from '@/component/TanstackQueryOption';
import {
  QueryClientProvider,
} from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ReactNode } from 'react'


export default function TanstackQueryProvider({ children }: { children: ReactNode }) {

  const queryClient = getQueryClient()

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools
        initialIsOpen={process.env.NEXT_PUBLIC_MODE === 'local'}
      />  
    </QueryClientProvider>
  )
}
// src/app/layout.tsx

import Banner from "@/component/Banner";
import Footer from "@/component/Footer";
import Header from "@/component/Header";
import TanstackQueryProvider from "@/component/TanstackQueryProvider";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "@/app/global.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <TanstackQueryProvider>
         <div className='container'>
          <Banner/>
          <Header/>
          <main>{children}</main>
          <Footer/>
         </div>
        </TanstackQueryProvider>
        </body>
    </html>
  );
}
// src/app/page.tsx

import ProductList from "@/component/ProductList";
import { getQueryClient } from "@/component/TanstackQueryOption";

import { getProducts } from "@/fetch/getProducts";
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import Image from "next/image"; 

export default function Page () {
  const newQueryClient = getQueryClient();

  newQueryClient.prefetchQuery({
    queryKey:['products'],
    queryFn: getProducts,
  })

  

  return (
    <>
    <section className='visual-sec'>
    <Image src="/visual.png" alt="visual" width={1920}  height={300}/>
    </section>
      <section className="product-sec">
        <h2>상품 리스트</h2>
        <HydrationBoundary state={dehydrate(newQueryClient)}>
          <ProductList />
        </HydrationBoundary>
      </section>
    </>
  )
};

'use client'

// src/components/ProductList.tsx

import Product from "@/component/Product";
import { getProducts } from "@/fetch/getProducts";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import styles from "@/component/ProductList.module.css";

export const ProductList = () => {
  
  const {data, isLoading, isFetching} = useSuspenseQuery({queryKey: ['products'], queryFn: getProducts});

  console.log(`isLoading: ${isLoading}, isFetching: ${isFetching}`)
  return (
    <div className={styles.productList}>
      {data?.map((product: any) => (
        <Product key={product.item_no} product={product} />
      ))}
    </div>
  )

};

export default ProductList;
// src/components/Product.tsx

import Link from "next/link";
import Image from "next/image";

export const Product = ({product} : any) => {
  return (
    <Link href={`/product/${product.item_no}`} prefetch>
      <Image src={product.detail_image_url} alt={product.item_name} width={500} height={300} />
      <h3>{product.item_name}</h3>
      <span>{product.price}</span>
    </Link>
  )
}

export default Product;
// src/app/product/[id]/page.tsx

export default function ProductDetailPage() {
  return (
    <>
     상품 상세페에지
    </>
  )
}
// src/fetch/getProducts.ts

export const getProducts = async () => {
  const res = await fetch(`http://localhost:9090/api/products`, {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
    },
    next: {
      revalidate: 10,
    }
  });


  const currentTime = new Date().toLocaleTimeString(); 

 
  const data = await res.json();

  if (typeof window === "undefined") {
    console.log('fetch products', 'server', currentTime);
    console.table(data);
  } else {
    console.log('fetch products', 'client', currentTime);
    console.table(data);
  }



  if(!res.ok) {
    throw new Error("Failed to fetch products");
  }

  return data;
}
// src/server/server.js

import express from "express";
import cors from "cors";

const app = express();
const port = 9090;

app.use(cors());
app.use(express.json());

app.get("/api/products", (req, res) => {

  const currentTime = new Date().toLocaleTimeString(); 
  console.log(`Received request at ${currentTime}`);

 const products = [
    {
      item_no: 122997,
      item_name: '상품 1',
      detail_image_url: 'https://picsum.photos/id/237/500/500',
      price: 75000,
    },
    {
      item_no: 768848,
      item_name: '상품 2',
      detail_image_url: 'https://picsum.photos/id/238/500/500',
      price: 42000,
    },
    {
      item_no: 552913,
      item_name: '상품 3',
      detail_image_url: 'https://picsum.photos/id/239/500/500',
      price: 240000,
    },
    // {
    //   item_no: 1045738,
    //   item_name: '상품 4',
    //   detail_image_url:
    //     'https://picsum.photos/id/240/500/500',
    //   price: 65000,
    // },
  ];
  res.json(products);
});

app.listen(port, () => console.log('Server is running')); 


안녕하세요, fetch와 tanstackQuery를 사용해서 ISR 동작을 테스트하고있었습니다.
테스트 마다 .next 파일은 지우고 새로 build 하여 run start를 통하여 확인하였습니다.
staleTime과 revalidate 의 시간이 서로 상이한데, 동일하게 설정했을때, 시간의 간격을 두었을때의 차이점을 직접 확인하려고 하였는데 어떤점에서 차이가 나는지 보고도 이해가 안가서 질문드립니다.

궁금점

1. staleTime과 revalidate 는 gcTime 처럼 staleTime이 revalidate보다 적은 시간으로 설정을 해야하는지? 그렇다면 그 이유는 gcTime보다 작게 설정하는 이유와 같은지? 가 궁금합니다.

2. server.js에 주석처리해놓은 item을 다시 주석을 해지하면 처음 revaildate의 10초 설정으로 인해
새로고침을해도 아이템은 계속 페이지에서 3개만 노출되고있고, 상품을 클릭해서 이동을 하면서
staleTime의 설정인 15초가 되었을때는 client 요청이 발생하여 아이템이 4개로 잘 노출되고있습니다.
하지만 이 때 새로고침을 하게되면 처음 fetch revalidate로 cache되어있던 데이터인 아이템 3개까지만 노출이 되고 새로고침을 한번 더 진행해야 그때서야 4개로 노출이되는데 클라이언트와 서버 쪽이 서로 싱크가 안맞는거같은데 이러한 문제점이 왜 일어나는지 이해가 잘안됩니다!

3. 확장된 fetch와 tanstackQuery를 어떻게 분리해서 사용해야할까도 많이 고민이 되는데.. queryFn 에 이미 fetch로 만들어둔 함수를 가져와 사용하니 분리라는 개념을 생각하면 안되는걸까요?
fetch를 독립적으로 사용하는 경우도있다고하는데 이 경우는 왜 독립적으로 사용하는지 잘모르겠습니다.

답변 1

0

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

2024. 10. 21. 12:26

  1. 둘은 서로 관련이 없습니다. revalidate는 서버컴포넌트의 fetch 캐싱 용도이고, staleTime은 클라이언트의 freshness 용도이니까요. 필요성에 의해 조절하는 겁니다.

  2. Router Cache 때문일 수도 있을 것 같습니다. 새로고침을 할 때 프론트서버에서 백엔드서버에 요청을 보내고 있는지, 아닌지(캐싱 상황) 확인해보세요.

  3. 왜 분리해서 써야 하나요?? fetch를 독립적으로 사용하는 경우는 서버 컴포넌트에서 데이터를 직접 받아와서 props로 전달해야할 때 주로 그렇게 합니다.

밍끼님의 프로필 이미지
밍끼
질문자

2024. 10. 21. 14:00

revalidate 10초
staleTime 15초 설정으로
2번 내용 확인 결과
1. router 이동 없이 새로고침 계속 진행 시 10초가 되엇을때 터미널에 이렇게 콘솔이 찍힙니다.

image.pngimage.png


  1. 메인 -> 상품상세 -> 메인 -> 상품상세 이동을 반복하였을때

    image.png

     

    스크린샷 2024-10-21 오후 1.50.44.png


    이렇게 요청하고있습니다.

    새로고침 시 에는 서버사이드 요청만 일어나고, router 이동을 하면서 확인했을때는 클라이언트 사이드 요청만 일어나고있습니다. 이래서 싱크가 안맞는거같은데..이러면 싱크를 맞추려면 ProductList 쪽에서 isFetching이 일어날때 revalidateTag를 통하여 재검증을 해서 싱크를 맞춰야되는걸까요?
    그리고 현재 상항이 Router Cache 와는 어떤 관련이있는걸까요??


제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

2024. 10. 21. 14:54

새로고침 시에는 서버사이드 요청만 일어나는 게 맞습니다. 하이드레이션 되기 때문입니다.

라우터 이동 시에는 하이드레이션이 일어나지 않어서 서버사이드랑 클라이언트 요청이 한 번씩 일어나야 합니다. 하지만 서버사이드 요청이 일어나지 않는 이유는 라우터 캐시(기본 30초)가 적용되어서 그런 것 같다는 추측입니다. router.refresh 같은 메서드로 라우터 캐시를 초기화할 수 있습니다.

밍끼님의 프로필 이미지
밍끼
질문자

2024. 10. 21. 16:01

image.png



Data Cache를 설명하는 위 이미지에서 한 가지 짚고 싶은 부분은 revalidate 시간이 지나더라도 첫 요청은 캐싱된 값을 (STALE 상태여도) 반환한다는 것입니다. 반환 후 백그라운드에서 API를 호출해서 값을 업데이트하는데, 개발자 의도와 다르게 동작할 수 있기 때문에 캐시를 적용할 때 주의가 필요합니다.

router.refresh로는 Data Cache가 revalidate되지 않고, revalidatePath를 사용해야 합니다. (이때는 즉시 revalidate 되기 때문에, 다음 첫 요청에도 새로운 값을 반환합니다.)

이러한 글도 보았는데 이 문제일수도있을까요?

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

2024. 10. 21. 16:09

그런데 지금은 반환 후 업데이트도 호출 안 하는 것 아닌가요? 데이터캐시를 요청하기 전에 라우터 캐시 선에서 반환하고 있어서 서버 요청이 실행 안 되는 것 같은데요.

밍끼님의 프로필 이미지
밍끼
질문자

2024. 10. 21. 16:15

처음 렌더링 시 데이터가 3개인 상태에서 시작하고 server.js에서 데이터를 1개 추가해놓고 revalidate 시간이 지나기전까지 새로고침을 계속해도 계속 호출안하다가 revalidate 시간이 지낫을때는 응답값이 날라오는데,

image.png


이때 터미널에는 4개로 잘가져오지만 저기 콘솔이 찍히는 시점에 ui는 3개만 그리고있고 한번 더 새로고침을 진행해야지 ui가 4개로 업데이트됩니다!

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

2024. 10. 21. 16:19

아 그러면 데이터 캐시인지 확인하기 위해서는 프론트서버는 4개, ui는 3개인 시점에서 각각 브라우저, 프론트서버, 백엔드 서버 시간을 체크해서

브라우저(데이터표시시간)->백엔드서버(데이터조회요청시간)->프론트서버(데이터조회응답시간)

시간순이면 해당 문제가 맞겠네요.

제가 데이터 캐시를 생각을 안 했던 이유는 아예 서버컴포넌트가 실행이 안 되는 건 줄 알았는데 서버컴포넌트는 실행되는데 그 안에 fetch가 실행이 안 되는 거였던건가요?

밍끼님의 프로필 이미지
밍끼
질문자

2024. 10. 21. 16:41

브라우저(데이터표시시간)->백엔드서버(데이터조회요청시간)->프론트서버(데이터조회응답시간) 시간 순이라는 말씀이 제가 이해가 잘 안가는데

image.png

 

image.png




image.png


지금 이렇게 본다면 프론트서버(데이터조회응답시간) 과 백엔드서버 (데이터조회요청시간)
이 같고 브라우저(데이터표시시간)이 응답값과 일치하는데 반응이 1초 느리면

"브라우저(데이터표시시간)->백엔드서버(데이터조회요청시간)->프론트서버(데이터조회응답시간) 시간 순" 으로 작동하고있는게 아닌건가요??

fetch가 새로고침 을 해야 10초 이후로 새로운 요청을 보내지만 10초 이후에 새로고침을 해서 새로운 요청을 보내도 프론트서버에는 제대로 된 data를 보여주지만 UI에서는 우선 이전에 캐시된 데이터를 ui가 표기하고있습니다!

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

2024. 10. 21. 16:44

fetch products server는 fetcher 내부에서 찍으신 건가요? 시간은 밀리세컨즈까지 찍으시는 게 좋습니다.

image.png

이 그림에 의하면 브라우저에 응답이 먼저 간 후에 revalidate가 작동해서 브라우저->백엔드->프론트 순이 되어야 하거든요.

그런데 단순히 ui 렌더링 시간이 1초 정도 걸려서 콘솔에 늦게 표시되었을 가능성도 있습니다.

밍끼님의 프로필 이미지
밍끼
질문자

2024. 10. 21. 16:46

export const getProducts = async () => {
  const res = await fetch(`http://localhost:9090/api/products`, {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
    },
    next: {
      revalidate: 10,
    }
  });


  const currentTime = new Date().toLocaleTimeString(); 

 
  const data = await res.json();

  if (typeof window === "undefined") {
    console.log('fetch products', 'server', currentTime);
    console.table(data);
  } else {
    console.log('fetch products', 'client', currentTime);
    console.table(data);
  }



  if(!res.ok) {
    throw new Error("Failed to fetch products");
  }

  return data;
}

fetch products server는 여기 따로 빼놓은 fetch 함수에서 실행하고있습니다!

밍끼님의 프로필 이미지
밍끼
질문자

2024. 10. 21. 19:16

export const getProducts = async () => {
  const currentTime = new Date().toLocaleTimeString('ko-KR', {
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
    fractionalSecondDigits: 3,
    hour12: false, 
  });

  const res = await fetch(`http://localhost:9090/api/products`, {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
    },
    next: {
      revalidate: 10,
    }
  });
 
  const data = await res.json();


  if (typeof window === "undefined") {
    console.log('fetch products', 'server', currentTime);
    console.table(data);
  } else {
    console.log('fetch products', 'client', currentTime);
    console.table(data);
  }



  if(!res.ok) {
    throw new Error("Failed to fetch products");
  }

  return data;
}

 

image.png

 

image.png


콘솔과 터미널에 찍히는 시간이 다른데 이유를 모르겠네요...

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

2024. 10. 21. 19:32

콘솔 시간 말고 네트워크 탭에서 페이지 이동 시 데이터를 받아오는 시간이 제일 정확하지 않나싶습니다. 응답 헤더에 시간 적혀있습니다.

밍끼님의 프로필 이미지
밍끼
질문자

2024. 10. 21. 19:35

응답헤더에는 Mon, 21 Oct 2024 10:31:46 GMT 밀리세컨즈까지가 노출이 안되네요ㅠ
저기 서버콘솔에 찍히는 ui업데이트 시간이랑 개발자도구 콘솔창에 ui 업데이트 시간이랑 다르고 length도 다르게 찍는걸로봐서는 prefetch하는 부분이랑 useQuerySuspence 문제인거같기도한데..관련없을까요?

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

2024. 10. 21. 19:53

서버에서 json데이터만 보내는 게 아니라 rsc 데이터도 같이 보내서 length는 다를 수 있습니다.

서버 컴포넌트가 실행되는데 fetch만 실행안되는 거라면 Data Cache 상황이 맞습니다.

밍끼님의 프로필 이미지
밍끼
질문자

2024. 10. 22. 11:55

import ProductList from "@/component/ProductList";
import { getQueryClient } from "@/component/TanstackQueryOption";

import { getProducts } from "@/fetch/getProducts";
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import Image from "next/image"; 

export default async function Page () {
  const queryClient = getQueryClient();

  await queryClient.prefetchQuery({
    queryKey:['products'],
    queryFn: getProducts,
  })

  

  return (
    <>
    <section className='visual-sec'>
    <Image src="/visual.png" alt="visual" width={1920}  height={300}/>
    </section>
      <section className="product-sec">
        <h2>상품 리스트</h2>
        <HydrationBoundary state={dehydrate(queryClient)}>
          <ProductList />
        </HydrationBoundary>
      </section>
    </>
  )
};

 

해당 Page부분에 async await 를 빼먹엇더라구요..
넣으니까 바로 해결됬습니다! 감사합니다

밍끼님의 프로필 이미지

작성한 질문수

질문하기