[커비샵 개발일지 #3] React-query와 캐싱 이해하기

김유진·2023년 4월 25일
1

React

목록 보기
59/64
post-thumbnail

이번 커비샵 프로젝트에서는 React-Query를 이용하여 데이터를 관리를 하기로 하였다. 그래서 이번 포스팅에서는 React-Query를 이용하여 커비샵의 API를 어떻게 관리하는지에 대하여 정리해보고자 한다.

1. QueryClient로 캐시 관리하기

yarn add react-query

해당 명령어를 입력하여서 React Query를 설치한다. 다음으로는 쿼리 클라이언트를 설치할 차례인데, 쿼리 클라이언트를 통하여 쿼리와 서버의 데이터 캐시를 관리할 수 있다.
React Query를 설치하였으므로 쿼리 클라이언트를 만들어보자!

export const getClient = (() => {
    let client: QueryClient | null = null;
    return () => {
        if (!client) client = new QueryClient({
            defaultOptions: {
                queries: {
                    //나중에 필요한거만 stale, cache 설정해두자.
                    cacheTime: Infinity,
                    staleTime: Infinity,
                    refetchOnMount: false,
                    refetchOnReconnect: false,
                    refetchOnWindowFocus: false, 
                },   
            },
        });
        return client
    }
})();

QueryClient는 캐시에 관련되어서 설정을 할 수 있는 객체이다. 기본적으로 캐시를 어떻게 관리할 것인지에 대하여 설정할 수 있다.
client가 존재하지 않는다면 새로운 QueryClient 객체를 만들어 준다.

React-query에서 cacheTime과 staleTime의 차이점은 무엇일까?

일단 이것을 알기 위해서는 React Query에서 데이터가 어떤 라이프사이클을 가지고 있는지 정리할 필요성이 존재한다.

React-Query의 라이프 사이클

  1. A쿼리 인스턴스가 mount된다.
  2. 네트워크에서 데이터가 fetch되고, A라는 쿼리 키로 캐싱을 진행한다.
  3. 해당 데이터는 fresh 상태에서, staleTime 이후 stale 상태로 변경된다.
  4. A쿼리 인스턴스가 unmount된다.
  5. 캐시는 cacheTime만큼 유지되다가, 가비지 콜렉터로 수집된다.
  6. 만약, cacheTime이 지나기 전에 A 쿼리 인스턴스가 새롭게 mount되면, fetch가 실행되고 fresh한 값을 가져오는 동안에 캐시 데이터를 보여준다.

이러한 라이프 사이클을 가지고 있고, 기본적으로 staleTime은 기본값이 0으로 설정되어 있고 cacheTime은 기본값이 Infinity이다.(v4버전)
개인적으로 cacheTime이 Infinity로 설정된 것은 개발자의 자유를 굉장히 많이 늘린 선택이라고 생각한다. (알아서 잘 설정하라는 뜻..)

staleTime은 무엇인가?

데이터가 fresh한 상태에서 stale한 상태로 넘어가는 데 걸리는 시간이다.
fresh한 상태일 때에는, 쿼리 인스턴스가 새롭게 mount되어도, 네트워크 fetch가 일어나지 않는다. stale한 데이터일때는 mount, 인터넷 재연결될 때마다 서버에서 refetch를 진행한다.

cacheTime은 무엇인가?

데이터가 inactive 상태일 때, 캐싱된 상태로 남아있는 시간을 의미한다. 쿼리 인스턴스가 unmount되면 데이터가 inactive한 상태로 남아 있다고 할 수 있다.
만약, cacheTime이 넘어가게 되면 가비지 콜렉터로 저장된다. 다음에 다시 데이터가 mount되면 데이터를 fetch하는 동안 캐시 데이터를 보여준다.

현재 queryClient에 모든 시간을 Infinity로 설정해 둔 이유는 각각의 react query mount 현상이 일어날 때마다 Cache시간을 따로 명시할 것이며, default값으로는 자유롭게 제한을 두지 않기 위하여 설정해둔 것이다.

refetchOnMount & refetchOnWindowFocus & refetchOnReconnect

데이터가 stale 상태일 경우 마운트, 윈도우 포커싱, 인터넷 재연결을 시도하면 refetch를 실행하는 옵션이다. 여기서는 false로 설정하였기 때문에 데이터가 stale상태에 있어도 refetch를 하지 않는다.
현재 api 데이터 정보가 빈번하게 바뀌는 것이 아니기 때문에 이렇게 설정해두었다.
이제 기본적인 queryclient에 대한 세팅은 완료하였다. 최상단 app파일에도 아래와 같이 설정해주자.

const App = () => {
    const elem = useRoutes(routes);
    const queryClient = getClient();
    return (
        <QueryClientProvider client = {queryClient}>
            <Gnb/>
            {elem}
            <ReactQueryDevtools initialIsOpen={false} />
        </QueryClientProvider>
    )
}

export default App

useQuery 훅은 fetcher함수를 인수로 받기 때문에 fetcher 함수를 만들어 주어야 한다.
RESTful의 형식을 따를 것이기 때문에 해당 형식을 따르는 fetcher 함수를 만들어 보자.

fetcher 함수 작성하기

export const restFetcher = async ({
    method,
    path,
    body,
    params,
}: {
    method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
    path: string;
    body ?: AnyOBJ;
    params ?: AnyOBJ;
}) => {
    try {
        let url = `${BASE_URL}${path}`
        const fetchOptions: RequestInit = {
            method,
            headers: {
                'Content-Type': 'application/json',
                'Access-Control-Allow-Origin': BASE_URL,
            },
        }
        ...
    }
}

fetchOptions 세팅을 통하여 restful method와 headers의 내용을 전달해준다. 전달하는 내용도, 받는 내용도 json이 될 것이므로 Content-Type을 json으로 설정해둔다.
Access-Control-Allow-Origin 헤더는 CORS에러를 대비하기 위해서 작성해두었다.

 if (params) {
   const searchParams  = new URLSearchParams(params);
   url += '?' + searchParams.toString();
 }

상품 상세정보에 대한 것을 params로 처리하기 때문에 이를 처리할 수 있는 로직이 필요하다.

if (body) fetchOptions.body = JSON.stringify(body);

그리고 body에 대한 내용이 있다면 json형식의 파일로 바꾸어 보내야 하므로 fetchOptions의 body의 데이터 형식을 수정하여 준다.

const res = await fetch(url, fetchOptions);
const json = await res.json();
return json;
	} catch (err) {
  console.error(err);
}

결론적으로 url과 fetch에 대한 옵션을 설정한 것을 인자로 넘겨 res를 받아오고, json파일로 변환하여 리턴한다.
만약 err를 마주하였을 경우에는 콘솔창에 error내용을 찍어줄 수 있도록 한다.

graphqlFetcher

export const graphqlFetcher = <T>(query: RequestDocument, variables = {}) =>
  request<T>(BASE_URL, query, variables);

grahpql 데이터를 fetch할 fetcher를 작성하여 준다.

QueryKey지정

queryKey를 지정하여 준다.

export const QueryKeys = {
  PRODUCTS: "PRODUCTS",
  CART: "CART",
};

react query 는 쿼리키가 있어야 그 훅을 제대로 사용할 수 있기 때문이다.

Mock 데이터 생성하기

커비샵의 가장 메인은 Mock데이터로 생성한 상품 목록을 랜더링하는 페이지이다. Mock데이터를 생성하고 그를 랜더링하는 과정을 지켜보도록 하자.

const mockProducts = (() => Array.from( {length: 20}).map((_, i) => ({
    id: i + 1 + '',
    imageUrl: `https://picsum.photos/id/${i+10}/200/150`,
    price: Math.floor(Math.random() * (50000 - 2000) + 1),
    title: `임시상품${i + 1}`,
    description: `임시상세내용${i + 1}`,
    createdAt: new Date(1651225354270+ (i * 1000 * 60 * 60 * 10)).toString(),
    rate: Math.floor(Math.random() * (5 - 0) + 1),
}))
)();

즉시실행함수로 mock 데이터를 생성하여보자. 20개의 상품을 임의로 만든다. image는 https://picsum.photos의 임의 생성 이미지를 이용하였다.
가격은 2000원에서 50000원사이로 생성을 진행하고, 제목과 상세내용을 mocK데이터와 같이 정리해주었다.
이렇게 mockProduct는 임의로 생성한 데이터를 반환한다.

현재 이렇게 생성한 데이터는 graphql을 이용하여 핸들러를 작성해 둔 상태이다. 이에 대한 자세한 글은 이후 작성하는 것으로 하고, react query가 어떻게 동작하는지 정리해 보자.
그래도 쿼리에 해당하는 키는 정리해 둘 필요가 있다.

GET_PRODUCTS : 모든 상품목록 가져오기
GET_PRODUCT : 특정 상품 가져오기
GET_CART : 카트 목록 가져오기
ADD_CART : 카트에 물품 추가하기
UPDATE_CART : 카트 결제선택물품 관리하기
DELETE_CART : 카트에 특정 물품 삭제하기
EXECUTE_PAY : 결제 진행하기

상품 목록 가져오기

이제 메인화면에서 상품 목록을 가져오도록 해보자.

const { data } = useQuery<Products>(QueryKeys.PRODUCTS, () =>
    graphqlFetcher<Products>(GET_PRODUCTS))

graphqlFetcher를 이용하여 쿼리 키를 넘겨주고, graphqlFetcher가 넘겨주는 json 형식의 데이터를 받아 와 화면에 보여준다.

<ProductContainer>
  <HeaderContainer>
    <Text typo = 'G_Header_28_B' color = "black" 
      className = "productTitle"
      >오늘의 추천 상품</Text>
	</HeaderContainer>
  <ProductList>
    {data?.products?.map(product => (
     <ProductItems {...product} key={product.id}/>
       ))}
  </ProductList>
</ProductContainer>

상품 카트에 추가하기

이제 메인화면에 상품 목록을 띄워줄 수 있게 되었다.
다음으로는 mutation을 이용하여 카트에 해당 상품을 추가해 보도록 하자. 일반적으로 mutation은 데이터를 업데이트, 삭제, 생성할 때 사용한다. mutation을 이용하게 되면 mutation 의 성공을 바라며 미리 UI부터 변화시켜주는 optimistic update 기능을 제공할 수 있다.
이전에 두둥을 개발할 때에도 react-query에서 매우 편리했던 기능 중 하나가 바로 invalidateQueries이었는데 이번 프로젝트에도 적용해보도록 하자.

useMutation을 사용하면 변형이 필요할 때마다 함수를 호출하여 처리할 수 있다.

그럼 useQuery를 사용하지 왜 useMutation을 사용하냐?

useQuery는 호출되는 즉시 실행되며 백엔드와의 데이터를 맞추기 위하여 동작한다.(선언적으로 동작) 하지만 useMutation을 이용하면 내가 원할 때 함수를 호출하여 데이터를 변경할 수 있기 때문이다.(명령)
그리고 낙관적 업데이트를 하게 해 주어 사용자가 원하는 동작을 여러 번 시도하거나 기대하는 것에 대하여 바로 확인할 수 있게 해 주는 것이다.

useMutation의 문법

import { useMutation } from "react-query";

const { data, isLoading, mutate } = useMutation(mutationFn, options);

useMutation은 첫번째 인자로 mutate를 위한 패치함수를 전달받고, 두번째 인자로 options를 받아온다. useMutation은 key값이 따로 필요없다.

Options의 종류

  • onMutate: (variables) => Promise | void. mutation 전에 실행되는 함수로, 미리 렌더링 하고자할 때 유용하다.
  • onSuccess: (data, variables, context?) => void. mutation이 성공하고 결과를 전달할 때 실행.
  • onError: (error, variables, context?) => void. mutation이 실패했을 시 에러를 전달한다.
  • onSettled: (data, error, variables, context?) => void. mutation의 성공/실패 여부와 상관없이 완료됬을 때 실행.
    이외에도, mutationKey, retry, cacheTime 등 다양한 옵션 존재

이제 상품 목록 페이지에서 해당 상품을 카트에 추가해보자.

const ProductItems= (props: Product) => {
  const [modalOpen, setModalOpen] = useState(false);
  const { mutate : addCart } = useMutation((id : string) => graphqlFetcher(ADD_CART, { id }));
  const addCartProduct = () => {
    addCart(props.id);
    setModalOpen(false);
  }
     return (
    <>
    <ProductItem>
      <Link  style={{ textDecoration: "none", cursor: "pointer"}} to = {`/products/${props.id}`}>
            <ProductImg>
              <img src = {props.imageUrl}/>
            </ProductImg>
            <Text typo="Text_14_SB"
              color="gray_400"
              as = "p"
            >{props.title}</Text>
            <Text
              typo="Header_24"
              color="black"
            >{props.price}</Text>
            <StarRate ratingnum = {props.rate}/>
        </Link>
        <FaShoppingCart 
          color = {theme.palette['main_400']} 
          className ="product-item__add-cart"
          onClick = {openModal}
          style = {{cursor:'pointer'}}
          />
        </ProductItem>
        <CartModal show = {modalOpen} proceed = {addCartProduct} cancel = {closeModal}/>
    </>
  )
};

id값을 받아와 ADD_CART라는 grapql동작을 수행하는 mutation을 등록한다. 이후 addCart라는 함수에 해당 mutation을 넘겨준다.

export const ADD_CART = gql`
    mutation ADD_CART($id: string){
        id
        imageUrl
        price
        title
        amount
    }
`

ADD_CART 키워드에는 id를 받아 id, image, price, title, amount를 받아온다.

graphql.mutation(ADD_CART, (req, res, ctx) => {
  const newData = {...cartData}
  const id = req.variables.id;
  const targetProduct = mockProducts.find(item => item.id === req.variables.id);

  if(!targetProduct) { throw new Error('상품이 없습니다.')}

  const newItem = {
    ...targetProduct,
    amount: (newData[id]?.amount || 0 ) + 1,
   }
  newData[id] = newItem;

  cartData = newData;
  return res(ctx.data(newItem)); 
}),

아이템을 추가하게 되면 기존 카트에 있는 데이터인 cartData를 받아온다. 가져온 id를 저장하고 mock데이터 중에서 id를 일치하는 것을 찾는다. 카트에 추가하였을 때니가 물품 수량이 있다면 늘려주고, 새로운 cartData를 반환한다.

이제 Cart페이지로 가보자.

const Cart = () => {
    const { data } = useQuery(QueryKeys.CART, () => graphqlFetcher(GET_CART), {
        staleTime: 0,
        cacheTime: 1000,
    });
    const cartItems = Object.values(data || {}) as TCart[]
    if(!cartItems.length) return <EmptyCart/>
    return (
        <>
            <CartList items = {cartItems} />
        </>
    );
};

초기에 default값으로 staleTimecacheTime을 무한대로 설정해두었는데, staleTime을 0으로 바꾼다. 대신 cacheTime을 제한하였다. 새롭게 카트가 mount되면 새로운 데이터를 패칭하여 받아오도록 설정해야 하기 때문이다. 받아온 데이터를 TCart[]라는 타입으로 명시하여주고 카트 리스트를 넘겨준다.

Cart 상품 수량 추가 및 삭제

수량 업데이트

<CartContainer>
    <ItemWrapper>
      <input
        className="cart-item__checkbox"
        type="checkbox"
        name="select-item"
        ref={ref}
        data-id={id}
       />
    <SelectedData imageUrl = {imageUrl} price={price} title={title} amount = {amount} data-id = {id}/>
    <PriceDelete>
      <Text typo ='Text_18_SB' color = 'black' className="finalPrice">{price*amount}</Text>
		<AmountInput type = "number" className = "cart-item__amount" 
			min = {1} value = {amount} 
			onChange={handleUpdateAmount}
		/>
  </PriceDelete>
  <MdOutlineDeleteOutline size = '2rem' color = {theme.palette.main_400} onClick = {handleDeleteItem}/>
</ItemWrapper>
  <br/>
</CartContainer>

AmountInput을 통하여 상품의 수량을 변경할 수 있다. 이때 handleUpdateAmount함수를 통하여 수량을 변경하는데 변경되는 수량에 대한 것은 mutation을 통하여 바로 서버에 반영할 수 있어야 할 것이다. 삭제하는 것도 마찬가지이다.

const handleUpdateAmount = (e: SyntheticEvent) => {
  const amount = Number((e.target as HTMLInputElement).value);
  if (amount < 1) return
  updateCart({id, amount})
}

const handleDeleteItem = () => {
  deleteCart({ id })
}

updateCart부터 하는 동작을 살펴보도록 하자.

const { mutate : updateCart } = useMutation(({id, amount} : {id: string, amount: number}) => 
  graphqlFetcher(UPDATE_CART, { id, amount }),
 {
   onMutate: async ({ id, amount }) => {
     await queryClient.cancelQueries(QueryKeys.CART)
     const prevCart = queryClient.getQueryData<{[key: string]: TCart}>(QueryKeys.CART);
     if (!prevCart?.[id]) return prevCart;

     const newCart = {
       ...(prevCart || {}),
       [id]: {...prevCart[id], amount}
     }
     queryClient.setQueryData(QueryKeys.CART, newCart);
     return prevCart
   },
   onSuccess: newValue => {
     const prevCart = queryClient.getQueryData<{[key: string]: TCart}>(QueryKeys.CART);
     const newCart = {
       ...(prevCart || {}),
       [id]: newValue,
     }
     queryClient.setQueryData(QueryKeys.CART, newCart);
   }
 }
        );

onMutate를 통하여 낙관적 업데이트를 실행한다. 서버에 요청하기 전에 미리 ui를 업데이트시킨다. idamount라는 데이터를 받아 어떤 상품인지 구별하고 그 수량을 받아온다.

코드 뜯어보기

다만 낙관적 업데이트를 기대하고 onMutate에 관한 코드만 작성하면 변경하기 이전의 데이터를 보게 되는 수가 있다.

(출처 : https://velog.io/@mskwon/react-query-cancel-queries)
리액트 쿼리는 refetchOnMount 옵션이 기본적으로 true로 설정되어 있다. 컴포넌트 mount와 동시에 데이터 최신성을 확신하기 위해 refetch를 진행한다. 이것이 낙관적 업데이트와 시점이 꼬이게 될 수 있다. 그럼 사용자는 최신 정보를 잠깐만 보고 refetch를 시켜주기 전까지는 예전 데이터를 보는 것이다.

기존 cart 데이터에 덧씌워지는 것을 방지하기 위하여 CART에 관련된 쿼리를 취소할때까지 아래 코드의 실행을 방지한다. 아래는 cancelqueries 관련된 공식문서를 일부 발췌했다.

The cancelQueries method can be used to cancel outgoing queries based on their query keys or any other functionally accessible property/state of the query.

이를 통하여 카트에 해당하는 데이터의 refetch를 무시하도록 할 수 있다.

await queryClient.cancelQueries(QueryKeys.CART)
const prevCart = queryClient.getQueryData<{[key: string]: TCart}>(QueryKeys.CART);
if (!prevCart?.[id]) return prevCart;

이렇게 CART에 관련된 쿼리 요청을 취소할때까지 기다렸다가 아래 코드를 실행시킬 수 있다. getQueryData를 통하여 이전 카트 데이터를 가져온다. 만약 이전 카트에 해당 데이터가 없다면, 이전 카트 데이터를 그대로 랜더링한다.
이제 데이터가 꼬이는 것에 대한 두려움은 끝!! 업데이트를 시켜주었을 때의 상황을 정리해보자.

const newCart = {
  ...(prevCart || {}),
  [id]: {...prevCart[id], amount}
}
queryClient.setQueryData(QueryKeys.CART, newCart);
return prevCart

setQueryData를 통하여 강제로 쿼리 데이터를 갱신할 수 있다.
newCart라는 객체를 만들고, 이전 카트 데이터들을 불러모은다음 갱신된 amount값으로 쿼리 값을 바꾸어준다.

만약 뮤테이션을 성공하면 아래 코드를 실행한다.

onSuccess: newValue => {
  const prevCart = queryClient.getQueryData<{[key: string]: TCart}>(QueryKeys.CART);
  const newCart = {
    ...(prevCart || {}),
    [id]: newValue,
  }
  queryClient.setQueryData(QueryKeys.CART, newCart);
}

반영이 성공적으로 진행된 것에 대하여 newValue를 이용하여 setQueryData를 통하여 성공적인 데이터를 업데이트하도록 한다.

카트 물품 삭제

graphqlFetcher(DELETE_CART, { id }),
  {
  onSuccess: () => {
    queryClient.invalidateQueries(QueryKeys.CART);
  }
})

invalidateQueries를 사용하여 모든 카트 리스트를 업데이트하여 삭제된 카트가 보이지 않도록 한다. 캐시된 데이터가 있든 모두 stale한 상태로 변경해주며 refetch가 진행되어 즉시 데이터를 갱신되도록 할 수 있는 것이다.

결제하기

const Payment = () => {
    const [modalShown, toggleModal] = useState(false);
    const { mutate: executePay } = useMutation((payInfos: PaymentInfos) =>
        graphqlFetcher(EXECUTE_PAY, payInfos),
    )
    const showModal  = () => {
        toggleModal(true);
    }
    const proceed = () => {
        const ids = checkedCartData.map(({ id }) => id)
        executePay(ids, {
            onSuccess: () => {
            setCheckedCartData([])
            alert('결제가 완료되었습니다.😊')
            navigate('/products', { replace: true })
        },
    })
    }
     return (
        <div>
            <PayContainer>
                <Text typo='G_Header_24_B'>구매자 정보</Text>
                <PayInfo/>
                <Text typo='G_Header_24_B'>배송 1건 중</Text>
                <DeilverInfoContainer>
                    <div className = 'InfoHead'>
                        <Text typo = 'Header_20'>내일 도착 예정</Text>
                    </div>
            
                    {checkedItems.map(({ title, id, amount }) => (
                        <Paydeilver key = {id} title={title} amount = {amount}/>
                    ))} 
                
                </DeilverInfoContainer>
            </PayContainer>
            <PayAmount handleSubmit = {showModal} submitTitle ="결제하기"/>
            <PaymentModal show = {modalShown} proceed = {proceed} cancel = {cancel}/>
        </div>
    );
};

결제를 하고자 하면 PaymentModal 이 나타난다. proceed 버튼이 '예'를 의미하는 버튼인데, 해당 버튼을 누르면 proceed 함수가 실행된다.
결제를 실행하는 mutate함수는 id 정보를 담고 있는 string리스트이다.
onSuccess를 받으면 결제가 완료되었다는 알림창과 함께, 메인화면으로 이동할 수 있도록 설정해두었다.

이번에는 React-Query를 이용하면서 본 cache관리와 client생성법, 그리고 기본적인 훅과 Mutation에 대하여 학습할 수 있었다. 다음에 React-Query를 사용해야 할 때가 있다면 상황에 맞추어 코드를 유연하게 작성할 수 있도록 여러 예시코드나 유튜브 강의영상 등을 참고해야겠다. ㅎㅎ

0개의 댓글