CRA + SSR.
(feat. TypeScript)
승형수
20@kross.kr
승형수
국문과 -> 개발자
(주) 한국어음중개 | Frontend Developer
React, IaC, Cloud Infrastructure...
• Server-Side Rendering?
• ImplementaDon
• Checklist
• 스파게티는 먹을 때에만 | 서버 코드를 분리하자
• asset-manifest.json | build 안에 담긴 보물
• 직선보다 빠른 굴절 | TypeScript를 사용하는 편이 좋은 이유
• Extra
• 필수 패키지 설치 이슈
https://github.com/
huskyhoochu/ssr-react-app
Server-Side Rendering?
기존 웹서버
전체 데이터 담긴
Full - HTML
멋대로 비유하기
백화점에서 완제품 소파를 사온다
Server-Side Rendering?
+
Client-Side Rendering
최소한의 HTML
+
화면 전체 렌더링을 위한 JS
멋대로 비유하기
이케아에서 조립형 소파를 사온다
Server-Side Rendering?
+
Server-Side Rendering
초기 렌더링 마친 HTML
+
일부 업데이트를 위한 JS
(AJAX, InteracEon...)
Implement
서버에서 한번:
1. ReactDOMServer.renderToString()
함수로 react app을 html string 으로 변환
2. 서버의 '^/$' route 에서 출력하게 함
클라이언트에서 또 한번:
3. 'build/index.html'에는 react 런타임 코
드와 페이지 코드가 붙어 있음!
4. 모든 리소스를 staDc 으로 서버에 제공하자
render | hydrate
전체 HTML
렌더링
기존 HTML과 비교 후
부분 업데이트
ReactDOM.hydrate(<App />, document.getElementById('root'));
Checklist
스파게티는 먹을 때에만 | 서버 코드를 분리하자
asset-manifest.json | build 안에 담긴 보물
직선보다 빠른 굴절 | TypeScript를 사용하는 편이 좋은 이유
스파게티는
먹을 때에만
renderToString() 함수가 서버 코드와 섞이기
마련
bootstrap.js 파일을 추가로 작성해 각자의 코드
를 따로 불러올 것
git submodule 운용 가능
Server Client
bootstrap.js
express app
객체
renderToString()
결과물
app.listen() ...
Bad
export default () => {
const app: express.Application = express();
const router: express.Router = express.Router();
router.use('^/$', (req, res) => {
const html = ReactDOMServer.renderToString(<App />);
...
});
...
};
서버 코드 안에서
React를 직접 호출
Front - Server
지나친 커플링
Folder Structure
.
/build
/src
App.tsx
html.tsx
...
/server
index.tsx
htmlRenderer.tsx
...
bootstrap.js
기존 CRA 구조
서버 관련 코드 모음
최종 실행 파일
Good
// src/html.tsx
export default ReactDOMServer.renderToString(<App />);
// server/index.tsx
export default (
html: string,
paths: { buildPath: string, htmlPath: string },
): express.Application => {
const app: express.Application = express();
const router: express.Router = express.Router();
router.use('^/$', htmlRenderer(html, paths.htmlPath));
router.use(express.static(paths.buildPath, { maxAge: '1y' }));
app.use(router);
return app;
};
Good
// bootstrap.js
const listener = require('./server').default;
const html = require('./src/html').default;
const buildPath = path.resolve(__dirname, 'build');
const htmlPath = path.resolve(buildPath, 'index.html');
listener(html, { buildPath, htmlPath }).listen(5000, () => {
console.log('server listening on http://localhost:5000 ...');
});
html & path
매개변수로 제공
Front 환경이 바뀌어도
Server가 영향 받지 않음
git submodules
.
/build
/src
App.tsx
html.tsx
...
/server
index.tsx
htmlRenderer.tsx
...
bootstrap.js
.gitmodules
submodule:
git repo 내부의 또 다른 repo
참조 파일을 통해 관리
[submodule "ssr-server"]
path = server
url = https://github.com/내저장소/ssr-server.git
asset-
manifest.json
build 폴더에 생성된 모든 자산의 일람표
html string을 만들 때 staDc file의 참조를 가져다
쓸 수 있다
asset-
manifest.json
<img
src={logo}
className="App-logo"
alt="logo"
/>
+
"/static/media/logo.5d5d9eef.svg"
<img
src="/static/media/logo.5d5d9eef.svg"
class="App-logo"
alt="logo"
/>
logo.svg 파일 import 문을
해석하지 못하여
빈 객체가 그대로 출력됨
key: build 이전 원본 파일명
value: webpack이 bundling을 마친 파일명
Overwrite asset module's path
// bootstrap.js
const path = require('path');
const register = require('ignore-styles').default;
const assetManifest = require('./build/asset-manifest.json');
const searchAssetUrl = (manifest, filename) =>
Object.keys(manifest.files)
.filter(asset => asset.replace('static/media/', '') === filename)
.map(fileKey => manifest.files[fileKey]);
register(undefined, (module, filename) => {
const imgExtensions = ['.png', '.jpg', 'jpeg', '.gif', '.svg'];
const isImg = imgExtensions.find(ext => filename.endsWith(ext));
if (!isImg) return;
const [assetUrl] = searchAssetUrl(assetManifest, path.basename(filename));
module.exports = assetUrl;
});
ignore-styles:
node 실행 중
asset import module을
무시하게 해 주어 에러 방지
asset import module 등장 시
후처리 callback 제공
직선보다
빠른 굴절
왜 TypeScript가 SSR에 도움이 될까?
tsc 컴파일을 통해 CommonJS 파일 생성 가능
-> babel을 쓰지 않아 성능 향상 🚀 페르마의 원리
빛은 최단시간으로 이동할 수 있는 경로를 택한다
In JavaScript
// bootstrap.js
require('@babel/polyfill');
require('@babel/register')({
extensions: ['.js', '.jsx'],
ignore: [/(node_modules)/],
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: ['dynamic-import-node'],
});
// src/html.jsx
export default ReactDOMServer.renderToString(<App />); 소스를 JSX 파일로 가져옴소스를 JSX 파일로 가져옴
JSX를 node에서 실행할 수
없으므로 babel register 사용
@babel/polyfill
(async/await, Promise 핸들링)
dynamic-import-node
동적 import 구문 처리 플러그인
// tsconfig.ssr.json
... (기존 CRA tsconfig.json과 동일)
"noEmit": false,
"jsx": "react",
"rootDir": "src",
"outDir": "app_compiled"
},
"include": [
"src"
],
"exclude": [
"src/*.test.tsx"
]
}
Extra
필수 패키지 설치 이슈
Redux
서버 렌더링 단계에서 데이터를 미리 주입하고 싶을
때만 사용
index.html에 인라인으로 window 전역 store
객체 삽입 -> 클라이언트 코드가 읽어들임
(공식 문서에서 권하는 방식)
https://redux.js.org/recipes/server-
rendering
<script>
window.__PRELOADED_STATE__={}
</script>
+
React-
Router-
DOM
html 생성 단계에서 StaticRouter 를 App 위에
Wrapping
서버 또한'*' 경로 라우터를 추가해 react-router
가 만든 path로 접속되더라도 html을 반환하도록 처
리
// server/index.ts
router.use('*', htmlRenderer);
// src/html.tsx
const routerContext = {};
<StaticRouter
location={req.baseUrl}
context={routerContext}
>
<App />
</StaticRouter>
React-
Loadable
모듈 이름을 특정해줘야
서버 렌더링 시에 모듈 추적을 할 수 있음!
const Index = Loadable({
loader: () => import(/* webpackChunkName: "IndexChunk" */
'../components/view/index'),
loading: () => null,
modules: ['IndexChunk'],
});
전체 패키지 적용 후
// src/html.tsx
module.exports = (req: any) => {
const csrfToken = req.csrfToken();
const store = configureStore();
store.dispatch(addCsrfToken(csrfToken));
const modules = [];
const routerContext = {};
const pushModule = (moduleName: string) => modules.push(moduleName);
const html = ReactDOM.renderToString(
<Capture report={pushModule}>
<Provider store={store}>
<StaticRouter
location={req.baseUrl}
context={routerContext}
>
<App />
</StaticRouter>
</Provider>
</Capture>,
);
const helmet = Helmet.renderStatic();
const preloadedState = JSON.stringify(store.getState());
return {
helmet,
html,
preloadedState,
};
};
Redux Store 정의
CSRF 토큰 전달받아 주입
Loadable.Capture
어떤 loadable 컴포넌트가 렌더링되는지
추적
StaEcRouter
locaEon이 변하지 않는 라우터
첫 접속 이후엔 SPA 형태가 될 것이므로 필요
// src/index.tsx
declare global {
interface Window {
__PRELOADED_STATE__: object;
}
}
const store = configureStore(window.__PRELOADED_STATE__);
ReactDOM.hydrate(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
document.getElementById('root'),
);
전역 객체 declare
// server/htmlRenderer.ts
return res.send(
htmlData
.replace('<div id="root"></div>', `<div id="root">${result.html}</div>`)
.replace(
'<title>React App</title>',
result.helmet.title.toString() + result.helmet.meta.toString(),
)
.replace(
'<script type="text/javascript">window.__PRELOADED_STATE__={}</script>',
`<script type="text/javascript">window.__PRELOADED_STATE__=${
result.preloadedState
}</script>`,
),
);
// bootstrap.js
Loadable.preloadAll().then(() => {
app.listen(PORT, '0.0.0.0', () => {
console.log(`Listening on http://localhost:${PORT} ...`);
});
});
성공보다 빠른 시행착오를
응원합니다

Create-React-App으로 SSR을 구현하며 배운 점 (feat. TypeScript)

  • 1.
    CRA + SSR. (feat.TypeScript) 승형수 20@kross.kr
  • 2.
    승형수 국문과 -> 개발자 (주)한국어음중개 | Frontend Developer React, IaC, Cloud Infrastructure...
  • 3.
    • Server-Side Rendering? •ImplementaDon • Checklist • 스파게티는 먹을 때에만 | 서버 코드를 분리하자 • asset-manifest.json | build 안에 담긴 보물 • 직선보다 빠른 굴절 | TypeScript를 사용하는 편이 좋은 이유 • Extra • 필수 패키지 설치 이슈
  • 4.
  • 5.
    Server-Side Rendering? 기존 웹서버 전체데이터 담긴 Full - HTML 멋대로 비유하기 백화점에서 완제품 소파를 사온다
  • 6.
    Server-Side Rendering? + Client-Side Rendering 최소한의HTML + 화면 전체 렌더링을 위한 JS 멋대로 비유하기 이케아에서 조립형 소파를 사온다
  • 7.
    Server-Side Rendering? + Server-Side Rendering 초기렌더링 마친 HTML + 일부 업데이트를 위한 JS (AJAX, InteracEon...)
  • 10.
    Implement 서버에서 한번: 1. ReactDOMServer.renderToString() 함수로react app을 html string 으로 변환 2. 서버의 '^/$' route 에서 출력하게 함 클라이언트에서 또 한번: 3. 'build/index.html'에는 react 런타임 코 드와 페이지 코드가 붙어 있음! 4. 모든 리소스를 staDc 으로 서버에 제공하자
  • 13.
    render | hydrate 전체HTML 렌더링 기존 HTML과 비교 후 부분 업데이트 ReactDOM.hydrate(<App />, document.getElementById('root'));
  • 14.
    Checklist 스파게티는 먹을 때에만| 서버 코드를 분리하자 asset-manifest.json | build 안에 담긴 보물 직선보다 빠른 굴절 | TypeScript를 사용하는 편이 좋은 이유
  • 15.
    스파게티는 먹을 때에만 renderToString() 함수가서버 코드와 섞이기 마련 bootstrap.js 파일을 추가로 작성해 각자의 코드 를 따로 불러올 것 git submodule 운용 가능 Server Client bootstrap.js express app 객체 renderToString() 결과물 app.listen() ...
  • 16.
    Bad export default ()=> { const app: express.Application = express(); const router: express.Router = express.Router(); router.use('^/$', (req, res) => { const html = ReactDOMServer.renderToString(<App />); ... }); ... }; 서버 코드 안에서 React를 직접 호출 Front - Server 지나친 커플링
  • 17.
  • 18.
    Good // src/html.tsx export defaultReactDOMServer.renderToString(<App />); // server/index.tsx export default ( html: string, paths: { buildPath: string, htmlPath: string }, ): express.Application => { const app: express.Application = express(); const router: express.Router = express.Router(); router.use('^/$', htmlRenderer(html, paths.htmlPath)); router.use(express.static(paths.buildPath, { maxAge: '1y' })); app.use(router); return app; };
  • 19.
    Good // bootstrap.js const listener= require('./server').default; const html = require('./src/html').default; const buildPath = path.resolve(__dirname, 'build'); const htmlPath = path.resolve(buildPath, 'index.html'); listener(html, { buildPath, htmlPath }).listen(5000, () => { console.log('server listening on http://localhost:5000 ...'); }); html & path 매개변수로 제공 Front 환경이 바뀌어도 Server가 영향 받지 않음
  • 20.
    git submodules . /build /src App.tsx html.tsx ... /server index.tsx htmlRenderer.tsx ... bootstrap.js .gitmodules submodule: git repo내부의 또 다른 repo 참조 파일을 통해 관리 [submodule "ssr-server"] path = server url = https://github.com/내저장소/ssr-server.git
  • 21.
    asset- manifest.json build 폴더에 생성된모든 자산의 일람표 html string을 만들 때 staDc file의 참조를 가져다 쓸 수 있다 asset- manifest.json <img src={logo} className="App-logo" alt="logo" /> + "/static/media/logo.5d5d9eef.svg" <img src="/static/media/logo.5d5d9eef.svg" class="App-logo" alt="logo" />
  • 22.
    logo.svg 파일 import문을 해석하지 못하여 빈 객체가 그대로 출력됨
  • 23.
    key: build 이전원본 파일명 value: webpack이 bundling을 마친 파일명
  • 24.
    Overwrite asset module'spath // bootstrap.js const path = require('path'); const register = require('ignore-styles').default; const assetManifest = require('./build/asset-manifest.json'); const searchAssetUrl = (manifest, filename) => Object.keys(manifest.files) .filter(asset => asset.replace('static/media/', '') === filename) .map(fileKey => manifest.files[fileKey]); register(undefined, (module, filename) => { const imgExtensions = ['.png', '.jpg', 'jpeg', '.gif', '.svg']; const isImg = imgExtensions.find(ext => filename.endsWith(ext)); if (!isImg) return; const [assetUrl] = searchAssetUrl(assetManifest, path.basename(filename)); module.exports = assetUrl; }); ignore-styles: node 실행 중 asset import module을 무시하게 해 주어 에러 방지 asset import module 등장 시 후처리 callback 제공
  • 26.
    직선보다 빠른 굴절 왜 TypeScript가SSR에 도움이 될까? tsc 컴파일을 통해 CommonJS 파일 생성 가능 -> babel을 쓰지 않아 성능 향상 🚀 페르마의 원리 빛은 최단시간으로 이동할 수 있는 경로를 택한다
  • 27.
    In JavaScript // bootstrap.js require('@babel/polyfill'); require('@babel/register')({ extensions:['.js', '.jsx'], ignore: [/(node_modules)/], presets: ['@babel/preset-env', '@babel/preset-react'], plugins: ['dynamic-import-node'], }); // src/html.jsx export default ReactDOMServer.renderToString(<App />); 소스를 JSX 파일로 가져옴소스를 JSX 파일로 가져옴 JSX를 node에서 실행할 수 없으므로 babel register 사용 @babel/polyfill (async/await, Promise 핸들링) dynamic-import-node 동적 import 구문 처리 플러그인
  • 29.
    // tsconfig.ssr.json ... (기존CRA tsconfig.json과 동일) "noEmit": false, "jsx": "react", "rootDir": "src", "outDir": "app_compiled" }, "include": [ "src" ], "exclude": [ "src/*.test.tsx" ] }
  • 30.
  • 31.
    Redux 서버 렌더링 단계에서데이터를 미리 주입하고 싶을 때만 사용 index.html에 인라인으로 window 전역 store 객체 삽입 -> 클라이언트 코드가 읽어들임 (공식 문서에서 권하는 방식) https://redux.js.org/recipes/server- rendering <script> window.__PRELOADED_STATE__={} </script> +
  • 32.
    React- Router- DOM html 생성 단계에서StaticRouter 를 App 위에 Wrapping 서버 또한'*' 경로 라우터를 추가해 react-router 가 만든 path로 접속되더라도 html을 반환하도록 처 리 // server/index.ts router.use('*', htmlRenderer); // src/html.tsx const routerContext = {}; <StaticRouter location={req.baseUrl} context={routerContext} > <App /> </StaticRouter>
  • 33.
    React- Loadable 모듈 이름을 특정해줘야 서버렌더링 시에 모듈 추적을 할 수 있음! const Index = Loadable({ loader: () => import(/* webpackChunkName: "IndexChunk" */ '../components/view/index'), loading: () => null, modules: ['IndexChunk'], });
  • 34.
  • 35.
    // src/html.tsx module.exports =(req: any) => { const csrfToken = req.csrfToken(); const store = configureStore(); store.dispatch(addCsrfToken(csrfToken)); const modules = []; const routerContext = {}; const pushModule = (moduleName: string) => modules.push(moduleName); const html = ReactDOM.renderToString( <Capture report={pushModule}> <Provider store={store}> <StaticRouter location={req.baseUrl} context={routerContext} > <App /> </StaticRouter> </Provider> </Capture>, ); const helmet = Helmet.renderStatic(); const preloadedState = JSON.stringify(store.getState()); return { helmet, html, preloadedState, }; }; Redux Store 정의 CSRF 토큰 전달받아 주입 Loadable.Capture 어떤 loadable 컴포넌트가 렌더링되는지 추적 StaEcRouter locaEon이 변하지 않는 라우터 첫 접속 이후엔 SPA 형태가 될 것이므로 필요
  • 36.
    // src/index.tsx declare global{ interface Window { __PRELOADED_STATE__: object; } } const store = configureStore(window.__PRELOADED_STATE__); ReactDOM.hydrate( <Provider store={store}> <BrowserRouter> <App /> </BrowserRouter> </Provider>, document.getElementById('root'), ); 전역 객체 declare
  • 37.
    // server/htmlRenderer.ts return res.send( htmlData .replace('<divid="root"></div>', `<div id="root">${result.html}</div>`) .replace( '<title>React App</title>', result.helmet.title.toString() + result.helmet.meta.toString(), ) .replace( '<script type="text/javascript">window.__PRELOADED_STATE__={}</script>', `<script type="text/javascript">window.__PRELOADED_STATE__=${ result.preloadedState }</script>`, ), );
  • 38.
    // bootstrap.js Loadable.preloadAll().then(() =>{ app.listen(PORT, '0.0.0.0', () => { console.log(`Listening on http://localhost:${PORT} ...`); }); });
  • 39.