본문 바로가기
DEV/FE

[FE] React Context API와 LocalStorage로 간단 상태관리 모듈 만들기

by krokerdile 2024. 10. 9.

1. 개요

뤼튼으로 만든 썸네일, 생각보다 잘 만들어주네요

지금까지 리액트로 여러 프로젝트를 했던 경험을 돌아봤을 때, Context API만으로도 충분히 해결할 수 있는 상황에서도 종종 Zustand나 Redux를 사용해야겠다는 생각에 치우쳤던 적이 많았습니다. 이러한 접근은 종종 '배보다 배꼽이 더 커지는' 상황을 초래했습니다. 즉, 핵심 기능 구현보다 상태 관리 라이브러리 사용법 익히는 데 더 많은 시간을 투자하게 되는 경우가 발생했습니다.

이러한 경험을 바탕으로, 부스트캠프 후반기 미션에서는 React가 제공하는 기본 도구를 최대한 활용하고자 했습니다. 특히 Context API를 이용한 전역 상태 관리와 함께, LocalStorage를 활용하여 데이터 지속성까지 확보하고자 했습니다. 이 아이디어는 이전에 다른 개발자분이 localStorage를 쉽게 사용할 수 있도록 만든 커스텀 훅을 만들어 쓰시는 걸 보고 영감을 얻었습니다.

이러한 배경에서, React의 Context API와 브라우저의 로컬 스토리지를 결합한 커스텀 상태 관리 시스템을 구현하기로 결정했습니다. 이 접근 방식은 다음과 같은 이점을 제공해준다고 생각합니다.

  1. 간단하고 직관적인 API
  2. 전역 상태 관리 기능
  3. 페이지 새로고침 후에도 유지되는 상태 지속성
  4. 타입스크립트를 통한 타입 안전성

이 시스템은 특히 중소규모 프로젝트에서 Redux나 MobX와 같은 복잡한 상태 관리 라이브러리의 대안으로 사용할 수 있을 것 같습니다. 그리고 리액트를 사용해본 팀원이라면 빠르게 이해도를 올릴 수 있기 때문에, 팀 전체가 쉽게 적응할 수 있다는 장점도 있을 것 같습니다.

브라우저에서 제공하는 API와 React의 내장 기능을 조합하는 이 접근 방식은 불필요한 복잡성을 줄여주고, 특히 추가적인 상태관리 라이브러리를 적용하지 않아도 된다는 장점도 있다고 생각합니다.

먼저 이 시스템의 핵심 구성 요소인 로컬 스토리지와 Context API에 대해 자세히 살펴보겠습니다.

2. 로컬 스토리지와 Context API

로컬 스토리지 (Local Storage)

로컬 스토리지는 웹 브라우저에서 제공하는 키-값 저장소입니다. 주요 특징은 다음과 같습니다:

  • 도메인별로 분리된 저장 공간
  • 페이지를 닫아도 데이터가 유지됨
  • 문자열 형태로 데이터 저장
  • 일반적으로 약 5MB의 저장 용량 (브라우저에 따라 다름)

기본적인 사용 방법

// 데이터 저장
localStorage.setItem('key', 'value');

// 데이터 조회
const value = localStorage.getItem('key');

// 데이터 삭제
localStorage.removeItem('key');

// 모든 데이터 삭제
localStorage.clear();

로컬 스토리지는 간단하고 사용하기 쉽지만, 몇 가지 주의할 점이 있습니다:

  • 보안에 민감한 정보는 저장하지 않아야 합니다.
  • 브라우저 설정에 따라 사용자가 로컬 스토리지를 비활성화할 수 있습니다.
  • 동기적으로 동작하므로 대량의 데이터를 다룰 때는 성능에 영향을 줄 수 있습니다.

Context API

Context API는 React에서 제공하는 전역 상태 관리 도구입니다.

주요 특징

  • 컴포넌트 트리 전체에 데이터를 제공
  • prop drilling 문제 해결
  • 간단한 설정으로 사용 가능
  • React의 내장 기능으로, 추가 라이브러리 설치 불필요

기본적인 사용 예:

// Context 생성
const MyContext = React.createContext(defaultValue);

// Provider 컴포넌트
function App() {
  return (
    <MyContext.Provider value={/* 공유할 값 */}>
      {/* 자식 컴포넌트들 */}
    </MyContext.Provider>
  );
}

// Consumer 컴포넌트
function MyComponent() {
  const value = React.useContext(MyContext);
  return <div>{value}</div>;
}

3. useStore 구현 과정

이제 로컬 스토리지와 Context API를 결합한 useStore 훅의 구현 과정을 살펴보겠습니다.

다이어그램을 통한 설계

Context API와 LocalStorage에 대한 이해를 가지고 전반적인 상태 관리 과정에 대해서 다이어그램을 그려봤습니다.

먼저, 애플리케이션의 최상위 컴포넌트인 App에서 StoreProvider를 렌더링하여 전체 애플리케이션에 상태 관리 컨텍스트를 제공합니다. StoreProvider는 초기화 과정에서 useLocalStorageState 훅을 사용하여 로컬 스토리지와 상태를 동기화합니다. 이 과정에서 로컬 스토리지에 저장된 데이터가 있다면 그것을 불러오고, 없다면 초기값을 사용합니다.

초기화된 상태를 바탕으로 StoreProvider는 자식 컴포넌트들을 렌더링합니다. 각 컴포넌트는 useStore 훅을 통해 필요한 상태에 접근할 수 있습니다. useStore는 내부적으로 React의 useContext를 사용하여 StoreProvider의 Context에서 상태를 조회하고, 해당 상태와 이를 업데이트할 수 있는 함수를 컴포넌트에 제공합니다.

컴포넌트에서 상태 변경이 필요할 때, useStore를 통해 업데이트를 요청합니다. 이 요청은 StoreProvider의 setState 함수를 통해 처리되며, 변경된 상태는 다시 useLocalStorageState를 거쳐 로컬 스토리지에 저장됩니다. 이러한 구조를 통해 페이지 새로고침 후에도 상태가 유지될 수 있습니다.

useLocalStorageState 구현

먼저, 로컬 스토리지와 React 상태를 동기화하는 useLocalStorageState 훅을 구현합니다:

import { useState, useEffect, useCallback } from 'react';

function useLocalStorageState<T>(key: string, defaultValue: T) {
  const [state, setState] = useState<T>(() => {
    const storedValue = localStorage.getItem(key);
    return storedValue ? JSON.parse(storedValue) : defaultValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(state));
  }, [key, state]);

  const updateState = useCallback((newState: T | ((prevState: T) => T)) => {
    setState(newState);
  }, []);

  return [state, updateState] as const;
}

export default useLocalStorageState;

이 훅은 다음과 같은 기능을 제공해줍니다.

  • 로컬 스토리지에서 초기 값을 로드합니다.
  • 상태가 변경될 때마다 로컬 스토리지를 업데이트합니다.
  • 함수형 업데이트를 지원합니다.

useStore 구현

다음으로, Context API와 useLocalStorageState를 결합한 useStore 훅을 구현합니다:

import React, {
  createContext,
  useContext,
  useCallback,
  ReactNode,
  Dispatch,
  SetStateAction,
} from 'react';
import useLocalStorageState from './useLocalStorageState';

function createStore<T extends object>(initialState: T) {
  const StoreContext = createContext<
    { state: T; setState: Dispatch<SetStateAction<T>> } | undefined
  >(undefined);

  function StoreProvider({ children }: { children: ReactNode }) {
    const [state, setState] = useLocalStorageState<T>('app-state', initialState);
    return <StoreContext.Provider value={{ state, setState }}>{children}</StoreContext.Provider>;
  }

  function useStore<K extends keyof T>(key: K): [T[K], (value: T[K]) => void] {
    const store = useContext(StoreContext);
    if (!store) {
      throw new Error('useStore는 StoreProvider 내부에서만 사용할 수 있습니다.');
    }

    const value = store.state[key];
    const setValue = useCallback(
      (newValue: T[K]) => {
        store.setState((prev) => ({ ...prev, [key]: newValue }));
      },
      [store, key]
    );

    return [value, setValue];
  }

  return { StoreProvider, useStore };
}

export default createStore;

이 구현의 주요 특징은 다음과 같습니다:

  1. createStore 함수를 통해 StoreProvider와 useStore 훅을 생성합니다.
  2. StoreProvider는 useLocalStorageState를 사용하여 상태를 관리합니다.
  3. useStore 훅은 특정 키의 상태값과 그 값을 업데이트하는 함수를 반환합니다.
  4. TypeScript를 사용하여 타입 안전성을 보장 하려고 했습니다.

사용 예시

이제 주어진 store.ts 코드를 기반으로 실제 컴포넌트 구현 예시를 살펴보겠습니다:

// store.ts
import createStore from './createStore';

const { StoreProvider, useStore } = createStore(
  {
    theme: 'light',
    user: null,
    newsType: 'all',
    viewType: 'list'
  },
  {
    newsType: 'string',
    viewType: 'string'
  }
);

export { StoreProvider, useStore };
// App.tsx
import { StoreProvider } from './store';
import Header from './Header';
import NewsList from './NewsList';
import Footer from './Footer';

function App() {
  return (
    <StoreProvider>
      <div className="app">
        <Header />
        <NewsList />
        <Footer />
      </div>
    </StoreProvider>
  );
}

export default App;
// Header.tsx
import { useStore } from './store';

function Header() {
  const [theme, setTheme] = useStore('theme');
  const [user] = useStore('user');

  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };

  return (
    <header className={`header ${theme}`}>
      <h1>My News App</h1>
      <button onClick={toggleTheme}>Toggle Theme</button>
      {user ? <span>Welcome, {user.name}</span> : <button>Login</button>}
    </header>
  );
}

export default Header;

Store ↔ App ↔ Header 간의 상태관리

  1. App 컴포넌트는 전체 애플리케이션을 StoreProvider로 감싸고 있어, 모든 자식 컴포넌트에서 상태에 접근할 수 있게 합니다.
  2. Header 컴포넌트에서는 theme와 user 상태를 사용합니다. 테마 토글 버튼을 통해 테마를 변경할 수 있고, 사용자 로그인 상태에 따라 다른 UI를 보여줍니다.

이 예시를 통해 useStore 훅을 사용하여 여러 컴포넌트에서 전역 상태에 쉽게 접근하고 수정할 수 있음을 볼 수 있습니다. 또한, 이 상태들은 로컬 스토리지에 저장되므로 페이지를 새로고침해도 유지됩니다.

결론

로컬 스토리지와 Context API를 결합한 커스텀 상태 관리 시스템은 라이브러리를 사용하지 않고 간단하게 상태관리를 지원 해줍니다. 특히 중소규모 프로젝트에서 복잡한 상태 관리 라이브러리를 대체할 수 있는 좋은 대안이 될 수 있을 것 같습니다. 또한, 이 시스템은 필요에 따라 쉽게 확장하고 커스터마이즈할 수 있어, 모듈화를 해두면 간단하게 불러와서 사용이 가능할 것 같습니다.

이 접근 방식의 주요 장점은 다음과 같습니다:

  1. 간결성: 복잡한 설정 없이 빠르게 구현하고 사용할 수 있습니다.
  2. 유연성: 프로젝트의 특정 요구사항에 맞게 쉽게 수정할 수 있습니다.
  3. 성능: 필요한 기능만 포함되어 있어 불필요한 오버헤드가 없습니다.
  4. 학습 용이성: React의 기본 개념만 알면 쉽게 이해하고 사용할 수 있습니다.

물론 대규모 애플리케이션이나 복잡한 상태 로직이 필요한 경우에는, Redux나 MobX 같은 더 강력한 상태 관리 라이브러리를 고려해봐야 될 것 같습니다. 특히 서버 상태 관리나 데이터의 최신화와 상태에 대해서 관리해야 되는 경우 React Query나 SWR과 같은 라이브러리를 사용하는 것이 더 적합할 것 같습니다.

참고자료