본문 바로가기
Web Study/Next.js

Next.js로 SNS x.com 클론코딩하기 - 6

by 쿠리의일상 2024. 1. 30.

무한 스크롤링

무한 스크롤링을 위해선 특정 지점까지 마우스 스크롤을 내리면 서버에서 데이터를 받아오게끔 처리해줘야 합니다.

로직 자체는 서버에서 데이터를 받아옴 -> 받아온 데이터를 React Query에 담아서 관리 -> 무한 스크롤링 -> 반복 이 순서를 따릅니다.

 

React Query

  • 왜 리액트 쿼리인가?

먼저 리액트 쿼리와 리덕스의 차이를 보면, 리액트 쿼리의 경우 서버에서 데이터를 가져오는 것이 중점이고 리덕스의 경우 데이터를 컴포넌트간 공유한다는 차이가 있습니다. 특히나 리액트 쿼리의 장점으로는 캐싱을 잘해준다는 점이 있는데, 요청이 많아지면 트래픽 관리가 중요합니다. 즉 캐싱을 최대한 많이 해둬서(재사용 가능하게) 트래픽을 관리해주는 것이 중요해짐에 따라 리액트 쿼리를 사용하게 된다고 합니다.

그래서 데이터가 발행되면 그 이후에 잘 수정되지 않는 정보(뉴스기사, 블로그글 등)을 일정 기간이나 사용자가 수정하는 액션을 취할 때만 DB에서 캐싱을 업데이트해주고 그 전까진 캐싱되어 있는 정보를 계속 보여주는 것이 트래픽을 줄일 수 있습니다. 또한 캐싱된 정보를 가져오는 것은 DB를 통하여 정보를 가져오는 것에 비하여 속도가 매우 빠르기에 UX적으로도 이점이 있다고 볼 수 있습니다. 이러한 기능을 알아서 처리해주는 라이브러리가 바로 리액트 쿼리입니다. 반면 리덕스는 이러한 캐싱 기능이 약한 편이라 추가로 SWR을 함께 사용한다고 합니다.

  • 서버에서 데이터 가져오기 : React Query, SWR ...
  • 컴포넌트간 데이터 공유하기 : Redux, Zustand ...

이렇게 상태 관리 라이브러리는 쓰임새가 조금씩 다른데, 두가지 라이브러리를 적당히 섞어 사용하는 것을 추천드립니다. 이 강의에선 리액트 쿼리와 저스텐드를 쓴다고 합니다.

그밖에도 리액트 쿼리는 로딩, 성공, 실패의 정보가 표준화가 되어 있어 따로 구현해줄 필요가 없다고 합니다. 

 

데이터를 저장하여 상태관리를 하는 것은 로그인 전이 아닌 로그인 후에만 사용하도록 하면 되므로 로그인 이후에 사용하게 됩니다. 추가로 리액트 쿼리 전용 dev tools 도 설치해줍니다.

설치하기

npm i @tanstack/react-query@5
npm i @tanstack/react-query-devtools@5 -D

 

설정하기

위에 설명했다시피 로그인 후에 데이터를 다루게 될 예정이므로 리액트 쿼리 Provider는 afterLogin 폴더 안에 만들어줍니다.

RQProvider.tsx

"use client";

import React, {useState} from "react";
import {QueryClientProvider, QueryClient} from "@tanstack/react-query";
import {ReactQueryDevtools} from "@tanstack/react-query-devtools";

type Props = {
  children: React.ReactNode;
};

function RQProvider({children}: Props) {
  const [client] = useState(
    new QueryClient({
      defaultOptions: {  // react-query 전역 설정
        queries: {
          refetchOnWindowFocus: false,
          retryOnMount: true,
          refetchOnReconnect: false,
          retry: false,
        },
      },
    })
  );

  return (
    <QueryClientProvider client={client}>
      {children}
      <ReactQueryDevtools initialIsOpen={process.env.NEXT_PUBLIC_MODE === 'local' }/>
    </QueryClientProvider>
  );
}

export default RQProvider;

ReactQueryDevtools 를 사용하여 개발 환경일 때만 사용하도록 설정합니다. 그리고 children 을 사용하여 QueryClientProvider 를 크게 감싸 설정해줍니다. client 속성에  QueryClient클래스로 만들어준 내용을 넣어주면 children에 존재하는 곳은 리액트 쿼리 상태를 공유할 수 있게 됩니다.

 

리액트 쿼리 사용법 - 서버 컴포넌트

우선 데이터가 실제로 필요한 시점에 미리 캐시 데이터가 존재하도록, 여러 api 를 호출하면 병렬 호출이 되는 prefetchQuery를 사용합니다.

import {
  HydrationBoundary,
  QueryClient,
  dehydrate,
} from '@tanstack/react-query';

 

async function getPostRecommands() {
  // 데이터 불러오는 코드
}

const queryClient = new QueryClient();

await queryClient.prefetchQuery({
queryKey: ['posts', 'recommand'],
queryFn: getPostRecommands,
});

 

QueryClient 를 가져와서 prefetchQuery() 함수를 통해 queryKey와 queryFn 을 지정해줍니다. queryKey 가 문자열이 아닌 배열형식이며 해당 배열을 getQueryData() 로 가져올 수 있습니다. 수정은 당연히 setQueryData() 로 해줄 수 있겠죠?

물론 그전에 dehydrate() 함수에 queryClient 를 넣어줘서 hydrate(서버와 클라이언트를 연동)해줘야 합니다.

const dehydrateState = dehydrate(queryClient);
queryClient.getQueryData(['posts', 'recommands']);

그리고 서버 측에서 데이터를 불러올 수 있는 코드는 qeuryFn 에 넣어주면 됩니다.

즉 queryKey 에 해당하는 값을 키로 넣으면 queryFn을 실행해서 값을 가져오라는 의미가 되는 것입니다.

<HydrationBoundary state={dehydrateState}>
    <TabProvider>
      <Tab />
      <PostForm />
      <Post />
      // ...
    </TabProvider>
</HydrationBoundary>

마지막으로 HydrationBoundary 로 데이터를 보여줄 부분을 감싸줍니다. state 속성에는 dehydrate() 함수로 가져왔던 state 를 넣어줍니다.

 

그 다음 서버에서 데이터를 불러오는 함수인 queryFn 을 작성해줍니다.

async function getPostRecommands() {
  const res = await fetch('http://localhost:3000/api/postRecommends', {
    next: {
      tags: ['posts', 'recommends'],
    },
  });

  if (!res.ok) {
    throw new Error('Failed to fetch data');
  }
  return res.json();
}

해당 함수는 결국 서버에서 호출되고 서버에서 받아온 데이터를 자동으로 저장하게 됩니다. (=캐싱) 그를 위한 키가 next 안의 tags 라고 생각하면 됩니다. 이 tag 는 revalidateTag() 함수의 매개변수에 사용하여 해당 키를 가지는 데이터를 한번에 동기화(= 캐시 초기)해줄 수 있습니다. 

revalidatePath() 는 매개변수로 주소에 해당하는 문자열이 들어가게 되는데,
만약 revalidatePath('/home') 이라면 '/home' 주소의 모든 요청들을 새로고침 해줄 수 있습니다.
revalidateTag() 는 지정된 키값에 해당하는 것만 새로 고침하는 것이고, revalidatePath() 는 지정된 주소에 해당하는 요청에 대한 모든 것을 새로고침 해주는 것

만약 캐싱을 안하기 위한 옵션을 넣고자 한다면, fetch option으로 cache: 'no-store' 을 추가해줍니다.

 

 

리액트 쿼리 사용법 - 클라이언트 컴포넌트

위에 작성해준 prefetchQuery 의 정보를 바탕으로 RQProvider로 감싸준 컴포넌트 안에선 해당 데이터를 가져와서 사용할 수 있습니다. 이때 사용되는 훅이 useQuery 입니다.

import {useQuery} from '@tanstack/react-query'

prefetchQuery 와 동일하게 useQuery 훅에도 queryKey와 queryFn 이 들어가게 되며 prefetchQuery에 넣어줬던 키와 함수를 넣어줍니다.

 const { data } = useQuery<IPost[]>({
    queryKey: ["posts", "recommends"],
    queryFn: getPostRecommands,
  });

이렇게 사용해주면 data 에 서버에서 읽어온 정보가 들어가게 됩니다.

 

interface

인터페이스는 타입을 만드는 틀이라고 생각하면 쉽습니다. Post와 Userm PostImage 까지 타입이 계속 반복되므로 따로 model 폴더를 만들어서 관리하도록 하겠습니다.

export interface User {
  id: string;
  nickname: string;
  image: string;
}
import { Post } from "./post";

export interface PostImage {
  link: string;
  imageId: number;
  Post?: Post;
}
import { PostImage } from "./PostImage";
import { User } from "./User";

export interface Post {
  postId: number;
  User: User;
  content: string;
  createdAt: Date;
  Images: PostImage[];
}

 

 

MSW 사용법

전 포스팅에서 간단히 설명했다시피 MSW 에서 http.요청메서드 형태로 사용하게 됩니다.

import { http, HttpResponse } from 'msw';

//...
http.요청메서드(요청주소, () => {})

요청메서드 안의 콜백 함수에는 return 으로 마무리 지어줘야만 하는데, 이때 HttpResponse 객체를 사용해줍니다. 무언가 정보를 담지 않아도 되는 경우 return new HttpResponse() 형식으로 body와 header를 null 등으로 간단히 채워주면 되지만, 응답 데이터가 존재하는 경우 return HttpResponse.json() 안에 body와 header 에 정보를 담아 응답해줍니다.

// 로그인 -> 응답 데이터가 필요 (로그인 여부를 위한 쿠키, 정보 등)
http.post('api/login', () => {
    return HttpResponse.json(
      {
        userId: 1,
        nickname: '제로초',
        id: 'zerocho',
        image: '/5Udwvqim.jpg',
      },
      {
        headers: {
          'Set-Cookie': 'connect.sid=msw-cookie;HttpOnly;Path=/',
        },
      }
    );
  }),

// 로그아웃 -> 응답 데이터가 불필요
http.post('/api/logout', () => {
    return new HttpResponse(null, {
      headers: {
        'Set-Cookie': 'connect.sid=;HttpOnly;Path=/;Max-Age=0',
      },
    });
}),

추가로 HttpResponse.text() 도 있으며 HttpResponse.json()와 유사하나, 매개변수로는 JSON.stringify()로 변환한 텍스트 값과 header 를 채워줍니다.

 

리액트 쿼리 데브툴

import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

일전에 RQProvider 상에 해당 컴포넌트를 불러와서 사용한 적이 있습니다. 이게 바로 리액트 쿼리 데브툴인데, 우측 하단에 야자수 아이콘이 보이고, 그 아이콘을 누르면 위와 같은 화면을 볼 수 있습니다.

  return (
    <QueryClientProvider client={client}>
      {children}
      <ReactQueryDevtools
        initialIsOpen={process.env.NEXT_PUBLIC_MODE === "local"}
      />
    </QueryClientProvider>
  );

이렇게 상태 관리 라이브러리들은 복잡한 상태 관리들을 위해 전용 개발툴을 넣어둔 경우가 있습니다. (Redux 등) 위 이미지 처럼 queryKey에 해당하는 값을 눌러보면 현재 state 가 stale인지 fresh인지 확인도 가능하며, refetch 를 새로고침이 아닌 Action의 버튼을 통해서 실행해줄 수 있습니다. 담겨있는 Data 또한 바로 확인이 가능하여 유용한 기능을 가지고 있습니다.

Action 은 직관적으로 확인 가능하니 간략히 설명하자면,

  1. Refetch: Last Updated 시간이 갱신됨. 즉 서버에서 정보를 다시 불러오는 것과 같음, inactive 상태와 무관하게 무조건 새로 가져옴
  2. Invalidate : Refresh와 유사, 해당 키값을 사용하지 않겠다는 의미로 inactive 상태일 땐 정보를 가져오지 않음
  3. Reset : useQuery 등 안에 initalData 속성이 있어서 지정해준 초기값으로 되돌리고 싶을 때 사용
  4. Remove : 키값 제거
  5. Trigger Loading : 로딩 상태로 만듦 (Fetching)
  6. Trigger Error : 에러 상태로 만듦

 

 

React Query State

* state의 기본값은 not fresh

1. Fresh : 최신 데이터, 굳이 업데이트할 필요 없음
2. Fetching : 데이터 가져오기
3. Paused : 데이터 멈춤 (오프라인 환경일 때 등)
4. Stale : 오래된 데이터, 업데이트 해야함
5. Inactive : state를 사용하는 키를 가진 컴포넌트가 화면상 보여지지 않을 때

위의 RQProvider 에서 옵션 지정해줬던 내용을 보면 아래와 같습니다.

const [client] = useState(
    new QueryClient({
      defaultOptions: {
        // react-query 전역 설정
        queries: {
          refetchOnWindowFocus: false,
          retryOnMount: true,
          refetchOnReconnect: true,
          retry: false,
        },
      },
    })
  );

1. refetchOnWindowFocus

이 속성은 background refetch와 관련되어, 브라우저가 해당 캐시에 대한 데이터를 재요청하는 것으로 캐시를 최신 상태로 유지하도록 하는 기능입니다. 그래서 굳이 새로고침을 하지 않더라고 캐시의 상태가 stale(오래된 데이터) 할 때 자동으로 refetch 를 진행합니다. 보통 다른 탭에서 현재 탭으로 이동(전환)했을 때 focus 됐다고 판단하여 refetch 가 실행되는 기능입니다.

기본값은 true로 캐시를 최신 상태로 유지하는 것이며 false는 refetch 를 막습니다. 추가로 always 값도 존재하는데, 항상 refetch 를 시키는 값입니다.

2. retryOnMount

컴포넌트가 Unmount 되었다가 Mount 됐을 때 실행되는 속성으로 컴포넌트 페이지를 이동하거나 State 때문에 컴포넌트가 Unmount 됐다가 Mount 되었을 때 refetch 여부입니다. 

3. refetchOnRecommect

인터넷 연결이 끊겼다가 재접속될 때 refetch 되는 속성입니다.

4. retry

데이터를 가져올 때 혹시나 실패했다고 하면 재시도하는 속성입니다.

 

 

useQuery의 속성

위에 작성했던 PostRecommneds 컴포넌의 useQuery 에서 추가 속성을 줄 수 있습니다.

1. staleTime

stale 상태가 되는 시간을 직접 설정해줄 수 있습니다. 기본값은 0이며, 그렇다면 캐싱된 정보가 불려와서 fresh 된 다음 바로 stale이 된다는 의미입니다. 여기서 Infinity 로 지정해주면 항상 fresh한 상태로 두겠다는 의미가 됩니다.

2. gcTime

가비지 컬렉터 타임으로 기본값은 5분이며, inactive 상태와 함께 사용되는 속성입니다. (전 버전에선 cacheTime이었다고 합니다)

우선 inactive 옵션의 쓰임새는 fresh 상태일 때는 inactive 가 되어도 이후에 active가 되면 캐싱된 데이터를 바로 가져와서 속도가 빠르지만, stale 상태일 땐 inactive 된 이후 active 될 때 서버에서 데이터를 가져와서 속도가 느리게 됩니다.

이 때 캐싱된 데이터의 수가 무수히 많다면? 이런 경우를 위해 gcTime 지정하여 메모리를 차지하는 안 쓰는 데이터를 일정 시간이 지나면 비워주기 위해 설정해줍니다. 이때 gcTime 은 inactive 상태일 때 측정되기 시작합니다. 

 

여기서 주의할 점은 staleTime 은 gcTime 보다 짧아야 한다는 점입니다. 이는 staleTime 이라하면 설명했다시피 캐싱된 데이터를 사용할 수 있는 시간입니다. gcTime은 inactive 됐을 때 해당 캐싱 데이터를 지우기 위한 판단 시간이고요. 즉 staleTime 이 gcTime 보다 길어지면 inactive 됐을 때 gcTime에 의해 캐싱 데이터가 지워졌음에도 staleTime은 아직 남아버리게 되면 결국 서버에서 다시 데이터를 받아와야 합니다. 그럼 staleTime 을 지정해둔 이유가 없어짐과 같습니다. 

그밖에 다른 정보는 공식 문서를 확인하는 습관을 들입시다. https://tanstack.com/query/v5/docs/framework/react/reference/useQuery

 

useQuery | TanStack Query Docs

 

tanstack.com