
로그인정보등 특정 정보로를 스토리지 또는 캐쉬에 저장하는 방식에 대해 정리한다.
yarn add redux react-redux redux-persist @reduxjs/toolkit
mkdir ./src/store
touch ./src/store/index.ts
touch ./src/store/counter.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;
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;
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;
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;
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>
);
}
대쉬보드 화면에서 add버튼과 SET0버튼을 눌러 변화하는지 검사 한다.
또한 페이지 새로고침을 통해 해당 값이 유지 되는 지 확인한다.
touch ./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;
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;
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를 직접 변경하는 실수 탐지 |
| 메모리 누수 | useEffect나 setTimeout 누락된 cleanup 감지 |
lazy() or Suspense 처리 오류 | 비동기 처리 누락 여부 확인 |
결론 StrictMode를 제거 하는 방법이 있지만 개발 안정성을 위해 참도록 하자
./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;
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"] };
즉 로그인 정보는 브라우저를 닫을 경우 초기화 시켜야 한다.
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>
);
}