Context API 와 Recoil

 

이제 1년차인 응애 개발자여서 처음에는 랜더링을 크게 신경쓰지 않고 우선 기능에 초점을 맞춰 개발해나갔다.

하지만 애플리케이션이 점점 커지고 child component들이 많아지던 어느 순간부터 '어? 랜더링 최적화 해야겠는데..?' 라고 생각 될 정도로 성능이 지수함수 형태로 안좋아지기 시작했다.

 

랜더링 최적화의 방법은 React.memo, useMemo, useCallback, 상태와 props의 좋은 아키텍쳐 설계, 상태관리 라이브러리 사용, key값 사용(diffing 알고리즘) 등등 많지만 여기서는 contextAPI와 recoil을 통해 해결했던 방법을 서술하겠음!

 

전역 상태관리를 라이브러리로 recoil을 사용했는데, 당시엔 개발할 애플리케이션 규모가 작아 복잡한 상태관리가 없을거라 판단해 직관적인 recoil을 선택했다. recoil 상태와 setter 함수를 가져와 커스텀훅도 만들고 열심히 개발할때는 몰랐는데 성능 최적화에 손을 대는 순간 내가 react도 recoil도 잘못 사용하고 있었다는것을...  깨달았다.

 

react는 상태가 바뀌면 리랜더링이되고, recoil은 기본적으로 setter 함수와 상태를 구분해서 불러올 수 있다.

아래 예시를 보자.

// atom 생성
export const numberState = atom({
  key: "numberState",
  default: 0,
});


// Test 컴포넌트
const Test = () => {
  const number = useRecoilValue(numberState); // numberState atom에서 value 값만 사용 
  console.log('number: ', number);

  return (
    <div>
      <h1>{number}</h1>
    </div>
  );
}


// Test2 컴포넌트
const Test2 = () => {
  const setNumber = useSetRecoilState(numberState); // numberState atom에서 setter함수만 사용
  const addNumberHandler = () => {
    setNumber((prev) => prev + 1);
  }
  console.log('Test2 리렌더링');

  return (
    <div>
      <button onClick={addNumberHandler}>
        <h1>test2</h1>
      </button>
    </div>
  );
}

 

 

아래 화면을 보면 같은 key의 atom에서 가져왔지만

Test2의 setter 함수를 실행시키면 state를 가진 Test 컴포넌트만 리랜더링 된다.

Test 컴포넌트만 리랜더링 된 모습

 

하지만 이걸 커스텀 훅으로 만들면?

// 커스텀훅 생성
const useRecoilCustomhook = () => {
  const [number, setNumber] = useRecoilState(numberState);

  const addNumberHandler = () => {
    setNumber((prev) => prev + 1);
  };

  return {
    number,
    addNumberHandler,
  };
};

export default useRecoilCustomhook;

// Test 컴포넌트
const Test = () => {
  const { number } = useRecoilCustomhook(); // 커스텀훅에서 state 사용
  console.log('number: ', number);

  return (
    <div>
      <h1>{number}</h1>
    </div>
  );
}

// Test2 컴포넌트
const Test2 = () => {
  const { addNumberHandler } = useRecoilCustomhook(); // 커스텀훅에서 함수 사용
  console.log('Test2 리렌더링');

  return (
    <div>
      <button onClick={addNumberHandler}>
        <h1>Test2</h1>
      </button>
    </div>
  );
}

 

당연하게 Test2 컴포넌트도 함께 랜더링된다.

addNumberHanlder 가 실행되면 커스텀훅에 있는 number state가 랜더링되며 커스텀훅을 사용하는 모든 컴포넌트에서 랜더링이 발생하는 당연한 현상이 일어납니다. (지금은 당연하지만 그때는 아니었습니다.. 또륵)

커스텀훅을 사용하는 모든 컴포넌트에서 랜더링 발생

 

물론 위 예시는 간단하니까 금방 최적화 할수 있지만 당시 커스텀훅 남발로 인해 리팩토링이 시급했고..

꼬인 코드를 떼어낼 방법으로 contextAPI 로 state 관심사를 분리해줬다.

그리고 context에서 상태와 setter 함수를 분리해서 상태를 가진 컴포넌트가 아니면 리랜더링이 되지않도록 했다.

관련 내용 docs

export const StateContext = createContext(); // 상태context 
export const SetterContext = createContext(); // setter 함수 관련 context

const GlobalProvider = ({ children }) => {

  const [number, setNumber] = useState(0);
  const addNumberHandler = () => {
    setNumber(prev => prev + 1);
  };

  const stateValue = useMemo(() => ({
    number
  }), [number]);

  const setterValue = useMemo(() => ({
    addNumberHandler
  }), []);

  return (
    <StateContext.Provider value={stateValue}>
      <SetterContext.Provider value={setterValue}>
        {children}
      </SetterContext.Provider>
    </StateContext.Provider>
  );
}

export default GlobalProvider;

 

아래와 같이 SetterContext를 사용한 컴포넌트는 StateContext 와 달리 랜더링 되지 않는다

const Test = () => {
  const { number } = useContext(StateContext); // StateContext 구독
  console.log('number: ', number);

  return (
    <div>
      <h1>{number}</h1>
    </div>
  );
}

const Test2 = () => {
  const { addNumberHandler } = useContext(SetterContext); // SetterContext 구독
  console.log('Test2 리렌더링');

  return (
    <div>
      <button onClick={addNumberHandler}>
        <h1>Test2</h1>
      </button>
    </div>
  );
}

  

state 와 setter context를 별도로 나눠 state쪽 context를 구독한 컴포넌트만 랜더링된 모습

 

위 context 분리 방법의 이점은 전역상태와 setter 함수를 별도로 관리하니 코드 관리와 유지보수가 용이한 점과 전역으로 사용할 state와 컴포넌트를 캡슐화 시킬수 있는 점이었는데, 위에서 특정 recoil state와 setter 함수로 커스텀훅을 사용하듯 전역으로 사용할 state와 settter 함수를 특정 컴포넌트들과 매칭시켜 코드를 큰 덩어리끼리 분리할수 있었다.

 

그리고 recoil은 goole-maps-api의 map 객체와 같이 복잡한 객체를 state에 저장할 수가 없다. 처음엔 필요한 부분만 뽑아서 state에 부분저장 하거나 ref로 어떻게 어떻게 처리하다가 만들던 애플리케이션이 maps의 꽤 많은 기능들을 사용하다보니 처음엔 다른 상태관리 라이브러리를 사용할까 하다가 contextAPI 가 react에 내장된 기능이기도하고 다른 라이브러리를 사용하기보다 이쪽으로 해결해보고 싶었다.

 

Recoil Selector

 

recoil로 남발해 놓은 커스텀훅을 덜어내려고 고민하다보니 기존에 외면해왔던 기능들이 눈에 들어왔다.

몇번 docs 읽어보긴했었는데 음..음.. 그렇군 왜 사용하는지 모르겠군! 난 커스텀 훅을 사용하겠어! 왜냐하면 react는 커스텀훅을 권장하니까! 라는 근거도 없는 논리로 사용하지 않았었는데 다시보니 선녀였다.

 

contextAPI로 큰 덩어리끼리 컴포넌트와 상태를 어느정도 분리시켰지만 찐 전역으로 사용하는 상태는 여전히 많았고 해당 상태들은 여전히 recoil로 다뤄야했다.

selector 는 리팩토링 중에 상태들의 분리와 응집도를 높여주는데 많은 도움을 줬다.

이번엔 조금 바뀐 예제다

export const itemState = atom({ // 과일이 3개 있는 배열이다
  key: "itemState",
  default: [
    {
      id: 1,
      name: "사과",
      price: 1000,
    },
    {
      id: 2,
      name: "바나나",
      price: 2000,
    },
    {
      id: 3,
      name: "물렁한 복숭아",
      price: 3000,
    },
  ],
});

export const filterState = atom({ // filter 기능으로 사용할 상태
  key: "filterState",
  default: "all",
});


// selector는 다른 상태를 가져와 가공할 수 있게하는 순수함수(view만 제공)
export const filteredItemState = selector({ 
  key: "filteredItemState",
  get: ({ get }) => {
    const items = get(itemState); // 과일 상태 get
    const filter = get(filterState); // filter 상태 get

    switch (filter) { // filter 상태에 따라 과일 상태 가공
      case "cheap":
        return items.filter((item) => item.price <= 1000);
      case "normal":
        return items.filter((item) => item.price > 1000 && item.price <= 2000);
      case "expensive":
        return items.filter((item) => item.price > 2000);
      default:
        return items;
    }
  },
});
const Test = () => {
  // 원본 itemState 가 아닌 가공된 filteredItemState
  const items = useRecoilValue(filteredItemState);

  return (
    <div>
      {items?.map(item =>
        <ul key={item.id}>
          <li>상품명: {item.name}</li>
          <li>가격: {item.price}</li>
        </ul>
      	)
      }
    </div>
  );
}

const Test3 = () => {
  const setFilter = useSetRecoilState(filterState);
  const onChangeFilter = (e) => {
    const value = e.target.value;
    setFilter(value);
  }

  return (
    <div>
      <select defaultValue={"all"} onChange={onChangeFilter}>
        <option value={"all"}>all</option>
        <option value={"cheap"}>cheap</option>
        <option value={"normal"}>normal</option>
        <option value={"expensive"}>expensive</option>
      </select>
    </div>
  )
}

selector로 관련 state와 로직을 분리하고 모아뒀다 (유지보수 올라감)

 

원래는 Test 컴포넌트 안에 있어야할 필터 로직들이 분리되었고 상태도 관련있는 상태끼리 모아서 관리하는 이점이있다.

아래는 필터 로직이 컴포넌트에 있는 코드

const Test = () => {
  const items = useRecoilValue(itemState); // 원본 과일 상태 get
  const filter = useRecoilValue(filterState); // filter 상태 get

  // 필터링 로직
  const filteredItems = items.filter(item => {
    switch (filter) {
      case "cheap":
        return items.filter((item) => item.price <= 1000);
      case "normal":
        return items.filter((item) => item.price > 1000 && item.price <= 2000);
      case "expensive":
        return items.filter((item) => item.price > 2000);
      default:
        return items;
    }
  });

  return (
    <div>
      {filteredItems.map(item => (
        <ul key={item.id}>
          <li>상품명: {item.name}</li>
          <li>가격: {item.price}</li>
        </ul>
      ))}
    </div>
  );
}

 

 recoil 함께해서 즐거웠고 다신 보지말자~ 

 

뭐 여차저차 리팩토링에 도움은 많이 되었는데 recoil이 알아볼수록 관련 괴담이 많았다.

일단 selectorFamily, family는 selector나 atom 사용 시에 동적으로 파라미터를 전달해 상태를 사용할 수 있게하는 기능이다. 아래처럼 사용하면 된다.

const itemState = atom({ // 기존의 과일 상태
  key: 'itemState',
  default: [
    { id: 1, name: '사과', price: 1000 },
    { id: 2, name: '바나나', price: 2000 },
    { id: 3, name: '복숭아', price: 3000 },
  ],
});

// Family Selector 정의
const itemByIdState = selectorFamily({
  key: 'itemByIdState',
  get: (id) => ({ get }) => { // id 값에 따라 해당 값만 return
    const items = get(itemState);
    return items.find(item => item.id === id);
  },
});

// 컴포넌트에서 사용
function ItemDetail({ itemId }) {
  const item = useRecoilValue(itemByIdState(itemId));
  return (
    <div>
      <h3>상품명: {item?.name}</h3>
      <p>가격: {item?.price} 원</p>
    </div>
  );
}

 

But, selector 나 family 나 기본적으로 캐싱을 해주는데 매개변수가 변경되어 기존 값을 사용하지 않아도 가비지 컬렉터에 제대로 수집되지 않아 해당 데이터가 메모리에 남아있어 메모리 누수가 발생한다.

아직도 열려있는 21년도 gitissue..

현재 기준 최신 버전 업그레이드도 1년전이고 무엇보다 활발한 커뮤니티가 있는 유사한 atom 개념인 jotai 도 있고 곰돌이 zustand도 있고 앞으로는 recoil을 프로젝트에 적용하는 일은 없을 것 같다.

 

그래도 찍먹해봤으니 만족~

 

참고 글

recoil Git_issue

Recoil, 이제는 떠나 보낼 시간이다

https://tech.osci.kr/recoil-selector/

+ Recent posts