[커비샵 개발일지 #4] Recoil로 장바구니 상태 관리하기

김유진·2023년 4월 25일
0

React

목록 보기
60/64
post-thumbnail

최근 SWR과 더불어 주목받고 있는 Recoil을 이용하여 커비샵의 장바구니 상태를 관리하게 되었다.
먼저 store/cart.ts경로를 생성하고 다음과 같이 Cart에 대한 상태를 만든다.

import { atom } from 'recoil';
import { TCart } from 'src/graphql/cart';

export const checkedCartState = atom<TCart[]>({
    key: 'cartState',
    default :[],
})

TCart 타입의 배열로 cart의 state를 관리할 수 있도록 만든 것이다. CartList 페이지에 담아져있는 Cart의 정보를 가져올 수 있도록 하자.

장바구니 체크박스 구현하기


완성된 화면이다.

Ref객체 생성하기

체크박스를 조작하기 위해서는 체크박스 DOM을 가져올 수 있도록 해당 DOM을 선택해야 한다. DOM을 조작하는 데 있어 최적의 훅은 useRef이므로 이를 이용해보자.

const formRef = useRef<HTMLFormElement>(null);

useRef를 이용하여 formElement를 지정할 수 있는 Ref 객체를 만들었다. 해당 객체를 선택하고 싶은 DOM에 ref값을 지정해주면 된다. 지정을 하고 나면 Ref객체의 .current 값은 원하는 DOM을 가리키게 될 것이다.

formRef로 지정하고자 하는 것은 form 타입 HTML이기 때문에 HTMLFormElement로 지정한다. 현재 DOM을 직접 조작하기 위해 프로퍼티로 useRef 객체를 사용하기 때문에, RefObject를 사용해야 하므로 초깃값으로 null을 넣어주어야 한다.

const checkboxRefs = items.map(()=> useRef<HTMLInputElement>(null));

전체적인 form에 대하여 관리하는 Ref를 만들어 주었으니, Item들의 checkbox를 감시할 ref 또한 만들어 주는 것이 인지상정!!! 이후 모든 item들에 대하여 순회하면서 각 체크박스에 대한 Ref를 만든다. 그리고 만들어 둔 ref를 각 Item에 배정해준다.

{items.map((item, i) => <CartItem { ...item} key = {item.id} ref = {checkboxRefs[i]}/>)}

전달된 ref는 각 아이템의 input 태그에 적용하여 check input 이벤트 DOM을 콕 찝어 올 것이다!

FormData 객체 활용하기

FormData는 폼을 쉽게 조작할 수 있도록 하는 객체이다. 더욱 자세한 설명은 링크를 확인하자.

let formData = new FormData([form]);

HTML에 form 요소가 있는 경우, 위와 같은 코드를 작성하면 해당 폼 요소의 필드 전체가 객체에 자동 반영된다!

이제 사용할 요소 하나하나 보았으니 현재 컴포넌트가 어떤 값을 랜더링하는지 확인해보자.

return (
  <form ref = {formRef} onChange = {handleCheckboxChanged}>
    <CartContainer>
      <CartHeader>
        <Text typo ='Text_16_SB' className = "info" color ='black' as ='h3'>
          <input className= "select-all" name = "select-all" type = "checkbox" />
          전체선택
        </Text>
        <Text typo ='Text_16_SB' className = "info" color ='black' as ='h3'>
          상품정보
        </Text>
        <Text typo ='Text_16_SB' className = "info" color ='black' as ='h3'>
          상품금액
        </Text>
      </CartHeader>
      {items.map((item, i) => <CartItem { ...item} key = {item.id} ref = {checkboxRefs[i]}/>)}
      <PayAmount handleSubmit = {handleSubmit} submitTitle="구매하기"/>
    </CartContainer>
  </form>
);

전체 form 태그에 formRef를 지정하였다. 앞으로 formRef는 form태그 전체를 DOM요소로 인식하게 될 것이며 current의 타입은 HTMLFormElement일 것이다.

form이 바뀔 때마다 handleCheckboxChanged 함수가 작동한다.

const handleCheckboxChanged = (e: SyntheticEvent) => {
  if(!formRef.current) return;
  const targetInput = e?.target as HTMLInputElement;

  if (targetInput && targetInput.classList.contains('select-all')) {
  setItemsCheckedFromAll(targetInput)
  } else {
  setAllCheckedFromItems()
  }
  const data = new FormData(formRef.current);
  setFormData(data);
}

현재 가리키고 있는 current 요소가 있는지 없는지 검증을 해준 뒤에 아래 함수를 실행한다. 현재 이벤트를 일으킨 것은 체크박스일 것이다. (HTMLInputElement)
아래 콘솔창의 모습은 targetInput의 모습이다. 현재는 아이템 개별 요소를 선택하였기 때문에 select-item의 클래스를 가진다.

이제 이 개념을 바탕으로 전체 선택과 개별 선택을 구현해보자.

전체 선택 버튼 구현하기

<CartHeader>
  <Text typo ='Text_16_SB' className = "info" color ='black' as ='h3'>
    <input className= "select-all" name = "select-all" type = "checkbox" />
    전체선택
  </Text>
  <Text typo ='Text_16_SB' className = "info" color ='black' as ='h3'>
    상품정보
  </Text>
  <Text typo ='Text_16_SB' className = "info" color ='black' as ='h3'>
    상품금액
  </Text>
</CartHeader>

전체 선택 버튼은 select-all 이라는 클래스를 가졌다.

  const handleCheckboxChanged = (e: SyntheticEvent) => {
    if(!formRef.current) return;
    const targetInput = e?.target as HTMLInputElement;
    if (targetInput && targetInput.classList.contains('select-all')) {
    	setItemsCheckedFromAll(targetInput)
    } else {
    	setAllCheckedFromItems()
    }
    const data = new FormData(formRef.current);
    setFormData(data);
}

만약 select-all 을 포함한 input이라면 setItemsCheckedFromall을 수행하도록 한다. 그럼 해당 함수가 어떤 역할을 하는지 살펴보자.

const setItemsCheckedFromAll = (targetInput: HTMLInputElement) => {
  const allChecked = targetInput.checked
  checkboxRefs.forEach(inputElem => {
  	inputElem.current!.checked = allChecked;
  })
}

전체 선택 버튼이 체크되어있는지 여부를 반환하는 allChecked라는 변수를 만든다. 이후, checkboxRef를 하나씩 순회하며 input태그의 current check상태를 true로 만든다. 만약 전체 선택 버튼이 false라면 current check상태는 모두 false로 바뀔 것이다.

개별 선택 버튼 구현하기

const handleCheckboxChanged = (e: SyntheticEvent) => {
  if(!formRef.current) return;
  const targetInput = e?.target as HTMLInputElement;
  if (targetInput && targetInput.classList.contains('select-all')) {
  	setItemsCheckedFromAll(targetInput)
  } else {
 	 setAllCheckedFromItems()
  }
  const data = new FormData(formRef.current);
  setFormData(data);
}

setAllCheckedFromItems()함수를 실행한다. 해당 함수가 어떻게 생겼는지 보자!

const setAllCheckedFromItems = () => {
  if(!formRef.current) return;
  const data = new FormData(formRef.current);
  const selectedCount = data.getAll('select-item').length;
  const allChecked = selectedCount === items.length;
  formRef.current.querySelector<HTMLInputElement>('.select-all')!.checked = allChecked;
}

selectedCount는 현재 선택된 select-item 클래스를 가진 것을 세 준다. 만약, 선택된 것의 개수가 아이템의 개수와 같다면 모두 선택된 것이므로 allChecked를 true로 설정해주고, 전체 선택 버튼을 가지고 와서 해당 값을 세팅해준다.

마지막으로 현재 form 태그의 상태를 기억할 수 있도록 아래 코드를 수행한다.

const data = new FormData(formRef.current);
setFormData(data);

장바구니 목록 불러오기

이제 장바구니 체크 관련한 기능은 다 만들었다. 이러한 체크된 상품만이 결제 페이지로 넘어가야 한다. 이 부분은 recoil을 이용하여 상태 관리를 하도록 하자.

useEffect(()=> {
  const checkedItems = checkboxRefs.reduce<TCart[]>((res, ref, i) => {
  if (ref.current!.checked) res.push(items[i]);
  return res;
  }, [])
  setCheckedCartData(checkedItems)
}, [items, formData]);

itemsformData의 값이 바뀔때마다 useEffect 함수를 실행한다. 체크된 아이템의 상태를 알기 위하여 checkboxRefs의 상태를 reduce 함수를 이용하여 연산을 실행한다. 현재 checkboxRefs에는 모든 input들에 대한 ref가 저장되어 있는 상태이다. 만약 현재 아이템이 체크된 상태라면 해당 아이템을 res 배열에 넣어서 반환한다. 체크된 아이템들만 recoil로 의하여 전역적으로 관리된다.

장바구니 체크된 상태도 보여주자!

처음에 장바구니를 랜더링 할 때에는 이전에 내가 체크해둔 상태를 저장해야 할 필요가 있다.

이럴 때를 대비하여 첫 랜더링을 대하는 useEffect 함수도 만들어주자.

useEffect(() => {
  checkedCartData.forEach(item => {
  	const itemRef = checkboxRefs.find(ref => ref.current!.dataset.id === item.id);
  	if(itemRef) itemRef.current!.checked = true
  })
  setAllCheckedFromItems();
}, [])

recoil로 관리되고 있는 체크된 값들만 들어 있는 state를 하나씩 돌면서 현재 아이템으로 랜더링 될 것들의 ref들을 하나씩 가져온다. ref의 data가 가지고 있는 Id와 일치하는 것만을 찾아 체크된 상태를 true로 설정하며, 전체 선택을 자동으로 실행할 수 있도록 setAllCheckedFromItems 함수를 하단에 추가해준다.

그럼 이로써 장바구니를 통하여 체크된 아이템들만 recoil로 관리할 수 있게 된 것이다.

결제 페이지 생성

현재 장바구니에 체크된 아이템만 결제를 진행해야 하므로 결제 페이지에 recoil로 만들어 둔 checkedCartData를 이용할 필요성이 존재한다.

  const checkedItems = useRecoilValue(checkedCartState);
  const [checkedCartData, setCheckedCartData] = useRecoilState(checkedCartState);
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>
  );

이렇게 전역 데이터를 받아와 화면에 뿌린다. 이후 결제를 진행해야 하고, 결제가 완료된다면 관리하고 있는 전역 데이터를 초기화해야 한다.
결제해야 하는 금액을 구하는 코드는 아래와 같다.

const checkedItems = useRecoilValue(checkedCartState);
const totalPrice = checkedItems.reduce((res, {price, amount}) => {
  res += price * amount;
  return res;
}, 0);

reduce함수를 통하여 간단히 구할 수 있다. price, amount의 값을 받아서 그 개수만큼 곱하고 결과론적으로 res를 반환하도록 한다.
결제가 완료되면 아래 함수를 수행한다.

const proceed = () => {
        const ids = checkedCartData.map(({ id }) => id)
        executePay(ids, {
            onSuccess: () => {
            setCheckedCartData([])
            alert('결제가 완료되었습니다.😊')
            navigate('/products', { replace: true })
        },
    })

전역적으로 관리하고 있는 recoil 값을 초기화해주고, graphql로 설정한 EXECUTE_PAY 함수를 실행하였기 때문에 결제한 물품은 장바구니에서 사라지도록 한다.

0개의 댓글