데이터 로딩은 서버 사이드 렌더링을 구현할 때 까다로운 문제 중 하나이다.
이때 데이터 로딩은 API 요청을 의미한다.
서버의 경우 문자열 형태로 렌더링하는 것이므로 state나 리덕스 스토어의 상태가
바뀐다고 해서 자동으로 리렌더링 되지 않는다.
그 대신 renderToString 함수를 한 번 더 호출해야 한다.
거기다 서버에서는 componentDidMount 같은 라이프 사이클 API도 사용 불가능 하다.
SSR의 데이터 로딩을 해결할 수 있는 방법은 많지만,
이번에는 redux-thunk와 redux-saga를 사용해 보자.
1) redux-thunk 코드 준비
여기에서 thunk와 saga, axios를 설치해 주자
npm add redux-thunk redux-saga axios
이번 프로젝트에도 액션 타입, 액션 생성 함수, 리듀서 코드를
한번에 관리하는 Ducks 패턴을 사용해서 리덕스 모듈을 작성해 보자.
API를 통해 얻는 정보는 다음과 같은 모습이다.
// JSONPlaceholder에서 제공되는 API를 호출하여 얻는 데이터
[
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz",
"address": {
"street": "Kulas Light",
"suite": "Apt. 556",
"city": "Gwenborough",
"zipcode": "92998-3874",
"geo": {
"lat": "-37.3159",
"lng": "81.1496"
}
},
"phone": "1-770-736-8031 x56442",
"website": "hildegard.org",
"company": {
"name": "Romaguera-Crona",
"catchPharase": "Multi-layered client-server neural-net",
"bs": "harness real-time e-markers"
}
},
(...)
]
modules/users.js
import axios from 'axios';
// 액션 타입
const GET_USERS_PENDING = 'users/GET_USERS_PENDING';
const GET_USERS_SUCCESS = 'users/GET_USERS_SUCCESS';
const GET_USERS_FAILURE = 'users/GET_USERS_FAILURE';
// 액션 생성 함수
const getUsersPending = () => ({ type: GET_USERS_PENDING });
const getUsersSuccess = payload => ({ type: GET_USERS_SUCCESS, payload });
const getUsersFailure = payload => ({
type: GET_USERS_FAILURE,
error: true,
payload
});
// thunk 함수
// 액션 생성 함수를 이용해 상태를 관리한다.
export const getUsers = () => async dispatch => {
try {
dispatch(getUsersPending()); // 요청 시작과 보류중
const response = await axios.get('https://jsonplaceholder.typicode.com/users');
dispatch(getUsersSuccess(response)); // 성공
} catch (e) {
dispatch(getUsersFailure(e)); // 에러 발생
throw e; // 에러
}
};
// 초기 상태
// loading과 error를 객체 형태로 만든 이유는 redux-saga를 이용한 SSR방법을 연습할 때 하나의 정보를 가져오는 다른 API를 호출할 것이기 때문
// 사용하는 API가 한가지 이상이므로 각 값에 대하여 이름을 지어주기보다는 객체에 넣어준것이다.
const initialState = {
users: null,
user: null,
loading: {
users: false,
user: false
},
error: {
users: null,
user: null
}
};
// 리듀서 함수
function users(state = initialState, action) {
switch (action.type) {
case GET_USERS_PENDING:
return { ...state, loading: { ...state.loading, users: true } };
case GET_USERS_SUCCESS:
return {
...state,
loading: { ...state.loading, users: false },
users: action.payload.data
};
case GET_USERS_FAILURE:
return {
...state,
loading: { ...state.loading, users: false },
error: { ...state.error, users: action.payload }
};
default:
return state;
}
}
export default users;
getUsers 라는 thunk 함수를 만들고, 액션 타입을 생성하여 상태관리를 해준다.
위에서 loading과 error 라는 객체안에 모듈의 상태가 있는데,
이렇게 해준 이유는 이 모듈에서 관리하는 API가 한개 이상이며 각 API에서 얻는 각각의 정보에 이름을 붙여주기 보다
객체안에 묶어주므로써 가독성을 높혀 주었다.
모듈을 작성한 다음 Provider 컴포넌트를 이용해 프로젝트에 리덕스를 적용시키자
modules/index.js
import { combineReducers } from 'redux';
import users from './users';
const rootReducer = combineReducers({ users });
export default rootReducer;
src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { applyMiddleware } from 'redux';
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import rootReducer from './modules';
const store = configureStore(
{
reducer: rootReducer,
middleware: [thunk]
},
);
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
2) Users, UsersContainer 컴포넌트 준비하기
사용자에게 정보를 보여 줄 컴포넌트를 만들자
components/Users.js
import { Link } from 'react-router-dom';
const Users = ({ users }) => {
if (!users) return null; // users가 없다면 아무것도 렌더링 하지 않는다.
return (
<div>
<ul>
{users.map(user => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>{user.username}</Link>
</li>
))}
</ul>
</div>
);
};
export default Users;
이제 UsersContainer를 만들어 보자
import { useEffect } from 'react';
import Users from '../components/Users';
import { connect } from 'react-redux';
import { getUsers } from '../modules/users';
const UsersContainer = ({ users, getUsers }) => {
// 컴포넌트가 마운트되고 나서 호출
useEffect(() => {
if (users) return; // users가 있다면 요청 안함
getUsers();
}, [getUsers, users]);
return <Users users={users} />;
};
export default connect(
state => ({
users: state.users.users
}),
{
getUsers
}
)(UsersContainer);
SSR을 할 때는 이미 있는 정보를 재요청 하지 않게 처리하는 작업이 중요하다.
이 작업을 하지 않으면 데이터가 있음에도 불구하고 브라우저에서 페이지 확인시 불필요한 API를 요청하게 된다.
이렇게 된다면 트래픽 낭비와 사용자 경험에 있어서 불리하다.
컨테이너 컴포넌트 작성이 끝났으면 라우트 설정을 해주자
pages/UsersPage.js
import UsersContainer from '../containers/UsersContainer';
const UsersPage = () => {
return <UsersContainer />
};
export default UsersPage;
App.js
import { Routes, Route } from 'react-router-dom';
import Menu from './components/Menu';
import RedPage from './pages/RedPage';
import BluePage from './pages/BluePage';
import UsersPage from './pages/UsersPage';
function App() {
return (
<div>
<Menu />
<hr />
<Routes>
<Route path="/red" element={<RedPage />} />
<Route path="/blue" element={<BluePage />} />
<Route path="/users" element={<UsersPage />} />
</Routes>
</div>
);
};
export default App;
브라우저에서 /users 경로로 이동할 수 있게 Menu 컴포넌트를 수정하자
components/Menu.js
import { Link } from 'react-router-dom';
const Menu = () => {
return (
<ul>
<li>
<Link to="/red">Red</Link>
</li>
<li>
<Link to="/blue">Blue</Link>
</li>
<li>
<Link to="/users">Users</Link>
</li>
</ul>
);
};
export default Menu;
3) PreloadContext 만들기
현제 getUsers 함수는 UsersContainer의 useEffect 에서 호출된다. 클래스형은 componentDidMount에서 호출한다.
하지만, SSR에서는 useEffect나 componentDidMount에서 설정한 작업이 호출되지 않는다.....
기본적으로 렌더링 전에 API 요청 후 데이터를 스토어에 담아야 한다.
서버 환경에서 이런 작업을 하려면 클래스형 컴포넌트가 지니고 있는 constructor 메서드나 render 함수로 처리한다.
그리고 요청이 끝날때 까지 대기하다 다시 렌더링 해주어야 한다.
✅이번에는 이러한 작업을 PreloadContext를 만들고, 이것을 사용하는 Preloader 컴포넌트를 만들어 처리하자
lib/PreloadContext.js
import { createContext, useContext } from 'react';
// 클라이언트 환경: null
// 서버 환경: { done: false, promises: [] }
const PreloadContext = createContext(null);
export default PreloadContext;
// resolve는 함수 타입이다.
export const Preloader = ({ resolve }) => {
const preloadContext = useContext(PreloadContext);
if (!preloadContext) return null; // constex 값이 유효하지 않다면 아무것도 안함
if (preloadContext.done) return null; // 이미 작업이 끝났으면 아무것도 안함
// promise 배열에 프로미스 등록
// resolve 함수가 프로미스를 반환하지 않더라도, 프로미스 취급을 하기 위해서
// Promise.resolve 함수 사용
preloadContext.promises.push(Promise.resolve(resolve()));
return null;
};
// PreloadContext는 SSR을 하는 과정에서 처리할 작업을 실행하고, 기다려야할 프로미스가 있다면 프로미스를 수집한다.
// 모든 프로미스를 수집하고 나서 수집된 프로미스들이 끝날 때까지 기다렸다가 다시 렌더링하면 데이터가 채워진 상태로
// 컴포넌트들이 나타난다.
// Preloader 컴포넌트는 resolve 함수를 props로 받으며, 컴포넌트가 렌더링될 때 서버 환경에서만 resolve함수를 호출해 준다.
- PreloadContext는 SSR을 하는 과정에서 처리할 작업을 실행하고,
기다려야할 프로미스가 있다면 프로미스를 수집한다. - 모든 프로미스를 수집하고 나서 수집된 프로미스들이 끝날 때까지 기다렸다가
다시 렌더링하면 데이터가 채워진 상태로컴포넌트들이 나타난다. - Preloader 컴포넌트는 resolve 함수를 props로 받으며,
컴포넌트가 렌더링될 때 서버 환경에서만 resolve함수를 호출해 준다.
이제 UsersContainer에서 사용하자
import { useEffect } from 'react';
import Users from '../components/Users';
import { connect } from 'react-redux';
import { getUsers } from '../modules/users';
import { Preloader } from '../lib/PreloadContext';
const UsersContainer = ({ users, getUsers }) => {
// 컴포넌트가 마운트되고 나서 호출
useEffect(() => {
if (users) return; // users가 있다면 요청 안함
getUsers();
}, [getUsers, users]);
return (
<>
<Users users={users} />
<Preloader resolve={getUsers} />
</>
);
};
export default connect(
state => ({
users: state.users.users
}),
{
getUsers
}
)(UsersContainer);
4) 서버에서 리덕스 설정 및 PreloadContext 사용
이제 서버에서 리덕스를 설정해 주자
index.server.js
(...)
import { configureStore } from '@reduxjs/toolkit';
import { applyMiddleware } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import rootReducer from './modules';
import PreloadContext from './lib/PreloadContext';
(...)
// 서버 사이드 렌더링을 처리할 핸들러 함수이다.
const serverRender = async (req, res, next) => {
// 이 함수는 404가 떠야 하는 상황에 404를 띄우지 않고 서버 사이드 렌더링을 해줌
const context = {};
// 리덕스를 server에 설정, 요청이 들어올 때마다 새로운 스토어를 만듬
const store = configureStore(
{ reducer: rootReducer,
middleware: [thunk],
},
);
// PreloadContext를 이용해 프로미스를 수집하고 기다렸다 다시 렌더링하는 작업
const preloadContext = {
done: false,
promises: []
};
const jsx = (
<PreloadContext.Provider value={preloadContext}>
<Provider store={store}>
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
</Provider>
</PreloadContext.Provider>
);
ReactDOMServer.renderToStaticMarkup(jsx); // renderToStaticMarkup으로 한번 렌더링 한다.
try {
await Promise.all(preloadContext.promises); // 모든 프로미스를 기다린다.
} catch (e) {
return res.status(500);
}
preloadContext.done = true;
const root = ReactDOMServer.renderToString(jsx); // 레더링을 함
res.send(createPage(root)); // 클라이언트에게 결과물 응답
};
(...)
이때 주의할 점은 서버가 실행될 때 스토어를 한번만 만드는 것이 아니라,
요청이 들어올 때마다 새로운 스토어를 만드는 것이다.
PreloadContext.Provider 컴포넌트에서 value props를 넣어준다.
그리고 정적인 페이지를 만들때 사용하는 ReactDOMServer의 renderToStaticMarkup을 사용한다.
(renderToStaticMarkup는 HTML DOM 인터랙션을 지원하기 힘들다)
(renderToString대신 renderToStaticMarkup을 사용하는 이유는 처리 속도가 좀더 빠르기 때문)
5) 스크립트로 스토어 초기 상태 주입
지금까지 작성한 코드는 API를 통해 받은 데이터를 렌더링 하지만,
렌더링 과정에서 만들어진 스토어의 상태를 브라우저에서 재사용하지 못하는 상황이다.
서버에서 만들어 준 상태를 재사용하려면, 현재 스토어 상태를 문자열로 변환 후 스크립트로 주입해야 한다.
index.server.js
(...)
function createPage(root, stateScript) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<title>React App</title>
<link href="${manifest.files['main.css']}" rel="stylesheet" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
${root}
</div>
${stateScript}
<script src="${manifest.files['runtime-main.js']}"></script>
${chunks}
<script src="${manifest.files['main.js']}"></script>
</body>
</html>
`;
} // 위의 runtime-main.js 참조는 현제 asset-manifest.json에 없으므로 적용안됨
const app = express();
// 서버 사이드 렌더링을 처리할 핸들러 함수이다.
const serverRender = async (req, res, next) => {
(...)
const root = ReactDOMServer.renderToString(jsx); // 레더링을 함
// JSON을 문자열로 변환하고 악성 스크립트가 실행되는 것을 방지하기 위해 <를 치환처리
// https://redux.js.org/recipes/server-rendering#security-considerations
const stateString = JSON.stringify(store.getState()).replace(/</g, '\\u003c');
const stateScript = `<script>__PRELOADED_STATE__=${stateString}</script>`; // 리덕스 초기 상태를 스크립트로 주입
res.send(createPage(root, stateScript)); // 클라이언트에게 결과물 응답
};
(...)
JSON을 문자열로 변환하고 악성 스크리브가 실행되는 것을 막기위해 <를 치환처리 하고
리덕스 초기 상태를 스크립트로 주입한다.
브라우저에서 상태를 재사용할 때는 다음과 같이 스토어 생성 과정에서
window.__PRELOADED_STATE__를 초깃값으로 사용하면 된다.
index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { applyMiddleware } from 'redux';
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import rootReducer from './modules';
// applyMiddleware는 store를 생성할 때 미들웨어를 적용시켜준다.(미들웨어가 여려개인 경우 파라미터로 여러개 전달, 순서대로 지정됨)
const store = configureStore(
{ reducer: rootReducer },
window.__PRELOADED_STATE__, // 이 값을 초기 상태로 사용함
applyMiddleware(thunk)
);
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
이제 빌드 후 서버를 실행해서 확인해 보면
개발자 도구의 Network 탭의 Response 부분에서 잘 동작하는 지 확인한다.
6) redux-saga 코드 준비
이제 redux-saga를 이용해 SSR을 하는 방법을 알아보자
우선 redux-saga를 설치하자
npm add redux-saga
users 리덕스 모듈에서 redux-saga를 사용하여 특정 사용자의 정보를 가져오는 작업을 관리하자
modules/users.js
import axios from 'axios';
import { call, put, takeEvery } from 'redux-saga/effects';
(...)
// 특정 유저 정보를 가져오는 액션 타입
const GET_USER = 'users/GET_USER';
const GET_USER_SUCCESS = 'users/GET_USER_SUCCESS';
const GET_USER_FAILURE = 'users/GET_USER_FAILURE';
(...)
// 특정 유저 정보를 가져오는 액션 생성 함수
export const getUser = id => ({ type: GET_USER, payload: id });
const getUserSuccess = data => ({ type: GET_USER_SUCCESS, payload: data });
const getUserFailure = error => ({
type: GET_USER_FAILURE,
payload: error,
error: true
});
// thunk 함수
// 액션 생성 함수를 이용해 상태를 관리한다.
export const getUsers = () => async dispatch => {
(...)
};
// 특정 유저의 정보를 가져오도록 API 설정
const getUserById = id => axios.get(`https://jsonplaceholder.typicode.com/users/${id}`);
// 특정 유저 정보를 가져오는 saga함수
function* getUserSaga(action) {
try {
const response = yield call(getUserById, action.payload);
yield put(getUserSuccess(response.data));
} catch (e) {
yield put(getUserFailure(e));
}
}
export function* usersSaga() {
yield takeEvery(GET_USER, getUserSaga);
}
// 초기 상태
// loading과 error를 객체 형태로 만든 이유는 redux-saga를 이용한 SSR방법을 연습할 때 하나의 정보를 가져오는 다른 API를 호출할 것이기 때문
// 사용하는 API가 한가지 이상이므로 각 값에 대하여 이름을 지어주기보다는 객체에 넣어준것이다.
const initialState = {
users: null,
user: null,
loading: {
users: false,
user: false
},
error: {
users: null,
user: null
}
};
// 리듀서 함수
function users(state = initialState, action) {
switch (action.type) {
(...)
case GET_USER:
return {
...state,
loading: { ...state.loading, user: true },
error: { ...state.error, user: null },
};
case GET_USER_SUCCESS:
return {
...state,
loading: { ...state.loading, user: false },
user: action.payload
};
case GET_USER_FAILURE:
return {
...state,
loading: { ...state.loading, user: false },
error: { ...state.error, user: action.payload },
};
default:
return state;
}
}
export default users;
모듈을 수정했다면 리덕스 스토어에 redux-saga를 적용해 보자
우선 루트 사가를 만들자
modules/index.js
import { combineReducers } from 'redux';
import users, { usersSaga } from './users';
import { all } from '@redux-saga/core/effects';
export function* rootSaga() {
yield all([usersSaga()]);
}
const rootReducer = combineReducers({ users });
export default rootReducer;
다음으로 스토어를 생성할 때 미들웨어를 적용하자
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { applyMiddleware } from 'redux';
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import rootReducer, { rootSaga } from './modules';
import createSagaMiddleware from 'redux-saga';
const sagaMiddleware = createSagaMiddleware();
// applyMiddleware는 store를 생성할 때 미들웨어를 적용시켜준다.(미들웨어가 여려개인 경우 파라미터로 여러개 전달, 순서대로 지정됨)
const store = configureStore(
{
reducer: rootReducer,
middleware: [thunk, sagaMiddleware]
},
window.__PRELOADED_STATE__, // 이 값을 초기 상태로 사용함
);
sagaMiddleware.run(rootSaga); // run 으로 등록해 준다
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
7) User, UserContainer 컴포넌트 준비하기
사용자에게 정보를 보여줄 User 컴포넌트를 만들자
components/User.js
const User = ({ user }) => {
const { email, name, username } = user;
return (
<div>
<h1>
{username} ({name})
</h1>
<p>
<b>email:</b> {email}
</p>
</div>
);
};
export default User;
Users 컴포넌트에서 진행했던 유효성 검사(users 값이 null인지 확인하는 검사)는 하지 않았다.
그렇다고 해서 안하는 것은 아니다.
이번에는 컨테이너 컴포넌트에서 진행해 보자
컨테이너 컴포넌트는 API 요청시 사용할 id 값을 props를 통해 받아온다.
이번에는 connect를 사용하지 않고 useSelector와 useDispatch Hooks를 사용해 보자.
containers/UserContainer.js
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import User from '../components/User';
import { Preloader } from '../lib/PreloadContext';
import { getUser } from '../modules/users';
import { useParams } from 'react-router';
const UserContainer = () => {
const { id } = useParams();
const user = useSelector(state => state.users.user);
const dispatch = useDispatch();
useEffect(() => {
if (user && user.id === parseInt(id, 10)) return; // 사용자가 존재하고, id가 일치하면 요청하지 않음
dispatch(getUser(id));
}, [dispatch, id, user]); // id가 바뀔 때 새로 요청해야 함
// 컨테이너 유효성 검사 후 return null 을 해야 하는 경우
// null 대신 Preloader 반환
if (!user) {
return <Preloader resolve={() => dispatch(getUser(id))} />;
}
return <User user={user} />;
};
export default UserContainer;
컨테이너에서 유효성 검사를 할 때 아직 정보가 없을 경우에는 user 값이 null 을 가르키므로,
User 컴포넌트가 렌더링 되지 않도록 컨테이너 컴포넌트에서 null을 반환해야한다.
🚫하지만, SSR을 할 때는 null이 아닌 Preloader 컴포넌트를 렌더링하여 반환한다.
이렇게 하면, SSR을 하는 과정에서 데이터가 없는 경우 GET_USER 액션을 발생시킨다. (재요청)
추가로 중복 요청을 방지하는 과정에서 URL파라미터를 통해 얻는 id값은 문자열이고
user 객체에 있는 id 값은 숫자 형태이기에 비교할 때는 parseInt()를 통해 타입을 통일시켜서 비교해야 한다.
( 엄격한 비교시 타입을 통일화 시켜야 한다 )
컨테이너 컴포넌트를 만들었다면 UsersPage에 렌더링 시켜보자
pages/UsersPage.js
import UsersContainer from '../containers/UsersContainer';
import UserContainer from '../containers/UserContainer';
import { Routes, Route } from 'react-router';
const UsersPage = () => {
return (
<>
<UsersContainer />
<Routes>
<Route
path=":id"
element={<UserContainer/>}
/>
</Routes>
</>
)
};
export default UsersPage;
🚫여기서 조금 어려움을 겪었는데, 책에서는 render 함수를 통해 UserContainer를 렌더링 하려했지만,
버전이 업그레이드 됨에 따라 render와 component는 element로 대체되었다.
그래서 자식 컴포넌트에서 useParams를 통해 id를 가져와 적용시킨 이유도 업그레이드로 인해 벌어진 일이다....ㅠ
8) redux-saga를 위한 SSR 작업
redux-thunk를 사용하면 Preloader를 통해 호출한 함수들이 Promise를 반환하지만,
redux-saga를 사용하면 Promise를 반환하지 않기에 추가 작업이 필요하다.
index.server.js
(...)
import rootReducer, { rootSaga } from './modules';
import createSagaMiddleware from 'redux-saga';
import { END } from '@redux-saga/core';
(...)
// 서버 사이드 렌더링을 처리할 핸들러 함수이다.
const serverRender = async (req, res, next) => {
// 이 함수는 404가 떠야 하는 상황에 404를 띄우지 않고 서버 사이드 렌더링을 해줌
const context = {};
const sagaMiddleware = createSagaMiddleware();
// 리덕스를 server에 설정, 요청이 들어올 때마다 새로운 스토어를 만듬
const store = configureStore(
{
reducer: rootReducer,
middleware: [thunk, sagaMiddleware],
}
);
const sagaPromise = sagaMiddleware.run(rootSaga).toPromise();
// PreloadContext를 이용해 프로미스를 수집하고 기다렸다 다시 렌더링하는 작업
const preloadContext = {
done: false,
promises: []
};
const jsx = (
<PreloadContext.Provider value={preloadContext}>
<Provider store={store}>
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
</Provider>
</PreloadContext.Provider>
);
ReactDOMServer.renderToStaticMarkup(jsx); // renderToStaticMarkup으로 한번 렌더링 한다.
store.dispatch(END); // redux-saga의 END 액션을 발생시키면 액션을 모니터링하는 사가들이 모두 종료됨
try {
await sagaPromise; // 기존에 진행 중이던 사가들이 모두 끝날 때까지 기다린다.
await Promise.all(preloadContext.promises); // 모든 프로미스를 기다린다.
} catch (e) {
return res.status(500);
}
preloadContext.done = true;
const root = ReactDOMServer.renderToString(jsx); // 레더링을 함
// JSON을 문자열로 변환하고 악성 스크립트가 실행되는 것을 방지하기 위해 <를 치환처리
// https://redux.js.org/recipes/server-rendering#security-considerations
// redux 내장 메서드인 getState()로 store에서 현재 상태를 받음
const stateString = JSON.stringify(store.getState()).replace(/</g, '\\u003c'); // replace()는 문자열에서 첫번째 파라미터와 일치하는 부분을 두번째 파라미터로 교체한 문자열을 반환
const stateScript = `<script>__PRELOADED_STATE__=${stateString}</script>`; // 리덕스 초기 상태를 스크립트로 주입
res.send(createPage(root, stateScript)); // 클라이언트에게 결과물 응답
};
(...)
toPromise는 sagaMiddleware.run을 통해 만든 Task를 Promise로 변환한다.
별도의 작업을 하지 않으면 Promise는 끝나지 않는다. ( 만든 루트사가에서 액션을 끝없이 모니터링 하기 때문 )
하지만, redux-saga의 END 라는 액션을 발생 키시면 Promise를 끝낼 수 있다.
END액션이 발생되면 액션 모니터링 작업이 모두 종료되고,
모니터링되기 전에 시작된 getUserSaga와 같은 사가 함수들이 있다면 해당 함수들이 완료되고 Promise가 끝난다.
그리고 이 Promise가 끝나면 리덕스 스토어에는 데이터가 채워진다.
9) usePreloader Hook 만들어 사용
지금까지 만든 컨테이너 컴포넌트에서 Preload 컴포넌트를 사용하여 SSR을 하기전에
데이터가 필요한 상황에 API요청을 했다.
이번에는 usePreloader라는 커스텀 Hook 함수를 만들어 편하게 만들어주자
lib/PreloadContext.js
import { createContext, useContext } from 'react';
// 클라이언트 환경: null
// 서버 환경: { done: false, promises: [] }
const PreloadContext = createContext(null);
export default PreloadContext;
// resolve는 함수 타입이다.
export const Preloader = ({ resolve }) => {
const preloadContext = useContext(PreloadContext);
if (!preloadContext) return null; // constex 값이 유효하지 않다면 아무것도 안함
if (preloadContext.done) return null; // 이미 작업이 끝났으면 아무것도 안함
// promise 배열에 프로미스 등록
// resolve 함수가 프로미스를 반환하지 않더라도, 프로미스 취급을 하기 위해서
// Promise.resolve 함수 사용
preloadContext.promises.push(Promise.resolve(resolve()));
return null;
};
// PreloadContext는 SSR을 하는 과정에서 처리할 작업을 실행하고, 기다려야할 프로미스가 있다면 프로미스를 수집한다.
// 모든 프로미스를 수집하고 나서 수집된 프로미스들이 끝날 때까지 기다렸다가 다시 렌더링하면 데이터가 채워진 상태로
// 컴포넌트들이 나타난다.
// Preloader 컴포넌트는 resolve 함수를 props로 받으며, 컴포넌트가 렌더링될 때 서버 환경에서만 resolve함수를 호출해 준다.
// Hook 형태로 사용할 수 있는 함수
export const usePreloader = resolve => {
const preloaderContext = useContext(PreloadContext);
if (!preloaderContext) return null;
if (preloaderContext.done) return null;
preloaderContext.promises.push(Promise.resolve(resolve()));
};
usePreloader 함수는 Preloader 컴포넌트와 코드가 비슷하다.
이 Hook을 UserContainer에 사용해 보자
contianers/UserContainer.js
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import User from '../components/User';
import { Preloader, usePreloader } from '../lib/PreloadContext';
import { getUser } from '../modules/users';
import { useParams } from 'react-router';
const UserContainer = () => {
const { id } = useParams();
const user = useSelector(state => state.users.user);
const dispatch = useDispatch();
usePreloader(() => dispatch(getUser(id))); // 서버 사이드 렌더링을 할 때 API호출하기
useEffect(() => {
if (user && user.id === parseInt(id, 10)) return; // 사용자가 존재하고, id가 일치하면 요청하지 않음
dispatch(getUser(id));
}, [dispatch, id, user]); // id가 바뀔 때 새로 요청해야 함
// 컨테이너 유효성 검사 후 return null 을 해야 하는 경우
// null 대신 Preloader 반환
if (!user) {
return <Preloader resolve={() => dispatch(getUser(id))} />;
}
if (!user) return null;
return <User user={user} />;
};
export default UserContainer;
코드가 훨씬 간결해 졌다.
함수 컴포넌트에서는 usePreloader Hook을 사용하고,
클래스 컴포넌트에를 사용하면 Preloader컴포넌트를 사용하면된다.
'React' 카테고리의 다른 글
React) React 18의 SSR이 가능해진 React.lazy와 Suspense (0) | 2022.06.03 |
---|---|
React) 20장 서버 사이드 렌더링 (3) SSR과 코드 스플리팅 (0) | 2022.06.02 |
React) 20장 서버 사이드 렌더링 (1) SSR 구현하기 (0) | 2022.05.31 |
React) 19장 코드 스플리팅 ( React.lazy-Suspense, loadable_component ) (0) | 2022.05.23 |
React) 18장 리덕스 미들웨어를 이용한 비동기 작업관리 ( redux-saga ) (0) | 2022.05.21 |