Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

리액트 전역 상태(zustand store)와 side effect, 이를 해결하는 방안 논의 기록 #66

Closed
Turtle-Hwan opened this issue Dec 6, 2024 · 2 comments · Fixed by #56
Assignees
Labels
bug Something isn't working

Comments

@Turtle-Hwan
Copy link
Member

Turtle-Hwan commented Dec 6, 2024

문제 상황

공공데이터 API에서 받은 하루 동안의 특정 경로의 고속버스 운행 리스트 데이터를 전역 상태인 useTowardBusListStore()에 Array concat으로 기존 배열 데이터 + 받은 데이터를 저장하고 있었다.

  • zustand로 전역 store를 만들었으며, 아래에서 concat 코드만 보면 된다.

    import { create } from 'zustand';
    import type { ForwardBusListState } from './index.types';
    
    const initialState = {
      forwardBusList: [],
    };
    
    const useForwardBusListStore = create<ForwardBusListState>((set) => ({
      ...initialState,
      reset: () => set({ ...initialState }),
      concat: (newforwardBusList) =>
        set((state) => ({
          ...state,
          forwardBusList: [...state.forwardBusList, ...newforwardBusList],
        })),
      deleteByStartId: (targetStartId) =>
        set((state) => ({
          ...state,
          forwardBusList: state.forwardBusList.filter(
            (forwardBus) => forwardBus.startId !== targetStartId
          ),
        })),
    }));
    
    export default useForwardBusListStore;
  • 아래 코드는 페이지가 처음 렌더링 될 때, 공공데이터 API에서 받아온 정보를 useForwardBusListStore ()의 concat()을 호출해서 전역 상태에 넣고 있다.

      useEffect(() => {
        getBusTicketsAPI(
          searchQuery.startId || 'NAEK032',
          searchQuery.destId || 'NAEK300',
          convertYYYYMMDD(searchQuery.startDate)
        )
          .then((data) => {
            concat(
              convertBusTicketsToBusList(data.response.body.items.item, searchQuery)
            );
          })
      }, []);

의문 사항 및 문제 정의

  • Q0. 이 때 어떤 문제가 발생할까?

    • A0. React Strict Mode로 인해 useEffect가 두 번 실행되는데, concat이 두 번 되어 배열에 동일 정보가 두 번 저장되는 문제가 발생한다!!!!
  • Q1. 어떻게 side effect를 없앨 수 있을까?

  • Q2. 그렇다면 이게 왜 side effect로 간주되는 걸까?

  • Q3. side effect를 없앴을 때 성능 이슈는 없을까?

이 부분들에 대해 [실력 좋은 친구](https://github.com/algoORgoal)와 이야기하면서 더 깊게 이해할 수 있었다.

해결 방안

A1. 먼저 어떻게 side effect를 없앨 수 있을지 이야기해보았다. 물론 StrictMode를 주석처리한다는 선택지는 제외하고서 말이다.

  • A1-1. 처음 떠오르는 생각은 원래 있던 배열 내용 뒤에 concat으로 이어붙이는 것이 아니라, 원래 있던 배열을 제거하고 대신 새로운 배열을 넣는 방법이었다.

    • 이 방법은 지금 사례와 같이 API에서 받아오는 배열 길이가 작아서 백에서 항상 한 번에 받아올 수 있는 경우에는 효과적이다.
    • 그런데 만약 무한 스크롤이나 pagenation 때문에 백엔드에서 일정량 단위로 정보를 잘라서 받아와야 한다면 어떨까?
      • 그러면 결국 이를 전역 상태에 저장하기 위해선 이전에 받아온 페이지들의 정보도 제거되면 안 되고 전역 상태에 유지되어야 한다.
  • A1-2. 이 문제를 해결하기 위해선 기존 상태를 탐색하고 필터링해서 존재하지 않는 것만 추가하면 된다.

    • 기존 상태에 존재하면 추가하지 않고, 존재하지 않으면 추가하는 방식은 우리의 의도와도 맞아떨어지면서 side effect도 발생하지 않는다.

A2. 그런데 왜 배열을 단순히 concat 하는 것이 side effect를 발생시키는지 의문이 들었다. 지금 사례에서는 그다지 부작용이 일어나 보이지 않아서 뭔가뭔가 다른 사례를 생각해 보기로 했다.

사례1) 만약 API를 호출하는데 응답이 지연되어 재호출을 시도했으나 지연된 응답과 재호출 응답이 두 번 도달하여 각각 전역 상태를 변경한 경우

사례2) 전역 상태 store 변경 함수가 여러 컴포넌트에서 호출될 가능성이 있고, 동시에 호출되어 전역 상태를 변경한 경우

위 두 사례 모두 다 중복 필터링 검증을 하지 않으면 전역 상태에 중복 값이 저장되게 된다!!!

A2-1. 게다가 순수 함수의 정의가 동일 입력에 대한 동일 출력이므로 애초에 단순 concat을 해서 반환하는 함수는 순수 함수가 아니었던 것이다!!!!

concat을 해서 반환하면 동일한 배열 입력을 계속 반복해서 주었을 때 return 값은 계속 달라지게 된다.

A3. 그런데 중복 필터링 검증을 하면 안 할 때에 비해 탐색을 다 돌려야해서 성능 이슈가 생길 것 같았다.

원래 길이 n의 배열이 있고, 새로 추가될 배열이 m이라 하면 단순하게만 봐도 O(nm)의 시간이 걸린다.

성능 이슈는 어떻게 해결 가능할까
배열이 아니라 객체로 저장하거나 id 기반 정규화가 필요하다. hashing을 해놓는 것도 좋을 수 있다.

id 기반 정규화를 프론트에서 진행해도 되지만 배열을 객체로 바꾸는 시간이 너무 길다면 백에서 정규화된 데이터를 받아오는 게 나을 수 있을 것이다.

그런데 state는 상태 불변성을 위해 항상 새로운 객체를 만든다.

이 때문에 immutable.js 등으로 시간복잡도를 줄여야 할 수 있다.

@Turtle-Hwan Turtle-Hwan added the bug Something isn't working label Dec 6, 2024
@Turtle-Hwan Turtle-Hwan linked a pull request Dec 6, 2024 that will close this issue
6 tasks
@Turtle-Hwan
Copy link
Member Author

#56

@Turtle-Hwan Turtle-Hwan mentioned this issue Dec 6, 2024
6 tasks
@algoORgoal
Copy link
Contributor

algoORgoal commented Dec 6, 2024

성능 이슈는 어떻게 해결 가능할까

전역 상태를 관리하면서 성능 이슈가 발생할 수 있는 구간

  1. state를 변경하면서 새로운 객체가 만들어진다.
  2. selector에서 기존 값과 새로 selector에서 반환되는 값의 비교
  3. 리렌더링

각각을 해결할 수 있는 해결안

  1. 새로운 오브젝트 생성이 너무 오래걸린다면 메모리 할당을 최소화하고 기존 메모리를 최대한 재활용할 수 있는 Immutable.js의 사용을 고려해볼 수 있다.
  2. DOM을 렌더링하게 된다면 주로 비교 연산보다는 메모리 할당이 bottleneck이기 때문에 아직 고민할 필요가 없을 것 같다.
  3. 불필요한 리렌더링을 방지할 수 있게 useShallow를 사용할 수 있다. useShallow는 Object.is()와 다르게 오브젝트 property 및 배열 item을 서로 얕은 비교한다.

다만, useShallow는 만능이 아니다. 어차피 object안의 property끼리는 얕은 비교를 하기 때문에, object내에서 사용하지 않는 property가 있다면 depth를 하나 더 파고들어서 property를 가져와야 불필요한 리렌더링을 막을 수 있다.

예시

나쁜 예시

const { a, b } = useStore(state => useShallow({ a: state.a, b: 
state.b }));
const { property1 } = a;
// 실제로는 a 내부의 property1만 사용

a 내부에 property2가 있을 경우, property2가 변경될 때마다 불필요한 리렌더링 발생한다.

좋은 예시

const { property1, b } =  useStore(state => useShallow({ property1: state.a.property1, b: state.b }));

코드에서 직접적으로 사용되는 property1, b가 바귈 때마다 렌더링되므로 불필요한 렌더링을 막을 수 있다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants