Skip to content

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

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

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

서버 상태 (Server State)의 특징

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

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

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

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

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

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

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

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

현재 서버 상태에선 @tanstack/react-queryswr을 사용하고 있으며,
클라이언트 상태는 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를 사용하지 않음
  • 상태 변경 빈도가 높지 않음

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