관리 메뉴

거니의 velog

(5) 서버 사이드 렌더링 5 본문

React_백엔드 프로그래밍

(5) 서버 사이드 렌더링 5

Unlimited00 2023. 12. 16. 16:34

(6)  redux-saga 코드 준비하기

* 이번에는 redux-saga를 사용하는 경우 서버 사이드 렌더링을 어떻게 해야 하는지 알아보자.

* yarn으로 redux-saga를 설치하자.

$ yarn add redux-saga

* users 리덕스 모듈에서 redux-saga를 사용하여 특정 사용자의 정보를 가져오는 작업을 관리해 보자.

[modules/users.js]

import axios from "axios";
import { call, put, takeEvery } from "redux-saga/effects";

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 GET_USER = "users/GET_USER";
const GET_USER_SUCCESS = "users/GET_USER_SUCCESS";
const GET_USER_FAILURE = "users/GET_USER_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,
});

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,
});

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;
  }
};

const getUserById = (id) =>
  axios.get(`https://jsonplaceholder.typicode.com/users/${id}`);

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);
}

const initialState = {
  users: 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 },
        error: { ...state.error, users: null },
      };
    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 },
      };
    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/effects';

export function* rootSaga() {
  yield all([usersSaga()]);
}

const rootReducer = combineReducers({ users });
export default rootReducer;

* 다음으로 스토어를 생성할 때 미들웨어를 적용하자.

[src/index.js]

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
import { BrowserRouter } from "react-router-dom";
import { createStore, applyMiddleware } from "redux";
import { Provider } from "react-redux";
import thunk from "redux-thunk";
import createSagaMiddleware from "redux-saga";
import rootReducer, { rootSaga } from "./modules";
import { loadableReady } from "@loadable/component";

const sagaMiddleware = createSagaMiddleware();

const store = createStore(
  rootReducer,
  window.__PRELOADED_STATE__, // 이 값을 초기상태로 사용함
  applyMiddleware(thunk, sagaMiddleware)
);

sagaMiddleware.run(rootSaga);

// 같은 내용을 쉽게 재사용 할 수 있도록 렌더링 할 내용을 하나의 컴포넌트로 묶음
const Root = () => {
  return (
    <Provider store={store}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </Provider>
  );
};

const root = document.getElementById("root");

// 프로덕션 환경 에서는 loadableReady 와 hydrate 를 사용하고
// 개발 환경에서는 기존 하던 방식으로 처리
if (process.env.NODE_ENV === "production") {
  loadableReady(() => {
    ReactDOM.hydrate(<Root />, root);
  });
} else {
  ReactDOM.render(<Root />, root);
}

serviceWorker.unregister();

(7) User, UserContainer 컴포넌트 준비하기

* 이제 특정 사용자의 정보를 보여 줄 User 컴포넌트를 만들어 보겠다.

[components/User.js]

import React from 'react';

const User = ({ user }) => {
  const { email, name, username } = user;
  return (
    <div>
      <h1>
        {username} ({name})
      </h1>
      <p>
        <b>e-mail:</b> {email}
      </p>
    </div>
  );
};

export default User;

* 이전에 만들었던 Users 컴포넌트에서는 users의 값이 null 인지 배열인지 확인하는 유효성 검사를 해 주었던 반면, 위 User 컴포넌트에서는 user 값이 null인지 객체인지 확인하는 유효성 검사를 해 주지 않았다. 컨테이너 컴포넌트에서 유효성 검사를 할 때는 어떻게 해야 하는지 알아보자.

* 컨테이너 컴포넌트를 다음과 같이 만들어 보자. API를 요청할 때 사용할 id 값은 props를 통해 받아 오자. 이번에는 connect 함수를 사용하지 말고, useSelector와 useDispatch Hooks를 사용할 것이다.

[containers/UserContainer.js]

import React, { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import User from "../components/User";
import { usePreloader } from "../lib/PreloaderContext";
import { getUser } from "../modules/users";

const UserContainer = ({ id }) => {
  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을 반환해 주어야 한다.

* 하지만 이번에는 서버 사이드 렌더링을 해야 하기 때문에 null 이 아닌 Provider 컴포넌트를 렌더링하여 반환할 것이다. 이렇게 해 주면 서버 사이드 렌더링을 하는 과정에서 데이터가 없을 경우 GET_USER 액션을 발생시킨다.

* 추가로 중복 요청을 방지하는 과정에서 user 값이 존재하는지 확인하고, id가 일치하는지도 확인했다. id 값은 추후 URL 파라미터를 통해 받아 오기 때문에 문자열로 이루어져 있다. 반면 user 객체 안에 들어 있는 id는 숫자 형태이다. 그러므로 이 두 값을 비교할 때는 props로 받아온 id 값을 parseInt를 사용하여 숫자로 변환해 준 다음에 비교해야 한다.

* 컨테이너 컴포넌트를 다 만들었다면 UsersPage 컴포넌트에서 렌더링해 보자.

[pages/UsersPage.js]

import React from "react";
import UsersContainer from "../containers/UsersContainer";
import UserContainer from "../containers/UserContainer";
import { Route } from "react-router-dom";

const UsersPage = () => {
  return (
    <>
      <UsersContainer />
      <Route
        path="/users/:id"
        render={({ match }) => <UserContainer id={match.params.id} />}
      />
    </>
  );
};

export default UsersPage;

* 여기서는 Route에 component 대신 render를 설정해 줌으로써 UserContainer를 렌더링할 때 URL 파라미터 id를 props로 바로 집어넣어 줄 수 있다.

* 이제 브라우저에서 /users 페이지에 있는 사용자 링크를 클릭하거나, /users/1 경로에 직접 들어가 보자.

사용자 정보 렌더링

* 위 이미지와 같이 사용자 정보가 렌더링되었는가?


(8) redux-saga를 위한 서버 사이드 렌더링 작업

* redux-thunk를 사용하면 Preloader를 통해 호출한 함수들이 Promise를 반환하지만,  redux-saga를 사용하면 Promise를 반환하지 않기 때문에 추가 작업이 필요하다.

* 우선 서버 사이드 렌더링을 위한 엔트리 파일에 redux-saga 미들웨어를 적용하자.

[index.server.js]

import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
import { StaticRouter } from "react-router-dom";
import App from "./App";
import path from "path";
import fs from "fs";
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import PreloadContext from './lib/PreloadContext';

import createSagaMiddleware from 'redux-saga';
import rootReducer, { rootSaga } from './modules';

// asset-manifest.json에서 파일 경로들을 조회한다.
const manifest = JSON.parse(
  fs.readFileSync(path.resolve("./build/asset-manifest.json"), "utf8")
);

const chunks = Object.keys(manifest.files)
  .filter((key) => /chunk\.js$/.exec(key)) // chunk.js로 끝나는 키를 찾아서
  .map((key) => `<script src="${manifest.files[key]}"></script>`) // 스크립트 태그로 변환하고
  .join(""); // 합침

function createPage(root, stateScript) {
  return `
    <!DOCTYPE html>
    <html lang="ko">
    <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" context="#000" />
      <title>React App</title>
      <link href="${manifest.files["main.css"]}" />
    </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>
  `;
}
const app = express();

// 서버 사이드 렌더링을 처리할 핸들러 함수이다.
const serverRender = (req, res, next) => {
  // 이 함수는 404가 떠야 하는 상황에서 404를 띄우지 않고 서버 사이드 렌더링을 해 준다.

  const context = {};
  const sagaMiddleware = createSagaMiddleware();

  const store = createStore(
    rootReducer,
    applyMiddleware(thunk, sagaMiddleware)
  );
  
  sagaMiddleware.run(rootSaga);
  
  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); // 렌더링을 한다.
  // 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)); // 결과물 응답
};

const serve = express.static(path.resolve("./build"), {
  index: false, // "/" 경로에서 index.html을 보여 주지 않도록 설정
});

app.use(serve); // 실행 순서가 중요하다. serverRender 전에 위치해야 한다.
// 서버 사이드 렌더링 핸들러
app.use(serverRender);

// 5000 포트로 서버를 가동한다.
app.listen(5000, () => {
  console.log("Running on http://localhost:5000");
});

* 여기까지는 스토어 생성 및 미들웨어 설정 부분이 브라우저용 엔트리 index.js에서 해 준 것과 똑같다.

* 여기서 코드만 몇 줄만 더 추가하면 redux-saga를 사용하는 환경에서도 서버 사이드 렌더링을 제대로 처리할 수 있다.

[index.server.js]

import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
import { StaticRouter } from "react-router-dom";
import App from "./App";
import path from "path";
import fs from "fs";
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import PreloadContext from './lib/PreloadContext';

import createSagaMiddleware from 'redux-saga';
import rootReducer, { rootSaga } from './modules';

import { END } from 'redux-saga';

// asset-manifest.json에서 파일 경로들을 조회한다.
const manifest = JSON.parse(
  fs.readFileSync(path.resolve("./build/asset-manifest.json"), "utf8")
);

const chunks = Object.keys(manifest.files)
  .filter((key) => /chunk\.js$/.exec(key)) // chunk.js로 끝나는 키를 찾아서
  .map((key) => `<script src="${manifest.files[key]}"></script>`) // 스크립트 태그로 변환하고
  .join(""); // 합침

function createPage(root, stateScript) {
  return `
    <!DOCTYPE html>
    <html lang="ko">
    <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" context="#000" />
      <title>React App</title>
      <link href="${manifest.files["main.css"]}" />
    </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>
  `;
}
const app = express();

// 서버 사이드 렌더링을 처리할 핸들러 함수이다.
const serverRender = (req, res, next) => {
  // 이 함수는 404가 떠야 하는 상황에서 404를 띄우지 않고 서버 사이드 렌더링을 해 준다.

  const context = {};
  const sagaMiddleware = createSagaMiddleware();

  const store = createStore(
    rootReducer,
    applyMiddleware(thunk, sagaMiddleware)
  );
  
  const sagaPromise = sagaMiddleware.run(rootSaga).toPromise();
  
  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 액션을 발생시키면 액션을 모니터링하는 saga 들이 모두 종료됩니다.
  try {
    await sagaPromise; // 기존에 진행중이던 saga 들이 모두 끝날때까지 기다립니다.
    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
  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); // 실행 순서가 중요하다. serverRender 전에 위치해야 한다.
// 서버 사이드 렌더링 핸들러
app.use(serverRender);

// 5000 포트로 서버를 가동한다.
app.listen(5000, () => {
  console.log("Running on http://localhost:5000");
});

* toPromise는 sagaMiddleware.run을 통해 만든 Task를 Promise로 변환한다. 별도의 작업을 하지 않으면 이 Promise는 끝나지 않는다. 왜냐하면, 우리가 만든 루트 사가에서 액션을 끝없이 모니터링하기 때문이다.

* 그런데 redux-saga의 END라는 액션을 발생시키면 이 Promise를 끝낼 수 있다. END 액션이 발생되면 액션 모니터링 작업이 모두 종료되고, 모니터링되기 전에 시작된 getUserSaga와 같은 사가 함수들이 있다면 해당 함수들이 완료되고 나서 Promise가 끝나게 된다. 이 Promise가 끝나는 시점에 리덕스 스토어에는 우리가 원하는 데이터가 채워진다. 그 이후에 다시 렌더링하면 우리가 원하는 결과물이 나타난다.

* 코드를 다 작성했으면 프로젝트를 빌드하고, 서버 사이드 렌더링 서버를 다시 실행한 다음에 아래 주소로 들어가서 새로고침을 해 보자.

redux-saga 서버 사이드 렌더링

* 위와 같이 데이터가 채워진 상태로 잘 렌더링되는가?


(9) usePreloader Hook 만들어서 사용하기

* 지금까지 만든 컨테이너 컴포넌트에서는 Preloader 컴포넌트를 사용하여 서버 사이드 렌더링을 하기 전 데이터가 필요한 상황에 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; // context 값이 유효하지 않다면 아무것도 하지 않음
  if (preloadContext.done) return null; // 이미 작업이 끝났다면 아무것도 하지 않음

  // promises 배열에 프로미스 등록
  // 설령 resolve 함수가 프로미스를 반환하지 않더라도, 프로미스 취급을 하기 위하여
  // Promise.resolve 함수 사용
  preloadContext.promises.push(Promise.resolve(resolve()));
  return null;
};

// Hook 형태로 사용 할 수 있는 함수
export const usePreloader = resolve => {
  const preloadContext = useContext(PreloadContext);
  if (!preloadContext) return null;
  if (preloadContext.done) return null;

  // promises 배열에 프로미스 등록
  // 설령 resolve 함수가 프로미스를 반환하지 않더라도, 프로미스 취급을 하기 위하여
  // Promise.resolve 함수 사용
  preloadContext.promises.push(Promise.resolve(resolve()));
};

* usePreloader 함수는 Preloader 컴포넌트와 코드가 매우 유사하다. 컴포넌트가 아닌 그냥 함수일 뿐이다. 이 Hook을 UserContainer 쪽에서 사용해 보자.

[containers/UserContainer.js]

import React, { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import User from "../components/User";
import { usePreloader } from "../lib/PreloaderContext";
import { getUser } from "../modules/users";

const UserContainer = ({ id }) => {
  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가 바뀔 때 새로 요청해야 함

  if (!user) return null;
  return <User user={user} />;
};

export default UserContainer;

* 코드가 훨씬 간결해진다. 함수형 컴포넌트에서는 이렇게 usePreloader Hook을 사용하고, 클래스형 컴포넌트를 사용하는 일이 있을 때는 Preloader 컴포넌트를 사용하면 된다.

* 코드를 다 작성했으면 서버 사이드 렌더링 서버를 종료하고, 다음 명령어를 실행하여 다시 시작한 후 이전과 똑같이 잘 작동하는지 확인해 보자.

$ yarn build
$ yarn build:server
$ yarn start:server

* 이제 여러분은 서버 사이드 렌더링 시 데이터 로딩을 어떻게 해야 하는지 완벽히 숙지했다. 원리를 이해했으니 이 프로젝트에서 사용된 코드를 변형하여 추후 여러분 프로젝트에 쉽게 적용할 수 있을 것이다.