장바구니 미션에서는 총 두가지 페이지(상품 목록 페이지, 장바구니 페이지)를 구현해야 하고 애플리케이션 전체에서 사용되는 전역 상태를 관리해야 한다.
리액트의 큰 특징 중 하나는 단방향 데이터의 흐름을 가진다는 점이다. 이로 인해 생기는 복잡한 상태를 관리하는 것은 리액트 개발자들의 큰 숙제 중 하나이다.
프론트엔드 시장에는 이러한 상태 관리를 도와주는 여러 라이브러리들이 있다. 예를 들어 Redux, Recoil, MobX, Jotai 등등 정말 많은 라이브러리가 존재하는데, 그 중에서 Recoil을 사용하려고 한다. 여러 관리 라이브러리를 비교해보며 느낀 Recoil의 특징은 다음과 같았다.
리코일의 사용법은 정말 간단하다. 먼저 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의 사용법을 만들어보자.
장바구니 미션에서 필요한 전역 상태는 장바구니 목록
상태이다. 두 페이지 모두 장바구니 목록 상태가 필요하고, 공용 컴포넌트인 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를 다음과 같이 작성했다.
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의 장바구니 개수를 가져와서 사용한다.
초기에는 장바구니 목록 상태의 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와 연동되도록 구성했다.
앱을 고도화 하면서 로컬 스토리지에 저장된 데이터를 사용하는 것에 머무르지 않고, 서버에서 직접 데이터를 받아와 저장하는 기능을 추가했다. 다음은 장바구니 목록을 서버로부터 가져와 저장하는 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 함수로 상태에 저장한다.
useRecoilState
, useRecoilValue
등 리액트의 훅들과 유사하게 사용할 수 있었기 때문에 금방 그 사용법을 쉽게 익힐 수 있었다. 또한 보일러 플레이팅을 위한 코드 작성이 매우 적어 빠르게 기능 구현 코드 작성을 할 수 있었다.선택된 장바구니 목록 상태(CheckedCartList)
라는게 있었는데, 이 컴포넌트는 전역이 아닌 장바구니 페이지에서만 사용 가능하도록 하고 싶었다. 그러나 최상위 컴포넌트에 감싼 RecoilRoot 외에 범위를 지정하는 것은 불가능해서 결국 리코일이 아닌 Context API를 사용해 해당 상태를 선언해줬다.