나는 상태관리 라이브러리를 사용하지 않고있다

심진석

수정일


리액트를 사용하면 꼭 등장하는 주제가 있다. 바로 상태관리(라이브러리) 뭐쓰지?

사용하지 않는 이유

처음에 리액트를 접할 땐 리덕스를 사용했다. 그냥 필수였다. 그럴만한게 당시엔 훅이라는 개념이 나오기 전이었어서 상태관리를 하려면 클래스 문법을 사용해야했다.

Flux라는 아키텍쳐를 따르다보니 거의 모든 상태를 한 곳에 몰아야했고, 불변성을 유지하는데 큰 공을 들여야했다.

리덕스를 사용하면 기본적으로 리덕스 외에도 리덕스 스토어를 합치는 라이브러리가 필요했고, 불변성을 유지하는 라이브러리가 필요했고, 리덕스 크롬 익스텐션도 사용하는 대환장파티가 열린다.

다행히(?) 퇴사를 하게되어 그 머리아픈 작업엔 손을 뗄 수 있었다. 그 이후엔 리액트 훅이 나왔고, 강의를 보며 익숙해졌다. 서비스를 만들다보니 상태관리에 대한 감각이 쌓였고, ‘왜 리덕스를 사용했었지?‘라는 결론이 나오게 되었다.

그러한 결론이 나온건 간단했다.

  1. 훅만으로도 어려움이 없는 서비스의 규모였고,
  2. 라이브러리를 사용하면 배우는데 시간이 걸린다.
  3. 어떤 라이브러리를 사용해야할지도 고민해야했다.

현재 상황에서 상태관리 라이브러리를 사용하는 것은 불필요하게 복잡해진다.

정말 안써도 되나?

SPA를 버린다

SPA의 붐으로 리액트가 활황이었다고 생각하지만, 몇년 전부터 SSR이 대세가 되었다. 2년전부터는 RSC가 대세가 될려다가 어찌될지 모르겠다.

그럼 SPA가 무슨 상관이냐 싶겠지만, SPA를 적용하다보면 자연스럽게 최적화에 눈이 돌아가게 된다. 방법은 상태가 필요한 컴포넌트 상위 컴포넌트에서 관리하는 것이다.

결론적으로 페이지를 관리하는 상위의 컴포넌트에서 각 페이지에 대한 상태를 가지게 되면서 거대한 객체가 만들어지는데, 이 때 자연스럽게 상태관리 라이브러리를 찾게된다.

하지만 SSR에선 캐시전략을 세워야하는데, 훨씬 어려운 개념이다보니 그냥 포기하게 된다.

dom 스펙 이용하기

리액트는 가상돔을 사용하다보니 실제돔에 접근하는 것은 자연스럽지 못하다. ‘누가 querySelector 소리를 내었는가?’ 같은 농담이 오간다.

하지만 form 작업에서는 애매한 부분이 생긴다. 타이핑할 때마다 상태가 업데이트 되면서 수십, 수백번의 렌더링이 발생하게 된다. 나는 이를 피하고자 uncontrolled 방식을 이용한다.

// controlled
<input name="address" value={data} onChange={({ target }) => setData(target.value)} />

// uncontrolled
<input name="address" />

API호출은 다음과 같이 할 수 있다.

<form
  onSubmit={(event) => {
    event.preventDefault();

    const address = event.target.address.value;
    // ...
  }}
>
  <input name="address" />
  <button type="submit">전송</button>
</form>

input이 form 태그안에 존재해야하는 제약이 생기고, 휴대폰 인증같이 추가 폼이 필요한 경우는 여러개의 폼을 만들어 for 속성을 이용할 수 있다. 그런데 솔직히 비효율적이긴 하다.

이런식으로 리얼돔에 접근하는 방식으로 마치 양방향 통신이 가능한 것 처럼 만들어 사용했다.

직접 만들기

내가 작업하는 서비스에서 가장 복잡한건 아무래도 장바구니였다. 체크상태, 수량이 변경될 때 마다 서버에 전송한다.

그런데 장바구니 항목이 20개 정도가 넘어가니 작업이 무거운게 보였다. 어떤 상품의 수량을 변경하는데 화면이 깜빡이는 것이다. 물론 상품의 옵션을 목록으로 보여주었기 때문에 배열의 배열구조를 렌더링하다보니 두드러지게 무거웠을 수 있다.

이 페이지에서만 필요하기 때문에 콘텍스트를 만들었고, 이벤트 리스너 추가와 똑같은 방식으로 구독해 해당 아이템만 업데이트 했다.

import React from 'react';

// 1. 콜백 함수를 저장할 배열을 만든다.
const event = { item: [] };

const CartContext = React.createContext();
function Cart() {
  // ...
}

// ...

function Item(item) {
  const { addEventListener, updateQuantity } = React.useContext(CartContext);

  const [quantity, setQuantity] = React.useState(item.quantity);

  React.useEffect(() => {
    // 2. 식별 정보와 상태 업데이트를 포함한 콜백을 넘긴다.
    addEventListener('item', item.id, (value) => {
        setQuantity(value);
    });
  }, []);

  return (
    <input
      name="quantity"
      onChange={() => {
        // 3. 변경 사항을 콘텍스트에 알려준다
        updateQuantity('item', item.id, value);
        // setQuantity를 여기에 넣어도 문제 없다
      }}
    />
  )
}
export default function Page() {
  const [items, setItems] = React.useState([]);

  const addEventListener = React.useCallback((type, id, callback) => {
    if (type === 'item') {

      event[type].push(callback);
      // ...
    }
  }, []);

  const updateQuantity = React.useCallback((type, id, value) => {
    // 4. 받은 식별 정보에 해당하는 콜백을 실행한다

    const cb = event[type].find((e) => e.id === id);

    let currentItem;
    // 업데이트 로직은 자유
    setItems((prev) => {
      // items 배열은 업데이트 하지 않고 배열 아이템의 객체 프로퍼티만 변경
      currentItem = prev.find((i) => i.id === id);
      currentItem.quantity = value;
      return prev;
    });

    // 5. 업데이트 하면서 영향을 받을 컴포넌트의 콜백도 실행한다.
    // ... 서버 전송 로직 ...
    // ... 묶음 배송 로직 ...
    const productShippingCostCb = event['products'].find((p) => p.id === currentItem.productId);
    productShippingCostCb(resultValue);

    cb(value);
  }, []);

  return (
    <CartContext.Provider value={{ items, addEventListener, updateQuantity }}>
      <Cart />
    </CartContext.Provider>
  );
}

여기선 단순 배열로 작성했다. 내가 다룬 실제 코드는 배열의 배열구조이지만 상위에서 업데이트한 컴포넌트만 리렌더링 된다. 단, 장바구니 삭제의 경우는 배열을 다시 만들어야하기 때문에 화면 깜빡임 문제를 피할 수 없다.

최근에 추가한 라이브러리

그러다가 최근에 상태관리 라이브러리를 도입했다. nanostore이다.

fetch를 할 때 토큰같은 특정 데이터를 담아야할 때가 있는데, 리액트와 상관없이 작동하길 원했고, 데이터는 로컬스토리지를 활용해 담았다.

import React from 'react';

function request(url, data) {
  //...
  const token = localStorage.getItem('token');
  return fetch(url, {
    headers: {
      authorization: `Bearer ${token}`
    }
  })
}

export default function Page() {
  // ...
  React.useEffect(() => {
    request('/cart').then(() => {
      // ...
    })
  }, []);
  // ...
}

문제는 localStorage는 blocking 작업이었기 때문에 단순 객체를 이용하고 싶었다.

유행하는 zustand를 고려했으나, 이 것도 내가 보기엔 불필요하게 복잡하고 우연히 nanostores를 접했는데, 딱 내가 원하는 기능만 있는 가벼운 라이브러리였다.