Redux

homewiz·2025년 5월 16일

React & CRA & typescript

목록 보기
14/18
post-thumbnail

intro

로그인정보등 특정 정보로를 스토리지 또는 캐쉬에 저장하는 방식에 대해 정리한다.

config

yarn add redux react-redux redux-persist @reduxjs/toolkit

mkdir ./src/store
touch ./src/store/index.ts
touch ./src/store/counter.ts

./src/store/index.ts

import { configureStore } from "@reduxjs/toolkit";
import { combineReducers } from "redux";
import { PERSIST, PURGE, REGISTER, REHYDRATE, persistReducer } from "redux-persist";
import localStorage from "redux-persist/lib/storage";

// add reducer slice
import counter, { IStateCounter } from "./counter";

export interface IState {
  counter: IStateCounter;
}

const rootPersistConfig = { key: "core", version: 2, storage: localStorage, rehydrated: false };

const rootReducer = combineReducers({ counter });

const persistor = persistReducer(rootPersistConfig, rootReducer);

const store = configureStore({
  reducer: persistor,
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware({
      serializableCheck: { ignoredActions: [PERSIST, PURGE, REGISTER, REHYDRATE] }
    })
});

export default store;

.src/store/counter.ts

import { createSlice } from "@reduxjs/toolkit";

export interface IStateCounter extends Record<string, number> {
  // unkown
  value: number;
}

const initialState: IStateCounter = { value: 0 };

export const counterSlice = createSlice({
  name: "counter",
  initialState,
  reducers: {
    addCounterValue: state => {
      state.value = state.value + 1;
    },
    setCounterValue: (state, action) => {
      state.value = action.payload;
    }
  }
});

// Action creators are generated for each case reducer function
export const { addCounterValue, setCounterValue } = counterSlice.actions;

export default counterSlice.reducer;

./src/components/GateLoader.tsx

import React from "react";
import PageLoader from "./PageLoader";

const GateLoader = () => {
  return (
    <div className="flex flex-row justify-center min-h-screen">
      <PageLoader />
    </div>
  );
};

export default GateLoader;

App.tsx

import React, { Suspense } from "react";
import { BrowserRouter as Router } from "react-router-dom";

// Redux 상태관리 및 Persist 설정 관련
import { Provider as ReduxProvider } from "react-redux";
import { persistStore } from "redux-persist";
import { PersistGate } from "redux-persist/integration/react";

// Redux 스토어
import store from "@/store";

// 전역 네비게이션 바 (상단 GNB)
import GNB from "@/components/Navigations/GNB";

// 라우트 구성
import Routes from "@/config/Routes";

// lazy 컴포넌트 로딩 시 로딩 화면
import GateLoader from "@/components/GateLoader";

// 메뉴 및 라우트 데이터 정의 (key-value 형태로 구성된 라우트 메타데이터)
import { RouteData } from "@/config/RouteData";

const App = () => {
  // redux-persist의 상태 저장 관리 객체 생성
  const persistor = persistStore(store);

  return (
    // Redux 상태 전역 제공
    <ReduxProvider store={store}>
      {/* persist된 상태를 재하이드레이션 될 때까지 대기 */}
      <PersistGate loading={<GateLoader />} persistor={persistor}>
        {/* 전역 라우팅 적용 (react-router) */}
        <Router>
          <div className="flex flex-row justify-center min-h-screen">
            <div className="flex flex-col flex-1">
              {/* 전역 GNB (Global Navigation Bar) - 메뉴 데이터 전달 */}
              <GNB data={RouteData} />

              {/* 메인 페이지 영역 (콘텐츠 뷰) */}
              <div className="flex flex-1 w-full text-black transition-colors duration-300 bg-white dark:bg-gray-900 dark:text-gray-100">
                {/* lazy로 불러오는 페이지에 대한 fallback 로딩 UI */}
                <Suspense fallback={<PageLoader />}>
                  {/* 라우트 렌더링 */}
                  <Routes data={RouteData} />
                </Suspense>
              </div>
            </div>
          </div>
        </Router>
      </PersistGate>
    </ReduxProvider>
  );
};

export default App;

Dashboard.tsx

import React from "react";
import { useDispatch, useSelector } from "react-redux";

import PageLayout from "@/components/PageLayout";
import { IStateCounter, addCounterValue, setCounterValue } from "@/store/counter";
import { IState } from "@/store";

export default function Dashboard() {
  const storeCounter: IStateCounter = useSelector((state: IState) => state.counter);
  const dispatch = useDispatch();

  function hdAddValue() {
    dispatch(addCounterValue());
  }

  function hdSetValue(val: number) {
    dispatch(setCounterValue(val));
  }

  return (
    <PageLayout>
      <h1 className="text-2xl font-bold">📊 대시보드</h1>
      <p className="mt-2 text-gray-600">이 페이지는 📊 대시보드용 템플릿입니다.</p>
      <p>COUNT : {storeCounter.value} </p>
      <div className="space-x-2 flex ">
        <button className="bg-blue-600 text-white rounded p-2" onClick={hdAddValue}>
          ADD
        </button>
        <button className="bg-blue-600 text-white rounded p-2" onClick={() => hdSetValue(0)}>
          SET O
        </button>
      </div>
    </PageLayout>
  );
}

TEST

대쉬보드 화면에서 add버튼과 SET0버튼을 눌러 변화하는지 검사 한다.
또한 페이지 새로고침을 통해 해당 값이 유지 되는 지 확인한다.


테마 및 로그인 적용해보기

touch ./src/store/config.ts

./src/store/config.ts

import { createSlice } from "@reduxjs/toolkit";

export interface IStateConfig extends Record<string, unknown> {
  darkMode: boolean;
  language: string;
}

const initialState: IStateConfig = {
  darkMode: true,
  language: "kr"
};

export const userSlice = createSlice({
  name: "config",
  initialState,
  reducers: {
    update: (state, action) => {
      // 중복되는 코드 이지만 함수화 하지 말것. state의 경우 내부 로직 이해도가 있어야함. 나아중에....
      const keys = Object.keys(action.payload);
      keys.forEach(k => (state[k] = action.payload[k]));
    },
    darkMode: (state, action) => {
      state.darkMode = action.payload;
    },
    setLanguage: (state, action) => {
      state.language = action.payload;
    }
  }
});

// Action creators are generated for each case reducer function

export const { update, darkMode, setLanguage } = userSlice.actions;

export default userSlice.reducer;

./src/store/index.ts

import { configureStore } from "@reduxjs/toolkit";
import { combineReducers } from "redux";
import { PERSIST, PURGE, REGISTER, REHYDRATE, persistReducer } from "redux-persist";
import localStorage from "redux-persist/lib/storage";

// add reducer slice
import counter, { IStateCounter } from "./counter";
import config, { IStateConfig } from "./config";

export interface IState {
  counter: IStateCounter;
  config: IStateConfig;
}

const rootPersistConfig = { key: "core", version: 2, storage: localStorage, rehydrated: false };

const rootReducer = combineReducers({ counter, config });

const persistor = persistReducer(rootPersistConfig, rootReducer);

const store = configureStore({
  reducer: persistor,
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware({
      serializableCheck: { ignoredActions: [PERSIST, PURGE, REGISTER, REHYDRATE] }
    })
});

export default store;

./src/components/Navigations/GNB.tsx

import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { NavLink } from "react-router-dom";

import { availableRouteDatas, RouteDataAtts } from "@/config/Routes";
import { darkMode } from "@/store/config";
import { IState } from "@/store";

export default function GNB({ data }: { data: RouteDataAtts }) {
  const menus = availableRouteDatas(data);
  const isDark: boolean = useSelector((state: IState) => state.config).darkMode;
  const dispatch = useDispatch();

  useEffect(() => {
    if (isDark) {
      document.documentElement.classList.add("dark");
    }
  }, [isDark]);

  const toggleDark = () => {
    const newVal = !isDark;
    document.documentElement.classList.toggle("dark", newVal);
    dispatch(darkMode(newVal));
  };

  return (
    <header className="relative z-50 flex items-center justify-between w-full h-16 px-8 text-gray-900 bg-white border-b border-gray-200 shadow dark:bg-gray-900 dark:border-gray-700 dark:text-white">
      <nav className="flex items-center space-x-6">
        <span className="text-lg font-bold tracking-tight">REACT</span>
        {menus.map((item, idx) =>
          item.children ? (
            <div key={idx} className="relative group">
              <span className="px-3 py-2 rounded cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">{item.icon + " " + item.name}</span>
              <div className="absolute top-full left-0 mt-1 bg-white dark:bg-gray-800 shadow-lg rounded border dark:border-gray-700 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity z-10 min-w-[160px]">
                {availableRouteDatas(item.children).map((child, i) => (
                  <NavLink key={i} to={child.path} className={({ isActive }) => `block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${isActive ? "font-semibold" : ""}`}>
                    {child.name}
                  </NavLink>
                ))}
              </div>
            </div>
          ) : (
            <NavLink key={idx} to={item.path} className={({ isActive }) => `px-3 py-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 ${isActive ? "font-semibold" : ""}`}>
              {item.icon + " " + item.name}
            </NavLink>
          )
        )}
      </nav>

      <div className="flex items-center space-x-4 text-sm">
        <button onClick={toggleDark} className="px-3 py-1 text-black bg-gray-200 rounded dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 dark:text-white">
          {isDark ? "🌙 다크" : "☀️ 라이트"}
        </button>
        <span className="text-gray-500 dark:text-gray-300">👤 운영자</span>
        <button className="px-3 py-1 text-white bg-red-500 rounded hover:bg-red-600">로그아웃</button>
      </div>
    </header>
  );
}

주의

소스 적용후 첫 페이지 로딩이 현저히 느려짐을 알수 있다.

⚠️ React.StrictMode는 개발 모드에서만 작동하며, 성능 저하처럼 느껴지는 "의도된 동작"을 발생시킬 수 있습니다.

React 18+에서는 StrictMode가 개발 환경에서 일부 컴포넌트를 두 번 렌더링하여 다음을 감지하기 위해 사용됩니다:

검증 목적설명
상태 불변성 위반state를 직접 변경하는 실수 탐지
메모리 누수useEffectsetTimeout 누락된 cleanup 감지
lazy() or Suspense 처리 오류비동기 처리 누락 여부 확인

결론 StrictMode를 제거 하는 방법이 있지만 개발 안정성을 위해 참도록 하자

Auth 구현

./src/store/auth.ts

./src/store/auth.ts

import { createSlice } from "@reduxjs/toolkit";

export interface IStateAuth extends Record<string, unknown> {
  id: number | null;
  username: string | null;
  nickname: string | null;
  token: string | null;
  isSigned: boolean;
}

const initialState: IStateAuth = {
  id: null,
  username: null,
  nickname: null,
  token: null,
  isSigned: false
};

export const authSlice = createSlice({
  name: "auth",
  initialState,
  reducers: {
    updateAuth: (state, action) => {
      // 중복되는 코드 이지만 함수화 하지 말것. state의 경우 내부 로직 이해도가 있어야함. 나아중에....
      const keys = Object.keys(action.payload);
      keys.forEach(k => (state[k] = action.payload[k]));
    },
    signIn: (state, action) => {
      const keys = Object.keys(action.payload);
      keys.forEach(k => (state[k] = action.payload[k]));
      state.isSigned = true;
    },
    signOut: state => {
      const keys = Object.keys(initialState);
      keys.forEach(k => (state[k] = initialState[k]));
      state.isSigned = false;
    }
  }
});

// Action creators are generated for each case reducer function

export const { updateAuth, signIn, signOut } = authSlice.actions;

export default authSlice.reducer;

./src/store/index.ts

import { configureStore } from "@reduxjs/toolkit";
import { combineReducers } from "redux";
import { PERSIST, PURGE, REGISTER, persistReducer, REHYDRATE } from "redux-persist";
import localStorage from "redux-persist/lib/storage";
import sessionStorage from "redux-persist/lib/storage/session";

// add reducer slice
import auth, { IStateAuth } from "./auth";
import config, { IStateConfig } from "./config";
import counter, { IStateCounter } from "./counter";

export interface IState {
  counter: IStateCounter;
  auth: IStateAuth;
  config: IStateConfig;
}

const authPersistConfig = { key: "auth", version: 1, storage: sessionStorage };
const rootPersistConfig = { key: "core", version: 1, storage: localStorage, blacklist: ["auth"] };
// rootReducer에 등록한 모든 slice를 기본적으로 storage에 저장 하는 것으로 하고 예외 항목만 등록 하는 방식으로 한다.
// whitelist: ["counter", "auth"] // -> save storage
// blacklist: ["auth"] // -> don't save storage

const rootReducer = combineReducers({
  counter,
  config,
  auth: persistReducer(authPersistConfig, auth)
});

const persistor = persistReducer(rootPersistConfig, rootReducer);

const store = configureStore({
  reducer: persistor,
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware({
      serializableCheck: { ignoredActions: [PERSIST, PURGE, REGISTER, REHYDRATE] }
    })
});

export default store;

const authPersistConfig = { key: "auth", version: 1, storage: sessionStorage };
const rootPersistConfig = { key: "core", version: 1, storage: localStorage, blacklist: ["auth"] };

  • auth의 경우 별도 config를 정의 하는 이유
    localstorage는 브라우져를 재실행해도 값을 유지 할수 있다
    sessionStorage는 부라우저 재실행시 값이 초기화 된다.

즉 로그인 정보는 브라우저를 닫을 경우 초기화 시켜야 한다.

./src/components/Navigation/GNB.tsx

import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { NavLink } from "react-router-dom";

import { availableRouteDatas, RouteDataAtts } from "@/config/Routes";
import { darkMode } from "@/store/config";
import { IState } from "@/store";
import { IStateAuth, signIn, signOut } from "@/store/auth";

export default function GNB({ data }: { data: RouteDataAtts }) {
  const menus = availableRouteDatas(data);
  const isDark: boolean = useSelector((state: IState) => state.config).darkMode;
  const auth: IStateAuth = useSelector((state: IState) => state.auth);

  const dispatch = useDispatch();

  useEffect(() => {
    if (isDark) {
      document.documentElement.classList.add("dark");
    }
  }, [isDark]);

  const toggleDark = () => {
    const newVal = !isDark;
    document.documentElement.classList.toggle("dark", newVal);
    dispatch(darkMode(newVal));
  };

  function hdClickSign() {
    if (auth.id) {
      if (confirm("로그아웃 하시겠습니까?")) {
        dispatch(signOut());
      }
    } else {
      dispatch(signIn({ id: "sample", isAdmin: true, username: "sample", nickname: "sample", token: "testewtest" }));
    }
  }

  return (
    <header className="relative z-50 flex items-center justify-between w-full h-16 px-8 text-gray-900 bg-white border-b border-gray-200 shadow dark:bg-gray-900 dark:border-gray-700 dark:text-white">
      <nav className="flex items-center space-x-6">
        <span className="text-lg font-bold tracking-tight">REACT</span>
        {menus.map((item, idx) =>
          item.children ? (
            <div key={idx} className="relative group">
              <span className="px-3 py-2 rounded cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">{item.icon + " " + item.name}</span>
              <div className="absolute top-full left-0 mt-1 bg-white dark:bg-gray-800 shadow-lg rounded border dark:border-gray-700 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity z-10 min-w-[160px]">
                {availableRouteDatas(item.children).map((child, i) => (
                  <NavLink key={i} to={child.path} className={({ isActive }) => `block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${isActive ? "font-semibold" : ""}`}>
                    {child.name}
                  </NavLink>
                ))}
              </div>
            </div>
          ) : (
            <NavLink key={idx} to={item.path} className={({ isActive }) => `px-3 py-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 ${isActive ? "font-semibold" : ""}`}>
              {item.icon + " " + item.name}
            </NavLink>
          )
        )}
      </nav>

      <div className="flex items-center space-x-4 text-sm">
        <button onClick={toggleDark} className="px-3 py-1 text-black bg-gray-200 rounded dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 dark:text-white">
          {isDark ? "🌙 Dark" : "☀️ Light"}
        </button>
        {auth.id && <span className="text-gray-500 dark:text-gray-300">👤 {auth.nickname}</span>}
        <button className="px-3 py-1 text-white bg-red-500 rounded hover:bg-red-600" onClick={hdClickSign}>
          {auth.id ? "Sing out" : "Sign in"}
        </button>
      </div>
    </header>
  );
}

0개의 댓글