고양이와 코딩
[React] - 서버 사이드 렌더링에서의 데이터 로딩 (Redux- thunk) 본문
일반 브라우저 환경에서의 데이터 로딩(API 요청)은 API를 요청 하고, 응답을 받아 리액트 state 혹은 리덕스 스토어에 넣으면
자동으로 데이터를 리렌더링 해주므로 편리합니다!
하지만 서버의 경우 문자열 형태로 렌더링 하는 것이므로 state나 리덕스 스토어의 상태가 바뀐다고 해서 자동으로 리렌더링 되지 않습니다.
Redux-thunk를 사용하여 서버 사이드 렌더링 시 데이터 로딩 하는 방법 १✌˚◡˚✌५
1. 액션 타입 및 액션 크리에이터 정의
// 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
});
2. Thunk 를 이용한 비동기 액션 생성
// users.js
// Thunk를 이용한 비동기 액션 생성
export const getUsers = () => async dispatch => {
try {
dispatch(getUsersPending());
const response = await axios.get(
'https://jsonplaceholder.typicode.com/users'
);
dispatch(getUsersSuccess(response.data));
} catch(e) {
dispatch(getUsersFailure(e));
throw e;
}
};
3. 리듀서 정의
// users.js
// 초기 상태 정의
const initialState = {
users: null,
loading: false,
error: null
};
// 리듀서 정의
const users = (state = initialState, action) => {
switch (action.type) {
case GET_USERS_PENDING:
return { ...state, loading: true };
case GET_USERS_SUCCESS:
return {
...state,
loading: false,
users: action.payload
};
case GET_USERS_FAILURE:
return {
...state,
loading: false,
error: action.payload
};
default:
return state;
}
};
export default users;
현재 작성된 모듈은 액션 타입, 액션 생성 함수, 리듀서 코드를 한 파일에 넣어서 관리하는 Ducks 패턴을 사용한 것입니다!
getUsers라는 thunk 함수를 만든 후, 이와 관련된 액션 GET_USERS_PENDING... 등을 사용해 상태 관리를 해 주고 있어요
모듈의 상태에는 loading과 error라는 객체가 들어 있는데요, 이 모듈에서 관리하는 API가 한 개 이상이므로 loadingUsers, loadingUser와 같이 각 값에 하나하나 이름을 지어 주는 대신 loading이라는 객체에 넣어 준 것입니다!
4. 컴포넌트에서 Thunk 액션 디스패치
// UsersContainer.js
import { useEffect } from "react";
import Users from '../components/Users';
import { useDispatch, useSelector } from "react-redux";
import { getUsers } from "../modules/users";
const UsersContainer = () => {
const users = useSelector((state) => state.users.users);
const dispatch = useDispatch();
useEffect(() => {
if(!users) {
dispatch(getUsers());
}
}, [dispatch, users]);
return <Users users={users} />;
};
export default UsersContainer;
5. 사용자 목록 렌더링
// Users.js
import { Link } from "react-router-dom";
const Users = ({ users }) => {
if (!users) return null;
return (
<div>
<ul>
{users.map(user => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>{user.username}</Link>
</li>
))}
</ul>
</div>
);
};
export default Users;
현재까지의 getUsers 함수는 UsersContainer의 useEffect 부분에서 호출됩니다! 하지만 서버 사이드 렌더링을 할 때는
useEffect나 componentDidMount에서 설정한 작업이 호출되지 않습니다. 렌더링하기 전에 API를 요청한 뒤, 스토어에 데이터를 담아야 합니다!
이 작업을 PreloadContext를 만들고, 이를 사용하는 Preloader 컴포넌트를 만들어서 처리해봅시다 😺
import {createContext, useContext} from 'react';
// 클라이언트 환경: null
// 서버 환경 : {done : false, promises: []}
const PreloadContext = createContext(null);
export default PreloadContext;
export const Preloader = ({resolve}) => {
const PreloadContext = useContext(PreloadContext);
if(!PreloadContext) return null; // context 값이 유효하지 않다며 아무것도 하지않음
if (PreloadContext.done) return null; // 이미 작업이 끝났다면 아무것도 하지 않음
// promises 배열에 프로미스 등록
// 설령 resolve 함수가 프로미스를 반환하지 않더라도, 프로미스 취급을 하기 위해
// Promise.resolve 함수 사용
PreloadContext.promises.push(Promise.resolve(resolve()));
return null;
}
preloadContext는 이름처럼 데이터를 미리 로딩하고 관리하기 위한 Context 인데요, 클라이언트와 서버 환경에서
각각 다른 값을 갖도록 설계되어 있습니다!
클라이언트 환경에서는 'null', 서버 환경에서는 {done: false, promises : [] } 와 같은 초기값을 갖습니다
preloadContext는 서버 사이드 렌더링 과정에서 처리해야 할 작업들을 실행하고, 기다려야 할 프로미스가 있다면 프로미스를 수집하고,
이 작업을 반복해서 모든 프로미스를 수집하고, 수집된 프로미스들이 끝날 때까지 기다렸다가 그 다음에 다시 렌더링 하면 데이터가
채워진 상태로 컴포넌트들이 나타납니다!
Preloader 컴포넌트는 resolve라는 함수를 props로 받아 오며, 컴포넌트가 렌더링 될 때 서버 환경에서만 resolve 함수를 호출 해줍니다.
// index.server.js
import ReactDOMServer from 'react-dom/server';
import express from 'express';
import { StaticRouter } from 'react-router-dom/server';
import React from 'react'; // React import 추가
import App from './App';
import path from 'path';
import fs from 'fs';
import { legacy_createStore as createStore, applyMiddleware} from 'redux';
import ReactDOMServer from 'react-dom/server';
import express from 'express';
import { StaticRouter } from 'react-router-dom/server';
import React from 'react'; // React import 추가
import App from './App';
import path from 'path';
import fs from 'fs';
import { legacy_createStore as createStore, applyMiddleware} from 'redux';
import { Provider } from 'react-redux';
import {thunk} from 'redux-thunk';
import rootReducer from './modules';
import PreloadContext from './lib/PreloadContext';
// asset-manifest.json 에서 파일 경로들을 조회합니다.
const manifest = JSON.parse(
fs.readFileSync(path.resolve('./build/asset-manifest.json'), 'utf8')
);
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['main.js']}"></script>
</body>
</html>`;
}
const app = express();
// 서버 사이드 렌더링을 처리할 핸들러 함수입니다.
const serverRender = async (req, res, next) => {
const context = {};
const store = createStore(rootReducer, applyMiddleware(thunk));
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);
try {
await Promise.all(preloadContext.promises);
} catch(e) {
return res.status(500);
}
preloadContext.done = true;
const root = ReactDOMServer.renderToString(jsx);
const stateString = JSON.stringify(store.getState()).replace(/</g, '\\u003c');
const stateScript = `<script>_PRELOADED_STATE__=${stateString}</script>` // 리덕스
res.send(createPage(root, stateScript));
};
const serve = express.static(path.resolve('./build'), {
index: false, // "/" 경로에서 index.html을 보여주지 않도록 설정
});
app.use(serve);
app.use(serverRender);
// 2000 포트로 서버를 가동합니다.
app.listen(2000, () => {
console.log('Running on http://localhost:2000');
});
지금까지의 코드 API를 통해 받아 온 데이터를 렌더링 하지만, 렌더링하는 과정에서 만들어진 스토어의 상태를
브라우저에서 재사용 하지 못하는 상태입니다. 이 상태를 브라우저에서 재사용 하려면,
현재 스토어 상태를 문자열로 변환한뒤 스크립트로 주입 해 주어야 합니다!
index.server.js
function createPage(root, stateScript) { //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['main.js']}"></script>
</body>
</html>`;
}
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
import {legacy_createStore as createStore, applyMiddleware} from 'redux';
import { Provider } from 'react-redux';
import {thunk} from 'redux-thunk';
import rootReducer from './modules';
const store = createStore(
rootReducer,
window.__PRELOADED_STATE__, // 이 값을 초기 상태로 사용
applyMiddleware(thunk));
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
);
yarn build
yarn build:server
yarn start:server
이렇게 Redux-thunk를 사용해 API를 호출하면 컴포넌트가 클라이언트가 닿기 전에 데이터가 미리 클라이언트에 전달 되므로
초기 로딩 시간을 줄여주고, 사용자로 하여금 더 빠른 첫 렌더링을 경험 할 수 있습니다!
redux-saga를 사용한 서버 사이드 렌더링의 경우도 해 보고, 차이점을 포스팅 해 보도록 할게요 🍀
'react' 카테고리의 다른 글
[React] - TypeScript : Record 유틸리티 타입 (0) | 2024.02.25 |
---|---|
코드 스니펫을 사용해보자 ! (snippet-generator) (2) | 2024.01.31 |
[React] 코드 스플리팅 (React.lazy & Suspense , Loadable Compononent) (0) | 2023.12.22 |
[React] Redux 미들웨어 (0) | 2023.12.11 |
[React] useActions로 액션 관리하기 (0) | 2023.12.09 |