Angular Universal

4 분 소요

Angular에서 SSR을 사용하는 방법입니다. 우수한 Framework 답게 쉽고 빠르게 적용할 수 있도록 잘 구성되어 있기 때문에, 아래의 예제만 따라해도 충분히 실전에 사용할 수 있을 것 입니다.

동작 원리

Universal 웹 서버는 static HTML 페이지를 template 엔진에 랜더링 하며, 브라우저의 도움 없이 DOM, XMLHttpRequest 또는 low-level 을 서버에서 처리할 수 있습니다.

서버는 클라이언트의 요청을 ngExpressEngine에 전달하고 renderModuleFactory() 함수를 통해 template 태그의 내용을 랜더링하여 client에 전달합니다.

지원 여부

Angular Universal은 대표적으로 4가지 방식의 엔진을 지원하고 있습니다.

@nguniversal/express-engine
@nguniversal/aspnetcore-engine
@nguniversal/hapi-engine
@nguniversal/socket-engine

이 외에도 모질라의 dom.js 기반 및 공통으로 처리할 common 등이 개발되어 있으며 계속 업데이트 중에 있습니다.

프로젝트 생성

angular 프로젝트를 생성합니다. 기존 angular 프로젝트 생성과 방식은 동일합니다.

ng new ssr_project

ssr_project라는 이름의 프로젝트를 생성하였습니다.

universal 엔진 추가

프로젝트가 생성된 폴더로 이동 후 서버 엔진을 추가합니다. 여기에서는 Node.js의 express 엔진을 사용하겠습니다. 따라서 Node가 반드시 설치 되어 있어야 합니다.

ng add @nguniversal/express-engine --clientProject ssr_project

이 명령이 완료되면 폴더에 몇 개의 파일이 추가되어 있을 것입니다.
이는 서버에서의 설정 및 Angular boostrap 등에 필요한 파일입니다.
하나씩 알아보겠습니다.

분석

server.ts

노드 서버를 가동하는 코드 이며, express 엔진을 호출하고 있습니다.

import 'zone.js/dist/zone-node';

import * as express from 'express';
import {join} from 'path';

const app = express();

const PORT = process.env.PORT || 4000;
const DIST_FOLDER = join(process.cwd(), 'dist/browser');

const {AppServerModuleNgFactory, LAZY_MODULE_MAP, ngExpressEngine, provideModuleMap} = require('./dist/server/main');

app.engine('html', ngExpressEngine({
  bootstrap: AppServerModuleNgFactory,
  providers: [
    provideModuleMap(LAZY_MODULE_MAP)
  ]
}));

app.set('view engine', 'html');
app.set('views', DIST_FOLDER);

app.get('*.*', express.static(DIST_FOLDER, {
  maxAge: '1y'
}));

app.get('*', (req, res) => {
  res.render('index', { req });
});

app.listen(PORT, () => {
  console.log(`Node Express server listening on http://localhost:${PORT}`);
});

서버를 구성할 때 유의할 점은 app 내의 페이지 호출과 다른 api 호출, asset 호출을 구분하는 것입니다.
이는 아래와 같이 정의하여 구분할 수 있습니다.

  • api 호출은 /api 내부에 구성하여 /api 호출은 data 호출로
  • /api 호출이 아니고 파일 확장자가 없으면 app 내의 페이지 호출로
  • 기타 호출은 asset 호출로

만일 이와 같이 구성한 경우 node 에서는 다음과 같이 link를 구분하여 처리할 수 있습니다.

app.get('/api/*', (req, res) => {
  res.status(404).send('data requests are not supported');
});

app.get('*', (req, res) => {
  res.render('index', { req });
});

app.get('*.*', express.static(join(DIST_FOLDER, 'browser')));

tsconfig.server.json

기존 tsconfig.app.json 파일은 클라이언트의 설정이라면 tsconfig.server.json은 서버의 설정입니다.

구성은 크게 다르지 않으나 angularCompilerOptions에서 entryModule을 정의하고 있는 점이 특징입니다.

{
  "extends": "./tsconfig.app.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app-server"
  },
  "angularCompilerOptions": {
    "entryModule": "./src/app/app.server.module#AppServerModule"
  }
}

webpack.server.config.js

webpack의 서버 설정 입니다.

const path = require('path');
const webpack = require('webpack');

module.exports = {
  mode: 'none',
  entry: {
    server: './server.ts'
  },
  externals: {
    './dist/server/main': 'require("./server/main")'
  },
  target: 'node',
  resolve: { extensions: ['.ts', '.js'] },
  optimization: {
    minimize: false
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js'
  },
  module: {
    noParse: /polyfills-.*\.js/,
    rules: [
      { test: /\.ts$/, loader: 'ts-loader' },
      {
        test: /(\\|\/)@angular(\\|\/)core(\\|\/).+\.js$/,
        parser: { system: true },
      },
    ]
  },
  plugins: [
    new webpack.ContextReplacementPlugin(
      /(.+)?angular(\\|\/)core(.+)?/,
      path.join(__dirname, 'src'), // location of your src
      {} // a map of your routes
    ),
    new webpack.ContextReplacementPlugin(
      /(.+)?express(\\|\/)(.+)?/,
      path.join(__dirname, 'src'),
      {}
    )
  ]
};

src/main.server.ts

서버에서 boostrap 하기 위한 코드 입니다.

import { enableProdMode } from '@angular/core';

import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

export { AppServerModule } from './app/app.server.module';
export { ngExpressEngine } from "@nguniversal/express-engine";
export { provideModuleMap } from "@nguniversal/module-map-ngfactory-loader";

src/app/app.server.module.ts

서버에서 호출하는 서버사이드용 module 입니다.

import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    ModuleMapLoaderModule,
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule {}

이제 SSR 설정을 마쳤습니다. Angular에서는 이렇게 명령어 한 두개로 쉽게 SSR 설정을 완료할 수 있습니다.

유의점

  • 랜더링 시점이 클라이언트가 아닌 서버로 변경되었으므로, 브라우저 명령을 더 이상 사용할 수 없습니다.
  • 즉, window, document, navigator, location은 더 이상 사용할 수 없습니다.
  • 이를 해결하기 위해 Angular에서는 Injectable한 Location, DOCUMENT와 같은 몇 가지 API를 제공합니다. (더 정확히는 platform의 상태가 server 일 때만 사용 불가 합니다. 더 자세한 설명은 Angular Universal Platform 을 확인하시기 바랍니다.)

  • 마찬가지로 클릭이나 유저의 이벤트 또한 서버 사이드에서는 처리할 수 없으므로 이를 처리할 랜더링 대상을 결정해야 하며, 반드시 Routing 기능을 구현하여야 합니다.

  • 링크는 반드시 절대 링크 값을 사용해야 합니다. Angular 공식 사이트에서는 아래의 코드를 가이드로 제시하고 있습니다.

universal-interceptor.ts 를 생성합니다.

import {Injectable, Inject, Optional} from '@angular/core';
import {HttpInterceptor, HttpHandler, HttpRequest, HttpHeaders} from '@angular/common/http';
import {Request} from 'express';
import {REQUEST} from '@nguniversal/express-engine/tokens';
 
@Injectable()
export class UniversalInterceptor implements HttpInterceptor {
 
  constructor(@Optional() @Inject(REQUEST) protected request: Request) {}
 
  intercept(req: HttpRequest, next: HttpHandler) {
    let serverReq: HttpRequest = req;
    if (this.request) {
      let newUrl = `${this.request.protocol}://${this.request.get('host')}`;
      if (!req.url.startsWith('/')) {
        newUrl += '/';
      }
      newUrl += req.url;
      serverReq = req.clone({url: newUrl});
    }
    return next.handle(serverReq);
  }
}

작성한 파일을 app.server.module.ts에 추가합니다.

import {HTTP_INTERCEPTORS} from '@angular/common/http';
import {UniversalInterceptor} from './universal-interceptor';
 
@NgModule({
  ...
  providers: [{
    provide: HTTP_INTERCEPTORS,
    useClass: UniversalInterceptor,
    multi: true
  }],
})
export class AppServerModule {}

이제 서버에서 수행된 모든 http 요청은 이 인터셉터에 의해 절대 url로 변경됩니다.

실행

package.json을 열어 scripts에 정의되어 있는대로 실행하면 됩니다.

  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e",
    "compile:server": "webpack --config webpack.server.config.js --progress --colors",
    "serve:ssr": "node dist/server",
    "build:ssr": "npm run build:client-and-server-bundles && npm run compile:server",
    "build:client-and-server-bundles": "ng build --prod && ng run ssr01:server:production --bundleDependencies all"
  },

빌드

build:ssr 명령을 실행합니다.

npm run build:ssr

서버 기동

package.json의 serve:ssr 명령을 수행하거나 dist 폴더로 이동하여 node server 명령을 수행해도 됩니다.

npm run serve:ssr

또는

dist> node server

브라우저에서 확인

서버 가동 후 server.ts 에 설정된 포트 (여기에서는 4000) 를 확인합니다.
로컬로 실행할 경우 localhost:4000을 확인합니다.

페이지가 기존 Angular 프로젝트와 동일하게 보인다면 성공입니다.

참고 사이트

댓글남기기