Skip to content

Latest commit

 

History

History
610 lines (465 loc) · 18.6 KB

File metadata and controls

610 lines (465 loc) · 18.6 KB

Angular Universal

앵귤러 유니버셜은 앵귤러를 이용한 Server Side Rendering 기술을 가리키는 용어입니다. 검색엔진, 소셜 미디어 사이트 등에 제대로 된 콘텐츠를 노출하기 위해서 서버 사이드에서 앵귤러를 실행할 수 있는 방법이 필요합니다. 대부분의 크롤러들은 자바스크립트 코드를 무시하기 때문에 자바스크립트가 기동해서 다이나믹하게 콘텐츠를 추가하는 방식에서는 제대로 된 콘텐츠를 제공할 수 없습니다.

참고사이트

예제 프로젝트

프로젝트 생성

$ ng new some-amazing-project

? Would you like to add Angular routing? Yes
? Which stylesheet format would you like to use? SCSS

Server Side Rendering 환경설정

다음처럼 Server Side Rendering을 위한 설정작업을 수행한다.

$ ng add @nguniversal/express-engine --client-project some-amazing-project

..생략
CREATE src/main.server.ts (220 bytes)
CREATE src/app/app.server.module.ts (318 bytes)
CREATE src/tsconfig.server.json (219 bytes)
CREATE webpack.server.config.js (1360 bytes)
CREATE server.ts (1472 bytes)

UPDATE package.json (1892 bytes)
UPDATE angular.json (4535 bytes)
UPDATE src/main.ts (432 bytes)
UPDATE src/app/app.module.ts (438 bytes)
..생략

5개의 파일이 생성되었고 4개의 파일이 수정되었다. 작업내용을 살펴보자.

CREATE 결과확인

src/main.server.ts

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

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

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

export { AppServerModule } from './app/app.server.module';

이제 진입점이 2개가 되었다.

  1. main.ts: 기존의 진입점, CSR 환경에서의 진입점
  2. main.server.ts: SSR 환경에서의 진입점

src/app/app.server.module.ts

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

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    AppModule,
    ServerModule,
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule {}
  • @angular/platform-server 패키지의 ServerModule 모듈을 임포트한다.
  • AppServerModule 모듈이 기존의 AppModule을 임포트해서 사용한다.

src/tsconfig.server.json

{
  "extends": "./tsconfig.app.json",
  "compilerOptions": {
    "outDir": "../out-tsc/app-server",
    "baseUrl": "."
  },
  "angularCompilerOptions": {
    "entryModule": "app/app.server.module#AppServerModule"
  }
}
  • 기존의 tsconfig.app.json 파일은 tsconfig.json 파일의 설정을 확장한다.
  • 새로 만들어진 tsconfig.server.json 파일은 tsconfig.app.json 파일의 설정을 확장한다.

webpack.server.config.js

// Work around for https://github.com/angular/angular-cli/issues/7200

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

module.exports = {
  mode: 'none',
  entry: {
    // This is our Express server for Dynamic universal
    server: './server.ts'
  },
  target: 'node',
  resolve: { extensions: ['.ts', '.js'] },
  optimization: {
    minimize: false
  },
  output: {
    // Puts the output at the root of the dist folder
    path: path.join(__dirname, 'dist'),
    filename: '[name].js'
  },
  module: {
    rules: [
      { test: /\.ts$/, loader: 'ts-loader' },
      {
        // Mark files inside `@angular/core` as using SystemJS style dynamic imports.
        // Removing this will cause deprecation warnings to appear.
        test: /(\\|\/)@angular(\\|\/)core(\\|\/).+\.js$/,
        parser: { system: true },
      },
    ]
  },
  plugins: [
    new webpack.ContextReplacementPlugin(
      // fixes WARNING Critical dependency: the request of a dependency is an expression
      /(.+)?angular(\\|\/)core(.+)?/,
      path.join(__dirname, 'src'), // location of your src
      {} // a map of your routes
    ),
    new webpack.ContextReplacementPlugin(
      // fixes WARNING Critical dependency: the request of a dependency is an expression
      /(.+)?express(\\|\/)(.+)?/,
      path.join(__dirname, 'src'),
      {}
    )
  ]
};

server.ts

import 'zone.js/dist/zone-node';
import {enableProdMode} from '@angular/core';
// Express Engine
import {ngExpressEngine} from '@nguniversal/express-engine';
// Import module map for lazy loading
import {provideModuleMap} from '@nguniversal/module-map-ngfactory-loader';

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

// Faster server renders w/ Prod mode (dev mode never needed)
enableProdMode();

// Express server
const app = express();

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

// * NOTE :: leave this as require() since this file is built Dynamically from webpack
const {AppServerModuleNgFactory, LAZY_MODULE_MAP} = require('./dist/server/main');

// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
app.engine('html', ngExpressEngine({
  bootstrap: AppServerModuleNgFactory,
  providers: [
    provideModuleMap(LAZY_MODULE_MAP)
  ]
}));

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

// Example Express Rest API endpoints
// app.get('/api/**', (req, res) => { });
// Serve static files from /browser
app.get('*.*', express.static(DIST_FOLDER, {
  maxAge: '1y'
}));

// All regular routes use the Universal engine
app.get('*', (req, res) => {
  res.render('index', { req });
});

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

UPDATE 결과확인

기록을 위해서 package.json 파일을 그대로 저장했다.

package.json

{
  "name": "some-amazing-project",
  "version": "0.0.0",
  "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 some-amazing-project:server:production"
  },
  "private": true,
  "dependencies": {
    "@angular/animations": "~7.1.0",
    "@angular/common": "~7.1.0",
    "@angular/compiler": "~7.1.0",
    "@angular/core": "~7.1.0",
    "@angular/forms": "~7.1.0",
    "@angular/http": "~7.1.0",
    "@angular/platform-browser": "~7.1.0",
    "@angular/platform-browser-dynamic": "~7.1.0",
    "@angular/platform-server": "~7.1.0",
    "@angular/router": "~7.1.0",
    "@nguniversal/express-engine": "^7.1.0",
    "@nguniversal/module-map-ngfactory-loader": "0.0.0",
    "core-js": "^2.5.4",
    "express": "^4.15.2",
    "rxjs": "~6.3.3",
    "tslib": "^1.9.0",
    "zone.js": "~0.8.26"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "^0.13.1",
    "@angular/cli": "~7.1.1",
    "@angular/compiler-cli": "~7.1.0",
    "@angular/language-service": "~7.1.0",
    "@types/jasmine": "~2.8.8",
    "@types/jasminewd2": "~2.0.3",
    "@types/node": "~8.9.4",
    "codelyzer": "~4.5.0",
    "jasmine-core": "~2.99.1",
    "jasmine-spec-reporter": "~4.2.1",
    "karma": "~3.1.1",
    "karma-chrome-launcher": "~2.2.0",
    "karma-coverage-istanbul-reporter": "~2.0.1",
    "karma-jasmine": "~1.1.2",
    "karma-jasmine-html-reporter": "^0.2.2",
    "protractor": "~5.4.0",
    "ts-loader": "^5.2.0",
    "ts-node": "~7.0.0",
    "tslint": "~5.11.0",
    "typescript": "~3.1.6",
    "webpack-cli": "^3.1.0"
  }
}

추가된 디펜던시는 다음과 같다.

  • @angular/http
  • @angular/platform-server
  • @nguniversal/express-engine
  • @nguniversal/module-map-ngfactory-loader
  • webpack-cli

서버 빌드에서 사용하는 스크립트는 다음과 같다.

  1. "compile:server": "webpack --config webpack.server.config.js --progress --colors"
  2. "serve:ssr": "node dist/server"
  3. "build:ssr": "npm run build:client-and-server-bundles && npm run compile:server"
  4. "build:client-and-server-bundles": "ng build --prod && ng run some-amazing-project:server:production"

3번 명령을 수행하면 명령중에 4번을 호출하는 코드가 있기 때문에 4번도 수행된다.

angular.json

다음 부분이 추가되었다.

"server": {
  "builder": "@angular-devkit/build-angular:server",
  "options": {
    "outputPath": "dist/server",
    "main": "src/main.server.ts",
    "tsConfig": "src/tsconfig.server.json"
  },
  "configurations": {
    "production": {
      "fileReplacements": [
        {
          "replace": "src/environments/environment.ts",
          "with": "src/environments/environment.prod.ts"
        }
      ]
    }
  }
}
  • 빌드결과는 dist/server 폴더 밑으로 배치된다.
  • 진입점은 별도로 src/main.server.ts이다.
  • 분리된 Typescript 설정파일 src/tsconfig.server.json을 사용한다.
  • @angular/platform-server 기술로 랜더링된다.

src/main.ts

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));

위 코드가 아래처럼 변경되었다.

document.addEventListener('DOMContentLoaded', () => {
  platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));
});

src/app/app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule.withServerTransition({ appId: 'serverApp' }),
    AppRoutingModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

BrowserModule을 그대로 임포트하는 대신 BrowserModule.withServerTransition() 함수를 호출하고 반환된 결과를 임포트하는 방식으로 변경되었다.

실행방법

Dynamic SSR

클라이언트가 서버에 URL로 접속할 때 동적으로 랜더링하여 HTML 문자열을 클라이언트에게 전달합니다.

$ npm run build:ssr && npm run serve:ssr

> some-amazing-project@0.0.0 build:ssr C:\...\some-amazing-project
> npm run build:client-and-server-bundles && npm run compile:server

> some-amazing-project@0.0.0 build:client-and-server-bundles C:\...\some-amazing-project
> ng build --prod && ng run some-amazing-project:server:production

Date: 2019-02-08T10:14:50.665Z
Hash: 0f867e9da8a4c8b20856
Time: 33154ms
chunk {0} runtime.a5dd35324ddfd942bef1.js (runtime) 1.41 kB [entry] [rendered]
chunk {1} main.23fc2c76f6359cdbb1c8.js (main) 239 kB [initial] [rendered]
chunk {2} polyfills.3eb7881d3a00da6c675e.js (polyfills) 41 kB [initial] [rendered]
chunk {3} styles.3ff695c00d717f2d2a11.css (styles) 0 bytes [initial] [rendered]

Date: 2019-02-08T10:15:04.758Z
Hash: c05c0354b6258c113fa6
Time: 7718ms
chunk {main} main.js, main.js.map (main) 52.5 kB [entry] [rendered]

> some-amazing-project@0.0.0 compile:server C:\...\some-amazing-project
> webpack --config webpack.server.config.js --progress --colors

Hash: 43526031ec4bedb1c6c7
Version: webpack 4.29.0
Time: 14370ms
Built at: 2019-02-08 19:15:23
    Asset      Size  Chunks             Chunk Names
server.js  5.28 MiB       0  [emitted]  server
Entrypoint server = server.js
  [0] ./server.ts 1.55 KiB {0} [built]
  [2] external "events" 42 bytes {0} [built]
  [3] external "fs" 42 bytes {0} [built]
  [4] external "timers" 42 bytes {0} [optional] [built]
  [5] external "crypto" 42 bytes {0} [built]
[207] ./src lazy namespace object 160 bytes {0} [built]
[215] external "url" 42 bytes {0} [built]
[274] external "http" 42 bytes {0} [built]
[275] external "https" 42 bytes {0} [built]
[276] external "os" 42 bytes {0} [built]
[286] external "path" 42 bytes {0} [built]
[295] external "util" 42 bytes {0} [built]
[303] external "net" 42 bytes {0} [built]
[308] external "buffer" 42 bytes {0} [built]
[392] ./dist/server/main.js 52.3 KiB {0} [built]
    + 379 hidden modules

> some-amazing-project@0.0.0 serve:ssr C:\...\some-amazing-project
> node dist/server

Node Express server listening on http://localhost:4000

http://localhost:4000/ 주소로 접근해서 확인해 보자. 브라우저에서 페이지 소스보기를 해 보면 body 부분에 마크업들이 이미 있다는 것을 확인할 수 있다.

빌드결과를 살펴보자. server, browser 폴더로 쉽게 구분된다.

dist
│  server.js
│
├─browser
│      3rdpartylicenses.txt
│      favicon.ico
│      index.html
│      main.23fc2c76f6359cdbb1c8.js
│      polyfills.3eb7881d3a00da6c675e.js
│      runtime.a5dd35324ddfd942bef1.js
│      styles.3ff695c00d717f2d2a11.css
│
└─server
        main.js
        main.js.map

Static Pre-Rendering

클라이언트가 서버에 URL로 접속할 때 미리 랜더링하여 만들어 논 HTML 파일을 클라이언트에게 전달합니다.

package.json 파일에 prerender와 관련한 스크립트 3개를 추가합니다. 이는 다음 사이트를 참고하여 알아낸 방법입니다.

https://github.com/angular/universal-starter/

"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 some-amazing-project:server:production",
  "build:prerender": "npm run build:client-and-server-bundles && npm run compile:server && npm run generate:prerender",
  "generate:prerender": "cd dist && node prerender",
  "serve:prerender": "cd dist/browser && http-server"
},

다음 명령을 수행합니다. 하지만 에러가 발생합니다. :(

$ npm run build:prerender && npm run serve:prerender

> some-amazing-project@0.0.0 build:prerender C:\...\some-amazing-project
> npm run build:client-and-server-bundles && npm run compile:server && npm run generate:prerender

> some-amazing-project@0.0.0 build:client-and-server-bundles C:\...\some-amazing-project
> ng build --prod && ng run some-amazing-project:server:production

Date: 2019-02-08T10:43:05.849Z
Hash: 0f867e9da8a4c8b20856
Time: 19265ms
chunk {0} runtime.a5dd35324ddfd942bef1.js (runtime) 1.41 kB [entry] [rendered]
chunk {1} main.23fc2c76f6359cdbb1c8.js (main) 239 kB [initial] [rendered]
chunk {2} polyfills.3eb7881d3a00da6c675e.js (polyfills) 41 kB [initial] [rendered]
chunk {3} styles.3ff695c00d717f2d2a11.css (styles) 0 bytes [initial] [rendered]

Date: 2019-02-08T10:43:21.876Z
Hash: c05c0354b6258c113fa6
Time: 9221ms
chunk {main} main.js, main.js.map (main) 52.5 kB [entry] [rendered]

> some-amazing-project@0.0.0 compile:server C:\...\some-amazing-project
> webpack --config webpack.server.config.js --progress --colors

Hash: 43526031ec4bedb1c6c7
Version: webpack 4.29.0
Time: 13870ms
Built at: 2019-02-08 19:43:38
    Asset      Size  Chunks             Chunk Names
server.js  5.28 MiB       0  [emitted]  server
Entrypoint server = server.js
  [0] ./server.ts 1.55 KiB {0} [built]
  [2] external "events" 42 bytes {0} [built]
  [3] external "fs" 42 bytes {0} [built]
  [4] external "timers" 42 bytes {0} [optional] [built]
  [5] external "crypto" 42 bytes {0} [built]
[207] ./src lazy namespace object 160 bytes {0} [built]
[215] external "url" 42 bytes {0} [built]
[274] external "http" 42 bytes {0} [built]
[275] external "https" 42 bytes {0} [built]
[276] external "os" 42 bytes {0} [built]
[286] external "path" 42 bytes {0} [built]
[295] external "util" 42 bytes {0} [built]
[303] external "net" 42 bytes {0} [built]
[308] external "buffer" 42 bytes {0} [built]
[392] ./dist/server/main.js 52.3 KiB {0} [built]
    + 379 hidden modules

> some-amazing-project@0.0.0 generate:prerender C:\...\some-amazing-project
> cd dist && node prerender

internal/modules/cjs/loader.js:582
    throw err;
    ^

Error: Cannot find module 'C:\...\some-amazing-project\dist\prerender'
    at Function.Module._resolveFilename (internal/modules/cjs/loader.js:580:15)
    at Function.Module._load (internal/modules/cjs/loader.js:506:25)
    at Function.Module.runMain (internal/modules/cjs/loader.js:741:12)
    at startup (internal/bootstrap/node.js:285:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:739:3)
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! some-amazing-project@0.0.0 generate:prerender: `cd dist && node prerender`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the some-amazing-project@0.0.0 generate:prerender script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR!     C:\Users\Seokwon\AppData\Roaming\npm-cache\_logs\2019-02-08T10_43_40_680Z-debug.log
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! some-amazing-project@0.0.0 build:prerender: `npm run build:client-and-server-bundles && npm run compile:server && npm run generate:prerender`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the some-amazing-project@0.0.0 build:prerender script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR!     C:\Users\Seokwon\AppData\Roaming\npm-cache\_logs\2019-02-08T10_43_40_805Z-debug.log

앵귤러 유니버셜은 아직 안정화가 되었다고 볼 수 없습니다. 매우 빠르게 변화가 되고 있기 때문에 설명을 그대로 따라한다고 해도 안되는 경우가 많습니다. 공식 사이트에 설명은 수동으로 작업하는 것을 설명하는데 사용하기에 너무 불편합니다. 시간이 해결하리라 보입니다.

Static Pre-Rendering 방식은 가장 빠른 방법이지만 변화를 수용하지 못합니다. 조금만 바뀌어도 다시 빌드해야 합니다. 실무에서 사용할 수 있는 경우는 거의 없다고 보는 것이 맞게습니다. 앵귤러의 문서화가 매우 나쁘기 때문에 문제가 생길 때 해결하는 방법을 찾는데 많은 시간이 소비됩니다. Static Pre-Rendering 방식은 안되지만 꼭 해야하는 것은 아니므로 안해봐도 되겠다 싶습니다.

앵귤러 설문조사에서 응답한 문장으로 마치겠습니다. 설문도 하는 걸 보면 좀 나아지려나요.

"I can not use Angular without Stackoverflow."