리액트에서 공식적으로 제공하는 코드 스플리팅 기능인 React.lazy와 Suspense는 SSR을 지원하지 않는다.
React 18 부터는 지원한다고 한다. 이번 정리가 끝나면 바로 알아보고 정리해보자.
지금은 Loadable Components를 사용해서 코드 스플리팅을 해보자.
Loadable Components는 SSR을 할 때 필요한 서버 유틸 함수와 웹팩 플러그인, babel 플러그인을 제공한다.
npm add @loadable/components @loadable/server @loadable/webpack-plugin @loadable/babel-plugin
1) 라우트 컴포넌트 스플리팅하기
우선 BluePage, RedPage, UserPage를 스플리팅 해주자
App.js
import { Routes, Route } from 'react-router-dom';
import Menu from './components/Menu';
import loadable from '@loadable/component'
// loadable component로 RedPage, BluePage, UsersPage를 스플리팅 해준다.
const RedPage = loadable(() => import('./pages/RedPage'));
const BluePage = loadable(() => import('./pages/BluePage'));
const UsersPage = loadable(() => import('./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;
Network 탭에서 slow 3g로 설정하면 깜빡임이 확인된다.
이는 느린 인터넷 사용 환경에 있는 사용자에게는 불편한 접근일 것이다.
2) 웹팩과 babel 플러그인 적용
Loadable Components에서 제공하는 웹팩과 babel 플러그인을 적용하면 깜빡임을 해결할 수 있다.
첫 번째로, babel 플러그인을 적용한다.
package.json을 열어 babel을 찾은 뒤, 그 안에 다음과 같이 plugin을 작성한다.
"babel": {
"presets": [
"react-app"
],
"plugins": [
"@loadable/babel-plugin"
]
},
두 번째로, webpack.config.js를 열어 상단에 LoadablePlugin을 불러오고,
하단에 plugin을 찾아 해당 플러그인을 적용한다.
webpack.config.js
'use strict';
// LoadablePlugin을 불러옴
const LoadablePlugin = require('@loadable/webpack-plugin');
(...)
plugins: [
// LoadablePlugin을 플러그 인에 적용
new LoadablePlugin(),
(...)
].filter(Boolean),
(...)
세 번째로, 수정 사항을 저장후 npm run build를 실행하고 build 디렉터리에 loadable-stats.json파일이 있는지 확인하자.
이 파일은 각 컴포넌트의 코드가 어떤 청크 파일에 들어가 있는지에 대한 정보를 갖고있다.
SSR을 할 때 이 파일을 참고하여 어떤 컴포넌트가 렌더링 되었는지에 따라
어떤 파일들을 사전에 불러와야 할지 설정할 수 있다.
3) 필요한 청크 파일 경로 추출하기
SSR후 브라우저에서 어떤 파일을 미리 불러와야 할지 알아내고 해당 파일들의 경로를 추출하기 위해
Loadable Components 에서 제공하는 ChunkExtractor와 ChunkExtractorManager를 사용해준다.
그리고 Loadable Components를 통해 파일 경로를 조회하므로
기존에 asset-manifest.json을 확인하더 코드는 지워준다.
index.server.js
(...)
// SSR후 브라우저에서 어떤 파일을 미리 불러와야할지 알아내고, 경로를 추출하기 위해 ChunkExtractor와 ChunkExtractorManager 사용
import { ChunkExtractor, ChunkExtractorManager } from '@loadable/server';
const statsFile = path.resolve('./build/loadable-stats.json');
function createPage(root, tags) {
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>
${tags.styles}
${tags.links}
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
${root}
</div>
${tags.scripts}
</body>
</html>
`;
} // 위의 runtime-main.js 참조는 현제 asset-manifest.json에 없으므로 적용안됨
const app = express();
// 서버 사이드 렌더링을 처리할 핸들러 함수이다.
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: []
};
// 필요한 파일을 추출하기 위한 ChunkExtractor
const extractor = new ChunkExtractor({ statsFile });
const jsx = (
<ChunkExtractorManager extractor={extractor}>
<PreloadContext.Provider value={preloadContext}>
<Provider store={store}>
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
</Provider>
</PreloadContext.Provider>
</ChunkExtractorManager>
);
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>`; // 리덕스 초기 상태를 스크립트로 주입
// 미리 불러와야 하는 스타일/스크립트를 추출한다
const tags = {
scripts: stateScript + extractor.getScriptTags(), // 스크립트 앞부분에 리덕스 상태 넣기
links: extractor.getLinkTags(),
styles: extractor.getStyleTags()
};
res.send(createPage(root, tags)); // 클라이언트에게 결과물 응답
};
(...)
4) loadableReady와 hydrate
Loadable Components를 사용하면 성능을 최적화하기 위해 모든 JS 파일을 동시에 받아온다.
모든 스크립트가 로딩되고 나서 렌더링하도록 처리하기 위해서는 loadableReady라는 함수를 사용해야 한다.
추가적으로 render 함수대신 hydrate 함수를 사용할 수 있다.
hydrate 함수는 기존에 SSR된 결과물이 이미 있는 경우 새로 렌더링 하지 않고,
기존에 존재하는 UI에 이벤트만 연동하여 애플리케이션을 초기 구동할 때
필요한 리소스를 최소화함으로써 성능을 최적화해 준다. index.js에 적용시켜 주자.
index.js
(...)
import { loadableReady } from '@loadable/component';
const sagaMiddleware = createSagaMiddleware();
// applyMiddleware는 store를 생성할 때 미들웨어를 적용시켜준다.(미들웨어가 여려개인 경우 파라미터로 여러개 전달, 순서대로 지정됨)
const store = configureStore(
{
reducer: rootReducer,
middleware: [thunk, sagaMiddleware]
},
window.__PRELOADED_STATE__, // 이 값을 초기 상태로 사용함
);
sagaMiddleware.run(rootSaga);
// render 함수 대신 hydrate함수를 이용
// (hydrate는 SSR된 결과물이 이미 있는 경우 새로 렌더링 하지 않고 존재하는 UI에 이벤트만 연동하여 초기 구동시 리소스최적화로 성능 최적화를 함)
// 같은 내용을 쉽게 재사용할 수 있도록 렌더링할 내용을 하나의 컴포넌트로 묶음
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);
}
// 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();
SSR의 환경 구축을 위한 대안 방법
- Next.js
Next.js 리액트 프레임워크를 사용하면 이 작업을 최소한의 설정으로 간단히 처리할 수 있다.
하지만 몇가지 제한이 있다. 대표적으로 리액트 라우터와 호환이 되지 않는다.
Next.js는 파일 시스템에 기반하여 라우트를 설정한다.
컴포넌트 파일의 경로와 파일 이름을 사용하여 라우트를 설정 하는 것이다. - Razzle
Razzle 또한 SSR을 쉽게 할 수 있도록 해 주는 도구이며, 프로젝트 구성이 CRA와 유사하다는 장점이 있다.
그렇기에 프로젝트의 구조를 마음대로 설정할 수 있으며, 리액트 라우터와도 잘 호환된다.
🔰정리🔰
서버 사이드 렌더링은 프로젝트를 만들 때 꼭 필요한 작업은 아니다.
하지만 이용자가 많고, 검색엔지 최적화 및 사용자 경험을 향상시키길 원한다면 도입을 고려해 봐도 좋다.
단, 서버 사이드 렌더링을 한다면 프로젝트가 복잡해질 수 있다.
'React' 카테고리의 다른 글
React) 21장 백엔드 프로그래밍: Koa 1 (0) | 2022.07.06 |
---|---|
React) React 18의 SSR이 가능해진 React.lazy와 Suspense (0) | 2022.06.03 |
React) 20장 서버 사이드 렌더링 (2) 데이터 로딩 (0) | 2022.06.02 |
React) 20장 서버 사이드 렌더링 (1) SSR 구현하기 (0) | 2022.05.31 |
React) 19장 코드 스플리팅 ( React.lazy-Suspense, loadable_component ) (0) | 2022.05.23 |