프론트엔드 개발에서 React로 애플리케이션을 구축할 때, 컴포넌트는 자체적인 지역 상태를 갖습니다. 그러나 애플리케이션이 커지고 복잡해지며 여러 컴포넌트 간에 데이터를 공유해야 할 때, 지역 상태만 사용하면 문제가 발생하는데요. 전역 상태 관리로 이 문제를 해결할 수 있습니다.

전역 상태를 효과적으로 관리하려면, 적절한 라이브러리를 사용해야 합니다. React에서는 전역 상태 관리 라이브러리로 Redux, Zustand, Jotai를 많이 이용하는데요. 이 글에서는 Redux, Zustand의 특징과 장단점을 살펴보겠습니다.

지역 상태 사용 시 문제점

앞서 ‘애플리케이션이 커지고 복잡해지며 여러 컴포넌트 간에 데이터를 공유해야 할 때, 지역 상태만 사용하면 문제가 발생한다’고 언급했는데요. 구체적으로 어떤 문제가 일어나는지 알아보겠습니다.

  • Prop Drilling: 상위 컴포넌트에서 하위 컴포넌트로 필요한 상태나 함수를 전달하기 위해 중간 컴포넌트가 불필요하게 props를 전달합니다.
  • 어려운 상태 동기화: 여러 컴포넌트에서 동일한 상태를 관리할 때, 각 컴포넌트의 상태를 일일이 동기화하기가 복잡하고, 오류도 발생합니다.
  • 코드 복잡도 증가: 상태 관리 로직이 여러 곳에 분산돼 가독성과 유지보수성이 떨어집니다.

이러한 문제를 해결하려면 전역 상태를 관리해야 하는데요. 이로써 애플리케이션, 상태를 중앙에서 관리해 컴포넌트 간에 데이터를 쉽게 공유하고, 상태를 편리하게 관리할 수 있습니다.

전역 상태 관리 도구: React의 Context API

React에서는 전역 상태를 관리하려면 어떤 도구를 사용해야 할까요? 대표적으로 Context API가 있는데요. React는 내장된 Context API로 전역 상태 관리를 지원합니다. Context API는 작은 애플리케이션에서 전역 상태를 관리할 때 유용하고, 사용하기가 간편하죠. 예를 들어, Context API로 다크 모드와 같은 테마 관련 전역 설정을 관리할 수 있습니다. 아래는 관련 예제입니다.

import React, { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();

export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
};

return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};

export const useTheme = () => useContext(ThemeContext);

그러나 Context API에는 한계가 있습니다. 상태가 변경될 때마다 해당 Context를 사용하는 모든 컴포넌트가 리렌더링되는데요. 이에 애플리케이션 규모가 커지면 성능 문제가 발생합니다. Context API는 아주 간단한 전역 상태 관리에 적합하고요. 상태가 복잡하거나, 애플리케이션 규모가 클 때는 Redux나 Recoil 등 라이브러리를 사용하는 게 좋습니다.

전역 상태 관리 라이브러리

React에서는 전역 상태 관리 라이브러리로 Redux, Zustand, Jotai를 많이 사용하는데요. 이중 Redux와 Zustand의 특징과 장단점을 살펴보겠습니다.

Redux

Redux는 9년 전 등장해 지금까지 널리 사용되는 전역 상태 관리 라이브러리입니다. 최신 버전은 9.1.2로, 약 6개월 전 업데이트됐습니다. 설치 크기는 764KB이며, GitHub 스타는 2만3380개입니다. React와 통합해 사용하려면 react-redux 패키지를 활용하면 됩니다.

  1. 핵심 원칙

    Redux는 상태를 예측 가능하게 관리하기 위해 다음 세 가지 핵심 원칙을 따릅니다.

    • Single Source of Truth: 애플리케이션의 모든 상태는 하나의 중앙 저장소(Store)에 보관됩니다. 이로써 상태의 일관성을 유지하고, 애플리케이션의 전역 상태를 한 곳에서 관리할 수 있습니다.
    • State is read-only: Redux 상태는 읽기 전용으로, 직접 수정할 수 없습니다. 액션으로만 상태를 변경할 수 있습니다. 그 결과, 상태 변경의 예측 가능성을 높이고 의도하지 않은 상태 변경을 막을 수 있습니다.
    • Changes are Made with Pure Functions: ‘Reducer’라는 순수 함수로 상태를 변경합니다. Reducer는 동일한 입력에 항상 동일한 출력을 반환하는 함수입니다. 이는 상태 변경을 예측할 수 있고 안정적으로 만듭니다.
  2. 주요 구성 요소

    Redux는 아래 네 가지 구성 요소로 이뤄집니다.

    • Store: 애플리케이션의 전체 상태를 보관하는 저장소입니다. Store는 애플리케이션의 전역 상태를 유지하며, createStore 함수로 생성됩니다.
    • Action: 상태 변경에 필요한 정보를 담은 객체입니다. 액션은 ‘상태를 어떻게 변경할지’ 정의하며, 일반적으로 type 속성과 추가 데이터를 포함합니다.
    • Reducer: 상태 변경을 실행하는 순수 함수입니다. Reducer는 현재 상태와 액션을 받아 새로운 상태를 반환합니다. 상태는 직접 수정되지 않고, Reducer로 불변성을 유지하며 새로운 상태를 생성합니다.
    • Dispatch: 액션을 Reducer로 전달해 상태 변경을 발생하는 함수입니다. dispatch 함수를 호출해 특정 액션을 스토어에 전달하고, Reducer가 해당 액션을 처리해 새로운 상태를 만듭니다.
  3. 예제 코드

    // store/userSlice.ts
    const initialState: UserState = {
    userId: 0,
    userName: ''
    };

    export const userSlice = createSlice({
    name: 'user',
    initialState,
    reducers: {
    setUserId: (state, action: PayloadAction<number>) => {
    state.userId = action.payload;
    },
    setUserName: (state, action: PayloadAction<string>) => {
    state.userName = action.payload;
    }
    }
    });

    export const { setUserId, setUserName } = userSlice.actions;
    export default userSlice.reducer;


    // store/index.ts
    import userReducer from './userSlice';

    const persistConfig = {
    key: 'app-storage',
    storage,
    whitelist: ['userId', 'userName'] // 저장할 상태 지정
    };

    const persistedReducer = persistReducer(persistConfig, userReducer);

    export const store = configureStore({
    reducer: {
    user: persistedReducer
    },
    middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
    serializableCheck: false // redux-persist와 함께 사용하기 위해
    })
    });

    export const persistor = persistStore(store);


    // hooks/useAppDispatch.ts
    import { useDispatch } from 'react-redux';
    import { AppDispatch } from '../store';

    export const useAppDispatch = () => useDispatch<AppDispatch>();


    // hooks/useAppSelector.ts
    import { TypedUseSelectorHook, useSelector } from 'react-redux';
    import { RootState } from '../store';

    export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;


    // components/UserComponent.tsx
    const UserComponent: React.FC = () => {
    const userName = useAppSelector((state) => state.user.userName);
    const dispatch = useAppDispatch();
    const [inputName, setInputName] = useState('');

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputName(e.target.value);
    };

    const handleSave = () => {
    dispatch(setUserName(inputName));
    };

    return (
    <div>
    <h2>내 이름은: {userName}</h2>
    <input
    type="text"
    value={inputName}
    onChange={handleChange}
    placeholder="이름을 입력하세요."
    />
    <button onClick={handleSave}>저장</button>
    </div>
    );
    };

    export default UserComponent;


    // App.tsx
    import { Provider } from 'react-redux';
    import { PersistGate } from 'redux-persist/integration/react';
    import { store, persistor } from './store';
    import UserComponent from './components/UserComponent';

    const App: React.FC = () => {
    return (
    <Provider store={store}>
    <PersistGate loading={null} persistor={persistor}>
    <UserComponent />
    </PersistGate>
    </Provider>
    );
    };

    export default App;

  4. 장점

    Redux에는 다음 네 가지 장점이 있습니다.

    • 예측 가능한 상태 관리: 애플리케이션의 모든 상태를 단일 Store에서 관리합니다. 상태는 변경될 때마다 순수 함수인 Reducer로 갱신돼 예측 가능하게 유지됩니다. 이로써 디버깅과 상태 추적이 쉽습니다.
    • 일관된 전역 상태 관리: 애플리케이션의 전역 상태를 일관된 방식으로 관리합니다. 이로써 규모가 큰 애플리케이션에서도 모든 컴포넌트가 동일한 상태를 참조하고 업데이트할 수 있습니다. 그 결과, 복잡한 상태를 안정적으로 관리할 수 있습니다.
    • Middleware 지원: Middleware로 액션의 처리 과정을 확장할 수 있습니다. Redux Thunk나 Redux-Saga와 같은 Middleware로 비동기 액션을 쉽게 처리합니다. 이로써 API 호출이나 복잡한 로직을 효율적으로 관리할 수 있습니다.
    • 풍부한 커뮤니티와 생태계: 2015년 출시된 이후 오랫동안 사용돼 커뮤니티와 생태계가 풍부합니다. Redux의 공식 기술 문서는 잘 정리됐고, 튜토리얼도 다양합니다. 또 오픈 소스 프로젝트가 있고, Stack Overflow와 같은 커뮤니티에 도움을 구해 문제를 빨리 해결할 수 있습니다. 학습 자료와 확장 기능이 풍부해 개발 속도와 효율성을 높일 수 있습니다.
  5. 단점

    • 보일러플레이트 코드: Redux의 상태 관리는 특정 패턴을 요구합니다. 따라서 액션 생성자, 액션 타입, Reducer 등을 작성하는 반복 코드가 많을 수 있습니다. 코드가 복잡해지면 유지보수에 부담이 되며, 간단한 상태 관리에 많은 설정이 필요합니다.
    • 높은 러닝 커브: Redux의 개념(Store, Reducer, Middleware, 액션 등)은 초심자에게 어렵습니다. 특히 비동기 로직을 처리하려면 Redux Thunk, Redux-Saga와 같은 Middleware를 추가로 학습해야 합니다. 이에 Redux를 처음 사용하는 개발자는 학습에 시간이 걸립니다.
    • 성능 문제: 상태 트리가 커지거나 업데이트가 빈번하면, 불필요한 리렌더링이 발생해 성능에 문제가 생깁니다. 또 상태 업데이트로 애플리케이션이 복잡해지면 성능 최적화에 추가 코드 관리가 필요합니다.

Zustand

Zustand는 약 6년 전 등장한 전역 상태 관리 라이브러리입니다. 이 라이브러리는 복잡한 설정 없이 전역 상태를 쉽고 간단하게 관리해 개발자에게 인기가 많습니다. 최신 버전은 5.0.1이고, 설치 크기는 86.5KB로 가볍습니다. GitHub 스타는 4만7896개로, 빠르게 성장하는 라이브러리입니다.

  1. 주요 특징

    • 간단한 API: 상태를 직관적으로 관리합니다. 또 보일러플레이트 코드 양이 적어 사용하기에 간편합니다.
    • 빠른 성능: React Context를 사용하지 않고도 전역 상태를 관리할 수 있습니다. 이로써 불필요한 리렌더링을 최소화합니다.
    • 유연성: 컴포넌트 외부에서도 전역 상태에 접근할 수 있습니다. 이에 React 컴포넌트가 아닌 곳에서도 상태를 쉽게 조작할 수 있습니다.
    • 경량성: 번들 크기가 작아 성능에 부담을 주지 않습니다.
  2. 주요 구성 요소

    • Store: 상태를 보관하는 저장소입니다. 상태와 이를 변경하는 함수를 정의합니다.
    • Selector: 특정 상태만 가져오는 데 사용하는 함수입니다. 필요한 상태에만 반응하며, 성능을 최적화합니다.
    • Middleware: 상태 관리 기능을 확장하는 데 쓰는 기능입니다. Middleware를 추가하면 로깅이나 비동기 처리를 쉽게 설정할 수 있습니다.
  3. 핵심 기능

    • Slice 패턴: 큰 상태를 여러 개의 작은 상태 조각, 즉 Slice로 나눠 관리하는 구조입니다. 이 패턴은 코드를 모듈화하고 상태를 직관적으로 관리하는 데 활용됩니다. Slice 패턴은 대규모 애플리케이션에 유용합니다. 이는 각 상태 조각이 독립적으로 유지되고, 필요할 때만 업데이트되도록 설계합니다.
    • Persist Middleware: 이 기능은 애플리케이션 상태를 로컬 스토리지(localStorage)나 세션 스토리지(sessionStorage)와 같은 지속가능한 저장소에 저장하도록 지원합니다. Persist Middleware는 페이지를 새로고침하거나 애플리케이션을 다시 시작해도 이전 상태를 유지하도록 합니다. 이로써 사용자 경험을 개선하고 일관성을 지킬 수 있습니다.
  4. 예제 코드

    // store/index.ts
    type AppState = UserStoreState & ...그 외 StoreState

    export const useStore = create<AppState>()(
    persist(
    (set, get, api) => ({
    ...createUserSlice(set, get, api),
    ...그 외 Slice
    }),
    {
    // 로컬 스토리지 키 이름
    name: 'app-storage',

    // partialize를 사용해 특정 상태만 저장 가능
    partialize: (state) => ({
    userId: state.userId
    userName: state.userName
    }),
    storage: createJSONStorage(() => localStorage),
    }
    )
    );

    // store/UserStore.ts
    export interface UserStoreState {
    userId: number;
    userName: string;
    setUserId: (userId: number) => void;
    setUserName: (userName: string) => void;
    }
    export const createUserSlice: StateCreator<UserStoreState> = (set) => ({
    userId: 0,
    userName: '',
    setUserId: (userId: number) => set({ userId }),
    setUserName: (userName: string) => set({ userName }),
    });

    // components/UserComponent.tsx
    const UserComponent: React.FC = () => {

    const userName = useStore((state) => state.userName);
    const setUserName = useStore((state) => state.setUserName);


    const [inputName, setInputName] = useState('');

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputName(e.target.value);
    };

    const handleSave = () => {
    setUserName(inputName);
    };

    return (
    <div>
    <h2>내 이름은: {userName}</h2>
    <input
    type="text"
    value={inputName}
    onChange={handleChange}
    placeholder="이름을 입력하세요."
    />
    <button onClick={handleSave}>저장</button>
    </div>
    );
    };
  5. 장점

    • 간단하고 직관적인 API: 직관적인 API를 제공해 상태 관리를 쉽게 설정하도록 돕습니다. 이는 create 함수로 스토어를 간편하게 생성하고, React 훅으로 상태를 관리합니다. 이로써 복잡한 설정 없이 빠르게 시작할 수 있습니다.
    • 높은 성능과 효율성: 상태를 변경할 때 필요한 컴포넌트만 리렌더링하도록 최적화됐습니다. 이로써 불필요한 리렌더링을 방지하고 성능을 높입니다. 또 React의 useState와 비슷하게 동작해 사용자 경험과 성능을 모두 충족합니다.
    • Middleware 지원: persist, combine, devtools 등 다양한 Middleware를 쉽게 추가하도록 지원합니다. 이로써 로컬 스토리지 저장이나 상태 분할 등 고급 기능을 필요에 따라 쉽게 확장할 수 있습니다.
    • 유연한 모듈화와 확장성: Slice 패턴을 지원해 상태를 기능별로 나누고 관리할 수 있습니다. 이에 대규모 애플리케이션에서 유지보수하기에 좋습니다. 모듈화로 상태를 기능별로 관리하면, 애플리케이션을 효율적으로 확장할 수 있습니다.
  6. 단점

    • 제한된 기능: Zustand는 간단한 상태 관리에 중점을 둡니다. 따라서 Redux의 복잡한 상태 관리 패턴이나 심화한 Middleware 체인에 익숙한 사용자에게 기능이 제한적일 수 있습니다. 복잡한 비동기 로직이나 고도의 액션 관리가 필요할 때, 다른 상태 관리 라이브러리를 사용하는 게 더 좋습니다.
    • 미성숙한 커뮤니티와 생태계: 커뮤니티와 관련 생태계가 Redux나 MobX와 같은 대형 상태 관리 라이브러리보다 작습니다. 이에 문제 해결이나 플러그인, 확장성 측면에서 선택 폭이 제한적입니다.
    • 불편한 의존성 관리: Zustand는 직접적인 의존성 주입을 기본으로 사용하지 않습니다. 따라서 의존성 관리가 복잡한 애플리케이션에서는 상태 간 의존성을 관리하기가 어렵습니다. 상태 간에 복잡한 관계가 생기면, 코드를 유지보수하기가 힘듭니다.
    • 비동기 상태 관리 간소화 부족: 기본적인 비동기 처리는 가능하지만, 비동기 처리용 내장 도구가 많지 않습니다. 비동기 로직이 복잡하면, 추가 설정이나 로직을 구현해야 합니다. React Query나 Redux-Saga와 같은 비동기 상태 관리 도구에 익숙한 개발자는 불편할 수 있습니다.

맺음말

Redux와 Zustand는 다양한 애플리케이션에 유용한 전역 상태 관리 라이브러리입니다. Redux는 강력한 Middleware 지원과 예측 가능한 상태 관리 방식, 풍부한 개발자 도구를 제공해 복잡한 상태 관리를 안정적으로 처리합니다. Zustand는 간결한 API와 성능 최적화로 직관적이고 효율적인 상태 관리를 돕습니다.

이 글이 ‘어떤 도구가 애플리케이션의 상태 관리에 더 적합한지’ 판단하는 데 도움이 되기를 바랍니다. Redux와 Zustand의 특성을 잘 이해하고, 적절한 도구를 선택해 상태 관리 환경을 효율적으로 구축하세요.

참고 자료

  1. “Redux 시작하기”, Redux, https://ko.redux.js.org/introduction/getting-started/
  2. “처음 만난 리덕스(Redux) 문서”, FrontOverflow, 프론트엔드 개발 매거진, https://www.frontoverflow.com/document/1/처음 만난 리덕스 (Redux)/chapter/2/Redux 소개/section/4/Redux의 탄생
  3. “Introduction”, Zustand docs, https://zustand.docs.pmnd.rs/getting-started/introduction

DevOps와 GitLab을 효과적으로 도입하는 방법, 지금 인포그랩에 문의하세요.