Angular Universal (앵귤라 유니버살) – 서버사이드 렌더링 SEO 구현

[경고] 이 포스트는 Server Side Rendering 을 구현하려고 시도하는 중에 개인적인 노트로 작성된 것 입니다. 완성도가 있는 것도 아니고 바로 프로덕션에 쓸 수도 없습니다. 참고하세요.

angular4 만으로는 아직 검색 엔진에 대응하는 seo 를 구현하기 어렵다. rendering 이 client side 에서 실행되기 때문에, 검색엔진이 해당 페이지를 접속하면, 내용이 입력되지 않은 간단한 html  프레임만 건지게 된다. 이런 seo에 대한 취약함때문에 angular 와 같은 single page application 이 모든 곳에서 사용될 수 없다.

이것을 해결하기 위한 방법 중 하나가 Server side rendering 이다. Server side rendering 을 하면 모든 내용을 포함한 페이지를 검색엔진이 볼 수 있게 된다.

Angular 에서 Server side rendering 을 구현하기 위해서는 Angular Universal 이라는 것을 사용한다.

Angular Universal 프로젝트 생성하기

angular cli 를 사용해 라우팅과 함께 새로운 프로젝트를 만든다.

ng new ang4-uni --routing

Angular Universal 을 위한 모듈을 설치한다.

cd ang4-uni
npm install -D ts-node
npm install --save @angular/platform-server @angular/animations

src/app/app.module.ts 를 열고, 아래처럼 BrowserModule 에  .withServerTransition({appId:’ang4-uni’})수정한다. appId 는 서버와 클라이언트를 이어주는 역할을 한다.

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

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

export { AppComponent };

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule.withServerTransition({ appId: 'cli-universal' }),
    RouterModule.forRoot([
      { path: '', loadChildren: './home/home.module#HomeModule' },
      { path: 'about', loadChildren: './about/about.module#AboutModule' },
      { path: '**', redirectTo: '', pathMatch: 'full' },
    ])
  ],
  exports: [AppComponent],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

src / app / app.server.module.ts 를 다음과 같이 생성해주어야한다. app.module.ts 와 매우 비슷하지만, 여기서는 BrowserModule 대신 ServerModule 을 Import 하고 있다. 즉, 서버측에서 사용하기위한 module 정의이다.

/// <reference types="node" />

import { NgModule, NgModuleFactory, NgModuleFactoryLoader } from '@angular/core';
import { ServerModule } from '@angular/platform-server';
import { AppModule, AppComponent } from './app.module';

export class ServerFactoryLoader extends NgModuleFactoryLoader {
  load(path: string): Promise<NgModuleFactory<any>> {
    return new Promise((resolve, reject) => {
      const [file, className] = path.split('#');
      const classes = require('../../dist/ngfactory/src/app' + file.slice(1) + '.ngfactory');
      resolve(classes[className + 'NgFactory']);
    });
  }
}

@NgModule({
  imports: [
    ServerModule,
    AppModule
  ],
  bootstrap: [AppComponent],
  providers: [
    { provide: NgModuleFactoryLoader, useClass: ServerFactoryLoader }
  ]
})
export class AppServerModule { }

src / server.ts 를 생성하고 express 서버를 만들어보자. node js 의 express 의 typescript 버전이다. 여기에서 사용되는 renderModuleFactory 메서드는 angular server-side rendering 의 핵심이다. renderModuleFactory는 app build 로 생성되는 app 의 검파일 버전인 AppServerModuleNgFactory를 가져온다.  AppServerModuleNgFactory 의 위치는 compile 후에 생성되는 dist 폴더다.

import 'reflect-metadata';
import 'zone.js/dist/zone-node';
import { platformServer, renderModuleFactory } from '@angular/platform-server'
import { enableProdMode } from '@angular/core'
import { AppServerModuleNgFactory } from '../dist/ngfactory/src/app/app.server.module.ngfactory'
import * as express from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';

const PORT = 4000;

enableProdMode();

const app = express();

let template = readFileSync(join(__dirname, '..', 'dist', 'index.html')).toString();

app.engine('html', (_, options, callback) => {
  const opts = { document: template, url: options.req.url };

  renderModuleFactory(AppServerModuleNgFactory, opts)
    .then(html => callback(null, html));
});

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

app.get('*.*', express.static(join(__dirname, '..', 'dist')));

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

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

src / tsconfig.app.json 을 열고 exclude 항목에 server.ts를 넣어준다.

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "../out-tsc/app",
    "module": "es2015",
    "baseUrl": "",
    "types": []
  },
  "exclude": [
    "server.ts",
    "test.ts",
    "**/*.spec.ts"
  ]
}

/tsconfig.json 을 열고 다음과 같이 angularCompilerOptions 를 추가해준다. genDir 은 검파일로 생성된 ngFactory 파일들이 저장될 곳을 지정하고, entryModule 은 app의 기본 모듈의 위치와 이름을 지정한다.

{
  "compileOnSave": false,
  "compilerOptions": {
    "outDir": "./dist/out-tsc",
    "baseUrl": "src",
    "sourceMap": true,
    "declaration": false,
    "moduleResolution": "node",
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "target": "es5",
    "typeRoots": [
      "node_modules/@types"
    ],
    "lib": [
      "es2016",
      "dom"
    ]
  },
  "angularCompilerOptions": {
    "genDir": "./dist/ngfactory",
    "entryModule": "./src/app/app.module#AppModule"
  }
}

/package.json 을 열고 run script 부분을 수정한다.

{
.....
.....
  "scripts": {
    "prestart": "ng build --prod && ngc",
    "start": "ts-node src/server.ts"
  },
.....
.....
}

이제 명령프롬프트에서 다음과 같이 실행해본다.

$ npm run start

 

Angular Universal 에서 Component 생성하기

지금까지 모든 작업은 angular cli 에 기반을 두고 해왔다. universal cli 라는 것도 있어 그것을 사용하면 더 편할 수도 있지만, 아직 개발 중에 있고 사용법도 익혀야하니 그냥 익숙한 angular cli 로 해본 것이다.

module 생성방법

$ ng g m home
$ ng g c home

$ ng g m about
$ ng g c about

home.module.ts

import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { HomePageComponent } from './home.component';

@NgModule({
  imports: [
    CommonModule,
    RouterModule.forChild([
      { path: '', component: HomeComponent, pathMatch: 'full' }
    ])
  ],
  declarations: [HomeComponent]
})
export class HomeModule { }

about.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { AboutPageComponent } from './about.component';

@NgModule({
  imports: [
    CommonModule,
    RouterModule.forChild([
      { path: '', component: AboutComponent, pathMatch: 'full' }
    ])
  ],
  declarations: [AboutComponent]
})
export class AboutModule { }

 

app root 밑에 component 생성방법

angular cli 에서 하듯이 home 컴포넌트를 생성해보자. 다음과 같이 에러가 발생한다.

$ ng g c home

Error locating module for declaration
        SilentError: Multiple module files found: ["app.module.ts","app.server
.module.ts"]

모듈이 두가지가 발견되어 어떤 것을 사용해야할 지 모르겠다는 불평이다. 따라서, 우리가 component 를 생성할때 모듈을 지정해주면 더 이상 불평하지 않을 테다.

$ ng g c home --module=app.module.ts
$ ng g c about --module=app.module.ts

이렇게 home, about 컴포넌트가 생성되었다.

생성된 component 를 위한 routing을 설정하려면 다음과 같이…

/src/app/app-routing.module.ts 을 열고 home, about 컴포넌트를 지정해주자. 자동 import 기능을 사용하면 import {} from ‘app/home/home.component’ 에서 처럼 절대값 주소가 입력된다. 그런데, 이렇게 하면 npm run start 하여 실행시에 컴포넌트를 찾을 수 없다는 에러가 발생한다. 따라서, 절대값 주소를 상대값 주소로 변환해주어야한다. ‘app/home/home.component’ => ‘./home/home.component’ 처럼 말이다. HomeComponent 와 AboutComponent 에 모두 상대값 주소를 넣어준다.

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from "./home/home.component";
import { AboutComponent } from "./about/about.component";

const routes: Routes = [
  {
    path: '',
    component: HomeComponent
  },
  {
    path: 'about',
    component: AboutComponent
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

 

app.module.ts 에서 위의 AppRoutingModule 을 import 해준다.

/src/app/app.component.html 을 열고 다음과 같이 router-outlet 과 링크를 추가하자.

<ul>
  <li><a routerLink="/">Home</a></li>
  <li><a routerLink="about">About</a></li>
</ul>

<router-outlet></router-outlet>

페이지 타이틀과 메타 설정하기

angular universal 를 사용하지 않고, 그냥 angular로 접근하면 페이지 타이틀과 메타가 소스보기에서 나타나지 않는다. title.setTitle(‘xxxx’)를 해도 브라우저 상에서는 제목이 나타나지만, 소스보기에서는 절대 나타나지 않는다. 따라서, 검색엔진이 페이지의 타이틀을 확인할 길이 없다.

angular universal 을 세팅했으니 이제 서버에서 렌더링된 페이지가 클라이언트 쪽에서 보이게 되고, 당연히 타이틀과 메타값도 소스보기에서 나타나게 된다.

하지만, 어떻게든 해당 페이지의 타이틀값과 메타값을 따로 설정해주어야한다.

각 컴포넌트에서 아래와 같이 Title 과 Meta 메서드를 호출한다.

import { Meta, Title } from "@angular/platform-browser";

그런후 컴포넌트 클라스 안에서 제목과 메타값을 정의 해준다.

예를 들면, HomeComponent에서

/src/app/home/home.component.ts

import { Component, OnInit } from '@angular/core';
import { Meta,Title } from "@angular/platform-browser";

@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {

  constructor(meta: Meta, title: Title) {

    title.setTitle('My Home Page');

    meta.addTags([
      { name: 'author',   content: 'me'},
      { name: 'keywords', content: 'angular seo, angular 4 universal, etc'},
      { name: 'description', content: 'SEO를 적용한 Angular App' }
    ]);

  }

  ngOnInit() {
  }

}

이렇게 하고 npm run start 를 실행하면 우리가 원하는 SEO 가 소스보기에 적용된 것을 확인할 수 있다.

기타 Angular Universal 기능

제목과 메타데이타 외에, 서버사이드 렌더링에서 구현해야할 주요한 기능 중 하나가 캐쉬 설정이다. 이제 슬슬 이 부분을 해결해보아야겠다.

Universal Starter 라는 github 페이지를 보자. /src/+app/shared/cache.service.ts 가 있는데, 이 코드들을 좀 살펴보면 답이 있지 않을까싶다.

그외에도 다양한 universal starter kit 들이 나와있다. 예를 들면 이런 페이지.

또 하나의 굿 레퍼런스 here and here

 

아래는 테스트 중인 것들

gulp webpack 설치

위 모든 프로세스를 완료후에 실행할 것!

$ npm install --save-dev gulp gulp-clean gulp-rename gulp-sass gulp-sequence ts-loader
$ npm install webpack-node-externals --save-dev

 

gulp file 만들기

프로젝트 루트 밑에 gulpfile.js 를 만든다.

const gulp = require('gulp');
const sass = require('gulp-sass');
const rename = require("gulp-rename");
const clean = require('gulp-clean');
const sequence = require('gulp-sequence');

gulp.task('sass', () => {
  return gulp.src('./src/app/**/*.scss')
    .pipe(sass().on('error', sass.logError))
    .pipe(gulp.dest('./src/app'));
});

gulp.task('copy:bkp', () => {
  return gulp.src('./src/app/**/*.scss')
    .pipe(rename({ extname: '.scssbkp' }))
    .pipe(gulp.dest('./src/app'));
});

gulp.task('rename:css:scss', () => {
  return gulp.src('./src/app/**/*.css')
    .pipe(rename({ extname: '.scss' }))
    .pipe(gulp.dest('./src/app'));
});

gulp.task('rename:bkp:scss', () => {
  return gulp.src('./src/app/**/*.scssbkp')
    .pipe(rename({ extname: '.scss' }))
    .pipe(gulp.dest('./src/app'));
});

gulp.task('clean', () => {
  return gulp.src(['./src/app/**/*.scssbkp', './src/app/**/*.css'], { read: false })
    .pipe(clean());
});

gulp.task('before:ngc', sequence('sass', 'copy:bkp', 'rename:css:scss'));
gulp.task('after:ngc', sequence('rename:bkp:scss', 'clean'));

프로젝트 루트 밑에 webpack.config.js 를 만든다.

const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  entry: {
    server: './src/server.ts'
  },
  resolve: {
    extensions: ['.ts', '.js']
  },
  target: 'node',
   externals: [nodeExternals({
     whitelist: [
       /^@angular\/material/
     ]
   })],
  node: {
    __dirname: true
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js'
  },
  module: {
    rules: [
      { test: /\.ts$/, loader: 'ts-loader' }
    ]
  }
}

package.json 파일에서 scripts 부분 수정

"scripts": {
  "prestart": "ng build --prod && gulp before:ngc && ngc && gulp after:ngc && webpack",
  "start": "ts-node src/server.ts"
},

 

기타 설치

npm install dotenv morgan mongoose@4.10.8 jso
nwebtoken font-awesome validator bcryptjs angular2-jwt bootstrap@next ngx-boot
strap@next --save

cookie localStorage 패키지 중에 angular cli server side rendering 이 지원 되는 것들을 찾아, 여러 종류를 테스트 해보았는데, 겨우 찾았다.

npm install --save angular-2-local-storage ng2-cookies;

로컬스토리지 사용법

로컬스토리지 패키지를 설치하고 token 을 저장한 후, 다시 빼내보면 string 이 아니라 Object 를 반환한다. 그래서, 스트링 타입으로 캐스팅해주어야한다.

let token = <string>this.storage.get(‘token’);

예를 들면 위와 같은 식으로….

쿠키 사용법

위의 ng2-cookies 는 아주 간단하게 사용하도록 만들어져있다. Service 로 Injection 하는 것이 아니라, 그냥

import {Cookie} from ‘ng2-cookies’;

한 후에

let token = Cookie.set (‘token’,value) ;

처럼 사용하면 된다.

script 설정

Package.json scripts 설정을 잘못해서 패키지 설치에서 다음과 같은 에러메시지가 자꾸 발생한다.

import { Directive, ElementRef, EventEmitter, HostBinding, Input, Output, Renderer } from ‘@angular/core’;
^^^^^^
SyntaxError: Unexpected token import
at Object.exports.runInThisContext (vm.js:76:16)
at Module._compile (module.js:542:28)
at Object.Module._extensions..js (module.js:579:10)
at Module.load (module.js:487:32)
at tryModuleLoad (module.js:446:12)
at Function.Module._load (module.js:438:3)
at Module.require (module.js:497:17)
at require (internal/module.js:20:19)

package.json 파일의 start 부분을 수정한 후 많은 문제가 해결되었다. server.ts 대신에 server.js 를 실행해야, webpack.config.js 가 실행되기 때문인 것 같다.

"scripts": {
  "prestart": "ng build --prod && gulp before:ngc && ngc && gulp after:ngc && webpack",
  "start": "node dist/server.js"
},

 

결과는 대체로 원만하게 사용할 수 있다. 단, Reactive Form 이나 Form 관련 요소가 페이지에 썩여있으면, 그 routing page는 server side rendering에서 생략되는 것을 확인할 수 있었다. 아마도 dynamic 하게 생성되는 컨텐츠는 server side rendering 이 되지 않는 것 같다.

그렇다면 지금까지 무슨 짓을 한 걸까!!!!!

중요사항

  • guard 를 사용한 페이지인 경우, 데이타가 소스보기에 서버사이드 렌더링이 되지 않는다.
  • this.http.get(url).subscribe 같이 http request 를 할 경우, url 은 상대주소가 아니라 절대주소, 즉 full address 를 적어주어야 서버사이드 렌더링이 된다.
  • /api/cats 가 아니라 http://localhost:3000/api/cats 로 적어주어야 한다는 말이다. 어디에도 이런 정보를 찾을 수가 없어서 정말 헤메고 헤메다가 실험과 실험을 통해 깨달음을 구할 수 있었다. ㅋㅋㅋ

댓글 남기기