Recoil 적용기와 느낀 점

정균·2023년 8월 10일
0

우아한테크코스

목록 보기
10/15
post-thumbnail

장바구니 미션 간단 개요

장바구니 미션에서는 총 두가지 페이지(상품 목록 페이지, 장바구니 페이지)를 구현해야 하고 애플리케이션 전체에서 사용되는 전역 상태를 관리해야 한다.

장바구니 배포 사이트
장바구니 미션 레포지토리

Recoil

리액트의 큰 특징 중 하나는 단방향 데이터의 흐름을 가진다는 점이다. 이로 인해 생기는 복잡한 상태를 관리하는 것은 리액트 개발자들의 큰 숙제 중 하나이다.

프론트엔드 시장에는 이러한 상태 관리를 도와주는 여러 라이브러리들이 있다. 예를 들어 Redux, Recoil, MobX, Jotai 등등 정말 많은 라이브러리가 존재하는데, 그 중에서 Recoil을 사용하려고 한다. 여러 관리 라이브러리를 비교해보며 느낀 Recoil의 특징은 다음과 같았다.

직접 사용해보기 전에 알아본 Recoil의 특징

  • 리액트와 동일하게 메타(구 페이스북)에서 개발한 라이브러리이다. 그렇기 때문에 리액트 친화적이다.
  • 복잡한 보일러 플레이팅 작업이 필요 없으며, 기존 리액트 훅과 사용법이 유사해 러닝 커브가 비교적 낮다.
  • 선언적인 방식으로 상태를 정의하고 사용할 수 있어 코드의 가독성과 유지 보수성을 높일 수 있다.
  • 기본 상태 단위인 atom과, atom을 기반으로 파생되는 데이터를 계산하는데 사용되는 selector가 있다.

리코일 시작하기

리코일의 사용법은 정말 간단하다. 먼저 recoil 패키지를 설치한다.

npm install recoil

리코일 설치가 끝났다면, 리코일의 상태를 사용하는 컴포넌트의 최상단 부모 컴포넌트의 RecoilRoot를 감싸준다.

// src/index.tsx
const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement,
);

root.render(
  <RecoilRoot>
    <Suspense>
      <RouterProvider router={router} />
    </Suspense>
  </RecoilRoot>
);

장바구니 미션에서는 모든 페이지에서 리코일 상태를 사용하므로 최상단의 index 파일의 RouterProvider를 감싸줬다.

이렇게 설정을 해줬다면 리코일을 사용하기 위한 보일러플레이팅은 끝났다. 다음은 사용할 리코일의 상태인 atom과 atom의 파생 상태인 selector의 사용법을 만들어보자.

장바구니 atom

장바구니 미션에서 필요한 전역 상태는 장바구니 목록 상태이다. 두 페이지 모두 장바구니 목록 상태가 필요하고, 공용 컴포넌트인 Header에도 장바구니 품목 개수가 필요하다. 가장 기본이 되는 장바구니 목록 상태를 리코일의 atom으로 다음과 같이 작성했다.

const cartState = atom<CartItem[]>({
  key: 'cartState',
  default: [
		{
			id: '0',
      quantity: 1,
			product: {
	      "id": 0,
	      "name": "순살치킨 1KG",
	      "price": 9900,
	      "imageSrc": "https://cdn-mart.baemin.com/sellergoods/main/c6f2f083-a8b8-4799-834b-444b5eaeb532.png?h=400&w=400"
	    }
		},	
		{
			id: '1',
      quantity: 3,
			product: {
	      "id": 1,
	      "name": "리코스 나초칩 454g",
	      "price": 6200,
	      "imageSrc": "https://cdn-mart.baemin.com/sellergoods/main/bf308ce9-cbe5-45a0-808a-4fdda168f992.jpg?h=400&w=400"
			}
    },
		{
			id: '2',
      quantity: 99,
			product: {
	      "id": 2,
	      "name": "오레오 600g 화이트/초코",
	      "price": 5800,
	      "imageSrc": "https://cdn-mart.baemin.com/sellergoods/main/3fe3f038-f00e-43f5-b289-23f1b6f8255d.jpg?h=400&w=400"
			}
    }
	],
});

atom은 유니크한 key가 필요하다. 장바구니 목록 상태는 cartState 라는 key를 줬고, 장바구니 상태를 사용하는 사용처에서는 이 key를 가지고 상태를 구독할 수 있다. 또한 atom의 default로 초기값을 줄 수 있는데, 위 코드에서는 mock 데이터를 넣어줬다.

이렇게 만든 장바구니 atom을 컴포넌트에서 읽고 쓰고 싶다면 다음과 같이 사용할 수 있다.

// 장바구니 리스트를 보여주는 컴포넌트
const CartList = () => {
	const [cartList, setCartList] = useRecoilState(cartState);

	...
}

useState 훅과 유사하게 배열의 첫 번째 값은 해당 상태를 가르키고, 두 번째 값은 상태의 setter를 반환한다.

만약 atom의 상태 데이터만 사용하고 싶다면 useRecoilValue 함수를 사용하고, atom의 setter만 사용하고 싶다면 useSetRecoilState 함수를 사용한다.

const cartList = useRecoilValue(cartState);

const setCartList = useSetRecoilState(cartState);

한 상품의 장바구니 갯수를 세는 selector

상품 리스트 페이지의 각 상품 아이템에는 그 상품이 장바구니에 몇개 들어있는지 표시해줘야 한다. 이렇게 장바구니 목록 상태로 부터 파생되는 데이터를 계산이 필요할 때 selector를 사용할 수 있다. 해당 상품의 장바구니 갯수를 계산하는 selector를 다음과 같이 작성했다.

const productQuantityInCart = selectorFamily({
  key: 'productQuantityInCart',
  get:
    (productId: number) =>
    ({ get }) => {
      const cart = get(cartState);

      const product = cart.find(
        (cartItem) => cartItem.product.id === productId,
      );

      if (!product) return 0;

      return product.quantity;
    },
});

selector는 atom과 동일하게 유니크한 키가 필요하기 때문에 productQuantityInCart 라는 키를 부여해줬다.

selector의 get 함수로 cartState atom의 값을 가져왔고, 인자로 받은 productId로 cartState에 해당하는 상품을 가져온다. 만약 productId에 해당하는 상품이 없다면 0을 반환하고, 있다면 해당 상품의 quantity 값을 반환한다.

selector의 종류로는 일반 selector와 selectorFamily라는게 존재하는데, selector와 selectorFamily의 차이점은 쉽게 말해 get과 set에 인자를 전달 해줄 수 있냐 없냐에 대한 차이다. productQuantityInCart에는 해당 상품의 id(productId)인자가 필요하므로 selectorFamily를 사용했다.

이렇게 만든 한 상품의 장바구니 개수를 가져오는 selector(Familiy)는 다음과 같이 사용한다.

// 상품 아이템 컴포넌트
const ProductItem = (product: Product) => {
  const { id: productId, name, price, imageSrc } = product;

  const quantityInCart = useRecoilValue(productQuantityInCart(productId));
  ...
}

상품 아이템 컴포넌트에서는 props로 받은 product의 id를 추출해서 selector의 인자로 넘겨줬다. useRecoilValue 함수를 사용해서 해당 product의 장바구니 개수를 가져와서 사용한다.

로컬 스토리지 저장 atom effect

초기에는 장바구니 목록 상태의 default(초기 값)에 단순 mock 데이터를 직접 때려 넣었다. 하지만 장바구니 앱을 고도화 하면서, 장바구니 목록의 상태를 저장하고 새로고침하거나 창을 닫아도 데이터가 유지되도록 하고 싶었다.

리코일의 atom을 로컬 스토리지와 연동하는 방법을 찾아보니 atom effect라는 기능이 있었고, 다음과 같이 로컬 스토리지 저장 및 불러오기에 대한 기능을 가지고 있는 atom effect 로직을 작성했다.

const localStorageEffect: <T>(key: string) => AtomEffect<T> =
  (key: string) =>
  ({ setSelf, onSet }) => {
    const savedValue = localStorage.getItem(key);
    if (savedValue !== null) {
      setSelf(JSON.parse(savedValue));
    }

    onSet((newValue, _, isReset) => {
      isReset
        ? localStorage.removeItem(key)
        : localStorage.setItem(key, JSON.stringify(newValue));
    });
  };

const cartState = atom<CartItem[]>({
  key: 'cartState',
  default: [],
  effects: [localStorageEffect<CartItem[]>('cart_list')],
});

localStorageEffect 함수는 다음과 같은 역할을 한다. 로컬 스토리지에 해당 키(cart_list)에 대한 데이터가 있으면 그 데이터를 setSelf 함수로 상태에 저장한다. 그리고 상태에 새로운 값 변경이 있을 때 마다 로컬 스토리지의 데이터를 새로 저장해준다.

이렇게 작선된 localStorageEffect를 atom의 effects에 넣어 cartState와 연동되도록 구성했다.

데이터 fetch atom effect

앱을 고도화 하면서 로컬 스토리지에 저장된 데이터를 사용하는 것에 머무르지 않고, 서버에서 직접 데이터를 받아와 저장하는 기능을 추가했다. 다음은 장바구니 목록을 서버로부터 가져와 저장하는 atom effect이다.

const fetchEffect: AtomEffect<CartProduct[]> = ({ setSelf, trigger }) => {
  const fetchCartItemList = async () => {
    const response = await fetch(CART_ITEMS_BASE_URL);

    if (response.status !== 200) throw new Error('서버에 장애가 발생했습니다.');

    const cartItemList = await response.json();
    setSelf(cartItemList);
  };

  if (trigger === 'get') {
    fetchCartItemList();
  }
};

const cartState = atom<CartProduct[]>({
  key: 'cartState',
  default: [],
  effects: [fetchEffect],
});

기존 localStorageEffect 함수를 fetchEffect 함수로 갈아끼운 모습이다. fetchEffect 함수에서는 서버로부터 데이터를 가져오고, 그 데이터를 setSelf 함수로 상태에 저장한다.

직접 사용해며 느낀 Recoil

실제로 느낀 Recoil의 장점

  • 사용하기 쉽다. 리코일에 대한 지식이 거의 없었지만 사용법을 익히는데 전혀 어렵지 않았다. 문법도 useRecoilState, useRecoilValue 등 리액트의 훅들과 유사하게 사용할 수 있었기 때문에 금방 그 사용법을 쉽게 익힐 수 있었다. 또한 보일러 플레이팅을 위한 코드 작성이 매우 적어 빠르게 기능 구현 코드 작성을 할 수 있었다.
  • 비동기 selector, 캐싱 등 다양한 기능이 지원된다. 특히 비동기 selector를 사용하면 Suspense를 쉽게 사용할 수 있다는 점이 좋았다.

Recoil을 처음 사용해보며 마주한 문제들

  • 리코일 atom의 구독 범위를 유동적으로 지정하지 못하다는 문제가 있었다. 최상위 컴포넌트에 RecoilRoot를 감쌌다면 리코일의 모든 atom은 모든 컴포넌트에서 접근이 가능하게 된다. 일부 atom을 특정 범위(ex. 하나의 페이지 컴포넌트)에서만 접근 가능하도록 설정하는 것이 불가능했다.
  • 이번 미션에서 선택된 장바구니 목록 상태(CheckedCartList)라는게 있었는데, 이 컴포넌트는 전역이 아닌 장바구니 페이지에서만 사용 가능하도록 하고 싶었다. 그러나 최상위 컴포넌트에 감싼 RecoilRoot 외에 범위를 지정하는 것은 불가능해서 결국 리코일이 아닌 Context API를 사용해 해당 상태를 선언해줬다.
  • 또한, 리코일의 비동기 selector에는 캐싱 기능을 지원하는데, 오히려 이 기능 때문에 selector에 fetch 로직이 있을 경우 서버로 부터 실시간 데이터를 받아오지 못한다는 문제가 발생했다.
  • 캐싱을 줄이기 위한 기능이 있긴 했지만 unstable한 기능이었고, 캐싱을 줄이기만 할뿐 아예 끄는 기능은 없었기 때문에 역시나 의도한 대로 작동하지 않았다. 결국 selector에 fetch 로직를 제거할 수 밖에 없었다.
profile
💻✏️🌱

0개의 댓글