Next.js
+TypeScript
+Redux-Toolkit
+Redux-Saga
์ ์ฉ ๋ฐฉ๋ฒ์ ๋ํ ํฌ์คํธ์ ๋๋ค.
create-next-app
์ ์ด์ฉํด์ ํ๋ก์ ํธ๋ฅผ ์์ฑํ๋ค๊ณ ๊ฐ์ ํ๊ณ ๋๋จธ์ง ์ค์นํ ํจํค์ง์ ๋๋ค.
npm i @reduxjs/toolkit next-redux-wrapper redux-saga react-redux
npm i -D @types/react-redux
Redux
์ ๋ํ ๋ด์ฉ์ ์ฌ๊ธฐ๋ฅผ ์ฐธ๊ณ ํด ์ฃผ์ธ์!
Redux-Toolkit
์ ๊ธฐ๋ณธ์ ์ผ๋ก Redux
๋ฅผ ๋ฒ ์ด์ค๋ก ์ก๊ณ ์ฝ๋ ์์ฑ์ ํธ์์ฑ์ ์ํด์ ์ฌ์ฉํ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์
๋๋ค.
Redux
๋ฅผ ์ฌ์ฉํด๋ดค๋ค๋ฉด ์๊ฒ ์ง๋ง ์ ์ฉํ ๋งํผ ์ฝ๋๋ฅผ ๊ด๋ฆฌํ๊ธฐ ๋ถํธํ๊ณ ๊ท์ฐฎ์ต๋๋ค.
ํ๋์ ์ํ๋ฅผ ๋ง๋ค๊ธฐ ์ํด์๋ ํ์
, ์ก์
, ๋ฆฌ๋์๋ฅผ ์ถ๊ฐ/์์ ์ด ํ์ํ๊ณ ๋ฐ์ดํฐ๋ฅผ ๋ณ๊ฒฝํ ๋๋ ๋ถ๋ณ์ฑ์ ์ง์ผ์ค์ผ ํด์ ์ฝ๊ฒ ๋ณ๊ฒฝํ๊ธฐ๊ฐ ํ๋ญ๋๋ค. ๊ทธ๋ฆฌ๊ณ typescript
๋ฅผ ์ ์ฉํ๋ฉด ๊ฐ๊ฐ ์ฃผ๊ณ ๋ฐ๋ ํ์
์ ์ ์ํ๊ณ ์ ์ ํ ๊ณณ์ ์ ์ฉํด์ค์ผ ํฉ๋๋ค.
๊ธ๋ก ์ ์ด์ ๋ณ๊ฑฐ ์๋ ๊ฒ์ฒ๋ผ ๋ณด์ผ ์ ์์ง๋ง ์ง์ ๋ง๋ค์ด๋ณด๋ฉด ํ๋์ ํ์
์ ์ถ๊ฐํ ๋๋ง๋ค ํ์จ์ด ๋์ฌ ์ ๋๋ก ๋ฐ๋ณต์ ์ธ ์์
์ด๋ผ๊ณ ๋๊ปด์ง๋๋ค.
์ด๋ฐ Redux
์ ๋ฐ๋ณต์ ์ธ ์์
์ ์ด๋ ์ ๋ ํด์ํ๊ธฐ ์ํด์ ์ฌ์ฉํ๋ ๊ฒ Redux-Toolkit
์ด๋ผ๊ณ ์๊ฐํฉ๋๋ค.
์ ์ค์น์์ ๋ณด๋ฉด ์๊ฒ ์ง๋ง, ๊ธฐ๋ณธ์ ์ผ๋ก Redux-Toolkit
์๋ Redux
, immer
, redux-thunk
๋ฑ์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ค์ด ๋ด์ฅ๋์ด ์์ด์ ๊ตณ์ด Redux
๋ฅผ ๋ฐ๋ก ์ค์นํด์ฃผ์ง ์์๋ ๋ฉ๋๋ค.
๊ธฐ์กด
Redux
์ ์ฝ๋๋ฅผ ๊ฐ๋จํ๊ฒ ๋ณด๊ณ ์ถ๋ค๋ฉด ์ฌ๊ธฐ๋ฅผ ์ฐธ๊ณ ํด ์ฃผ์ธ์!
์ ์ฒด ์ฝ๋๋ ๋๋ฌด ๋ง์์ ๊ธฐ๋ณธ ์ธํ ํ ์ ์๊ฒ ๊ฒ์๊ธ๋ค์ ๋ก๋ํ๋ ์ฝ๋๋ง ์ถ๊ฐํ์ต๋๋ค.
/src/configureStore.ts
: ๋ฆฌ๋์, ์ฌ๊ฐ, ๋ฏธ๋ค์จ์ด๋ฅผ ํฉ์ณ ์คํ ์ด๋ฅผ ์์ฑ + ๋ฆฌ๋์ ํ์
์ ์ ์ํ๋ ๊ณต๊ฐreact-redux
์ useSelector()
ํ
์ ์ด์ฉํด์ ๊ฐ์ ธ์ค๋ store
์ ํ์
์ ์๋ฏธ )/src/store/api
: ajax
์์ฒญ ํจ์๋ฅผ ์ ์ํ๋ ๊ณต๊ฐ/src/store/reducers
: ๋ฆฌ๋์๋ค์ ์ ์ํ๊ณ index.ts
์์ rootReducer
๋ฅผ ์ ์ ( createSlice()
์ฌ์ฉ )/src/store/sagas
: ์ฌ๊ฐ๋ค์ ์ ์ํ๊ณ index.ts
์์ rootSaga
๋ฅผ ์ ์/src/store/types
: ๋ฆฌ๋์ค์์ ์ฌ์ฉํ๋ ๋ชจ๋ ํ์
๋ค์ ์ ์ํ๋ ๊ณต๊ฐ ( api
์ ๋ํ ์์ฒญ/์๋ต ํ์
์ ์ )์ก์
ํ์
๊ณผ ์ก์
ํฌ๋ฆฌ์์ดํฐ๋ createSlice()
๋ก ๋ง๋ slice.actions
๋ก ๋์ฒด ( ์์ฑ ๋ฐฉ์์ ์ฐธ๊ณ ํด์ ์๋์ผ๋ก ์ก์
์ ์์ฑํด์ค )
type responseType = {
status: {
ok: boolean,
// ... ์ํ๋ ๊ด๋ จ๋ ๋ฐ์ดํฐ๋ค
},
data: {
message: string;
// ... ์๋ต์ ํ์ํ ๋ฐ์ดํฐ๋ค
}
}
/src/store/types/index.ts
// ์๋ต status ๊ธฐ๋ณธ ํ์
export type ResponseStatus = {
status: {
ok: boolean;
};
};
// ์๋ต ๋ฐ์ดํฐ ๊ธฐ๋ณธ ํ์
export type ResponseData = {
message: string;
};
// ์์ธก๊ฐ๋ฅํ ์คํจ์ธ ๊ฒฝ์ฐ ์๋ต ํ์
( 403, 409 ๋ฑ )
export type ResponseFailure = {
status: { ok: boolean };
data: { message: string };
};
export type { LoadPostsBody, LoadPostsResponse } from "./post";
/src/store/types/post.ts
import type { ResponseData, ResponseStatus } from ".";
// ๋ชจ๋ ๊ฒ์๊ธ๋ค ์ ๋ณด ๋ก๋ ์์ฒญ/์๋ต ํ์
export type LoadPostsBody = {
lastId: number;
limit: number;
};
export type LoadPostsResponse = ResponseStatus & {
data: ResponseData & {
limit: number;
posts: IPostWithPhotoAndCommentAndLikerAndCount[]; // "IPostWithPhotoAndCommentAndLikerAndCount"๋ ๊ฒ์๊ธ ํ์
};
};
/src/store/reducers/index.ts
import { HYDRATE } from "next-redux-wrapper";
import { combineReducers } from "@reduxjs/toolkit";
import type { AnyAction, CombinedState } from "@reduxjs/toolkit";
// reducers ( ๋๋จธ์ง ๋ฆฌ๋์๋ ์๋ค๊ณ ๊ฐ์ )
import authReducer, { AuthStateType } from "./authReducer";
import userReducer, { UserStateType } from "./userReducer";
import postReducer, { PostStateType } from "./postReducer";
import chatReducer, { ChatStateType } from "./chatReducer";
// actions ( ํ๋์ ํ์ผ์์ import ํด์ฃผ๊ธฐ ์ํด์ export ~ from ์ฌ์ฉ )
export { authActions } from "./authReducer";
export { userActions } from "./userReducer";
export { postActions } from "./postReducer";
export { chatActions } from "./chatReducer";
type ReducerState = {
auth: AuthStateType;
post: PostStateType;
user: UserStateType;
chat: ChatStateType;
};
// ์๋ "rootReducer"๋ก ํฉ์ณ์ค ํ์ ์์ด "configureStore()"์์ ํฉ์น ์ ์์ง๋ง "HYDRATE"๋ฅผ ์ํด์ ์ฌ์ฉ
const rootReducer = (state: any, action: AnyAction): CombinedState<ReducerState> => {
switch (action.type) {
// SSR์ ์ํด์ ์ฌ์ฉ ( "next.js"์ "getServerSideProps()" )
case HYDRATE:
return {
...state,
...action.payload,
};
default:
return combineReducers({
auth: authReducer,
user: userReducer,
post: postReducer,
chat: chatReducer,
})(state, action);
}
};
export default rootReducer;
/src/store/reducers/postReducer.ts
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { LoadPostsBody, LoadPostsResponse } from "@src/store/types";
export type PostStateType = {
posts: IPostWithPhotoAndCommentAndLikerAndCount[]; // "IPostWithPhotoAndCommentAndLikerAndCount"๋ ๊ฒ์๊ธ ํ์
loadPostsLoading: boolean;
loadPostsDone: null | string;
loadPostsError: null | string;
};
const initialState: PostStateType = {
// ๋ชจ๋ ๊ฒ์๊ธ๋ค์ ์ ๋ณด๋ฅผ ์ ์ฅํ ๋ณ์
posts: [],
// ๋ชจ๋ ๊ฒ์๊ธ๋ค ์์ฒญ ๊ด๋ จ ๋ณ์
loadPostsLoading: false,
loadPostsDone: null,
loadPostsError: null,
};
/**
* "createSlice()"๋ ์ก์
ํ์
, ์ก์
ํฌ๋ฆฌ์์ดํฐ, ๋ฆฌ๋์๋ฅผ ํ ๋ฒ์ ๋ง๋๋ ํจ์์
๋๋ค.
* name: ์ ๋ํฌํ ์ก์
์ ๋ง๋ค ๋ ์ฌ์ฉ
* initialState: ์ต์ด ์ํ
* reducers: ๋ฆฌ๋์๋ค์ ์ ์
* PayloadAction๋ก ์ธ์์ ํ์
์ ์ ์ํด์ฃผ๋ฉด ์๋์์ฑ ์ง์๋จ
*/
const postSlice = createSlice({
name: "post",
initialState,
reducers: {
// ๋ชจ๋ ๊ฒ์๊ธ๋ค ํจ์น
loadPostsRequest(state, action: PayloadAction<LoadPostsBody>) {
state.loadPostsLoading = true;
state.loadPostsDone = null;
state.loadPostsError = null;
},
loadPostsSuccess(state, action: PayloadAction<LoadPostsResponse>) {
state.loadPostsLoading = false;
state.loadPostsDone = action.payload.data.message;
// ์ฌ๊ธฐ์๋ "immer"๊ฐ ์ ์ฉ๋๊ธฐ ๋๋ฌธ์ ๋ถ๋ณ์ฑ์ ์งํค์ง ์์๋ ๋จ
// ํ์ง๋ง ์๋์ฒ๋ผ ๋ถ๋ณ์ฑ ์งํค๋๊ฒ ์ฝ๋๊ฐ ๋ ๊ฐ๋จํด๋ณด์ฌ์ ์ด๋ ๊ฒ ์์ฑํจ
state.posts = [...state.posts, ...action.payload.data.posts];
state.hasMorePosts = action.payload.data.posts.length === action.payload.data.limit;
},
loadPostsFailure(state, action: PayloadAction<ResponseFailure>) {
state.loadPostsLoading = false;
state.loadPostsError = action.payload.data.message;
},
},
});
// ์ก์
ํ์
๊ณผ ์ก์
ํฌ๋ฆฌ์์ดํฐ ๋์ ์ฌ์ฉ ( "dispatch()"์์ ์ฌ์ฉ => ex) dispatch(postActions.loadPostsRequest({ lastId: 0, limit: 10 })) )
export const postActions = postSlice.actions;
// RootReducer ์์ฑ ์ ์ฌ์ฉ
export default postSlice.reducer;
/src/store/api/index.ts
import axios from "axios";
export const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_SERVER_URL + "/api",
withCredentials: true,
timeout: 10000,
});
export { apiLoadPosts } from "./post";
/src/store/api/post.ts
import { axiosInstance } from ".";
import type { LoadPostsBody, LoadPostsResponse } from "@src/store/types";
// ๋ชจ๋ ๊ฒ์๊ธ๋ค ์์ฒญ
export const apiLoadPosts = ({ lastId, limit }: LoadPostsBody) =>
axiosInstance.get<LoadPostsResponse>(`/posts?lastId=${lastId}&limit=${limit}`);
/src/store/sagas/index.ts
import { all, fork } from "redux-saga/effects";
// ๋๋จธ์ง ์ฌ๊ฐ๋ ์๋ค๊ณ ๊ฐ์
import authSaga from "./authSaga";
import userSaga from "./userSaga";
import postSaga from "./postSaga";
import chatSaga from "./chatSaga";
export default function* rootSaga() {
yield all([fork(authSaga), fork(userSaga), fork(postSaga), fork(chatSaga)]);
}
/src/store/sagas/post.ts
import { all, call, fork, put, takeLatest } from "redux-saga/effects";
// action
import { postActions } from "@src/store/reducers";
// types
import type { AxiosResponse } from "axios";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { LoadPostsResponse, LoadPostsBody } from "@src/store/types";
// api
import { apiLoadPosts } from "@src/store/api";
function* loadPosts(action: PayloadAction<LoadPostsBody>) {
try {
// api ์์ฒญ ๋ฐ ์๋ต ๋๊ธฐ
const { data }: AxiosResponse<LoadPostsResponse> = yield call(apiLoadPosts, action.payload);
// ์ฑ๊ณตํ ์ก์
๋์คํจ์น
yield put(postActions.loadPostsSuccess(data));
} catch (error: any) {
console.error("postSaga loadPosts >> ", error);
// "AxiosError"๋ผ๋ฉด ์์ธกํ ์๋ฌ๋ผ์ ๋ฐฑ์๋๋ก๋ถํฐ ์ ์ก๋ ๋ฉ์์ง๋ก ์๋ตํ๊ณ ์๋๋ผ๋ฉด ์ ์ ์๋ ์๋ฒ ์ธก ์๋ฌ๋ผ๋ ๋ฉ์์ง ์๋ต
// ํ๋ก์ฐํ ์ ์ ๋ฅผ ๋ค์ ํ๋ก์ฐ, ์ข์์ ๋๋ฅธ ๊ฒ์๊ธ์ ๋ค์ ์ข์์ ๋๋ฅด๋ ๊ฒฝ์ฐ ๊ฐ์ ๊ฒฝ์ฐ "409"๋ก ์๋ตํ๋๋ฐ "axios"์์ "2xx"๊ฐ ์๋๋ฉด ์๋ฌ๋ก ์ฒ๋ฆฌํจ
const message = (error?.name === "AxiosError") ? error.response.data.data.message : "์๋ฒ์ธก ์๋ฌ์
๋๋ค. \n์ ์ํ์ ๋ค์ ์๋ํด์ฃผ์ธ์";
// ์คํจํ ์ก์
๋์คํจ์น
yield put(postActions.loadPostsFailure({ status: { ok: false }, data: { message } }));
}
}
function* watchLoadPosts() {
// "postActions.loadPostsRequest"์ ์์ฒญ์ด ์ค๋ฉด "loadPosts()" ์คํ
yield takeLatest(postActions.loadPostsRequest, loadPosts);
}
export default function* postSaga() {
yield all([fork(watchLoadPosts)]);
}
/src/store/configureStore.ts
import createSagaMiddleware from "redux-saga";
import { createWrapper } from "next-redux-wrapper";
import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./reducers";
import rootSaga from "./sagas";
const createStore = () => {
const sagaMiddleware = createSagaMiddleware();
const middlewares = [sagaMiddleware];
const store = configureStore({
reducer: rootReducer,
middleware: middlewares,
devTools: process.env.NEXT_PUBLIC_NODE_ENV === "development",
});
// ์ฌ๊ธฐ์ type ์๋ฌ ๋ฐ์ ์ "redux.d.ts" ์ ์ ํ์
store.sagaTask = sagaMiddleware.run(rootSaga);
return store;
};
const wrapper = createWrapper(createStore, {
debug: process.env.NEXT_PUBLIC_NODE_ENV === "development",
});
const store = createStore();
// "useSelector()"์์ ์ฌ์ฉํ๋ ํ์
export type RootState = ReturnType<typeof store.getState>;
// "_app.ts"์์ "wrapper.withRedux()"๋ก ๊ฐ์ธ์ฃผ๋ฉด ๋จ
export default wrapper;
import { useDispatch, useSelector } from "react-redux";
// redux + server-side-rendering
import wrapper from "@src/store/configureStore";
import { END } from "redux-saga";
import { axiosInstance } from "@src/store/api";
// actions
import { postActions } from "@src/store/reducers";
// type
import type { GetServerSideProps, GetServerSidePropsContext, NextPage } from "next";
import type { RootState } from "@src/store/configureStore";
// redux์ ๊ด๋ จ ์๋ ์ฝ๋ ์๋ต
const Home: NextPage = () => {
const dispatch = useDispatch();
const { posts, hasMorePosts, loadPostsLoading } = useSelector(({ post }: RootState) => post);
// ์์๋ก ๋ง๋ ์์
const onClick = useCallback(() => {
dispatch(postActions.loadPostsRequest({ lastId: -1, limit: 15 }));
}, [dispatch])
return (<></>);
};
export const getServerSideProps: GetServerSideProps =
wrapper.getServerSideProps(
(store) => async (context: GetServerSidePropsContext) => {
/**
* front-server์ backend-server๊ฐ ์๋ก ๋ค๋ฅด๊ธฐ๋๋ฌธ์
* axios์ "withCredentials" ์ต์
์ผ๋ก ๋ธ๋ผ์ฐ์ ์ ์ฟ ํค๋ฅผ ์ ๋ฌํ ์ ์์
* ๋ฐ๋ผ์ ์ง์ axios์ ์ฟ ํค๋ฅผ ๋ฃ์ด์ฃผ๊ณ ์๋ฒ๋ก ์์ฒญ ํ ๋ค์ axios์ ์ฟ ํค๋ฅผ ์ ๊ฑฐํด์ฃผ๋ ๊ณผ์ ์ ๊ฑฐ์นจ
* ํด๋ผ์ด์ธํธ๋ ์ฌ๋ฌ ๋์ง๋ง ์๋ฒ๋ ํ๋์ด๊ธฐ ๋๋ฌธ์ ์๋ฒ ์ฌ์ฉํ ์ฟ ํค๋ ๋ฐ๋์ ์ ๊ฑฐํด ์ค์ผ ํจ
*/
let cookie = context.req?.headers?.cookie;
cookie = cookie ? cookie : "";
axiosInstance.defaults.headers.Cookie = cookie;
// ์๋ฒ ์ฌ์ด๋์์ dispatchํ ๋ด์ฉ์ ์ ์ด์ค
store.dispatch(postActions.loadPostsRequest({ lastId: -1, limit: 15 }));
// ๋ฐ์ ๋ ๊ฐ๋ REQUEST์ดํ SUCCESS๊ฐ ๋ ๋๊น์ง ๊ธฐ๋ค๋ ค์ฃผ๊ฒ ํด์ฃผ๋ ์ฝ๋
store.dispatch(END);
await store.sagaTask?.toPromise();
// ์์์ ๋งํ๋๋ก axios์ ์ฟ ํค ์ ๊ฑฐ
axiosInstance.defaults.headers.Cookie = "";
// ์์ ์์
๋ค์ด ์ ์์๋์ ํ๋ค๋ฉด ํด๋ผ์ด์ธํธ์์ ๋ ๋๋งํ ๋ ์ด๋ฏธ redux์ store์ ๋ฐ์ดํฐ๊ฐ ๋ค์ด๊ฐ ์์
// ๋ฐ๋ผ์ props์ ๋ฐ์ดํฐ๋ฅผ ์ ๋ฌํ ํ์ ์์
return {
props: {},
};
}
);
export default Home;
๊ฐ์ํํ ์์ ์ฝ๋๋ ๋๋ฌด ๋ง์ง๋ง, ๊ธฐ์กด์ Redux
๋ง ์ฌ์ฉํ ๋๋ณด๋ค๋ ์ฝ๋์์ด ์ค์์ต๋๋ค. ( ๊ธฐ์กด ๊ฒ์๊ธ ๋ฆฌ๋์ -> 1130์ค์์ 890์ค )
๋ฌผ๋ก ๊ธฐ์กด์๋ ๋ถ๋ณ์ฑ์ ์งํค๋ฉด์ ๋ง๋ค์ด๋ณด๊ณ ์ต์ํด์ง๋ฉด immer
๋ฅผ ์ฌ์ฉํด์ผ ํ๋ค๋ ๋ง์ธ๋ ๋๋ฌธ์ ๋ถ๋ณ์ฑ์ ์งํค๋๋ผ ์ฝ๋๊ฐ ๋ง์ด ๊ธธ์ด์ง ์ด์ ๋ ์์ต๋๋ค.
๊ทธ๋ฆฌ๊ณ Action
๊ด๋ จํ ์ฝ๋๋ฅผ ์ ๊ฒฝ ์ธ ํ์ ์๋ค๋ ๊ฒ๋ ์ฅ์ ์ด์ง๋ง ๊ฐ์ฅ ํฐ ์ฅ์ ์ TypeScript
์์ ํธํ์ฑ์ด๋ผ๊ณ ๋๊ผ์ต๋๋ค.
๊ธฐ์กด์ ์ ๋๋ก ์ ์ฉํ์ง ๋ชปํ ๊ฒ์ผ ์ ์์ง๋ง reducer
, saga
์์ ํ์
์ด ์ ์ฉ์ด ์ ๋๊ธฐ ๋๋ฌธ์ ๋ถํธํ๊ณ , ์ด๋๊ฐ ๋ง์ถฐ์ง์ง ์๋ ๋๋์ด ๋ค์์ต๋๋ค.
ํ์ง๋ง Redux-Toolkit
์ ์ฌ์ฉํ๋ฉด์ ํ์
์ด ์ ๋๋ก ์ ์ฉ๋๋ ์ ์ด ๋งค์ฐ ์ข์์ต๋๋ค. ๊ฐ์ธ์ ์ผ๋ก React.js
์ Redux
๋ฅผ ์ฌ์ฉํ ์ ๋๋ก ๊ณต๋ถํ๋ค๋ฉด ์ด๋ฏธ TypeScript
๋ฅผ ์ฌ์ฉํ ์ ์๋ ๋ฅ๋ ฅ์ด ์๋ค๊ณ ์๊ฐํ๊ณ ์ด์ Redux
๋ฅผ ์ฌ์ฉํ๋ ๊น์ Redux-Toolkit
๊ณผ TypeScript
๋ฅผ ์ฌ์ฉํ๋ ๊ฒ ๋ฏธ๋์ ๋ณธ์ธ์๊ฒ๋ ๋ ์ด์ ์ด ๋์ง ์์๊น ์๊ฐํฉ๋๋ค.