PJH's Shopping Mall - 장바구니

박정호·2022년 12월 20일

Shopping Project

목록 보기
4/11
post-thumbnail

🚀 Start

앞서 진행했던 상품에 대한 Mocking 데이터 통신처럼 장바구니도 구현해보자.

장바구니는 상품리스트에서 상품을 클릭하였을 때 각각 상품의 id값을 전달하여, 장바구니에서 해당 상품 데이터를 불러오자.



🧺 장바구니

✔️ 상품 담기 클릭

클릭하는 순간 addCart가 실행된다. 이때 상품 id값이 전달된다.

  • useMutation: 서버에 데이터 변경 작업을 요청할 때 사용
  • graphqlFetcher: 첫번째인자로 ADD_CART, 두번째인자로 id를 전달.
// src/product/items.tsx
const ProductItem = ({ id, imageUrl, price, title }: Product) => {
  const { mutate: addCart } = useMutation((id: string) =>
    	graphqlFetcher(ADD_CART, { id })
  );
  
  return (
    ...
    <button onClick={() => addCart(id)}> 담기 </button>
  )
}


✔️ Fetcher 실행

  • query : ADD_CART
  • variables : 상품 id
// src/queryClient.ts
export const graphqlFetcher = (query: RequestDocument, variables = {}) =>
  request(`${BASE_URL}/graphql`, query, variables, {
    "Content-Type": "application/json",
    "Access-Control-Allow-Origin": BASE_URL,
  });


✔️ Handler 실행

graphQL fetcher 요청에 따라 모의 통신을 하는 MSW의 핸들러가 실행.

1️⃣ 장바구니페이지에 출력될 cartData 객체를 생성.

  • (()=>{})(): 이 안에 들어있는 코드를 바로 실행(IIFE(= 즉시 작동하는 함수식))

2️⃣ 요청된 값인 variables에 담긴 id를 변수에 저장.

3️⃣ 전달된 id와 일치하는 id를 갖는 데이터를 찾는다.

4️⃣ newItem: 새롭게 추가되는 상품데이터 객체의 수량을 1씩 증가.

5️⃣ 장바구니 데이터에 새롭게 추가된 상품 데이터 추가.

6️⃣ 응답으로 newItem를 전달. (추가된 장바구니 데이터를 전달)

// src/mocks/handler.ts
...
let cartData: { [key: string]: CartType } = (() => ({}))(); // 1️⃣ 번

export const handlers = [
  ...
  
 graphql.mutation(ADD_CART, (req, res, ctx) => {
    const newCartData = { ...cartData }; 
    const id = req.variables.id; // 2️⃣ 번
    const found = mockProducts.find((item) => item.id === req.variables.id); // 3️⃣ 번
    if (!found) { throw new Error("상품이 없습니다.")}

    const newItem = {
      ...found,
      amount: (newCartData[id]?.amount || 0) + 1,
    };
    newCartData[id] = newItem; // 5️⃣ 번
    cartData = newCartData;
    return res(ctx.data(newItem));
  })
// graphql/cart.ts
export const ADD_CART = gql`
  mutation ADD_CART($id: string) {
    cart(id: $id) {
      id
      imageUrl
      price
      title
      amount
    }
  }
`;


✔️ 장바구니 상품 출력(조회)

  • 이제 상품페이지에서 상품 데이터를 가져오듯이, 장바구니페이지에서 데이터를 가져오면 된다.
// src/pages/cart/index.ts

const Cart = () => {
   // 장바구니 데이터의 변경이 일어나므로 staleTime, cachTime을 설정하여 데이터를 fresh상태로 만듦
  const { data } = useQuery(QueryKeys.CART, () => graphqlFetcher(GET_CART), {
    staleTime: 0,
    cacheTime: 1000,
  });
  // cartData가 객체형태이므로 value값 불러오기
  const cartItems = Object.values(data || {}) as CartType[];

  // 데이터 유무에 따른 결과값 출력
  if (!cartItems.length) return <div>장바구니가 비었어요</div>;

  return <CartList items={cartItems} />;
};

export default Cart;
  • 앞서 생성한 장바구니 데이터인 cartData 객체를 요청에 대한 모의응답값으로 전달.
 // src/mocks/handler.ts
export const handlers = [
...
  graphql.query(GET_CART, (req, res, ctx) => {
    return res(ctx.data(cartData));
  }),

...


✔️ 장바구니 상품 수량 업데이트

이제 장바구니에 담긴 데이터를 update해보자. 대표적인 기능으로 상품의 수량을 장바구니에서 증감시킬 수 있을 것이다. 앞서 상품 ID값을 전달하듯이, amount값도 전달되면 될 것같다.

  • idamount값을 전달

// components/cart/item.tsx
const CartItem = ({ id, imageUrl, price, title, amount }: CartType) => {
  const { mutate: updateCart } = useMutation(
    ({ id, amount }: { id: string; amount: number }) =>
      graphqlFetcher(UPDATE_CART, { id, amount })
  );

  const handlerUpdateAmount = (e: SyntheticEvent) => {
    const amount = Number((e.target as HTMLInputElement).value);
    if (amount < 1) return;
    updateCart({ id, amount });
  };
  return (
    <li>
    	...
      <input
        type="number"
        value={amount}
        onChange={handlerUpdateAmount}
		min={1}
      />
    </li>
  );
};

export default CartItem;
  • 해당 데이터가 없다면 Error를 반환하고, 기존의 장바구니 데이터에 업데이트된 amount값을 저장한다.
graphql.mutation(UPDATE_CART, (req, res, ctx) => {
    const newData = { ...cartData };
    const { id, amount } = req.variables;
    if (!newData[id]) {
      throw new Error("상품이 존재하지 않습니다.");
    }
    const newItem = {
      ...newData[id],
      amount,
    };
    newData[id] = newItem;
    cartData = newData;
    return res(ctx.data(newData));
  }),
export const UPDATE_CART = gql`
  mutation UPDATE_CART($id: string, $amount: number) {
    cart(id: $id, amount: $amount) {
      id
      imageUrl
      price
      title
      amount
    }
  }
`;


⛔️ Update Error

🧐 분명 수량을 증가시키면 amount가 증가하는 것을 확인 가능하다. 하지만, 실제로 화면에서는 수량변경이 확인되지 않는다.

따라서, invalidateQueris를 사용했다.

  • queryClient.invalidateQueries 함수는 query가 오래 되었다는 것을 판단하고 다시 fetch할 때 사용한다.(참고)

  • invalidateQueries에 의해 invalidate(무효화)되면

    • useQuery가 사용하는 staleTime이라는 속성이 변경된다.
    • rendering 재실행
// components/cart/item.tsx
const CartItem = ({ id, imageUrl, price, title, amount }: CartType) => {
  const queryClient = getClient();
  	...
  const handlerUpdateAmount = (e: SyntheticEvent) => {
    const amount = Number((e.target as HTMLInputElement).value);
    updateCart({ id, amount });
    queryClient.invalidateQueries(QueryKeys.CART);
  };

👍 생성 API를 호출한 뒤 성공한 경우 invalidateQueries 를 통해 기존에 조회했던 쿼리를 무효화시키고 데이터를 새로 조회.

하지만, 두번 클릭해야 값이 변경?

😐 왜 다음과 같은 현상이 발생할까?


mutation이 끝나기전에 해당 컴포넌트가 unmount되며 해당 쿼리 결과는 inactive 상태가 된다.

즉, 업데이트전에 값을 불러와 기존의 값을 불러오고 그 다음에 업데이트된 값을 가져온다. 따라서, 두번 클릭하니까 값이 증가하는 것처럼 보이는 것이다.


1️⃣ 쿼리를 무효화 시켜 다시 조회

  • 업데이트 성공시 invalidateQueries가 실행되어 데이터를 Update 후에 Get 해온다.
 updateCart(
      { id, amount },
      {
        onSuccess: () => queryClient.invalidateQueries(QueryKeys.CART),
      }
    );

2️⃣ 낙관적 업데이트 처리

위와 같이 invalidateQueries를 사용하 경우 업데이트마다 기존 쿼리를 무효화하고 데이터를 새롭게 조회하므로 하나의 장바구니 데이터마다 Update,Get API 요청이 수없이 반복된다.

이 것을 흔히 비관적(pessimistic) 업데이트라고 할 수 있다.

  • pessimistic(비관적)

    • 사용자 입력 -> 서버에 수정 요청 -> 성공 시 화면 갱신
  • optimistic(낙관적)

    • 사용자 입력 -> 바로 화면 갱신 -> 서버에 수정 요청 (만약 실패시 이전 상태로 화면 수정)

따라서, 비관적업데이트의 경우 예를 들어 좋아요 클릭시 서버에 전송후 화면이 갱신된다면 좋아요 버튼은 한참 후에 수행될 것이다.

그렇다면 낙관적 업데이트는 어떻게?

onMutate를 통해 서버로 보내지는 것을 가로채서 업데이트 후에 수정사항을 보내는 것이다.
(참고)

 const { mutate: updateCart } = useMutation(
    ({ id, amount }: { id: string; amount: number }) =>
      graphqlFetcher(UPDATE_CART, { id, amount })
    {
      onMutate: async ({ id, amount }) => {
        // 사용자 데이터에 대한 모든 퀴리요청을 취소하여 이전 서버 데이터가 낙관적 업데이트를 덮어쓰지 않도록.
        await queryClient.cancelQueries(QueryKeys.CART);
        // 이전 사용자 값의 snapshot
        const prevCart = queryClient.getQueriesData<{
          [key: string]: CartType;
        }>(QueryKeys.CART);
        if (!prevCart) return null;
        const newCart = {
          ...(prevCart || {}),
          [id]: { ...prevCart[id], amount },
        };
        // 쿼리 캐시에 해당 키에 대한 값을 설정
        queryClient.setQueryData(QueryKeys.CART, newCart);
        return prevCart;
      },
      // 실제 서버 요청 성공시
      onSuccess: (newValue) => {
        const prevCart = queryClient.getQueryData<{
          [key: string]: CartType;
        }>(QueryKeys.CART);
        const newCart = { ...(prevCart || {}), [id]: newValue };
        queryClient.setQueryData(QueryKeys.CART, newCart);
      },
    }
  );

💡 참고하자!
👉 React Query의 InvalidateQueries가 동작하지 않을 때
👉 [ERROR] react-query : invalidateQueries 를 해도 refetch 가 되지 않는다
👉 TodoApp(2)_낙관적 업데이트
👉 React Query(리액트 쿼리) 개념 및 예제(6)

장바구니 상품 수량 수정 정상 작동!



✔️ 장바구니 상품 삭제


장바구니에 담긴 상품을 삭제시키는 기능을 구현해보자.

  • 삭제 버튼 클릭시 id값 전달
// components/cart/item.tsx
const CartItem = ({ id, imageUrl, price, title, amount }: CartType) => {

  const queryClient = getClient()
	 ...

  const { mutate: deleteCart } = useMutation(
    ({ id }: { id: string }) => graphqlFetcher(DELETE_CART, { id }),
    {
      onSuccess: () => {
        queryClient.invalidateQueries(QueryKeys.CART)
      },
    },
  )

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

  return (
    <li>
      ...
      <button type="button" onClick={handleDeleteItem}>
        삭제
      </button>
    </li>
  )
}

export default CartItem
  • 전달된 id값에 해당하는 상품데이터를 삭제
// mocks/handler.ts
graphql.mutation(DELETE_CART, ({variables: {id}}, res, ctx) => {
    const newData = {...cartData}
    delete  newData[id]
    cartData = newData
    return res(ctx.data(id))
  })
export const DELETE_CART = gql`
  mutation DELETE_CART($id: ID!) {
    deleteCart(cartId: $id) {
      id
    }
  }
`

장바구니 상품 삭제 정상 작동!



✔️ 장바구니 상품 체크

상품을 삭제하거나, 결제를 할 때 상품을 선택적으로 구현할 수 있어야 한다. 따라서, 체크박스를 생성하여 체크의 유무에 따른 기능을 구현해보자.

CartList 컴포넌트에 전체선택 기능을 구현!

1️⃣ formRef라는 useRef 객체를 생성하여 특정 DOM(전체선택체크박스(form태그))을 선택하는 경우 useRef함수의 current 속성값의 변화에 따라 체크를 판단한다.

Ref: Ref 는 render 메서드에서 생성된 DOM 노드 혹은 React Element에 접근하는 방법을 제공.

2️⃣ 각각 상품의 체크박스에 대한 checkboxRefs라는 createRef 객체를 생성하여 동작을 판단한다. 이때는 useRef가 아닌 createRef를 사용.

  • 함수형 컴포넌트의 경우 createRefuseRef 둘 다 사용 가능.

    • createRef: 매 번 함수가 실행 될때마다 새로운 ref를 만들어 낸다. 리랜더링 될때마다 값을 기억해두지 않고, 새로운 instance를 만들어 사용.

    • useRef: 리랜더됨에도 불구하고 값을 기억하여 새로운 instance를 생성하지않고 저장하여 사용

3️⃣ 전체선택 동작시 handleCheckboxChanged 실행

4️⃣ 아무런 동작(체크박스선택) 없을 시 return

5️⃣ form태그의 동작에 대한 데이터

  • FormData: 폼을 쉽게 보내도록 도와주는 객체 (참고)

6️⃣ 동작한 데이터의 개수 저장.

  • FormData.getAll(): FormData 객체에서 지정된 키와 연관된 모든 값을 반환(참고)

7️⃣ 해당요소(targetInput)의 클래스가 select-all일 경우 즉, 전체선택을 클릭할 경우.

  • checkboxRefs.forEach: 각각 상품의 체크박스도 check!

8️⃣ 체크한 상품 개수와 상품 개수가 같을 경우 즉, 하나씩 선택하여 전체를 다 선택할 경우

  • 전체선택 체크박스의 값은 true가 되어 check 동작!
// components/cart/index.tsx
const CartList = ({ items }: { items: CartType[] }) => {
  const formRef = useRef<HTMLFormElement>(null); // 1️⃣ 번
  const checkboxRefs = items.map(() => createRef<HTMLInputElement>()); // 2️⃣ 번

  const hanldleCheckboxChanged = (e: SyntheticEvent) => {
    if (!formRef.current) return; // 4️⃣ 번
    const targetInput = e.target as HTMLInputElement;
    const data = new FormData(formRef.current); // 5️⃣ 번
    const selectedCount = data.getAll("select-item").length; // 6️⃣ 번

    if (targetInput.classList.contains("select-all")) { // 7️⃣ 번
      const allChecked = targetInput.checked;
      checkboxRefs.forEach((inputElem) => {
        inputElem.current!.checked = allChecked;
      });
    } else { // 8️⃣ 번
      const allChecked = selectedCount === items.length;
      formRef.current.querySelector<HTMLInputElement>(".select-all")!.checked =
        allChecked;
    }
  };
  return (
    <>
      <form ref={formRef} onChange={hanldleCheckboxChanged}> // 3️⃣ 번
        <label>
          <input className="select-all" name="select-all" type="checkbox" />
          전체선택
        </label>
        <ul className="cart">
          {items.map((item, i) => (
            <CartItem {...item} key={item.id} ref={checkboxRefs[i]} />
          ))}
        </ul>
      </form>
    </>
  );
};

CartItem 컴포넌트에 각각 상품 선택 기능을 구현!

forwardRef를 사용하여 자식컴포넌트인 CartItem의 각각 상품데이터의 체크박스 동작 유무를 부모컴포넌트인 CartList가 파악한다.

  • React.forwardRef: 부모 컴포넌트에서 자식 컴포넌트로 자동으로 ref를 전달하여 부모가 자식 ref를 참조하는 기술(참고)
const CartItem = ( ..., ref: ForwardedRef<HTMLInputElement>
) => {
  	...
  return (
    <li className="cart-item">
      <input
        className="cart-item__checkbox"
        type="checkbox"
        name="select-item"
        ref={ref}
        data-id={id}
      />
     ...
    </li>
  );
};

export default forwardRef(CartItem);

장바구니 상품 체크 정상 작동!

profile
기록하여 기억하고, 계획하여 실천하자. will be a FE developer (HOME버튼을 클릭하여 Notion으로 놀러오세요!)

0개의 댓글