React로 Next.js 처럼 Server-side-rendering 구현하기

DylanJu
17 min readApr 15, 2020

안녕하세요. Next.js 없이 Client-side-rendering을 Server-side-rendering 으로 바꾸는 방법을 작성해보았습니다. 이전에 올렸던 글에서 미흡했던 부분을 보완해보았습니다.

Next.js는 서버사이드 렌더링에 필요한 거의 모든 기능을 지원하는 좋은 프로젝트입니다. 하지만 처음부터 Next.js로 만들지 않은 프로젝트는 전체를 리팩토링 해야 하고 이후에도 종속된다는 단점이 있습니다. 일반적으로 create-react-appreacr-router 로 시작하는데 적용이 어려워지죠.

그래서 기본적인 React 앱에서 시작해 오픈소스들로 Next.js의 기능 대부분을 지원하는 Example을 만들어봤습니다. Cookbook 느낌으로 작성한 글이니 편하게 커스터마이징해서 쓰실 수 있을 것입니다. Webpack이나 babel 등을 잘 모르시는 분들도 이해할 수 있도록 가능한 풀어 쓰려고 노력했습니다.

여기의 Repository를 Fork해서 자유롭게 사용하시면 됩니다. 아래 글을 읽어보시면 쉽게 이해하실 수 있을 겁니다. 저는 아래 설명드리는 기본 설정 외에 Jest 로 유닛 테스팅, Storybook 으로 UI 테스팅, eslintprettier 로 개발환경을 구축해 신규 프로젝트를 개발 중입니다.

Benefits and Limitations

이 글에 포함된 내용은 다음과 같습니다.
1. 저는 Typescript를 썼지만 Javascript로도 사용 가능합니다.
2. 상태관리는 Redux를 쓰지만 언제든지 Mobx로 바꿀 수 있습니다.
3. React-router와 @loadable-component의 조합으로 Server-side-rendering을 지원하는 Code-splitting을 사용할 수 있습니다.
4. React-helmet으로 SEO에 자유롭게 대응하실 수 있습니다.
5. Webpack middleware를 이용해 Hot-reloading이 가능한 개발환경을 세팅합니다.
6. 절대경로(Absolute path) 설정 방법입니다. 필수는 아니지만 Typescript와 Server-side-rendering을 함께 쓰면서 참고할만한 자료가 많이 없어서 고생했는데 개인적으로 기록할겸 공유드립니다.

물론 한계도 있습니다.

개발환경에서 CSS는 Hot reload는 아직 불가능합니다. @loadable-component 에서 지원하지 않기 때문인데 추후에 보완하도록 하겠습니다. 만약 지금도 가능한 방법을 아신다면 알려주세요 :-)
이 예제에서는 CSS 설정을 다루기는 하지만 Hot reload를 위해서 css-in-js 방식을 사용합니다. 만약 CSS로 개발환경까지 사용하실 분들은 Webpack-dev-server를 이용해 Client-side-rendering 방식으로 개발하고 배포만 Server-side-rendering으로 하셔도 무리 없을 것 같습니다.

Outputs for Server-side-rendering

요즘 프론트엔드에서 말하는 SSR은 엄밀히 말하면 Universal Rendering 이라고 불러야 합니다. Server-side rendering과 Client-side rendering을 혼합한 방법이기 때문입니다. 자세한 내용은 여기를 참고해주세요.
Universal Rendering 에서는 최초 렌더링(SSR)에 필요한 node/ 파일과 이후 CSR에 필요한 web/ 파일 2가지 결과물이 필요합니다. 즉 기존 코드에서 node/ 파일을 새로 만들어줘야 한다는 뜻입니다.
위에 그림에서 볼 수 있듯이 webpack 의 @loadable-component 설정을 통해 web/node/ 를 자동으로 만들어주니 걱정하지 않으셔도 됩니다 :-)

  • node/ : Server-side-rendering (최초 렌더링)에 필요한 Markup (=dehydrated)을 만드는 코드가 들어가 있습니다.
  • web/ : 최초 렌더링 이후 hydrate와 Client-side-rendering 에 필요한 파일이 들어가 있습니다. SSR을 적용하기 전의 코드와 거의 똑같습니다.
  • Rendering Server: node/ 를 이용해 Markup을 만들고 web/ 의 파일들을 <script>에 주입시켜 클라이언트로 전달하는 express server 입니다.

이렇게 3 종류의 결과물을 만들어내면 SSR을 배포할 준비가 끝난 것입니다. 배포할 최종 결과물뿐만 아니라 SSR 개발환경까지 함께 세팅해보겠습니다.

Server-side-rendering 을 위해 실제로 신경써야 하는 부분은 3 가지 입니다.

  1. @loadable-component 적용.
    React 공식 홈페이지에서 서버사이드 렌더링용 코드 스플리팅으로 추천하는 라이브러리 입니다. 현재는 Next.js 없이 서버사이드 렌더링용 코드 스플리팅을 구현하는 최선의 방법입니다. Webpack 설정이 다소 까다롭지만 하나씩 알아보며 구현해보겠습니다.
  2. Webpack & babel 설정
    Server-side-rendering 에서는 Webpack과 babel을 사용해 코드를 node/ , web/ 2종류로 컴파일해야 합니다. (엄밀히 말해 컴파일이 아니라 트랜스파일링이지만 이 글에서는 컴파일이라고 통일하겠습니다)
    Webpack으로 여러종류의 Output을 만들어 내는 방식은 Multil-compiler 라고 하는데, Array로 export 해주면 webpack engine이 각각 컴파일해줍니다.
    또한 SSR에서는 webpack-dev-server 대신 webpack-dev-middleware와 webpack-hot-middleware로 개발용 dev server를 직접 세팅해줘야 합니다.
  3. 렌더링용 Express Server 구축
    아마 프론트엔드 개발자라면 백엔드에 익숙하지 않아서 URL에 맞는 초기 렌더링과 static file, SEO 설정이 어려우실 수도 있습니다. 하지만 @loadable-componentreact-helmet 에서 제공하는 API를 사용하면 손쉽게 처리하실 수 있습니다.

@loadable-component

React 공식문서에서 SSR에서도 Code-splitting 으로 추천해주는 라이브러리 입니다. SSR의 장점인 초기 로딩속도를 더욱 빠르게 해줄 수 있습니다.
CSR에서의 Code-splitting 과 SSR에서의 Code-splitting 비교는 여기에서 확인하실 수 있습니다.(준비 중입니다)

먼저 서버와 클라이언트에서 쓰일 엔트리포인트 파일을 만들어 보겠습니다. 최초 렌더링은 서버에서 해주므로, 클라이언트의 엔트리 파일은 index.tsx 가 아닌 app.tsx 가됩니다.

index.tsx (server entry point)

hydrate : Server-side-rendering 에서 ReactDOM.render 대신에 쓰이는 메소드입니다. 서버에서 렌더링된 최초의 html에 javascript 이벤트(로직)을 붙이는 식의 방법입니다. 자세한 사항은 여기를 참고해주세요.

loadableReady : @loadable-component 로 Code-splitting 한 컴포넌트들을 정상적으로 렌더링할 수 있게 준비해주는 함수입니다. Server-side-rendering 전용 함수입니다.

app.tsx (client entry point)

loadable : 컴포넌트들을 Code-splitting 해주는 import 함수입니다. 저는 보통 Route 기반으로 나누는데, 다른 경우에도 적용할 수 있다는걸 보여주기 위해서 Header , Footer 컴포넌트들도 적용해보았습니다. webpackChunkName 은 나눠진 파일에 이름을 지정할 수 있게 해주는 magic commnets 라는 기능입니다. 자세한 사항은 여기를 참고해주세요.

webpack

webpack.client.js (you can change this filename you want)

이번에는 해당 파일들을 컴파일 할 수 있도록 webpack과 babel 설정을 다뤄보겠습니다.

getEntryPoint : 서버와 클라이언트에 맞게 entry 파일도 각각 설정해줍니다. 기존에는 앱 전체 렌더링을 항상 index.tsx 에서 담당했는데, 서버사이드 렌더링은 최초의 렌더링을 express server 에서 담당하게 됩니다. 마지막 module.exports 부분에서 array 형식으로 export 하는데 위에서 말씀드린 Multi Compiler 방식입니다. 하나의 webpack config로 Server-side 파일(node)과 Client-side 파일(web)을 동시에 Compile하는 방법입니다.

hotMiddlewareScript: 개발환경에서 hot-reload 를 가능하게 해주는 설정입니다. target: 'web' 의 entry point 맨 앞에 넣어줍니다. Multi Compiler 로 compile하는 코드 중에 web 만 hot-reload를 해주면 되기 때문에 query parameter 로 name=web 을 전달해주면 됩니다. webpack-hot-middleware 가 어떤 파일을 hot-reload 해야하는지 알 수 있습니다.

target : 어떤 환경에서 실행할지 webpack에게 알려주는 역할입니다. Rendering server(express) 에서 필요한 node 와 Client 에서 필요한 web 으로 설정해주면 실행 환경에 맞게 컴파일 해줍니다.

output : 서버사이드 렌더링에 필요한 파일과 클라이언트 사이드 렌더링에 필요한 파일을 나눠서 저장하기 위해 target 이름에 따라 폴더를 구분해주겠습니다.

libraryTarget : node 일 경우 commonjs2 로 설정해둡니다. node.js는 module 시스템에서 commonjs 방식을 채택했기 때문입니다. web 은 default var 설정을 명시해주거나 undefined 를 넣어줍니다.

plugins : 서버사이드 렌더링을 도와줄 @loadable/webpack-plugin 을 넣어줍니다. node 타겟의 경우 HMR plugin은 제외시켜줍니다.

externals : 서버사이드 렌더링은 node 환경에서 실행되므로 nodeExternals() 를 추가해서 npm package 들을 제외시켜줍니다. 단 코드 스플리팅된 파일은 express server 에서 사용해야 하므로 @loadable-component 를 Array pattern 의 첫 번째 인자로 설정해줍니다.

babel

@loadable-component 의 가이드를 그대로 가져와서 Typescript 설정만 추가해주었습니다.

isWebTarget : webpack 에서 설정 target을 인식합니다. useBuiltIns 은 babel polyfill 설정인데, 'entry' 인 경우 @core-js 모듈로 import을 파싱합니다. target: { node: 'current' } 설정은 현재 node 버전에 맞게 최적화를 해줍니다. 나중에 더 많은 브라우저를 대응해야 할때 web에 설정된 undefined 대신 browserlist 방식으로 ie 등을 대응하시면 됩니다.

plugins : 코드 스플리팅을 인식하기 위해 @loadable/babel-plugin 을 추가해줍니다.

Express Server for rendering

첫번째 그림 우측의 Rendering Server 를 만드는 과정입니다. Rendering Server 는 React 코드를 Initial Render(=Server-side Render) 해주는 일을 합니다. 동시에 접속 URL에 따라 SEO를 적용합니다.

server.tsx

renderToString : 리액트에서 지원하는 서버사이드 렌더링 메소드입니다. 클라이언트 사이드에서도 ReactDOM.render 대신 hydrate를 사용하는 것과 연관이 있는데 해당 코드에서 설명하겠습니다.

helmet : 우리가 클라이언트 사이드에서 사용한 react-helmet의 정보를 가져오는 method 입니다. 꼭! renderToString 다음에 선언해주셔야 helmet의 정보를 정상적으로 가져올 수 있습니다. res.send 부분을 보시면 이번에는 title 정보를 주입해주고 있습니다. react-helemt은 head에 사용할 수 있는 대부분의 태그를 지원하니 필요하시면 공식 문서를 참고해 진행하시면 됩니다.

StaticRouter : 서버사이드 렌더링에서 BrowserRouter 대신 사용하는 React-router 입니다. 접속 Url의 Router 정보를 클라이언트 사이드 파일에 전달해주는 역할을 합니다. location 에 req.url, context 에 object를 넣어 유저가 클라이언트의 BrowserRouter에게 전달해줍니다.

res.send : body 태그에 앞에서 뽑아낸 html string을 주입해줍니다. 그리고 script tag로 bundle파일의 uri를 넣어주시면 됩니다. 아직은 Code-splitting을 하지 않았으므로 main.js 하나의 번들파일을 넣어주시면 됩니다.

if (process.env.NODE_ENV !== 'production') 부분에는 WDM과 WHM 설정을 해줍니다. webpack.client.js 파일을 weppack 으로 실행시켜주면 컴파일러가 됩니다. express.static 처럼 request가 들어왔을 경우 파일을 보내주기 위해 app.use 해주시면 됩니다.

webpackConfig : 원래는 단순히 require('../webpack.client.js') 하면 되는 부분이지만 제 설정상 약간의 꼼수가 들어간 부분입니다. 저는 둘 다 타입스크립트를 쓰다보니 server.tsx 를 중복해서 컴파일하는 상황이 발생했습니다. 처음에 tsxjs 로 컴파일하고, webpack-dev-middleware 가 파일을 읽을때 webpack으로 또 컴파일하는 프로세스 때문입니다. 결과적으로 output path가 처음 의도대로 dist/ 가 아닌 dist/dist 에 생성됩니다. 그래서 강제로 2번째 컴파일하는 부분에 원복시켜주는 방법을 썼습니다.

webpack-dev-middleware : 첫 번째 파라미터로 compiler를, 두 번째 파라미터로 options를 설정해주실 수 있습니다. options의 다른 설정은 모두 optional 이지만 publicPath는 필수인데요. 보통 { publicPath: compiler.output.publicPath } 형태로 컴파일러의 설정을 그대로 씁니다.

webpack-hot-middleware : WDM과 마찬가지로 두 번째 파라미터로 options를 전달할 수 있습니다. 저는 webpack.client.js 에서 query string 방식으로 설정하겠습니다.

이제 서버사이드 렌더링을 위한 webpack 설정을 해보겠습니다.

target : node.js 환경에서 돌아갈 파일을 컴파일할 때 쓰는 설정입니다. 보통 프론트엔드 파일은 브라우저에서 실행하기 때문에 target: web 이 되지만 server.tsx 파일은 node.js의 로 express 서버를 실행하기 때문에 node로 바꿔주시면 됩니다.

node : node.js의 global property에 polyfill을 적용할지 여부입니다. boolean 혹은 object 값을 할당하시면 되는데, false일 경우 모든 polyfill을 쓰지않는 것이고 object는 각각을 설정해주실 수 있습니다.
* polyfill을 적용하지 않으면 __dirname 같은 node.js global property를 webpack 규칙으로 처리하게 됩니다. __dirname을 node.js가 처리할 경우 항상 ‘/’를 반환합니다. 저희는 webpack을 이용해 파일위치를 설정할 것이므로 false 혹은 { __dirname: false } 라고 해주시면 됩니다.

entry : react 코드가 아닌 express 코드를 컴파일 하니까, src/server.tsx 의 위치를 입력해주세요.

output : 저는 dist/ 폴더에 배포할 모든 파일을 모아놓을 것입니다. 다음에 나올 클라이언트 번들파일과 함께 server 파일 역시 dist 폴더에 컴파일 하겠습니다.

externals : 앞서 말씀드린대로 서버에서 필요없는 node_modules 를 제외하기 위해 nodeExternals() 를 넣어주세요.

Typescript

Typescript 를 Javascript로 컴파일하는데 필요한 tsconfig.json 파일을 작성해보겠습니다.

node_modules/.bin/tsc --init

이번 과정에서 Typescript 컴파일을 직접 할 필요는 없어서 .bin의 명령어를 직접 입력했습니다. npm script로 실행하실 분들은 다음과 같이 scripts 에 추가해주시고 CLI에서 실행해주시면 됩니다.

"scripts": {
"tsc": "tsc"
},
$ yarn tsc --init

CLI를 실행하면 tsconfig.json 파일이 자동으로 생성되고 각 옵션에 대한 default 값과 설명이 함께 들어가서 매우 편리합니다. 각각에 대한 설명은 주석을 읽어보면 자세히 설명되어 있으니 저는 필요한 설정만 말씀드리겠습니다.

targetmodule“esnext”로, moduleResolution 값은 “node”로 해두셔야 합니다. Typescript는 Javascript의 슈퍼셋으로 babel이 하는 역할도 포함하고 있습니다. 따라서 이론적으로는 Typescript를 사용하면 babel을 쓰지 않아도 됩니다. 하지만 아직까지 대부분의 package 들이 babel 에서 돌아가는 것을 전제로 하고, babel plugin을 제공합니다. 따라서 Typescript 는 type checking에 집중하고, babel은 es6/es7 문법에 집중하게 할 것입니다.

Typescript로 작성된 파일들은 Typescript 컴파일 후에 babel이 읽는 순서로 진행됩니다. (babel은 Javascript 파일만 읽을 수 있기 때문에 Typescript 컴파일이 선행돼야 합니다) 이때 target이나 module을 “esnext”로 설정하지 않는다면 import 구문을 babel 대신 Typescript가 파싱하고, babel plugins이 정상작동을 하지 않을 수 있습니다.

이번에 code-splitting에 사용할 @loadable-component 역시 babel plugins을 통해 import 구문을 파싱하기 때문에 Typescript가 import 구문을 해석하면 정상적으로 작동하지 않습니다.

jsx 는 리액트 jsx 문법을 보존하도록 “preserve”로 설정하시면 됩니다.

Bonus: Absolute Path 적용하기

Typescript는 기본적으로 Absolute Path를 지원하지만, Javascript는 babel plugin 을 통해서 지원합니다. 해당 패키지를 설치하고 path를 지정해보겠습니다. 적용할 파일은 .eslint, babel.config.js, tsconfig.json 세 가지 파일입니다.

$ yarn add --dev babel-plugin-module-resolver

.eslintrc : settings 항목에 typescript: { "alwaysTryTypes" : true } 라는 값을 추가시켜 줍니다. module resolution 할 때 tsconfig.json 을 우선적으로 적용해주는 옵션입니다.

babel.config.js : plugins 에위에서 설치한 module-resolver 을 추가하고 path와 extensions를 설정해주시면 됩니다. 저는 @를 prefix로 사용해서 내부 모듈을 사용하도록 설정했습니다.

tsconfig.json : 기존에 비어있던 path 부분에 추가시켜주면 됩니다. babel 과 설정하는 부분이 미세하게 다른데, 폴더의 경우 마지막에 /* 을 지정해주셔야 합니다. 만약 폴더가 아니라 파일을 absolute path로 지정하실 경우에는 빼주시면 됩니다.

혹시 잘못된 내용이 있다면 적극적으로 알려주세요!
긴 글 읽어주셔서 감사합니다 :-)

--

--