Skip to content

상태관리 (클라이언트편) ​

1. 왜 서버와 클라이언트 상태 관리가 두 가지로 나뉘는가? ​

웹 애플리케이션에서 상태는 크게 서버 상태와 클라이언트 상태로 구분된다.
이러한 구분이 필요한 이유는 각각의 특성과 관리 방식이 근본적으로 다르기 때문이다.

서버 상태 (Server State)의 특징 ​

서버 상태는 원격 데이터로서 API를 통해 서버로부터 가져오는 데이터를 의미한다.
이 데이터는 비동기적으로 네트워크 요청을 통해 가져오며, 요청과 응답 사이에는 시간 지연이 발생한다.

서버 상태 관리에서 가장 중요한 요소는 캐싱이다.
동일한 데이터를 반복적으로 요청하는 것은 네트워크 비용과 서버 부하를 증가시키므로,
한 번 가져온 데이터를 메모리에 저장해두고 재사용한다.

또한 서버의 데이터는 다른 사용자나 시스템에 의해 언제든 변경될 수 있어 동기화 문제가 발생할 수 있다. 따라서 데이터의 freshness를 확인하고 필요시 다시 가져오는 로직이 필요하다.

클라이언트 상태 (Client State)의 특징 ​

클라이언트 상태는 로컬 데이터로서 애플리케이션 내부에서 생성되고 관리되는 데이터다.
이 데이터는 동기적으로 즉시 접근이 가능하며, 네트워크 지연이나 에러에 대한 걱정이 없다.

대표적인 클라이언트 상태로는 UI 상태가 있다.
모달 창의 열림/닫힘 상태, 드롭다운의 펼침/접힘 상태, 현재 선택된 탭, 다크/라이트 테마 설정 등이 여기에 해당한다.

또한 폼의 입력값, 임시로 저장된 사용자 설정, 애플리케이션의 라우팅 상태 등도
클라이언트 상태에 포함된다.

현재 프로젝트의 상태 관리 구조 ​

현재 서버 상태에선 @tanstack/react-query와 swr을 사용하고 있으며,
클라이언트 상태는 React의 Context API와 useState 훅을 활용하고 있다.

2. 클라이언트 상태관리엔 뭐가 있는가? ​

클라이언트 상태 관리 도구는 크게 React 내장 도구와 외부 라이브러리로 나눌 수 있다.

React 내장 도구 ​

useState는 가장 기본적인 상태 관리 훅으로,
컴포넌트 레벨에서 로컬 상태를 관리한다.
useReducer는 복잡한 상태 로직을 관리할 때 사용하고,
useContext는 컴포넌트 트리 전체에서 전역 상태를 공유할 때 사용한다.

외부 라이브러리 ​

Redux/Redux Toolkit ​

가장 널리 사용되는 상태 관리 라이브러리다. 예측 가능한 상태 업데이트를 위한 단방향 데이터 플로우를 제공하며, 풍부한 개발자 도구와 미들웨어 생태계를 갖추고 있다.

Zustand ​

간단하고 가벼운 상태 관리 라이브러리로, 보일러플레이트 코드가 적고 직관적인 API를 제공한다.
Redux의 복잡성 없이도 전역 상태를 효과적으로 관리할 수 있다.

tsx
const useStore = create((set) => ({  
  count: 0,  
  increment: () => set((state) => ({ count: state.count + 1 })),  
  decrement: () => set((state) => ({ count: state.count - 1 })),  
}));

function Counter() {  
  const { count, increment, decrement } = useStore();  
    
  return (  
    <div>  
      <p>Count: {count}</p>  
      <button onClick={increment}>+</button>  
      <button onClick={decrement}>-</button>  
    </div>  
  );  
}

보일러플레이트(Boilerplate)란 반복적으로 작성해야 하는 틀에 박힌 코드를 의미한다.
여러 신문사에서 공통으로 사용하는 표준 기사 템플릿을 보일러플레이트라고 부르는데
이와 같이 프로그래밍에서도 매번 똑같이 작성해야 하는 반복적인 코드를 보일러플레이트라고 한다.

Jotai ​

Atomic 기반의 상태 관리 접근 방식을 사용한다.
상태를 작은 단위(atom)로 나누어 관리하여
필요한 컴포넌트만 선택적으로 구독할 수 있어 불필요한 리렌더링을 최소화한다.

tsx
const countAtom = atom(0);  
const doubleCountAtom = atom((get) => get(countAtom) * 2);

function Counter() {  
  const [count, setCount] = useAtom(countAtom);  
  const doubleCount = useAtomValue(doubleCountAtom);  
    
  return (  
    <div>  
      <p>Count: {count}</p>  
      <p>Double: {doubleCount}</p>  
      <button onClick={() => setCount(c => c + 1)}>Increment</button>  
    </div>  
  );  
}

3. 왜 라이브러리를 써야하는가? ​

React 내장 도구만으로 상태를 관리하면 여러 한계점이 나타난다.
가장 대표적인 문제는 Provider Hell 현상이다.
여러 개의 Context를 사용하게 되면 컴포넌트 트리가 다음과 같이 복잡해진다.

tsx
<UserContext.Provider>  
  <ThemeContext.Provider>  
    <CartContext.Provider>  
      <NotificationContext.Provider>  
        <SettingsContext.Provider>  
          <App />  
        </SettingsContext.Provider>  
      </NotificationContext.Provider>  
    </CartContext.Provider>  
  </ThemeContext.Provider>  
</UserContext.Provider>

또 다른 문제는 불필요한 리렌더링이다.
Context의 값이 변경되면 해당 Context를 구독하는 모든 컴포넌트가 리렌더링된다.
즉, 실제로 사용하지 않는 값이 변경되어도 리렌더링이 발생한다는거다.

tsx
const AppContext = createContext();

const AppProvider = ({ children }) => {  
  const [user, setUser] = useState(null);  
  const [theme, setTheme] = useState('light');  
  const [cart, setCart] = useState([]);  
    
  // user만 변경되어도 theme이나 cart만 사용하는 컴포넌트도 리렌더링됨  
  const value = { user, setUser, theme, setTheme, cart, setCart };  
    
  return (  
    <AppContext.Provider value={value}>  
      {children}  
    </AppContext.Provider>  
  );  
};

성능 최적화의 어려움도 중요한 문제다.
React.memo나 useMemo를 사용하여 최적화를 시도할 수 있지만,
Context 구조에서는 여전히 한계가 있다.

4. 그래서, 이 프로젝트에선 외부 라이브러리를 쓰는게 좋은가? ​

현재 클라이언트 상태는 Context API와 useState를 사용하고 있다.

그 중 전역에서 관리하는건 page 번호와 관련된 코드들이다.

tsx
// PageContext - 페이지 번호 관리
const PageContextProvider = ({ children }: Props) => {  
  const [page, setPage] = useSessionStorage('offset', 0);  
    
  return (  
    <PageDispatchContext.Provider value={setPage}>  
      <PageStateContext.Provider value={page}>  
        {children}  
      </PageStateContext.Provider>  
    </PageDispatchContext.Provider>  
  );  
};

// PageCountContext - 페이지 수 관리  
const PageCountContextProvider = ({ children }: Props) => {  
  const [pageCount, setPageCount] = useState(1);  
    
  return (  
    <PageCountDispatchContext.Provider value={setPageCount}>  
      <PageCountStateContext.Provider value={pageCount}>  
        {children}  
      </PageCountStateContext.Provider>  
    </PageCountDispatchContext.Provider>  
  );  
};

PageContext 통합해서 새로고침 시에도 두 값 모두 유지되게할까 생각했는데,
그럼 새로고침의 의미가 없을 것 같아서...

원랜 zustand를 도입하려 했으나
아래와 같은 이유로 클라이언트 상태관리는 마이그레이션을 안 하기로했다.

  • 현재 관리하는 클라이언트 상태가 단순함 (페이지 번호, 페이지 수 정도)
  • 컴포넌트 트리가 깊지 않아 Context drilling 문제가 심각하지 않음
  • Provider Hell이 발생할 정도로 많은 Context를 사용하지 않음
  • 상태 변경 빈도가 높지 않음

그래서 굳이?인 것 같아서
앞으론 테스팅 라이브러리를 도입하고 최적화에 집중하는 편이 좋을 것 같다.