서버 사이드 렌더링(SSR)은 서버에서 먼저 렌더링을 수행하여 HTML을 구성해서 클라이언트에게 보내주는 렌더링 방식이다. 이렇게 하면 빈 HTML만 보내주고 클라이언트 측에서 자바스크립트를 실행해 나머지를 렌더링하도록 하는 클라이언트 사이드 렌더링(CSR) 방식보다 더 빠르게 첫 화면을 그려줄 수 있다. Next.js, Remix와 같은 리액트 기반 프레임워크에서도 SSR을 지원해주고 있다.
이번 프로젝트에서는 SSR을 지원해주는 프레임워크를 전혀 사용하지 않고, Node 서버와 React만을 가지고 SSR 서버를 구성해보기로 했다.
우리 서비스는 목표 관리 기법 중 하나인 만다라트 기법을 간소화한 ‘반다라트’ 서비스이다. 반다라트 표는 휴대폰 앱으로 만들 수 있고, 내가 만든 표를 웹 공유 링크를 통해 공유할 수 있다.
어떤 서비스인지 궁금하다면? 데모 확인하기웹 공유 링크를 구현하기 위해, 웹 서버는 공유 키가 포함된 url로 GET 요청을 받으면, 이 공유 키에 대한 실제 표 데이터를 API 서버에서 받아와야 하고, 반다라트 표를 보여줄 수 있는 HTML을 응답으로 보내줘야 한다.
이번 프로젝트에서는 SSR 방식을 활용하기로 했다.
웹 공유 페이지는 사용자와의 인터랙션이 전혀 없기 때문에, 자바스크립트가 필요하지 않은 상황이었다. 최초 렌더링도 서버에서 수행한다면 서버는 클라이언트에게 자바스크립트를 아예 보내주지 않을 수도 있었다. 그래서 서버 사이드 렌더링을 수행해 가볍고 빠른 웹 서비스를 만들고 싶었다.
이때, Next.js와 같은 서버 사이드 렌더링 프레임워크는 사용하지 않기로 했다.
Next.js나 Remix는 서버 사이드 렌더링만 제공하진 않는다. 그 외에 이미지 최적화나 파일 시스템 기반 라우팅 등 다양한 부가기능도 제공하는데, 우리 서비스는 이런 부가기능을 전혀 사용하지 않아도 되는 상황이어서 오히려 이런 프레임워크는 불필요하게 무겁다고 판단했다.
기술 선택
Koa
우선 서버 사이드 렌더링을 수행해줄 Node.js 서버가 필요했다. Node.js 기반 프레임워크는 여러가지가 있지만, 이번 프로젝트에서는 Koa를 선택했다.
Koa는 가장 유명한 Node 프레임워크인 express 개발자 일부가 만든 프레임워크로, 가장 큰 특징은 코어와 모든 미들웨어를 각각의 패키지로 분리시켜 뒀다는 것이다. 그래서 Koa 코어 패키지에는 라우팅조차 없다. 오직 서버를 띄우는 것만 가능하다.
Koa를 선택한 이유는 무엇보다도 가볍기 때문이다. 미들웨어가 필요해질 때마다 필요한 것만 설치해서 사용하기로 했다.
React
UI 레이아웃 구성을 효율적으로 하기 위해 리액트를 선택했다. SSR 서버를 구성하는 것에 좀 더 집중하기 위해 가장 익숙한 UI 라이브러리를 선택했다. 나중에는 다른 것으로 변경하는 것도 고려하고 있다.
Linaria
내가 가장 선호하는 스타일링 방식은 css-in-js 방식이다. 그렇지만 styled-components는 선호하지 않는데, 런타임에 자바스크립트로 동작하다 보니 조금만 잘못 사용해도 눈에 띄는 성능 저하가 발생하기 때문이다.
Linaria는 css-in-js 방식을 제공하면서 스타일을 빌드 타임에 생성해 런타임에는 자바스크립트를 전혀 실행하지 않을 수 있게 해주는 라이브러리이다.
빌드 시점에 Linaria로 작성한 스타일은 CSS 파일로 생성되고, JSX에 포함된 Linaria 코드는 각 엘리먼트의 classname으로 설정된다. 서버에서 SSR을 수행하는 동안에도 스타일 관련 코드는 실행되지 않기 때문에 스타일과 관련된 오버헤드를 제거할 수 있다.
Webpack
번들러는 웹팩을 사용했고, 리액트와 타입스크립트 트랜스파일링을 위해 바벨을 함께 사용했다. Linaria 빌드를 위한 플러그인을 사용하려면 웹팩이 가장 편할 것 같아 웹팩을 선택했다.
서버 구성하기
서버에서 해줘야 하는 일은 아래와 같다.
- /share/{share_key} 경로로 들어오는 GET 요청을 받는다
- share_key에 대한 반다라트 정보를 API 서버에 요청한다
- 받아온 데이터로 반다라트 표를 렌더링해 HTML을 만든다
우선 브라우저를 통해 /share/{share_key}
경로에 접근할 수 있도록 Node.js를 사용해 웹 서버를 구축했다.
1// server.ts2import koa from "koa";3import { configDotenv } from "dotenv";45configDotenv();67const PORT = process.env.PORT;8const HOST = process.env.HOST;910const app = new koa();1112app.listen(PORT, () => {13 console.log(`Server is running on http://${HOST}:${PORT}`);14});
이렇게 하면 간단한 서버를 내 로컬에 띄울 수 있다. 서버 port나 host는 나중에 코드 수정 없이도 변경할 수 있도록 환경변수로 빼냈다.
특정 경로의 요청을 처리하려면 라우팅 처리가 필요하다. Koa에서 라우팅을 처리하기 위해선 @koa/router
미들웨어를 설치해야 한다.
1// router.ts2import Router from "@koa/router";34const router = new Router();56router.get("/share/:key", (ctx) => {7 const key = ctx.params.key;8 ctx.response.body = key;9});1011export default router;
1// server.ts2const app = new koa();34app.use(viewRouter.routes()).use(viewRouter.allowedMethods());
이렇게 하면, /share/sharekey
경로에 접근했을 때 응답으로 sharekey
값을 보내주게 된다.
React로 UI 구성하기
JSX 문법을 통해 편리하게 UI를 구성하기 위해 복잡하고 가변적인 UI는 리액트를 사용해 구성하기로 했다. 리액트는 서버용 API를 따로 제공하는데, renderToString과 같은 함수를 호출하면 리액트 트리를 렌더링해서 HTML string으로 변환해준다.
아래와 같이 간단한 React 컴포넌트를 만들었다. 이 App 컴포넌트를 root로 하는 리액트 트리를 만들어서 웹 페이지를 구성할 것이다.
1// ui/App.tsx2import React from "react";34export const App = () => {5 return <div>hello world</div>;6};
그리고 이 React 컴포넌트가 그려질 빈 HTML을 작성했다.
1const createHtml = (content: string) => `2 <!DOCTYPE html>3 <html lang="ko">4 <head>5 <title>반다라트</title>6 </head>7 <body>8 <div id="root">${content}</div>9 </body>10 </html>11`;
리액트 트리를 렌더링해서 위 문자열의 content 부분에 넣어줄 것이다.
서버에서 리액트를 렌더링하려면 리액트에서 제공하는 서버용 API를 사용해야 한다. 공식 문서를 참고하면 스트리밍 여부에 따라 API가 분리되는데, 지금은 스트리밍이 필요하지 않아서 non-streaming enviornments API 중 인터랙티브하지 않은 리액트 트리를 위한 API인 renderToStaticMarkup
을 사용해서 렌더링할 것이다.
renderToString
VS renderToStaticMarkup
renderToString
과 renderToStaticMarkup
의 차이는 클라이언트 측에서 hydration을 실행할 것인지 여부로 나뉜다. renderToString
은 hydration을 실행할 것을 전제로 DOM을 구성하는 반면, renderToStaticMarkup
은 이름 그대로 마크업만을 만든다.
App 컴포넌트 내에서 리액트 훅을 사용하게 만들었다.
1export const App = () => {2 const [count, setCount] = useState(123);3 return (4 <div>5 <div>count: {count}</div>6 <button onClick={() => setCount((prev) => prev + 1)}>add</button>7 </div>8 );9};
두 서버 API 모두 아래와 같은 화면을 그리는 DOM을 만들어준다.
보이는 건 같지만, DOM 상에서 약간의 차이가 생긴다. renderToString
은 ‘count: 123’ 문자열 사이에 주석을 만들어 둔 것을 확인할 수 있다.
renderToString
renderToStaticMarkup
count: 와 123이 주석으로 분리되는 이유는 두 값은 서로 다른 element로 생성되기 때문이다. renderToString
실행 시 리액트는 hydration 과정에서 이들을 다르게 처리하기 위해 둘을 주석으로 분리해 두었다.
1{2 type: 'div',3 key: null,4 ref: null,5 props: {6 children: [7 {8 type: 'div',9 key: null,10 ref: null,11 props: { children: ['count: ', 123] },12 _owner: null,13 _store: {}14 },15 {16 type: 'button',17 key: null,18 ref: null,19 props: { children: 'add' },20 _owner: null,21 _store: {}22 }23 ]24 },25 _owner: null,26 _store: {}27};
현재는 클라이언트 측에서 hydration이 필요한 기능이 없어서, renderToStaticMarkup
을 사용하기로 했다.
렌더링 시, 당연히 컴포넌트에 prop 값도 넘겨줄 수 있다.
1// ui/App.tsx2import React from "react";34export const App = ({ bandalartKey }: { bandalartKey: string }) => {5 return <div>{bandalartKey}</div>;6};78// router.tsx9router.get("/share/:key", (ctx) => {10 const key = ctx.params.key;11 const content = renderToStaticMarkup(<App bandalartKey={key} />);12 ctx.response.body = createHtml(content);13});
이렇게 하면 /share/key
경로로 접근한 사용자에게 응답으로 HTML을 보내줄 수 있다.