리액트 상태 관리 라이브러리에는 다양한 종류가 있다. 익숙한 것들만 간단히 나열해보자면 Recoil, Redux, Jotai, Mobx, zustand 등등 ...
우리가 사용할 기술을 선정할 때 고려해야 할 것이 몇가지 있다. 각 기술의 특성과 장단점을 무조건 파악을 해야 한다는 거다.
제일 주의해야하는 건 무작정 '남들이 다 사용하니까' 선택하는 일이다.
그렇게 되면 내가 만들 프로젝트와 그 기술의 성격이 맞지 않을 수가 있고, 몸집이 커진 프로젝트를 다시 싹 다 갈아 엎어야 하는 대참사가 발생할 수도 있다.
위와 같은 다양한 라이브러리 중 나는 zustand 또는 Redux 사이에서 고민하게 되었다.
나의 상황을 적어보자면
1) 온전히 혼자 작업을 해야 한다.
2) 최대한 빠르게 작업을 마무리 하면 좋다.
3) 두 라이브러리 모두 사용해 본 경험이 없거나 적다.
위 같은 상황에서 빠르고 쉽게 작업할 수 있는 zustand 를 선택하는게 더 효율적이었겠지만, 나는 redux를 선택했다!
왜냐면 redux는 대규모 프로젝트에 더 적합하기 때문이다. (나는 런칭해서 운영까지, 즉 유지보수까지 해보고 싶었기 때문이다.)
그리고 '쉬운걸 먼저 적용해버리면 나중에 더 큰 어려움을 마주하게 되지 않을까? 그러니까 어려운 것부터 먼저 깊게 다뤄보자!' 싶은 마음이었다.
따라서 redux를 next.js(App Route) + typescript 에 적용하게 되는데 ,,,
자 차근차근 적용해보도록 하자.
우선, redux와 관련 패키지를 설치해야 한다.
Redux 설치
npm install @reduxjs/toolkit react-redux
Redux 설정
일단, store를 만든다. 이때 store는 redux의 모든 상태들을 관리하게 된다.
// store.ts
import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./rootReducer";
export const makeStore = () => {
return configureStore({
reducer: rootReducer,
});
};
export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];
slice 생성
slice는 액션 및 리듀서를 포함한다.
import { createSlice } from "@reduxjs/toolkit";
const userAuthSlices = createSlice({
name: "userAuth", // slice 식별
initialState: { name: "subeen" },
reducers: {
setUserAuth: (state, action) => {
state.name = action.payload;
},
},
});
export const { setUserAuth } = userAuthSlices.actions;
export default userAuthSlices.reducer;
이때, name에 따라 slice가 식별되니 직관적이게 작명하도록 유의한다.
Provider 설정
Provider를 설정해 모든 페이지에서 Redux 상태에 접근할 수 있도록 한다.
Next.js App Router 구조에서는 app/layout.tsx 파일에서 설정한다.
"use client";
import React from "react";
import { Provider } from "react-redux";
import { makeStore } from "./lib/store";
const store = makeStore(); // makeStore 함수를 호출하여 스토어 객체를 생성합니다.
const ClientLayout: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
return (
<Provider store={store}>
{/* store 객체를 Provider에 전달합니다. */}
<main>{children}</main>
</Provider>
);
};
export default ClientLayout;
import Footer from "@/components/Footer";
import Header from "@/components/Header";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import ClientLayout from "./ClientLayout";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export const viewport: Viewport = {
initialScale: 1,
width: "device-width",
};
const RootLayout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return (
<html lang="en">
<body className={inter.className}>
<ClientLayout>
<Header />
{children}
</ClientLayout>
<Footer />
</body>
</html>
);
};
export default RootLayout;
위의 코드를 보면 바로 Provider로 감싸는 대신 ClientLayout으로 감쌌는데, 이는 Redux가 클라이언트 컴포넌트에서만 작동하는 훅을 사용해야하기 때문이다. (useSelector, useDispatch)
여기서 잠깐
Redux는 클라이언트 사이드 상태 관리 (브라우저 환경에서 동작하는 상태 관리 도구) 라이브러리이기 때문에 "use client" 지시어가 필요하다.
("use client" 지시어는 초기 페이지 로딩 속도나 사용자 경험에 부정적인 영향을 미칠 가능성이 있다. 번들 크기가 커져 페이지 로드 시간이 증가하는 단점도 있다.)
보통 그래서 next-redux-wrapper 라이브러리를 사용한다.
Redux 사용 예시
컴포넌트에서 redux 상태와 액션 사용 방법이다.
useSelector와 useDispatch 훅을 이용하여 상태를 조회하고, 액션을 디스패치 할 수 있다.
// app/login/page.tsx
'use client';
import { useState } from 'react';
import { useDispatch } from 'react-redux';
import { login } from '../../features/userSlice';
import { useRouter } from 'next/navigation';
const LoginPage = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const dispatch = useDispatch();
const router = useRouter();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
// 실제로는 백엔드에서 로그인 API 호출
const userData = {
id: '123',
name: 'John Doe',
email: email,
};
// 로그인 성공 시 Redux에 사용자 정보를 저장
dispatch(login(userData));
// 대시보드로 리디렉션
router.push('/dashboard');
};
return (
<form onSubmit={handleLogin}>
<div>
<label>Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button type="submit">Login</button>
</form>
);
};
export default LoginPage;
가장 많이 언급되는 불편함: 새로고침 하면 날라가요 ,,,
이를 해결하기 위한 몇가지 방법이 있다.
아. 일단 왜 이런 문제가 발생하냐면, 브라우저를 새로고침 하면 페이지 전체를 다시 로드하기 때문에 Redux 상태가 초기화된다.
첫번째 해결방법) redux-persist 라이브러리 사용
간략한 사용법 설명
npm install redux-persist
// lib/store.ts
import { configureStore } from '@reduxjs/toolkit';
import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage'; // 기본적으로 localStorage 사용
import rootReducer from './rootReducer'; // 모든 리듀서를 합친 rootReducer
const persistConfig = {
key: 'root',
storage, // localStorage에 저장
};
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
});
export const persistor = persistStore(store);
// app/layout.tsx
'use client';
import './globals.css';
import { Provider } from 'react-redux';
import { store, persistor } from '../lib/store';
import { PersistGate } from 'redux-persist/integration/react';
import Header from '../components/Header';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<Header />
<main>{children}</main>
</PersistGate>
</Provider>
</body>
</html>
);
}
redux-persist의 PersistGate를 사용하여, 스토어가 복원되기 전까지 앱을 지연시킬 수 있다.
만약 세션 중에만 상태를 유지하고 싶다면 redux-persist/lib/storage/session을 사용할 수 있다.
단점
두번째 해결방법) localStorage나 sessionStorage 직접 사용
// lib/store.ts
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './rootReducer';
const loadState = () => {
try {
const serializedState = localStorage.getItem('reduxState');
if (serializedState === null) {
return undefined;
}
return JSON.parse(serializedState);
} catch (e) {
console.error('Could not load state', e);
return undefined;
}
};
const saveState = (state: RootState) => {
try {
const serializedState = JSON.stringify(state);
localStorage.setItem('reduxState', serializedState);
} catch (e) {
console.error('Could not save state', e);
}
};
const preloadedState = loadState();
const store = configureStore({
reducer: rootReducer,
preloadedState, // 저장된 상태를 미리 로드
});
store.subscribe(() => {
saveState(store.getState());
});
export default store;
장점
세번째 해결방법) 쿠키 사용
쿠키를 사용하여 상태를 저장할 수 있지만, 쿠키는 브라우저가 서버에 전송하는 용도로 주로 사용되므로 큰 데이터를 저장하는 데는 부적합하다. 그러나 인증 정보나 작은 상태값을 저장하는 데에는 적합.
import { parseCookies, setCookie } from 'nookies'; // nookies 라이브러리 사용
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './rootReducer';
const loadStateFromCookies = () => {
const cookies = parseCookies();
return cookies.reduxState ? JSON.parse(cookies.reduxState) : undefined;
};
const saveStateToCookies = (state: RootState) => {
setCookie(null, 'reduxState', JSON.stringify(state), {
maxAge: 30 * 24 * 60 * 60,
path: '/',
});
};
const preloadedState = loadStateFromCookies();
const store = configureStore({
reducer: rootReducer,
preloadedState,
});
store.subscribe(() => {
saveStateToCookies(store.getState());
});
export default store;
장점
네번째 해결방법) 서버 사이드에서 상태 저장
Next.js의 API Routes를 활용하여 서버에 상태를 저장하고, 필요할 때 API를 통해 불러오는 방법.
// pages/api/saveState.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
// 상태를 데이터베이스나 파일 시스템에 저장
// 예: db.save(req.body)
res.status(200).json({ message: 'State saved' });
} else {
res.status(405).json({ message: 'Method not allowed' });
}
}
새로고침 시 상태가 서버로부터 복원될 수 있도록 useEffect로 API를 호출하여 상태를 불러오고, 저장할 때는 API로 상태를 보낸다.
장점
나는 위 방법들 중에 localStorage 직접 설정을 택했다.
redux-persist 는 나중에 사용해보도록 하자 ~
import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./rootReducer";
const loadState = () => {
try {
const serializedState = localStorage.getItem("reduxState");
if (serializedState === null) {
return undefined;
}
return JSON.parse(serializedState);
} catch (e) {
console.log("Could not load state", e);
return undefined;
}
};
const saveState = (state: RootState) => {
try {
const serializedState = JSON.stringify(state);
localStorage.setItem("reduxState", serializedState);
} catch (e) {
console.error("Could not save state", e);
}
};
const preloadedState = loadState();
export const makeStore = () => {
return configureStore({
reducer: rootReducer,
preloadedState,
});
};
makeStore.subscribe(() => {
saveState(makeStore.getState());
});
export type AppStore = ReturnType<typeof store>;
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];
이런식으로 작성했더니 타입 오류가 났다.
makeStore 함수에 직접적으로 subscribe 메서드를 호출하려고 했기 때문이다.
makeStore 함수는 store를 반환하는 함수이지 store 자체가 아니다.
(makeStore 함수가 호출된 후에 반환된 store 인스턴스를 사용해야 함)
코드를 수정해준 후, 디버깅을 해보았는데 store를 참조하고 있지 않았다.
(그래서 자꾸 데이터가 보존되지 않고 날아감)
각 store는 name에 따라 식별되고 있는데, localStorage에서 가져온 이 데이터가 어느 store에 대한 데이터인지 매칭이 안되고 있었다.
따라서 아래와 같이 수정해주었다.
import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./rootReducer";
const loadState = () => {
try {
const serializedState = localStorage.getItem("reduxState");
if (serializedState === null) {
return undefined;
}
const parsedState = JSON.parse(serializedState);
// 로컬 스토리지의 상태를 rootReducer의 구조에 맞게 변환
return {
userAuth: parsedState, // userAuth로 감싸기
};
} catch (e) {
console.log("Could not load state", e);
return undefined;
}
};
const saveState = (state: RootState) => {
try {
const serializedState = JSON.stringify(state.userAuth); // userAuth만 저장
localStorage.setItem("reduxState", serializedState);
} catch (e) {
console.log("Could not save state", e);
return undefined;
}
};
const preloadedState = loadState();
export const makeStore = () => {
return configureStore({
reducer: rootReducer,
preloadedState,
});
};
// Create store instance
const store = makeStore();
// 아래와 같이 하면 타입 오류가 발생
// const store = configureStore({
// reducer: rootReducer,
// preloadedState
// })
// Subscribe to store updates and save state to localStorage
store.subscribe(() => {
const state = store.getState();
saveState(state);
});
export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];
이상으로 Redux 적용기 1탄을 마무리해보도록 하겠다.
velog 예쁘고 가독성 있게 작성하는거 왜케 어려워 ...