상태관리 공부하자~

밍글·약 16시간 전
2

FE스터디

목록 보기
5/5
post-thumbnail

시작하기 전에

상태관리는 프론트엔드를 하다보면 무조건 다뤄야 하는 부분이기 때문에 이번에 간단하게 정리를 해보려고 한다.

리액트 상태관리

양방향 바인딩을 하는 Angular나 Vue와 달리 리액트는 단방향 바인딩을 지원한다.

즉, 부모의 상태를 자식으로 전달할 수는 있지만 반대의 방향(자식 → 부모)으로는 불가능하다는 것이다.

props drilling

부모 컴포넌트가 있고 그 자식인 A, 그리고 A의 자식인 B, B의 자식인 C가 있다고 해보자.

만약 A의 상태값을 C에서 사용한다면, B는 사용하지 않음에도 불구하고 상태값을 props로 전달해야 한다.

만약 Volume이 커지고 상태값도 더 많아지고 복잡해진다면 props drilling이슈로 골머리를 앓을 수 있다.

이를 해결하기 위해서 나온 것이 상태 관리 툴이라고 생각하면 된다.

Context API는 해결책이 안될까?

리액트에서는 context API가 존재하고 이를 사용하면 컴포넌트의 깊이 여부와 상관없이 데이터가 필요한 컴포넌트에서만 가져다 쓸 수 있도록 할 수 있다.

다만 Volume이 커질수록 Provider의 개수가 많아져서 Provider hell이 발생할 수 있고 동일한 Provider 하위에서 context를 구독하는 모든 컴포넌트는 Provider의 value prop이 바뀔 때마다 모두 리렌더링 된다는 치명적인 단점이 있다..

그리고 근본적으로 Context API는 상태 관리 도구가 아니라고 한다.

공식 문서에도 Context는 props 없이 데이터를 받을 수 있게 해주는 기능이라고 적혀있을 뿐, 상태 관리를 한다는 내용은 없다.

즉, Context는 만들어진 상태를 단순히 전달할 뿐, 리액트에서의 상태를 관리하는 훅은 useState와 useReducer 훅인 것이다.

Flux

MVC패턴

프론트엔드에서도 MVC패턴을 구조화할 수 있다.

  1. View가 Controller에게 이벤트를 보내면
  2. Controller는 Model을 업데이트하고
  3. Model은 View를 업데이트한다.
  4. Controller가 View를 직접 업데이트할 수도 있다.(ex. 정렬)

이런 패턴은 규모가 커지게 되면 Controller마다 많은 Model, View를 가지고 있게 되어 복잡해진다.

또한 MVC 패턴에서의 데이터 흐름은 양방향으로 이루어지는데 이는 단방향 데이터 흐름을 지향하는 리액트와는 맞지 않는 아키텍처였다.

Flux 패턴

MVC에서는 사용자의 상호작용(Action)은 Controller가 담당했다. Store는 상태 관리를 하는 곳이라고 보면 된다.

  1. Action이 들어오면 어떤 Store에 할당할 지 Dispatcher가 판단한다.
  2. Store는 Action에 의해 상태가 변경되면 View에서 변경점을 표현해준다.
  3. 변경된 데이터는 View에 반영되며, View에서는 다시 Action을 받을 수 있다.

Redux

나오게 된 배경

싱글톤 모델

Flux는 여러 개의 스토어를 사용하는 구조였는데 스토어 간 의존성 관리가 큰 문제였다.

Redux는 싱글톤 모델을 선택함으로써 모든 것이 의존성 없이 다른 곳에 접근할 수 있게 되었다. reducer는 뒤에 서술하겠지만 순수하기 때문에 여러 상태 조각들을 다루는 모든 로직은 스토어 밖에 있어야 한다. Redux가 처음에 고치지 못한 유일한 결함은 보일러플레이트였다.

우리가 흔히 아는 RTK는 이러한 악명 높은 보일러플레이트를 단순화해주었다.

모듈식 상태에서는 의존성 트리가 너무나 복잡해져서 최선의 해결책이 다름 아닌 “그냥 하지 말자”로 귀결되었다.

즉, “모든 것이 서로 접근 가능하니까 의존성 문제가 없다!”라는 접근인 것이다.

데메테르의 법칙 (기존 Flux패턴의 문제점)

정의

  • 각 유닛은 다른 유닛에 대해 제한된 지식만 가져야 한다. 현재 유닛과 “밀접하게”관련된 유닛만 알면 된다.
  • 각 유닛은 자신의 친구들하고만 대화해야 한다. 모르는 사람과는 대화하지 말아라.

이 법칙은 원래 OOP를 위해 만들어졌지만, React 상태 관리를 포함한 여러 분야에 적용할 수 있다.
쉽게 말해서 Store가 이런 행동을 못하도록 막는 것이다.

  1. 다른 스토어의 내부 구현에 과도하게 의존하는 것
  2. 굳이 알 필요도 없는 스토어를 사용하는 것
  3. 명시적으로 의존성을 선언하지 않고 다른 스토어를 마음대로 사용하는 것

사실 스토어 간의 의존성은 좋은 모듈식 시스템의 자연스러운 부분이다. 스토어가 새로운 의존성을 추가해야 한다면, 그렇게 하되 최대한 명시적으로 해야 한다. 코드를 보면 다음과 같다.

PromosStore.dispatchToken = dispatcher.register(payload => {
  if (payload.actionType === 'add-to-cart') {
    // wait for CartStore to update first:
    dispatcher.waitFor([CartStore.dispatchToken])

    // now send the request
    sendPromosRequest(UserStore.userId, CartStore.items).then(promos => {
      dispatcher.dispatch({ actionType: 'promos-fetched', promos })
    })
  }

  if (payload.actionType === 'promos-fetched') {
    PromosStore.setPromos(payload.promos)
  }
})

PromosStore는 여러 의존성을 다양한 방식으로 선언하고 있다. CartStore를 기다리고 읽으면서, 동시에 UserStore도 읽고 있다. 이러한 의존성을 발견하려면 PromosStore의 구현 코드를 직접 뒤져봐야만 한다. 다시 말해서 의존성이 너무 암시적이라 직접 봐야만 하는 것이다.

이 예시는 매우 단순하고 인위적이지만, Flux가 데메테르의 법칙을 어떻게 잘못 해석했는지 보여준다. Flux 구현을 작게 유지하려는 바람에서 비롯된 것일 수 있지만 바로 이 부분이 Flux의 약점이 되었다.

Redux패턴 구조 및 정의

Redux는 Reduce + Flux의 합성어이다. 여기서 Reducer는 순수함수 역할을 한다.

💡 순수함수는 “동일한 인자가 주어졌을 때 항상 동일한 결과를 반환, 외부의 상태를 변경하지 않는 함수”를 뜻한다.

Flux의 Dispatcher 는 사라졌는데, 이는 Store 한개로 전부 관리하기 때문이다.

💡 Redux가 Flux인가요?
이거에 대한 확답을 하긴 어렵지만 Flux로부터 영향을 받았다고 보면 된다. Redux는 순수 함수를 통해 더 간단화된 Flux 아키텍처라고 할 수 있다.
자세한 건 아래 공식문서에서 확인할 수 있다.
redux 공식문서

  • 💡 다른 상태 관리 라이브러리와 Redux를 비교하고자 한다면 redux-toolkit(RTK)과 비교하는 게 맞을 것이다. Redux에서 권장하는 구현 방식이 redux-toolkit이기 때문!

💡 RTK의 장점

  1. createSlice를 통해 action과 reducer를 한 번에 생성할 수 있다.
  2. immer를 내장하여 불변성 관리가 쉽다.
  3. 보일러플레이트 코드가 크게 줄어든다

이 모든건 기존 Redux의 복잡한 설정 없이도 깔끔하게 상태 관리를 할 수 있다는 것이다.

사용법 흐름

RTK 기준으로 작성했다.

  1. 저장할 state와 action을 만든다.

    import { createSlice, PayloadAction } from '@reduxjs/toolkit';
    
    interface Todo {
      id: number;
      text: string;
      completed: boolean;
    }
    
    const initialState: Todo[] = [];
    
    const todosSlice = createSlice({
      name: 'todos',
      initialState,
      reducers: {
        addTodo: (state, action: PayloadAction<string>) => {
          state.push({
            id: Date.now(),
            text: action.payload,
            completed: false
          });
        },
        toggleTodo: (state, action: PayloadAction<number>) => {
          const todo = state.find(todo => todo.id === action.payload);
          if (todo) {
            todo.completed = !todo.completed;
          }
        }
      }
    });
    
    export const { addTodo, toggleTodo } = todosSlice.actions;
    export default todosSlice.reducer;
  2. action을 토대로 reducer를 만든다.
    a. RTK의 장점으로서 createSlice가 자동으로 리듀서와 액션을 생성해준다.

  3. reducer들을 합친다.

    // app/store.ts
    import { configureStore } from '@reduxjs/toolkit';
    import todosReducer from '../features/todos/todosSlice';
    
    export const store = configureStore({
      reducer: {
        todos: todosReducer,
        // 다른 리듀서들도 여기에 추가할 수 있다.
      }
    });
    
    export type RootState = ReturnType<typeof store.getState>;
    export type AppDispatch = typeof store.dispatch;
  4. rootReducer를 store에 저장한다.(3번에서 이미 처리를 하였다.)

  5. provider를 통해 store에 접근할 수 있게 한다.

    // index.tsx or App.tsx
    import { Provider } from 'react-redux';
    import { store } from './app/store';
    
    function App() {
      return (
        <Provider store={store}>
          <TodoList />
        </Provider>
      );
    }
  6. useDispatch, useSelector를 사용한다.

    // components/TodoList.tsx
    import { useDispatch, useSelector } from 'react-redux';
    import { RootState } from '../app/store';
    import { addTodo, toggleTodo } from '../features/todos/todosSlice';
    
    function TodoList() {
      const dispatch = useDispatch();
      const todos = useSelector((state: RootState) => state.todos);
    
      const handleAddTodo = (text: string) => {
        dispatch(addTodo(text));
      };
    
      const handleToggle = (id: number) => {
        dispatch(toggleTodo(id));
      };
    
      return (
        <div>
          <button onClick={() => handleAddTodo("새로운 할 일")}>
            할 일 추가
          </button>
          
          {todos.map(todo => (
            <div key={todo.id} onClick={() => handleToggle(todo.id)}>
              <input
                type="checkbox"
                checked={todo.completed}
                readOnly
              />
              <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
                {todo.text}
              </span>
            </div>
          ))}
        </div>
      );
    }
    
    export default TodoList;

Zustand

zustand 는 단순화된 Flux 패턴을 사용하는 작고 빠르고 확장가능한 상태관리 솔루션이며, Hooks 를 기반으로하는 간편한 API가 있다.
이 라이브러리는 스토어 생성 함수를 호출할 때 클로저를 활용한다. 클로저는 함수와 그 함수가 선언될 당시의 lexcial environment을 기억하는 것으로, 스토어의 상태는 스토어 조회나 변경을 해주는 함수 외부 스코프에서 항상 유지되도록 만들어졌다.

그렇게 되면, 상태의 변경, 조회, 구동 등을 통해서만 스토어를 다루고 실제 상태는 애플리케이션 생명주기 동안 의도치 않게 변경되는 것을 방지시킬 수 있다.

즉, 해당 라이브러리는 Redux처럼 Flux를 사용하는 친구이며 Redux보다 더 간단하고 직관적인 훅 기반의 API 제공과 Provider로 감싸지 않아도 된다는 점에서 러닝커브가 낮다. 또한 SSR을 공식적으로 지원해준다.

기본 사용

create 함수로 스토어를 생성한다. create 함수의 콜백은 set, get 매개변수를 가지며, 이를 통해 상태를 변경하거나 조회할 수 있다.

create 함수의 콜백이 반환하는 객체에서의 속성은 state이고, 메소드는 action이다.

import { create } from 'zustand'
export const use이름Store = create((set, get) => {
  return {
    상태: 초깃값,
    액션: () => {
      const state = get()
      const { 상태 } = state
      set({ 상태: 상태 + 1 })
    }
  }
})

필자는 처음 사용했을 때 다음과 같은 방식을 사용했었다.

  • create<타입>()으로 create의 함수의 제네릭으로 상태와 액션 타입을 전달하였지만 이 중 states와 actions를 분리하여 관리를 하였다.
type TFeedBackStore = {
  states: {
    data: Array<TFeedBack>;
    mentorInfo: TFeedBack;
    ratingMenteeId: number;
    open: boolean;
  };
  actions: {
    setData: (data: TFeedBack) => void;
    setMenteeId: (id: number) => void;
    resetMenteeId: () => void;
    setOpen: () => void;
    reset : () => void;
  };
};
const useFeedBackStore = create<TFeedBackStore>()(
  devtools((set) => ({
  states: { //.... },
  actions: {
      setData: (data: TFeedBack) => {
        set((state) => ({
        // .....
   }))
);
  • 상태 초기화는 resetState함수를 추가하였는데 다음과 같이 initialState를 설정해두었다.
const initialState = {
  data: [],
  mentorInfo: {} as TFeedBack,
  ratingMenteeId: 0,
  open: false,
};
// .....
const useFeedBackStore = create<TFeedBackStore>()(
  devtools((set) => ({
  states: { //.... },
  actions: {
      setData: (data: TFeedBack) => {
        set((state) => ({
        // .....
       reset : () => set({states : initialState}),
   }))
);
  • 혹은 부분으로 사용할 땐 다음과 같이 활용하였다.
    type TFeedBackStore = {
      states: TFeedBackState;
      actions: {
        // .....
        resetStates: (keys?: Array<keyof TFeedBackState>) => void; 
      };
    };
    // 활용 부분
        resetStates: keys => {
          if (!keys) {
            // 전체 states 초기화
            set({ states: initialState });
            return;
          }
          // 특정 states만 초기화
          set((state) => ({
            states: {
              ...state.states,
              // 여기선 객체를 사용하였기 때문에 Object.fromEntries()을 활용했다.
              // 만약에 그냥 배열의 배열을 반환해도 된다면 해당 부분은 생략해도 된다.
              ...Object.fromEntries(
                keys.map(key => [key, initialState[key]])
              )
            }
          }));
  • Zustand의 상태를 모니터링할 수 있는 개발자 도구를 사용할 수 있다. devtools미들웨어를 사용하면, 개발자 도구가 활성화된다.
  • 스토리지는 persist 미들웨어를 사용하여 상태를 저장하고 불러올 수 있다. 스토리지에 저장될 스토어의 고유한 이름을 필수 옵션(name)으로 제공해야 해야 한다. 또한 로컬 스토리지를 기본으로 사용하며, 필요하면 세션 스토리지나 커스텀 스토리지를 만들어 사용할 수도 있다.

필자는 zustand를 사용했을 때 아래 사이트의 도움을 많이 받았고 이 외에도 다양한 기능들이 있으니 필요에 따라 확인해서 활용하면 좋을 것이다. 여기서 다 다루기에는 Zustand 포스팅이 아니라서 생략하도록 하겠다.
Zustand 핵심 정리

Proxy

Proxy 방식은 JavaScript의 Proxy 객체를 활용하여 객체의 속성에 대한 접근, 할당, 삭제 등을 가로채고 추가적인 로직을 수행하는 방식이다. → 객체를 직접 관찰하고 변경 사항을 자동으로 감지할 수 있다.

MobX

흐름

  1. Action이 실행되면 상태값이 Update된다.
  2. 해당 값을 구독하는 곳에 Notify가 된다.
  3. Notify가 되면 렌더링이 트리거된다.

MobX에서는 Action이 직접 상태를 업데이트할 수 있고 Redux에 비해 코드량이 훨씬 적으며 Proxy방식이기 때문에 객체지향적인 성격을 가지고 있다. 다만…. 커뮤니티가 너무 작다는 큰 문제점이 있다.

Atomic

Atomic패턴이라는 새로운 접근방식이 등장하였다. 이건 기존의 단방향 데이터 흐름을 다르게 해석한 것이다.

Atom

스토어라고 부르던 것들이 이제는 "아톰(atom)"이라는 이름으로 바뀌었고, 각각이 독립적으로 동작하면서도 필요할 때 서로 연결될 수 있게 되었다. 특히 이 아톰들의 장점은 다음과 같았다.

  1. 코드를 나눠서 필요할 때만 불러올 수 있다.
  2. React의 최신 기능들도 지원한다.
  3. 다른 아톰과의 관계를 명확하게 선언할 수 있다.

상태는 atom이라는 작고 독립적인 단위로 나뉜다. 이러한 atom에 접근해야하는 컴포넌트는 이를 구독하고 atom이 업데이트되면 다시 렌더링된다.

Recoil

Recoil은 Context API 기반으로 구현된 라이브러리이다. Redux의 가장 큰 단점인 거대한 보일러 플레이트 작성을 하는 대신, Recoil은 react의 useState와 유사하게 사용할 수 있으며, 굉장히 간결하게 작성할 수 있다. 다만 Recoil은 업데이트를 안하는…

Recoil의 공식 문서에 따르면 Recoil의 출시 컨셉은 다음과 같다.

💡 출시 컨셉

  • Boilerplage-free API 제공
  • Concurrent Mode(동시성 모드)를 비롯한 새로운 React 기능들과의 호환 가능성
  • Code Splitting - 상태 정의에 대한 증분 및 분산 가능
  • 상태에서 파생된 데이터 사용
  • 파생된 데이터에 대한 동기/비동기 모두 가능
  • 캐싱

atom 은 컴포넌트들이 구독할 수 있는 상태의 단위를 의미하며, selector 는 상태를 동기/비동기적으로 변경시킬 수 있는 순수 함수를 의미한다.

Jotai

Jotai 는 Conext 의 리렌더링 문제 해결을 위해 만들어진 React 에 특화된 상태관리 라이브러리로 recoil 에서 영감을 받아 제작되었다.  Recoil처럼 atomic 개념을 따르는 친구이며, recoil과 달리 key를 정의할 필요가 없으며 여러 Provider를 정의할 수 있다. (만약 Provider를 사용하지 않으면 글로벌에 존재하는 atom에 저장된다.) 또한 SSR을 공식적으로 지원한다.

store기능으로 리액트 컴포넌트 밖에서 훅 없이 상태를 바꿀 수는 있지만 권장하지는 않는다.

그리고 Jotai는 렌더링에 대한 최적화가 큰데  원래 대용량 객체나 배열 형태의 state에서 변경사항이 생기면 일반적으로 해당 상태를 사용하는 모든 컴포넌트에서 리렌더링이 발생하지만 이러한 문제점을 optics 라는 외부 라이브러리를 사용해 해결했다고 한다.

사용 예시들

  1. 기본적인 Provider 사용 예시

    import { Provider, atom, useAtom } from 'jotai';
    
    // 여러 개의 Provider를 사용할 수 있음
    function App() {
      return (
        <Provider>
          <FeatureA />
        </Provider>
        <Provider>
          <FeatureB />
        </Provider>
      );
    }
    
    // Provider 없이도 사용 가능 (글로벌 스토어에 저장)
    function AppWithoutProvider() {
      return <FeatureA />;
    }
  2. Provider Scope를 활용한 독립적인 상태 관리

    import { Provider, atom, useAtom } from 'jotai';
    
    const countAtom = atom(0);
    
    function Counter() {
      const [count, setCount] = useAtom(countAtom);
      return (
        <button onClick={() => setCount(c => c + 1)}>
          count: {count}
        </button>
      );
    }
    
    // 각 Provider는 독립적인 상태를 가짐
    function App() {
      return (
        <div>
          <Provider>
            <Counter /> {/* 이 카운터의 상태는 */}
          </Provider>
          <Provider>
            <Counter /> {/* 이 카운터의 상태와 독립적 */}
          </Provider>
        </div>
      );
    }
  3. optics를 활용한 대규모 상태 최적화 예시

    import { atom, useAtom } from 'jotai';
    import { focusAtom } from 'jotai-optics';
    
    // 대규모 상태를 가진 atom
    const bigDataAtom = atom({
      users: [
        { id: 1, name: 'John', settings: { theme: 'dark', notifications: true } },
        { id: 2, name: 'Jane', settings: { theme: 'light', notifications: false } },
        // ... 수많은 사용자 데이터
      ]
    });
    
    // optics를 사용해 특정 사용자의 설정만 포커싱
    const userSettingsAtom = focusAtom(bigDataAtom, optic => 
      optic.prop('users').filter(user => user.id === 1).prop('settings')
    );
    
    function UserSettings() {
      const [settings, setSettings] = useAtom(userSettingsAtom);
      
      // 이제 전체 상태가 아닌 특정 사용자의 설정만 구독
      return (
        <div>
          <label>
            Theme:
            <select
              value={settings.theme}
              onChange={e => setSettings({
                ...settings,
                theme: e.target.value
              })}
            >
              <option value="light">Light</option>
              <option value="dark">Dark</option>
            </select>
          </label>
          <label>
            Notifications:
            <input
              type="checkbox"
              checked={settings.notifications}
              onChange={e => setSettings({
                ...settings,
                notifications: e.target.checked
              })}
            />
          </label>
        </div>
      );
    }

그래서 어떻게 사용하면 좋을까?

top-down에는 flux가, bottom-up에는 atomic이 잘 맞다고 생각한다.

💡 top-down과 bottom-up 형식?
1. top-down형식의 상태 관리 예시 : 최상위 메뉴에서 선택된 어떤 값이라거나 유저 및 권한 정보에 관한 상태관리
2. bottom-up 형식의 상태 관리 예시 : 하위 컴포넌트의 모달에 관한 상태 관리

최상위에서 선택된 어떤 아이템이나, 유저 및 권한에 대한 핸들링이 개발 시 우선 사항이라면 flux패턴 라이브러리를 사용한 뒤 이후 상태들에 따라 flux패턴 라이브러리를 유지하거나 atomic패턴 라이브러리를 부분적으로 도입하고, 그렇지 않은 경우라면 atomic패턴 라이브러리로 가볍게 아톰을 선언해서 사용하는 편이다.

요약본

특성/접근 방식FluxProxyAtomic
개념단방향 데이터 흐름을 통한 상태 관리객체의 속성 접근 및 변경을 가로채는 방식을 통한 상태 관리원자적 상태 단위를 통한 불변성 기반 상태 관리
주요 구성 요소Dispatcher, Stores, Actions, ViewsProxy 객체Atoms, Selectors(Recoil의 경우)
데이터 흐름단방향(Action → Dispatcher → Store → View)양방향(상태 변경 시 자동 감지 및 반응)데이터 흐름이 없으며 상태가 불변하고 필요 시 새로 생성됨
주요 라이브러리Redux, ZustandMobx, ValtioRecoil, Jotai

참고자료

현대 앱 아키텍쳐 설명 (Backend/Frontend/MVC/Flux/Redux/MSA)

How Atoms Fixed Flux | HackerNoon

개발자 단민 | Redux MobX Zustand Recoil Jotai 뭐가 이렇게 많아

profile
예비 초보 개발자의 기록일지

0개의 댓글

관련 채용 정보