도승

프론트엔드 상태관리도구에 대해서 알아보자 (2)

[Now] 상태 관리


페이지 단위 라우팅 전역 상태를 남용하지 않게 됨
서버 컴포넌트 구조 클라이언트 상태가 필요한 곳이 명확해짐
SSR/ISR 초기 데이터 로딩 시 전역 상태가 덜 필요해짐
  • 페이지가 전환 될 때 컴포넌트가 언마운트 됨
    • 그렇기 때문에 전역 상태 범위가 줄어듬, 즉 context나 props로도 사실 충분합니다.
    • 서버 컴포넌트는 상태를 가질 수 없음(상태가 필요한 곳은 Client Component로 감싸야 함)

왜 Next에서는 전역 상태 관리의 필요성이 줄어들었을까?

  1. 페이지 단위의 명확한 분리
  • app/ 디렉토리를 기준으로 폴더별 독립적 구조 → 자연스러운 언마운트 발생
  • 페이지 전환 시 대부분

완전히 새로운 컴포넌트 트리로 구성됨

  1. 서버 컴포넌트 중심 구조
  • Server Component가 상태를 가질 수 없어 자연스럽게 전역 상태 사용이 감소
  • 상태가 필요한 부분만 ’use client’로 클라이언트 컴포넌트에서 관리
  1. 세분화된 컴포넌트 구조
  • Layout → Page → Segment 단위로 세분화 됨

페이지 단위 상태나 로컬 상태 만으로도 충분한 구조가 됨

컴포넌트 로컬 상태

const [isAccepted, setIsAccepted] = useState(false);const [sellerPrice, setSellerPrice] = useState('');

Prop Drilling 형태로 부모 컴포넌트에서 자식 컴포넌트로 상태와 상태 변경 함수를 전달

<PriceInput letterId={id} setIsAccepted={setIsAccepted} setSellerPrice={setSellerPrice} />

React Hook Form을 통한 폼 상태 관리

const form = useForm<AnswerFormValues>({
  resolver: zodResolver(answerFormSchema),
  defaultValues,
  mode: 'onChange',
});

부모-자식 간 상태 동기화

  • useEffect를 사용해 상태 동기화
useEffect(() => {
  parentSetSellerPrice(sellerPrice);}, [sellerPrice, parentSetSellerPrice]);

현재 방식에 대한 장점

  • 간단한 구현: 추가 라이브러리 없이 React 기본 기능으로 구현
  • 직관적인 데이터 흐름: 부모에서 자식으로 명확한 props 전달

Context API를 활용한 사례

<StepContext.Provider value={{ currentStep, setCurrentStep, productInfo }}>  <FormProvider {...form}>  </FormProvider></StepContext.Provider>

Custom Hook을 통해 로직 관리

const { currentStep, setCurrentStep, nextStep, totalSteps } = useFormNavigation();const productInfo = useProductInfo(productId!, form.setValue, editMode);const { submitting, handleRegister } = useFormSubmission(form, editMode);

복잡한 폼 관리에는 React Hook Form + Context 조합을 통해 관리 하는게 가장 효과적입니다.

하지만 해당 기능에 스펙이 커진다면 상태가 변경 될 때마다 context를 사용하는 모든 컴포넌트가 리렌더링이 되다보니 성능에 대한 문제가 발생할 수 있습니다.

복잡한 상태관리를 방지를 위해 Zustand 와 같은 리팩토링이 필요합니다.

복잡한 컴포넌트 계층 구조를 갖고 있을 경우 Zustand 적용 사례

import { create } from 'zustand';interface WishProductState {
  wishProducts: Map<number, boolean>;  change: (key: number, value: boolean) => void;}
const useWishProducts = create<WishProductState>(set => ({
  wishProducts: new Map(),  change: (key, value) =>    set(state => {
      const updated = new Map(state.wishProducts);      updated.set(key, value);      return { wishProducts: updated };    }),}));export default useWishProducts;
  • use-wish-products.ts에 정의되어 있으며, 위시 상품들의 상태를 관리합니다.
  • wishProducts라는 Map 객체를 사용하여 product ID를 키로 위시 상태(boolean)를 값으로 저장합니다.
  • change 메서드를 통해 특정 상품의 위시 상태를 업데이트합니다.
export async function GET() {
  try {
    const [couponsCount, productsCount, wishCount] = await Promise.all([
      getIssuedCouponsCount(),      getProductsCount({ userId: 'my' }),      getWishProductsCount({ filters: DEFAULT_PRODUCT_FILTERS }),    ]);    return NextResponse.json({
      couponsCount,      productsCount,      wishCount,    });  } catch (error) {
    return NextResponse.json({ error: 'Failed to fetch stats' }, { status: 500 });  }
}
  • 서버에서는 route.ts를 통해 위시 상품 개수를 제공합니다.
  • getWishProductsCount 함수를 호출하여 사용자의 위시 아이템 수를 가져옵니다.
/ 위시 추가 /export default async function addWishedProduct(productId: number) {
  const { data: auth } = await supabaseClient.auth.getSession();  if (!auth.session) throw new Error('Not authenticated');  const { error } = await supabaseClient.from('wished_products').insert({
    user_id: auth.session.user.id,    product_id: productId,  });  if (error) return { error: true };  return { success: true };}
/ 위시 삭제 /export default async function deleteWishedProduct(productId: number) {
const { error } = await supabaseClient.from('wished_products').delete().match({
    user_id: auth.session.user.id,    product_id: productId,  });
  • 사용자가 위시 버튼을 클릭하면 상태를 변경하고 서버에 API 요청을 보냅니다.
  • addWishedProduct와 deleteWishedProduct 함수를 통해 서버와 통신하고, 클라이언트 상태를 변경합니다.
  • 로컬 상태(useState)와 Zustand 상태(useWishProduct)를 함께 관리합니다.
// wishProducts 상태가 변경될 때마다 데이터를 다시 가져옴 useEffect(() => {
    const fetchData = async () => {
      setIsLoading(true);      try {
        const response = await fetch('/api/my-stats');        if (response.ok) {
          const data = await response.json();          setWishCount(data.wishCount);        }
      } finally {
        setIsLoading(false);      }
    };    fetchData();  }, [wishedProducts.size]);
  • TodaysGemProductCard 컴포넌트에서는 WishButton을 통해 위시 상태를 표시합니다.
  • MemberCoupon 컴포넌트는 wishedProducts.size의 변화를 감시하여 API를 다시 호출하고 위시 카운트를 업데이트합니다.
export default function WishButton({ product, className, isOutlinedIcon }: WishButtonProps) {

  const { setPreviousPath } = usePreviousPathStore();

  const wishedProducts = useWishProduct(state => state.wishProducts);
  const changeWishProduct = useWishProduct(state => state.change);
  • 비로그인 상태인 경우 로그인 후 저장된 페이지로 복귀할 수 있도록 setPreviousPath 를 통해 이전 경로 저장합니다.
  • use-wish-products.ts에 정의된 Zustand 스토어에서 selector 패턴을 통해 필요한 상태와 액션 구독

우리에게 잘 어울리는 상태 관리 라이브러리는 뭘까?


Redux

Store, Action, Reducer, Dispatch의 4가지 핵심 구성 요소로 이루어진 FLUX 패턴 기반 라이브러리입니다.

  1. 복잡한 보일러플레이트: actions, reducers, types, constants 등 다수의 파일과 코드 작성이 필요합니다.
  2. 높은 학습 곡선: Redux를 처음 접하는 개발자에게 큰 진입 장벽이 됩니다.
  3. 무거운 번들 크기: 약 30KB로 상대적으로 큽니다.
  4. 비동기 처리의 복잡성: Redux-Thunk나 Redux-Saga와 같은 미들웨어가 필수적입니다.

Recoil

  1. 불안정한 API: 실험적 단계로 API가 수시로 변경될 수 있습니다.
  2. 개발 지속성 우려: Facebook의 지속적인 지원이 불확실합니다.
  3. 큰 번들 크기: 약 21KB로 Zustand에 비해 무겁습니다.
  4. React 종속성: React 외부 환경에서 상태 접근이 제한적입니다.
  5. 서버 기능 제약: Next.js 서버 액션에서의 상태 접근이 제한됩니다.

Jotai

  1. 제한된 커뮤니티: 생태계와 커뮤니티 규모가 작습니다.
  2. 문서화 부족: 복잡한 상태 관리 패턴에 대한 가이드가 부족합니다.
  3. 원자 상태 관리의 한계: 복잡한 객체 상태 처리가 비효율적입니다.
  4. 개발 도구 제한: Redux DevTools에 비해 디버깅 도구가 부족합니다.
  5. 타입스크립트 복잡성: 복잡한 원자 구조에서 타입 추론이 어렵습니다.

MobX

  1. 새로운 패러다임 적응: 반응형 프로그래밍이 React의 선언적 모델과 차이가 있습니다.
  2. 디버깅 어려움: 자동 상태 변화 감지로 인해 변화 추적이 까다롭습니다.
  3. 구조화 어려움: 자유도가 높아 일관된 코드 패턴 유지가 어렵습니다.
  4. React 최적화 충돌: React.memo나 useMemo 같은 최적화 기법과 호환성 문제가 있습니다.
  5. 번들 크기: 약 22KB로 상대적으로 무겁습니다.

Context API

사용방법

context Provider 생성

import { useState } from 'react'import { ThemeContext } from './ThemeContext'export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>      {children}    </ThemeContext.Provider>  )
}

ThemeProvider는 우리가 만든 ThemeContext를 앱의 하위 컴포넌트들과 공유하는 역할을 합니다.

theme라는 상태를 useState로 생성하고 이를 value로 전달하면,하위 컴포넌트 어디에서든 theme과 setTheme에 접근할 수 있습니다.

children은 ThemeProvider로 감싸지는 컴포넌트들을 의미합니다.

(보통 전체 앱을 감싸게 됩니다.)

import { useContext } from 'react'import { ThemeContext } from './ThemeContext'export function useTheme() {
  return useContext(ThemeContext)
}

useTheme()은 우리가 만든 ThemeContext를 쉽게 사용하기 위한 커스텀 훅입니다.

이렇게 하면 useContext(ThemeContext)를 반복해서 작성하지 않아도 됩니다.

실무에서 여러 context를 사용할 때 이런 방식이 코드를 깔끔하게 유지하고 관리하기 좋습니다.

import { ThemeProvider } from './ThemeProvider'import App from './App'export default function Root() {
  return (
    <ThemeProvider>      <App />    </ThemeProvider>  )
}

이제 ThemeProvider로 App을 감싸주었습니다.

이를 통해 App 내부의 모든 컴포넌트에서 theme에 직접 접근할 수 있습니다.

컴포넌트 계층 구조를 통해 props를 전달할 필요가 없어져서 props 드릴링을 방지할 수 있습니다.

실제 사용 예시

import { useTheme } from './useTheme'function ThemeToggleButton() {
  const { theme, setTheme } = useTheme()
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>      현재 테마를 확인해보아요: {theme}    </button>  )
}

useTheme() 훅을 통해 context의 값을 가져옵니다.

위 예시는 테마를 토글하는 간단한 예제를 보여줍니다.

이것이 바로 Context API의 장점입니다. 중간 컴포넌트를 거치지 않고도 데이터에 직접 접근할 수 있습니다.

Redux를 알아보자

store 생성하기

// store.tsimport { configureStore } from '@reduxjs/toolkit'import themeReducer from './themeSlice'export const store = configureStore({
  reducer: {
    theme: themeReducer,  },})
export type RootState = ReturnType<typeof store.getState>export type AppDispatch = typeof store.dispatch

Redux의 전역 상태 저장소인 store를 생성하는 코드입니다.

themeReducer는 테마 관련 로직을 담고 있는 slice입니다.

RootState, AppDispatch는 타입스크립트에서 타입 추론에 사용됩니다.

slice 정의하기

// themeSlice.tsimport { createSlice } from '@reduxjs/toolkit'const initialState = {
  theme: 'light',}
const themeSlice = createSlice({
  name: 'theme',  initialState,  reducers: {
    toggleTheme(state) {
      state.theme = state.theme === 'light' ? 'dark' : 'light'    },  },})
export const { toggleTheme } = themeSlice.actionsexport default themeSlice.reducer

Redux Toolkit의 createSlice를 사용해 상태와 reducer를 한 곳에서 관리합니다.

toggleTheme 액션을 통해 테마를 light ↔︎ dark로 전환할 수 있습니다.

Provider로 앱 감싸기

// main.tsx (또는 _app.tsx)import { Provider } from 'react-redux'import { store } from './store'import App from './App'export default function Root() {
  return (
    <Provider store={store}>      <App />    </Provider>  )
}

React 앱에서 Redux를 사용하려면 반드시 Provider로 최상위 컴포넌트를 감싸야 합니다.

이로 인해 전체 앱에서 Redux store를 사용할 수 있습니다.

Redux 전용 커스텀 훅 만들기

// hooks.tsimport { useSelector, useDispatch, TypedUseSelectorHook } from 'react-redux'import type { RootState, AppDispatch } from './store'export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

Redux에 타입을 잘 붙이기 위한 커스텀 훅입니다.

useAppDispatch()는 액션을 보내기 위한 함수,

useAppSelector()는 state를 가져오기 위한 함수입니다.

상태 가져와서 사용하기 (실제 컴포넌트)

// ThemeToggleButton.tsximport { useAppDispatch, useAppSelector } from './hooks'import { toggleTheme } from './themeSlice'export function ThemeToggleButton() {
  const dispatch = useAppDispatch()
  const theme = useAppSelector((state) => state.theme.theme)
  return (
    <button onClick={() => dispatch(toggleTheme())}>      현재 테마는 {theme}입니다
    </button>  )
}

Redux의 상태를 조회하고 액션을 디스패치하는 예시입니다.

Redux에서는 context 없이도 전역 상태를 쉽게 관리할 수 있습니다.

Recoil를 알아보자

Recoil을 사용하기 위해서는 RecoilRoot를 앱의 최상단에 선언해 둬야 합니다.

이는 Recoil에서 생성되는 상태값을 저장하기 위한 스토어를 생성하는 역할을 합니다.

스토어 내부에는 스토어의 아이디 값을 가져올 수 잇는 함수인 getNextStoreId()와 스토어의 값을 가져오는 함수인 getState, 값을 수정하는 함수인 replaceState 등으로 이루어져 있습니다.

replaceState 내부에는 상태가 변할 때 이 변경된 상태를 하위 컴포넌트로 전파해 컴포넌트에 리렌더링을 일으키는 notifyComponents가 존재합니다.

type Statement = {
name: string
amount: number
}
const InitialStatements: Array<Statement> = [
    { name: 'fabrill', amount: 200 },    { name: 'doseung', amount: 100 },    { name: 'yang', amount: 300 },    ]
// declare Atomconst statementsAtom = atom<Array<Statement>>({
    key: 'statements',    default: InitialStatements
    })

atom은 구별하는 식별자를 위해 key를 필수로 가지며, default는 atom의 초기값을 의미한다.

atom의 값을 컴포넌트에서 읽고 이 값의 변화에 따라 컴포넌트를 리렌더링하려면 다음의 두 훅 useRecoilValue와 useRecoilState을 사용합니다.

useRecoilValue는 atom의 값을 읽어오는 hook입니다.

이 내부의 getLoadable 함수는 Recoil이 가지고 있는 상태값을 가지고 있는 클래스인 loadable을 반환하는 함수입니다.

이 값을 이전값과 비교해 렌더링이 필요한지 확인하기 위해 렌더링을 일으키지 않으면서 값을 저장할 수 있는 ref에 매번 저장합니다.

useEffect를 통해 recoilValue가 변경됐을 때 forceUpdate를 호출해 렌더링을 강제로 일으킵니다.

%EA%B0%9C%EC%9D%B8_%ED%8E%98%EC%9D%B4%EC%A7%80__%EA%B3%B5%EC%9C%A0%EB%90%9C_%ED%8E%98%EC%9D%B4%EC%A7%802%EC%A3%BC%EC%B0%A8_React_App%EC%9D%98_%EC%83%81%ED%83%9C_%EA%B4%80%EB%A6%AC_1ca371ada9278017bbc0c6ce42756b7fimage.png

image.png

useRecoilState는 좀 더 useState와 유사하게 값을 가져오고, 또 이 값을 변경할 수도 있는 훅입니다.

useRecoilState는 useState와 매우 유사한 구조로 작성되어 있으며, 현재 값을 가져오기 위해 이전에 작성한 훅인 useRecoilValue를 그대로 사용합니다.

상태를 설정하는 훅으로는 useSetRecoilState 훅을 사용하는데, 이는 내부에서 먼저 스토어를 가져온 다음에 setRecoilValue를 호출해 값을 업데이트합니다.

setRecoilValue 내부에서는 queueOrPerformStateUpdate 함수를 호출하여 상태를 업데이트 하거나 업데이트가 필요한 내용을 등록합니다.

%EA%B0%9C%EC%9D%B8_%ED%8E%98%EC%9D%B4%EC%A7%80__%EA%B3%B5%EC%9C%A0%EB%90%9C_%ED%8E%98%EC%9D%B4%EC%A7%802%EC%A3%BC%EC%B0%A8_React_App%EC%9D%98_%EC%83%81%ED%83%9C_%EA%B4%80%EB%A6%AC_1ca371ada9278017bbc0c6ce42756b7fimage_1.png

image.png

간단 예시

RecoilRoot로 앱 감싸기

// main.tsx (또는 _app.tsx)import { RecoilRoot } from 'recoil'import App from './App'export default function Root() {
  return (
    <RecoilRoot>      <App />    </RecoilRoot>  )
}

RecoilRoot는 Context의 Provider와 유사한 역할을 합니다. Recoil 상태를 사용하기 위해서는 앱의 루트에 RecoilRoot를 반드시 감싸야 합니다.

(RecoilRoot로 감싸지 않은 컴포넌트에서는 스토어에 접근할 수 없습니다.) Redux의 와 비슷합니다.

atom 만들기

// themeAtom.tsimport { atom } from 'recoil'export const themeAtom = atom({
  key: 'themeAtom',  default: 'light',})

Recoil의 atom은 상태 관리의 가장 기본적인 단위입니다.

여기서는 themeAtom을 만들어 테마 상태를 ’light’로 초기화했습니다. key 값은 devtools나 persistence 등에서 사용되므로 전역적으로 유일해야 합니다.

상태 사용하기 (읽기 + 쓰기)

// ThemeToggleButton.tsximport { useRecoilState } from 'recoil'import { themeAtom } from './themeAtom'export function ThemeToggleButton() {
  const [theme, setTheme] = useRecoilState(themeAtom)
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>      현재 테마는 {theme}입니다
    </button>  )
}

useRecoilState()는 상태를 읽고 변경할 수 있는 React 훅입니다.

useState()와 사용법이 거의 동일하기 때문에 학습이 쉽습니다. Recoil은 별도의 디스패치나 reducer가 필요 없이 직접 값을 변경할 수 있습니다.

읽기 전용으로만 상태 사용하기

// ThemeLabel.tsximport { useRecoilValue } from 'recoil'import { themeAtom } from './themeAtom'export function ThemeLabel() {
  const theme = useRecoilValue(themeAtom)
  return <p>현재 설정된 테마: {theme}</p>}

useRecoilValue()는 읽기 전용으로 상태에 접근하는 훅입니다. 상태를 변경하지 않고 읽기만 할 때 사용하면 성능상 이점이 있습니다. 또한 불필요한 리렌더링을 방지할 수 있습니다.

실제 사용 예시

import { RecoilRoot } from 'recoil'import { Header } from './Header'import { ThemeToggleButton } from './ThemeToggleButton'export default function App() {
  return (
    <RecoilRoot>      <Header />      <ThemeToggleButton />    </RecoilRoot>  )
}

RecoilRoot로 컴포넌트를 감싸야만 Recoil 상태가 정상적으로 작동합니다. 이러한 구조 덕분에 Header와 Button 컴포넌트는 서로 다른 위치에 있더라도 동일한 theme 상태를 공유할 수 있습니다.

Jotai를 알아보자

Atom 생성합니다.

Jotai는 atom을 생성할 때 별도의 key를 넘겨주지 않습니다.

내부에 key의 변수가 존재하지만, 외부에서 받는 값은 아니며 단순히 toString()을 위한 용도로 한정됩니다.

또한 config라는 객체를 반환하는데, 여기에는 init, read, write만 존재하며 atom에 따로 상태를 저장하고 있지 않는다.

  • useAtomValue 내부를 보자면, atom의 값은 store에 존재한다는 것을 알 수 있다. store에 객체 그 자체로 키를 활용해 값을 저장합니다.

%EA%B0%9C%EC%9D%B8_%ED%8E%98%EC%9D%B4%EC%A7%80__%EA%B3%B5%EC%9C%A0%EB%90%9C_%ED%8E%98%EC%9D%B4%EC%A7%802%EC%A3%BC%EC%B0%A8_React_App%EC%9D%98_%EC%83%81%ED%83%9C_%EA%B4%80%EB%A6%AC_1ca371ada9278017bbc0c6ce42756b7fimage_2.png

image.png

내부에 renderIfChanged라는 함수를 통하여 리렌더링을 일으켜 atom의 값이 어디서 변경되더라도 useAtomValue로 값을 사용하는 쪽에서는 언제든 최신 값의 atom을 사용해 렌더링을 할 수 있게 됩니다.

리렌더링이 일어나는 경우는 첫째, 넘겨 받은 atom이 Reducer를 통해 스토어에 있는 atom과 달라지는 경우, 둘째, subscribe를 수행하고 있다가 어디선가 이 값이 달라지는 경우입니다.

  • useAtom은 useState와 동일한 형태의 배열을 반환한다. 첫째로 atom의 현재값인 useAtomValue 훅의 결과, 둘째로 useSetAtom 훅을 반환한다.

이를 통해 atom을 수정할 수 있는 기능을 갖고 있습니다.

Recoil에서는 atom에서 파생된 값을 만들기 위해서는 selector가 필요했지만, Jotai에서는 selector가 없이도 atom 값에서 또 다른 파생된 상태를 만들 수 있습니다.

객체의 참조를 WeakMap에 보관해 해당 객체 자체가 변경되지 않는 한 별도의 키가 없이도 객체의 참조를 통해 값을 관리할 수 있습니다.

간단 예시

import { atom } from 'jotai'export const themeAtom = atom('light')

Jotai에서는 상태를 atom()으로 정의합니다.

’light’를 기본값으로 하는 테마 상태를 생성했으며, 이 themeAtom은 애플리케이션의 모든 위치에서 접근할 수 있습니다.

Atom 값을 읽고 쓰기

import { useAtom } from 'jotai'import { themeAtom } from './themeAtom'export function ThemeToggleButton() {
  const [theme, setTheme] = useAtom(themeAtom)
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>      현재 테마는 {theme}입니다
    </button>  )
}

useAtom() 훅으로 themeAtom의 상태를 가져올 수 있습니다. 이 훅은 읽기와 쓰기가 모두 가능하므로 버튼 클릭 시 테마를 토글할 수 있습니다.

읽기만 필요한 경우

import { useAtomValue } from 'jotai'import { themeAtom } from './themeAtom'export function Header() {
  const theme = useAtomValue(themeAtom)
  return <h1>현재 테마: {theme}</h1>}

useAtomValue()는 읽기 전용 훅입니다.

상태를 읽기만 하고 변경할 필요가 없는 컴포넌트에서 더욱 간단하게 사용할 수 있습니다.

앱 전체에 적용하기

import { Provider } from 'jotai'import { ThemeToggleButton } from './ThemeToggleButton'import { Header } from './Header'export default function App() {
  return (
    <Provider>      <Header />      <ThemeToggleButton />    </Provider>  )
}

Provider로 감싸면 내부 컴포넌트들이 atom을 사용할 수 있습니다.

이는 Recoil의 RecoilRoot와 유사한 개념입니다.

Jotai는 Provider 없이도 동작하지만, SSR이나 Scope 분리가 필요한 경우에는 Provider를 사용해야 합니다.

MobX를 알아보자

@observable

@observable 데코레이터로 지정한 state는 관찰 대상으로 지정되고, 그 State는 값이 변경도리 때마다 렌더링 됩니다.

observable한 객체를 만들기 위해서는 makeObservable 함수를 사용하며, 각 프로퍼티마다 적절한 annotations를 지정합니다.

  • observable 은 state를 저장하는 추적 가능한 필드를 정의
  • action은 state를 수정하는 메서드를 표시
  • computed는 state로부터 새로운 사실을 도출하고 그 결괏값을 캐시 하는 getter를 나타냄

Mobx 개념

Observable : ‘추적 가능한’ 상태.

Action : State를 수정하는 메서드

Computed : state의 변화를 통해 계산되는 값.

Derivation : state를 통해서 자동으로 계산될 수 있는 모든 값 (Computed Value)를 포함

Reaction : state를 통해서 자동으로 수행되는 Task. 가령 특정 컴포넌트를 렌더링 하는 등을 의미한다. Derivation이 값을 만든다면, Reaction은 활동ㅇ르 수행한다.

즉 Action으로 Observable State를 변화시키면, 이를 통해 Derivation이 변화하거나 Reaction이 일어난다.

스토어(Store) 생성합니다.

// themeStore.tsimport { makeAutoObservable } from 'mobx'class ThemeStore {
  theme = 'light'  constructor() {
    makeAutoObservable(this)
  }
  toggleTheme() {
    this.theme = this.theme === 'light' ? 'dark' : 'light'  }
}
const themeStore = new ThemeStore()
export default themeStore

makeAutoObservable은 상태와 액션을 자동으로 관찰할 수 있는 형태로 변환합니다.

클래스를 사용해 스토어를 정의하고, 이를 인스턴스로 생성하여 내보냅니다.

이렇게 만든 스토어는 애플리케이션 전역에서 접근할 수 있습니다.

Provider로 앱에 스토어 주입

// root.tsximport { Provider } from 'mobx-react'import App from './App'import themeStore from './themeStore'export default function Root() {
  return (
    <Provider themeStore={themeStore}>      <App />    </Provider>  )
}

mobx-react의 Provider를 사용하면 모든 하위 컴포넌트에서 themeStore에 접근할 수 있습니다.

스토어 사용하기 (observer와 함께)

// ThemeToggleButton.tsximport { observer } from 'mobx-react-lite'import { useContext } from 'react'import { MobXProviderContext } from 'mobx-react'function useStores() {
  return useContext(MobXProviderContext)
}
const ThemeToggleButton = observer(() => {
  const { themeStore } = useStores()
  return (
    <button onClick={() => themeStore.toggleTheme()}>      현재 테마: {themeStore.theme}    </button>  )
})
export default ThemeToggleButton

observer로 감싼 컴포넌트는 themeStore의 변화를 자동으로 감지하고 리렌더링합니다.

MobXProviderContext를 통해 주입된 스토어에 접근할 수 있습니다.

themeStore.theme으로 상태를 읽고, toggleTheme() 메서드로 상태를 변경합니다.

Zustand를 알아보자


Zustand 내부에 store를 만드는 코드를 보자면, setState가 partial과 replace로 나눠져 있는데, partial은 state의 일부분만 변경하고 싶을 때 사용하고, replace는 state를 완전히 새로운 값으로 변경하고 싶을 때 사용합니다.

subscirbe 함수는 listener를 등록하는데, listener는 마찬가지로 Set 형태로 선언되어 추가와 삭제, 그리고 중복 관리가 용이하게끔 설계되어 있다. _즉, 상태값이 변경될 때 리렌더링이 필요한 컴포넌트에 전파될 목적_으로 만들어 진 것 입니다.

destroy는 listener를 초기화하는 역할을 합니다.

createStore는 이러한 getState, setState, subscribe, destory를 반환합니다.

  • Zustand의 create를 사용해 스토어를 만들고, 반환 값으로 이 스토어를 컴포넌트 내부에서 사용할 수 있는 훅을 받아, 스토어 내부에 있는 getter와 setter 모두에 접근해 사용할 수 있게 되었습니다

%EA%B0%9C%EC%9D%B8_%ED%8E%98%EC%9D%B4%EC%A7%80__%EA%B3%B5%EC%9C%A0%EB%90%9C_%ED%8E%98%EC%9D%B4%EC%A7%802%EC%A3%BC%EC%B0%A8_React_App%EC%9D%98_%EC%83%81%ED%83%9C_%EA%B4%80%EB%A6%AC_1ca371ada9278017bbc0c6ce42756b7fimage_3.png

리액트 컴포넌트 외부에 store를 만드는 것도 다음과 같이 가능합니다.

%EA%B0%9C%EC%9D%B8_%ED%8E%98%EC%9D%B4%EC%A7%80__%EA%B3%B5%EC%9C%A0%EB%90%9C_%ED%8E%98%EC%9D%B4%EC%A7%802%EC%A3%BC%EC%B0%A8_React_App%EC%9D%98_%EC%83%81%ED%83%9C_%EA%B4%80%EB%A6%AC_1ca371ada9278017bbc0c6ce42756b7fimage_4.png

createStore를 사용하여 리액트와 상관없는 바닐라 스토어를 만들 수 있으며, 이는 useStore 훅을 통해 리액트 컴포넌트 내부에서 사용할 수 있게 됩니다.


우리 프로젝트에 Zustand가 적합한 이유

  1. 우리 프로젝트(특히 answer-form.tsx)는 중규모의 상태 관리가 필요하며, Zustand의 복잡도가 높지 않고 보일러플레이트를 포함한 간결한 특성이 이상적입니다.
  2. 기존에 사용하고 있던 Context API와 함께 단계적인 도입이 가능합니다.

무엇보다 기존 코드는 유지하면서 필요한 부분만 Zustand로 교체 가능합니다.

const { isAccepted, sellerPrice } = useLetterStore();
  1. 페이브릴 프로덕트는 클라이언트 상태 관리에 중점을 두고 있어 Zustand의 특성이 잘 부합합니다.
  2. React Hook Form을 자주 사용하는 우리 프로젝트와 Zustand가 원활하게 통합됩니다.

결론

최적의 접근 방법은 하이브리드 접근법이라고 생각합니다.

현 규모에서는 전면적으로 전환이 필요하다는 생각보다는 스펙에 대한 규모가 커지면서 전역 상태 관리가 필요할 때 점진적으로 Zustand와 같은 상태 관리 라이브러리를 통해 마이그레이션 하는게 좋을 것 같습니다.


의견 남기기

이 글에 대한 의견이나 질문이 있으시다면 언제든 연락주세요!