데이터 로딩

데이터 로딩은 서버사이드 렌더링을 구현하게 될 때, 해결하기에 가장 까다로운 문제중 하나입니다. 해결방법 또한 다양한 방법이 있는데요 그 중에서 깔끔하고, 쉬운 방법을 소개드리도록 하겠습니다. 이 튜토리얼에서는 redux와 redux-thunk 를 사용을 하여 구현을 해보도록 하겠습니다. 하지만 이게 꼭 redux-thunk 가 아니여도, redux-promise-middleware 나 redux-pender 등의 미들웨어로도 비슷한 방식으로 해결 할 수 있습니다.

우선 현재의 프로젝트에 리덕스를 적용해보겠습니다.

리덕스 적용

redux, react-redux, redux-thunk, 그리고 웹 요청을 위한 axios 를 설치하세요.

$ yarn add redux react-redux redux-thunk axios

그 다음엔 ducks 패턴을 사용하여 리덕스 모듈을 작성해주겠습니다.

modules/photo.js

import axios from 'axios';

const GET_PHOTO_PENDING = 'photo/GET_PHOTO_PENDING';
const GET_PHOTO_SUCCESS = 'photo/GET_PHOTO_SUCCESS';
const GET_PHOTO_FAILURE = 'photo/GET_PHOTO_FAILURE';

const getPhotoPending = () => ({
  type: GET_PHOTO_PENDING
});

const getPhotoSuccess = payload => ({
  type: GET_PHOTO_SUCCESS,
  payload
});

const getPhotoFailure = payload => ({
  type: GET_PHOTO_PENDING,
  payload,
  error: true
});

export const getPhoto = () => async dispatch => {
  try {
    dispatch(getPhotoPending());
    const response = await axios.get(
      'https://jsonplaceholder.typicode.com/photos/1'
    );
    dispatch(getPhotoSuccess(response));
  } catch (e) {
    dispatch(getPhotoFailure(e));
    throw e;
  }
};

const initialState = {
  data: null, // { albumId, id, title, url, thumbnailurl }
  error: null,
  loading: false
};

function photo(state = initialState, action) {
  switch (action.type) {
    case GET_PHOTO_PENDING:
      return {
        ...state,
        loading: true
      };
    case GET_PHOTO_SUCCESS:
      return {
        ...state,
        loading: false,
        data: action.payload.data
      };
    case GET_PHOTO_FAILURE:
      return {
        ...state,
        loading: false,
        error: action.payload
      };
    default:
      return state;
  }
}

export default photo;

그리고 modules 디렉터리에 index.js 파일을 생성하여 루트 리듀서를 만들어주겠습니다.

modules/index.js

import { combineReducers } from 'redux';
import photo from './photo';

const rootReducer = combineReducers({
  photo
});

export default rootReducer;

물론, 지금은 리듀서가 한개 뿐이니까 combineReducers 를 통하여 루트 리듀서에 만드는 것은 지금 당장은 큰 의미가 없긴 합니다. 하지만 우리가 튜토리얼 후반부에서는 하나의 리듀서를 더 만들게 될 것이니 이렇게 루트 리듀서를 만들어주도록 하겠습니다.

루트 리듀서를 만드셨으면, src 디렉터리의 index.js 에서 리덕스를 프로젝트에 적용해주세요.

index.js

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

const store = createStore(rootReducer, applyMiddleware(thunk));

const Root = () => (
  <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();

컴포넌트 준비

이제 API 를 요청하고 보여주기 위해서 하나의 프리젠테이셔널 컴포넌트와 하나의 컨테이너 컴포넌트를 작성해보겠습니다.

components/Photo.js

import React from 'react';

const Photo = ({ loading, photo }) => {
  if (loading) return <div>로딩중..</div>;
  if (!photo) return null;
  return (
    <div>
      <img src={photo.thumbnailUrl} alt="thumbnail" />
      <h1>{photo.title}</h1>
    </div>
  );
};

export default Photo;

그 다음에는 Photo 컴포넌트를 위한 컨테이너 컴포넌트를 작성하세요.

containers/PhotoContainer.js

import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import { getPhoto } from '../modules/photo';
import Photo from '../components/Photo';

const PhotoContainer = ({ loading, data, getPhoto }) => {
  // 컴포넌트가 마운트 될 때 호출함
  useEffect(() => {
    if (data) return; // 이미 존재한다면 요청 하지 않음
    getPhoto();
  }, []);
  return <Photo loading={loading} photo={data} />;
};

export default connect(
  state => ({
    loading: state.photo.loading,
    data: state.photo.data
  }),
  {
    getPhoto
  }
)(PhotoContainer);

컨테이너 컴포넌트를 다 작성하셨으면 이 컴포넌트를 보여줄 페이지 컴포넌트와 라우트 설정을 해주세요.

pages/Photo.js

import React from 'react';
import PhotoContainer from '../containers/PhotoContainer';

const PhotoPage = () => {
  return <PhotoContainer />;
};

export default PhotoPage;

App.js

import React from 'react';
import { Route } from 'react-router-dom';
import loadable from '@loadable/component';
import Menu from './components/Menu';
const RedPage = loadable(() => import('./pages/RedPage'));
const BluePage = loadable(() => import('./pages/BluePage'));
const PhotoPage = loadable(() => import('./pages/PhotoPage'));
const App = () => {
  return (
    <div>
      <Menu />
      <hr />
      <Route path="/red" component={RedPage} />
      <Route path="/blue" component={BluePage} />
      <Route path="/photo" component={PhotoPage} />
    </div>
  );
};

export default App;

그리고 Menu 컴포넌트에 링크도 추가해줍시다.

components/Menu.js

import React from 'react';
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="/photo">Photo</Link>
      </li>
    </ul>
  );
};

export default Menu;

이제 개발 서버에서 /photo 경로에 들어가보세요. 다음과 같이 데이터가 잘 뜨고있나요?

05

요청을 미리하기 위한 PreloadContext 와 withPreload 작성

만약 이 상태로 서버사이드 렌더링을 한다면 서버쪽에서는 데이터 요청이 이루어지지 않을 것입니다. 우리가 현재 useEffect 를 사용하여 컴포넌트가 페이지에 실제로 마운트 될 시에만 요청을 시작하도록 처리를 해놓았기 때문이죠. 만약 컴포넌트를 클래스형으로 작성하고 componentDidMount 를 사용해도 마찬가지입니다.

서버사이드 렌더링을 할 때에도 데이터를 제대로 요청하기 위하여 PreloadContext 라는 Context와 withPreload 라는 유틸 함수를 작성해보겠습니다.

lib/PreloadContext.js

import React, { Component, createContext } from 'react';

const PreloadContext = createContext(null);

export const withPreload = callback => WrappedComponent => {
  class WithPreload extends Component {
    static contextType = PreloadContext;
    constructor(props, context) {
      super(props);
      if (context === null) return; // 값이 null 이면 아무것도 하지 않음
      context.push({ callback, props });
    }
    render() {
      return <WrappedComponent {...this.props} />;
    }
  }
  return WithPreload;
};

export default PreloadContext;

withPreload 라는 함수는 컴포넌트가 생성 될 때 콜백 함수를 props 와 함께 PreloadContext 가 지니고 있는 배열 안에 추가합니다. 이렇게 추가 된 콜백함수는 나중에 서버 사이드 렌더링 서버 쪽에서 호출됩니다.

다 작성하셨으면 PhotoContainer.js 를 다음과 같이 withPreload 로 감싸세요.

containers/PhotoContainer.js

import React, { useEffect } from 'react';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { getPhoto } from '../modules/photo';
import Photo from '../components/Photo';
import { withPreload } from '../lib/PreloadContext';

const PhotoContainer = ({ loading, data, getPhoto }) => {
  // 컴포넌트가 마운트 될 때 호출함
  useEffect(() => {
    if (data) return; // 이미 존재한다면 요청 하지 않음
    getPhoto(id);
  }, []);
  return <Photo loading={loading} photo={data} />;
};

export default connect(
  state => ({
    loading: state.photo.loading,
    data: state.photo.data
  }),
  {
    getPhoto
  }
)(withPreload(({ props }) => props.getPhoto())(PhotoContainer));

withPreload 에 넣어주는 콜백 함수에서는 Promise 를 반환해주어야 합니다. 지금의 경우엔 props 로 받아온 getPhoto 함수를 호출하고있지요.

connect도 사용하고, withPreload 도 사용하니까 코드가 좀 복잡해졌습니다. 이런 코드는 redux 에서 제공하는 compose 함수를 사용하면 코드를 더 깔끔하게 작성 할 수 있습니다.

containers/PhotoContainer.js

import React, { useEffect } from 'react';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { getPhoto } from '../modules/photo';
import Photo from '../components/Photo';
import { withPreload } from '../lib/PreloadContext';

const PhotoContainer = ({ loading, data, getPhoto }) => {
  // 컴포넌트가 마운트 될 때 호출함
  useEffect(() => {
    if (data) return; // 이미 존재한다면 요청 하지 않음
    getPhoto();
  }, []);
  return <Photo loading={loading} photo={data} />;
};

export default compose(
  connect(
    state => ({
      loading: state.photo.loading,
      data: state.photo.data
    }),
    {
      getPhoto
    }
  ),
  withPreload(({ props }) => props.getPhoto())
)(PhotoContainer);

코드가 훨씬 깔끔해졌죠?

서버에서 PreloadContext 에 등록된 콜백함수 호출하기

서버사이드 렌더링을 할 때에는 JSX를 먼저 한번 렌더링 하고, 그 과정에서 withPreload 를 사용해서 등록했던 콜백 함수들을 수집합니다. 그리고 그 콜백 함수들을 호출 및 대기 후 다시 렌더링 하면 필요한 데이터를 제대로 보여줄 수 있습니다.

서버사이드 렌더링 엔트리 파일을 다음과 같이 수정하세요.

index.server.js - serverRender

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

  // 필요한 파일 추출하기 위한 사전 작업
  const extractor = new ChunkExtractor({ statsFile });
  const context = {};

  const store = createStore(rootReducer, applyMiddleware(thunk));

  const preloads = []; // 수집된 콜백함수들을 이 배열에 추가합니다.
  const jsx = (
    <PreloadContext.Provider value={preloads}>
      <Provider store={store}>
        <ChunkExtractorManager extractor={extractor}>
          <StaticRouter location={req.url} context={context}>
            <App />
          </StaticRouter>
        </ChunkExtractorManager>
      </Provider>
    </PreloadContext.Provider>
  );

  ReactDOMServer.renderToStaticMarkup(jsx); // renderToString 보다 적은 리소스를 사용합니다.

  // 콜백함수들을 호출하고, Promise 들이 모두 끝날 때 까지 대기합니다.
  const promises = preloads.map(preload =>
    preload.callback({ props: preload.props })
  );
  try {
    await Promise.all(promises);
  } catch (e) {}

  const root = ReactDOMServer.renderToString(jsx); // 렌더링을 하고
  const tags = {
    // 미리 불러와야 하는 스타일 / 스크립트를 추출하고
    scripts: extractor.getScriptTags(),
    links: extractor.getLinkTags(),
    styles: extractor.getStyleTags()
  };
  res.send(createPage(root, tags)); // 결과물을 응답합니다.
};

우리가 콜백 함수를 withPreload 의 constructor 에서 호출하지 않고 서버쪽에서 호출해준 이유는, 서버쪽에서 요청에 대한 정보를 조회할 수 있게 하기 위함입니다. 예를 들어서, 만약 나중에 요청의 헤더나 쿠키를 필요로 하게 된다면 다음과 같이 콜백 함수를 호출하는 부분을 수정해주면 됩니다.

const promises = preloads.map(preload =>
  preload.callback({ req, props: preload.props })
);

그렇게 해주고 나면 withPreload 에 넣어주는 콜백 함수에서 서버의 req 값을 조회해서 사용 할 수도 있습니다.

withPreload(({ props, req }) => {
  props.loadUser(req.headers.authorization);
});

주로 유저 정보에 관련된 값이나, 사용자의 브라우저 정보를 참고해야 할 때 req 값을 조회합니다.

리덕스 상태를 스크립트에 주입하기

지금 상황으로는 서버쪽에서 콜백 함수를 호출하고 기다렸다가 다시 렌더링을 한다고 해도, 브라우저 상에서는 성공적으로 서버사이드 렌더링이 이뤄지지만 리덕스의 상태를 재사용하지는 못합니다. 리덕스 상태를 재사용하려면 스토어에서 상태를 JSON 문자열로 변환 후 스크립트로 주입해주어야 합니다.

index.server.js - serverRender

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

  // 필요한 파일 추출하기 위한 사전 작업
  const extractor = new ChunkExtractor({ statsFile });
  const context = {};

  const store = createStore(rootReducer, applyMiddleware(thunk));

  const preloads = []; // 수집된 콜백함수들을 이 배열에 추가합니다.
  const jsx = (
    <PreloadContext.Provider value={preloads}>
      <Provider store={store}>
        <ChunkExtractorManager extractor={extractor}>
          <StaticRouter location={req.url} context={context}>
            <App />
          </StaticRouter>
        </ChunkExtractorManager>
      </Provider>
    </PreloadContext.Provider>
  );

  ReactDOMServer.renderToStaticMarkup(jsx); // renderToString 보다 적은 리소스를 사용합니다.

  // 콜백함수들을 호출하고, Promise 들이 모두 끝날 때 까지 대기합니다.
  const promises = preloads.map(preload =>
    preload.callback({ props: preload.props })
  );
  try {
    await Promise.all(promises);
  } catch (e) {}

  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>`;
  const tags = {
    // 미리 불러와야 하는 스타일 / 스크립트를 추출하고
    scripts: stateScript + extractor.getScriptTags(),
    links: extractor.getLinkTags(),
    styles: extractor.getStyleTags()
  };
  res.send(createPage(root, tags)); // 결과물을 응답합니다.
};

스크립트를 통해서 __PRELOADED_STATE__ 라는 글로벌 변수를 선언해주었습니다. 이제 이 값을 리덕스 스토어를 생성 할 때 기본값으로 사용하도록 index.js 를 수정해주겠습니다.

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

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

const Root = () => (
  <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();

그리고, 리덕스 상태를 직접 조회하기 위하여 개발자 도구를 적용해보겠습니다.

$ yarn add redux-devtools-extension

index.js - createStore

import { composeWithDevTools } from 'redux-devtools-extension';

const store = createStore(
  rootReducer,
  window.__PRELOADED_STATE__,
  composeWithDevTools(applyMiddleware(thunk))
);

이제 서버와 클라이언트를 다시 빌드하고 서버를 실행해봅시다.

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

http://localhost:5000/photo 페이지에 들어가보세요.

07

위 스크린샷과 같이 처음부터 데이터가 로딩되어있는 상태로 페이지가 나타났나요?

데이터 로딩시 라우트 파라미터 사용하기

이번에는 데이터를 로딩 할 떄 라우트 파라미터를 사용하도록 기존 코드를 조금 수정해주겠습니다.

App.js

import React from 'react';
import { Route } from 'react-router-dom';
import loadable from '@loadable/component';
import Menu from './components/Menu';
const RedPage = loadable(() => import('./pages/RedPage'));
const BluePage = loadable(() => import('./pages/BluePage'));
const PhotoPage = loadable(() => import('./pages/PhotoPage'));
const App = () => {
  return (
    <div>
      <Menu />
      <hr />
      <Route path="/red" component={RedPage} />
      <Route path="/blue" component={BluePage} />
      <Route path="/photo/:id" component={PhotoPage} />
    </div>
  );
};

export default App;

그 다음에는 PhotoPage 컴포넌트에서 URL 파라미터를 PhotoContainer 에게 넘겨주세요.

PhotoPage.js

import React from 'react';
import PhotoContainer from '../containers/PhotoContainer';

const PhotoPage = ({ match }) => {
  return <PhotoContainer id={match.params.id} />;
};

export default PhotoPage;

기존에는 /photos/1 API 를 호출하고 있었습니다. 이제 여기서 id 값이 1이 아닌 동적인 값을 넣어줄 수 있도록 thunk 함수를 수정해봅시다.

export const getPhoto = id => async dispatch => {
  try {
    dispatch(getPhotoPending());
    const response = await axios.get(
      'https://jsonplaceholder.typicode.com/photos/' + id
    );
    dispatch(getPhotoSuccess(response));
  } catch (e) {
    dispatch(getPhotoFailure(e));
    throw e;
  }
};

그리고 나서 기존 PhotoContainer.js 를 수정해줍시다.

containers/PhotoContainer.js

import React, { useEffect } from 'react';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { getPhoto } from '../modules/photo';
import Photo from '../components/Photo';
import { withPreload } from '../lib/PreloadContext';

const PhotoContainer = ({ loading, data, getPhoto, id }) => {
  // 컴포넌트가 마운트 될 때 호출함
  useEffect(() => {
    if (data) return; // 이미 존재한다면 요청 하지 않음
    getPhoto(id);
  }, [id]);
  return <Photo loading={loading} photo={data} />;
};

export default compose(
  connect(
    state => ({
      loading: state.photo.loading,
      data: state.photo.data
    }),
    {
      getPhoto
    }
  ),
  withPreload(({ props }) => props.getPhoto(props.id))
)(PhotoContainer);

그리고 Menu 컴포넌트에서도 주소를 바꿔주세요.

components/Menu.js

import React from 'react';
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="/photo/1">Photo</Link>
      </li>
    </ul>
  );
};

export default Menu;

이제 서버와 클라이언트를 다시 빌드하고 서버사이드 렌더링 서버를 실행 후 다음 주소를 직접 입력하여 들어가보세요.

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

http://localhost:5000/photo/1 http://localhost:5000/photo/2 http://localhost:5000/photo/3

08-1 08-2 08-3

결과들이 잘 나타나나요?

라우트 변동시에 요청 새로하기

우리가 기존에 작성했던 PhotoContainer 에서는 data 가 존재한다면 요청을 하지 않도록 해주었었습니다.

useEffect(() => {
  if (data) return; // 이미 존재한다면 요청 하지 않음
  getPhoto(id);
}, [id]);

그런데, 만약에 주소를 직접 입력한것이 아니라 클라이언트 라우팅을 통해 (history 객체의 함수를 호출하거나, Link 컴포넌트르 눌렀을 때) 페이지를 이동했을 시에도 data 가 이미 존재한다는 조건이 만족되어 새로 요청을 하지 않게 됩니다.

따라서, 이렇게 업데이트가 될 일이 있는 요청의 경우엔 단순히 현재 data 의 유무로 요청을 새로 해야 할 지 판단하면 안되고, 다른 방식을 사용해주어야 합니다.

이를 관리하기 위한 간단한 방법은, 서버사이드 렌더링이 되었을 때 특정 값을 true 로 해주고, 라우트 전환 시에는 false 로 처리를 해서 이 값을 참조하여 요청을 할지말지 정하는 것 입니다. 이 값은 프로젝트 전역적으로 사용되야 하기에, 리덕스쪽에서 관리해주도록 하겠습니다. 물론, 원한다면 Context 로 만들어서 관리해도 상관 없습니다.

요청을 무시할지 말지 관리해주는 ignore 리덕스 모듈을 작성해보세요.

modules/ignore.js

const APPLY_IGNORE = 'ignore/APPLY_IGNORE';
const RESOLVE_IGNORE = 'ignore/RESOLVE_IGNORE';

export const applyIgnore = () => ({
  type: APPLY_IGNORE
});
export const resolveIgnore = () => ({
  type: RESOLVE_IGNORE
});

const initialState = false;

export default function ignore(state = initialState, action) {
  switch (action.type) {
    case APPLY_IGNORE:
      return true;
    case RESOLVE_IGNORE:
      return false;
    default:
      return state;
  }
}

APPLY_IGNORE 액션은 서버쪽에서 디스패치 해주고, RESOLVE_IGNORE 는 라우트에 변동이 있을때 디스패치 해주도록 하겠습니다.

루트 리듀서에 ignore 모듈을 추가하세요.

import { combineReducers } from 'redux';
import photo from './photo';
import ignore from './ignore';

const rootReducer = combineReducers({
  photo,
  ignore
});

export default rootReducer;

그 다음 서버쪽에서 APPLY_IGNORE 를 디스패치 하겠습니다.

index.server.js - 콜백 호출 이후

// 콜백함수들을 호출하고, Promise 들이 모두 끝날 때 까지 대기합니다.
const promises = preloads.map(preload =>
  preload.callback({ props: preload.props })
);
try {
  await Promise.all(promises);
  store.dispatch(applyIgnore());
} catch (e) {}

이 액션을 디스패치 하는 시점은 콜백 호출을 성공적으로 끝내고난 시점이여야 합니다. 만약에 어쩌다가 실패하게 된다면 브라우저에서 재시도를 해봐야 하니 이 액션을 디스패치 하면 안되니까요.

RouteListener 작성하기

라우트에 변동이 있으면 RESOLVE_IGNORE 액션을 디스패치하는 RouteListner 컴포넌트를 작성하겠습니다. 이 컴포넌트에서 렌더링하는 결과물은 없지만, 리덕스와 연동이 되어있으므로 containers 디렉터리에 저장하세요.

containers/RouteListener.js

import { useEffect } from 'react';
import { connect } from 'react-redux';
import { resolveIgnore } from '../modules/ignore';
import { withRouter } from 'react-router-dom';

const RouteListener = ({ history, resolveIgnore, ignore }) => {
  useEffect(() => {
    if (!ignore) return;
    const unlisten = history.listen(location => {
      resolveIgnore();
      unlisten();
    });
    return unlisten;
  }, []);
  return null;
};

export default connect(
  state => ({
    ignore: state.ignore
  }),
  { resolveIgnore }
)(withRouter(RouteListener));

이제 이 컴포넌트를 App 에서 렌더링 시키세요.

import React from 'react';
import { Route } from 'react-router-dom';
import loadable from '@loadable/component';
import Menu from './components/Menu';
import RouteListener from './containers/RouteListener';

const RedPage = loadable(() => import('./pages/RedPage'));
const BluePage = loadable(() => import('./pages/BluePage'));
const PhotoPage = loadable(() => import('./pages/PhotoPage'));
const App = () => {
  return (
    <div>
      <Menu />
      <hr />
      <Route path="/red" component={RedPage} />
      <Route path="/blue" component={BluePage} />
      <Route path="/photo/:id" component={PhotoPage} />
      <RouteListener />
    </div>
  );
};

export default App;

요청 제대로 무시하기

그리고 PhotoContainer 컴포넌트에서 리덕스 상태에 있는 ignore 값을 조회하여 이 값이 true 면 요청을 무시 처리해주세요.

containers/PhotoContainer.js

import React, { useEffect } from 'react';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { getPhoto } from '../modules/photo';
import Photo from '../components/Photo';
import { withPreload } from '../lib/PreloadContext';

const PhotoContainer = ({ loading, data, getPhoto, id, ignore }) => {
  // 컴포넌트가 마운트 될 때 호출함
  useEffect(() => {
    if (ignore) return; // SSR 을 해서 무시해야 하는 상황이면 무시
    getPhoto(id);
  }, [id]);
  return <Photo loading={loading} photo={data} />;
};

export default compose(
  connect(
    state => ({
      loading: state.photo.loading,
      data: state.photo.data,
      ignore: state.ignore
    }),
    {
      getPhoto
    }
  ),
  withPreload(({ props }) => props.getPhoto(props.id))
)(PhotoContainer);

마지막으로, Photo 컴포넌트에 다음 id 페이지로 이동하는 링크를 추가해주세요.

import React from 'react';
import { Link } from 'react-router-dom';
const Photo = ({ loading, photo }) => {
  if (loading) return <div>로딩중..</div>;
  if (!photo) return null;
  return (
    <div>
      <img src={photo.thumbnailUrl} alt="thumbnail" />
      <h1>{photo.title}</h1>
      <Link to={`/photo/${photo.id + 1}`}>다음</Link>
    </div>
  );
};

export default Photo;

모든 작업이 다 끝났습니다!

서버와 클라이언트를 빌드하고 서버사이드 렌더링 서버를 시작하세요.

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

Photo 페이지에서 다음 링크를 눌렀을때 새로 잘 불러와지나요?

09

results matching ""

    No results matching ""