MERN 기술 스택 고급 튜토리얼 (7)

서버 렌더링

이 장에서는 React의 핵심 개념 중 하나인 서버에서 HTML을 생성하는 기능을 탐구합니다. 이를 통해 동일한 코드베이스를 사용해 서버와 클라이언트 모두에서 렌더링할 수 있는 동일성 애플리케이션(isomorphic app)을 구축할 수 있습니다.

기존의 단일 페이지 애플리케이션(SPA)은 초기에 빈 문서를 로드하고, 데이터를 비동기적으로 가져와 브라우저에서 DOM을 구성하는 방식입니다. 반면 서버 렌더링은 서버가 전체 HTML을 미리 생성하여 전송하는 방식입니다. 이는 검색 엔진 인덱싱이 필요한 경우 필수적입니다. 검색봇은 일반적으로 루트 경로(/)부터 시작해, 응답된 HTML 내부의 링크를 따라 탐색합니다. 하지만 자바스크립트를 실행하거나 동적으로 변경된 DOM을 분석하지 않기 때문에, 데이터가 서버에서 사전에 포함되어야만 정확한 인덱싱이 가능합니다.

예를 들어 /issues 요청 시 반환되는 HTML은 문제 목록이 미리 채워져 있어야 합니다. 이 원칙은 모든 공개 접근 가능한 페이지에 적용됩니다. 그러나 이는 SPA의 네비게이션 경험을 훼손할 수 있으므로, 다음과 같은 혼합 전략을 취합니다:

  • 처음으로 페이지를 열거나 새로고침하면, 서버에서 완전한 페이지를 생성하여 반환 → 서버 렌더링
  • 이후 네비게이션은 클라이언트에서만 처리 → 브라우저 렌더링

이러한 방식은 홈 페이지뿐 아니라, 특정 잡지 편집 페이지와 같은 직접적인 URL 접근에도 적용됩니다.

새로운 디렉터리 구조

현재 소스 구조는 클라이언트와 서버 사이의 경계가 명확하지 않습니다. 이제 세 가지 유형의 파일을 분리해야 합니다:

  1. 공유되는 리액트 컴포넌트
  2. 서버용으로 실행되는 리액트 코드 (서버 렌더링용)
  3. 브라우저에서 실행되는 번들 파일

이를 위해 ui 폴더 아래에 src, browser, server 폴더를 생성하고, 각각의 파일을 이동합니다.

$ cd ui
$ mkdir browser server
$ mv src/App.jsx browser/
$ mv uiserver.js server/

이 변경은 린팅 및 번들링 설정에도 영향을 미칩니다. .eslintrc 파일을 각 폴더에 별도로 배치하고, 상속 관계를 설정합니다. ui/.eslintrc에서는 기본 규칙을 지정하고, src/.eslintrc에서는 브라우저 및 노드 환경을 모두 지원하도록 설정합니다. browser/.eslintrc는 브라우저 전용이고, server/.eslintrc는 노드 전용입니다.

또한 babel 설정과 webpack.config.js의 엔트리 포인트, package.json의 스크립트도 수정해야 합니다. 최종적으로 npx webpack 명령어로 서버 번들을 생성할 수 있게 됩니다.

기본 서버 렌더링

리액트는 ReactDOM.render()로 브라우저의 DOM에 렌더링하지만, 서버에서는 ReactDOMServer.renderToString() 메서드를 사용합니다. 이 메서드는 컴포넌트의 문자열 표현을 반환합니다.

간단한 About 컴포넌트를 예제로 사용합니다. 먼저 App.jsxPage.jsx에 라우팅 및 네비게이션 항목을 추가합니다.

컴포넌트를 서버에서 사용하려면 JSX를 순수 자바스크립트로 변환해야 합니다. 다음 명령어로 컴파일합니다:

$ npx babel src/About.jsx --out-dir server

이제 server/About.js 파일이 생성되며, 이를 서버 코드에서 불러올 수 있습니다. 그러나 단순히 컴포넌트를 렌더링하는 것만으로는 충분하지 않습니다. <head> 태그, 스타일 시트, 스크립트 등 전체 HTML 구조를 포함해야 하므로, index.html을 템플릿으로 활용합니다. 이 작업은 template.js 파일에 구현됩니다.

function template(body, data) {
  return `
<html>
<head>
  <meta charset="utf-8">
  <title>Pro MERN Stack</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <style>table.table-hover tr {cursor: pointer;}</style>
</head>
<body>
  <div id="contents">${body}</div>
  <script>window.__INITIAL_DATA__ = ${JSON.stringify(data)}</script>
  <script src="/env.js"></script>
  <script src="/vendor.bundle.js"></script>
  <script src="/app.bundle.js"></script>
</body>
</html>
`;
}

이 템플릿은 렌더링된 컨텐츠와 초기 데이터를 포함한 전체 문서를 반환합니다. 이 정보를 바탕으로 render.js 파일을 작성하고, uiserver.js에 라우트를 추가합니다.

웹팩을 통한 서버 번들링

수동으로 컴파일하는 것은 비효율적이므로, 웹팩을 서버용 번들링에도 적용합니다. webpack.config.js에 서버용 설정을 추가하고, webpack-node-externals를 사용해 외부 모듈을 제외합니다.

이후 import/export 문법을 일관되게 사용하기 위해, ESLint 규칙을 조정하고, render.jsx 파일을 생성합니다. 또한 source-map-support 패키지를 설치해 오류 추적을 개선합니다.

서버용 HMR (Hot Module Replacement)

서버 코드의 변경을 실시간으로 반영하기 위해, 웹팩의 HMR 기능을 확장합니다. webpack.serverHMR.js라는 별도의 설정 파일을 만들어, 변경 감지 후 서버가 재시작되지 않고도 모듈을 갱신하도록 합니다.

$ npx webpack -w --config webpack.serverHMR.js

이후 package.jsondev-all 스크립트를 추가해, 서버 번들링과 실제 서버 실행을 동시에 시작할 수 있도록 합니다.

서버 라우팅

서버 렌더링 시 BrowserRouter 대신 StaticRouter를 사용해야 합니다. 이는 서버 환경에서 동작하는 라우터이며, 요청된 location을 직접 제공해야 합니다.

const element = (
  <StaticRouter location={req.url} context={{}}>
    <Page />
  </StaticRouter>
);

이렇게 함으로써 서버에서도 네비게이션 바와 같은 요소가 포함된 완전한 페이지를 렌더링할 수 있습니다.

하이드레이션 (Hydration)

렌더링된 마크업에 이벤트 리스너를 추가하기 위해 ReactDOM.hydrate()를 사용합니다. 이는 render()와 유사하지만, 이미 존재하는 DOM에 연결되어 이벤트를 붙이는 데 특화되어 있습니다.

ReactDOM.hydrate(element, document.getElementById('contents'));

이 과정을 통해 서버에서 생성된 페이지가 실제로 사용자 인터랙션을 할 수 있도록 합니다.

API에서 데이터 가져오기

About 컴포넌트는 서버에서 graphQLFetch를 호출해 실제 데이터를 가져옵니다. 이 함수는 isomorphic-fetch를 사용해 브라우저와 서버 모두에서 작동하도록 설계됩니다.

서버에서 가져온 데이터는 store.js라는 전역 저장소에 저장됩니다. 이 데이터는 브라우저에서 다시 초기화되어, 서버와 클라이언트 사이의 데이터 일치성을 보장합니다.

// 서버
store.initialData = await graphQLFetch('query{about}');

// 브라우저
store.initialData = window.__INITIAL_DATA__;

공통 데이터 추출기

모든 페이지에 대해 동일한 패턴을 적용하기 위해, 컴포넌트마다 fetchData 정적 메서드를 정의합니다. 이 메서드는 라우트 매칭을 통해 어떤 컴포넌트가 활성화되었는지 확인하고, 해당 컴포넌트의 fetchData를 호출합니다.

const activeRoute = routes.find(route => matchPath(req.path, route));
if (activeRoute && activeRoute.component.fetchData) {
  const match = matchPath(req.path, activeRoute);
  initialData = await activeRoute.component.fetchData(match);
}

매개변수 및 검색 파라미터 처리

IssueEditIssueList 컴포넌트는 라우트 파라미터나 쿼리 스트링을 처리해야 합니다. 이를 위해 fetchData 메서드에 matchsearch 파라미터를 전달하고, 서버에서는 요청 URL에서 ? 이후 부분을 추출하여 전달합니다.

const index = req.url.indexOf('?');
const search = index !== -1 ? req.url.substr(index) : null;

중첩 컴포넌트 처리

IssueDetail 컴포넌트는 IssueList 내부에서 처리됩니다. routes.js/:id? 형식으로 옵셔널 파라미터를 정의하고, fetchData에서 @include 지시자를 사용해 선택된 문제의 세부 정보를 조건부로 가져옵니다.

issue(id: $selectedId) @include(if: $hasSelection)

이 방식으로 한 번의 쿼리로 문제 목록과 선택된 문제의 세부 정보를 함께 가져옵니다.

리다이렉트 처리

홈페이지(/) 요청 시, StaticRoutercontext.url 속성을 이용해 301 리다이렉트를 발생시킵니다.

if (context.url) {
  res.redirect(301, context.url);
} else {
  res.send(template(body, initialData));
}

이로써 검색엔진은 / 경로에서도 /issues의 내용을 얻을 수 있게 됩니다.

태그: React Node.js Webpack GraphQL Server-Side Rendering

5월 23일 19:42에 게시됨