Angular Universal로 SEO 적용하기 (상)

소스코드

이 게시물에는 코드작성이 포함되어 있습니다. 소스코드를 받으신 후 진행해 주세요. MEAN Stack/개발 환경 구축에서 설명된 프로그램들(git, npm, atom editor)이 있어야 아래의 명령어들을 실행할 수 있습니다.

이 게시물의 소스코드는 기본사이트 만들기 / User Show/Edit 만들기에서 이어집니다.

angular-site.git 을 clone 한 적이 있는 경우: 터미널에서 해당 폴더로 이동 후 아래 명령어들을 붙여넣기합니다. 폴더 내 모든 코드가 이 게시물의 코드로 교체됩니다. 이를 원치 않으시면 이 방법을 선택하지 마세요.

git reset --hard
git pull
git reset --hard 3733c5b
git reset --soft c2aab37
npm install
atom .

angular-site.git 을 clone 한 적이 없는 경우: 터미널에서 코드를 다운 받을 폴더로 이동한 후 아래 명령어들을 붙여넣기하여 angular-site.git 을 clone 합니다.

git clone https://github.com/a-mean-blogger/angular-site.git
cd angular-site
git reset --hard 3733c5b
git reset --soft c2aab37
npm install
atom .

- Github에서 소스코드 보기: https://github.com/a-mean-blogger/angular-site/tree/3733c5b7029247f873cdcd610cd964b5fbbe6679


SEO

SEOSearch Engine Optimization(검색 엔진 최적화)의 약어로 사이트를 구글, 네이버, 다음 등등의 검색엔진에 검색이 잘 되도록, 상위에 노출이 되도록 사이트를 수정하는 것을 말합니다. 사이트를 검색엔진에 등록해서 접속자를 늘려야 하는 경우에 필요하며, 그렇지 않는 경우에는 신경쓰지 않아도 됩니다.

그런데 Angular사이트는 사이트의 컨탠츠가 검색엔진에서 아예 검색되지 않습니다. 그 이유는 Angular가 SPA로 되어 있어 서버는 index.html만 전달할 뿐 모든 컨탠츠가 사이트에 접속한 다음 생성되기 때문입니다. 검색엔진들은 서버에서 생성되는 파일을 수집하기 때문에 Angular 사이트는 기본적으로 SEO가 아예 불가능합니다.

Angular 2/기본사이트 만들기에서 만든 사이트를 예로 들어 봅시다. 사이트를 실행하고 사이트 아무곳에나 마우스 오른쪽 버튼을 누른 후 '페이지소스보기' 를 클릭하시거나 크롬, 파이어폭스의 경우 아래 그림과 같이 view-source: 를 사이트 주소 앞에 붙여주면 서버에서 브라우저로 전달된 페이지 내용을 볼 수 있습니다.

위 스크린샷처럼 src/app/index.html의 내용에 21번째 줄처럼 여러 스크립트 파일들이 추가된 형태로 코드가 나오며 사이트의 컨탠츠는 전혀 보이지 않습니다. 또한 해당 사이트의 다른 페이지로 이동해서 봐도 동일한 코드만 나옵니다.

Angular Universal

Angular Universal은 Angular 사이트가 컨탠츠를 표시하지 않는 문제를 해결하기 위해 컨탠츠를 서버에서 만들어 줍니다. 기본 Angular 사이트는 다이나믹 서버가 필요 없지만 Angular Universal을 사용하면 Node JS 서버를 통해 Angular 코드가 실행되고 페이지를 만들어 클라이언트에 제공해 주게 됩니다.
좀 더 자세히 설명하면, Angular Universal를 이용해 서버용 Angular app을 만들 수 있습니다. 결국 하나의 Angular 소스코드로 서버용 Angular app과 클라이언트용 Angular app 두개가 생성되고, 서버로 오는 요청은 서버용 Angular app이, 클라이언트로 오는 요청은 클라이언트용 Angular app이 처리하게 되는 것입니다.

Angular Universal을 적용하기 위해 해야 할 일

하나의 소스코드를 가지고 서버용과 클라이언트용 두개의 Angular app을 만들게 되므로 발생할 수 있는 문제점이 있습니다. 본래 Angular는 브라우저용 앱으로 웹브라우저의 기능들을 사용할 수 있습니다. 예를들어 브라우저의 alert을 띄운다든가, 브라우저의 local storage에 정보를 저장한다든가 등등.
하지만 Angular Universal이 서버를 만들게 되면 해당 기능들이 사용되지 않는 것은 물론이고 에러를 내는 경우가 생깁니다. 이 문제를 해결하기 위해 소스코드에서 현재 앱이 브라우저용으로 실행되고 있는지 서버용으로 실행되고 있는지를 인식하여 만약 서버용이라면 해당 부분을 실행하지 않도록 해야 합니다.

Angular Universal로 SEO 적용하기 단편강의에서는 Angular 2/기본사이트 만들기에서 만든 사이트에 Angular Universal을 적용하여 검색엔진이 사이트 컨탠츠를 읽을 수 있도록 해봅시다. 상편과 하편으로 나누어, 상편에서는 Angular Universal을 적용하기 위해 기존 코드를 수정하고, 하편에서 실제로 Angular Universal을 적용해 보겠습니다.

위에서 말했던 것처럼 Angular Universal의 서버용 앱은 브라우저용 함수들을 사용할 수 없습니다. 현재 사이트는 alert과 local storage를 가지고 있으므로 서버용 앱이 이 부분을 처리할 수 있게 해 주어야 합니다. 여기에는 두가지 방법이 있는데, Angular Universal을 처리할 수 있는 package가 있는 경우 해당 package를 사용해서 package가 알아서 처리하게 하는 법과, 코드 상에서 해당 함수들을 사용할지 안할지 정하는 방법입니다. local storage는 Angular Universal을 처리하는 package가 있으므로 첫번째 방법을 사용하고, alert은 두번째 방법을 사용해서 처리해 보겠습니다.

Package 설치

Local storage는 자바스트립트에 기본적으로 들어가 있는 함수(window.localStorage)지만 NodeJS에는 window 객체가 아예 없으로 실행시 에러가 나게 됩니다. local storage package를 설치해서 package가 알아서 처리하게 합시다. 참고로 이 package를 설치해서 서버앱에서 local storage 기능이 작동하는 것은 아닙니다.

$ npm install angular-2-local-storage --save

번거롭게도 자바스크립트 기본 localStorage와 이 package의 localStorage는 사용되는 함수 이름이 달라서 해당 부분을 모두 고쳐주어야 합니다.

아래 코드 부분에서 살펴보겠습니다.

폴더 구조

주황색은 변경된 파일, 녹색은 새로 생성된 파일, 회색은 변화가 없는 파일입니다.

새로운 package가 설치되었으므로 app.module에 변화가 생겼고, 그 외 alert, local storage가 사용된 파일의 코드가 수정되었습니다. 

코드

local stroage를 app.module에 넣어줍니다.

// src/app/app.module.ts

//... 생략
import { MdProgressBarModule } from '@angular/material';
import { LocalStorageModule } from 'angular-2-local-storage'; //1

//... 생략

@NgModule({
  //... 생략
  imports: [
    //... 생략
    MdProgressBarModule,
    LocalStorageModule.withConfig({storageType: 'localStorage'}), //1
  ],
  //... 생략
})
export class AppModule { }

1. Angular 2 Local Storage package는 module을 import할 때.withConfig({storageType: 'localStorage'})와 같이 config를 넣어주어야 합니다. 항상 package는 공식문서를 통해 사용법을 익히도록 합시다.(angular 2 local storage 공식 NPM 페이지 : https://www.npmjs.com/package/angular-2-local-storage)

// src/app/auth.service.ts

import { environment } from '../environments/environment';
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { LocalStorageService } from 'angular-2-local-storage'; //1

import 'rxjs/add/operator/toPromise';

import { UtilService } from './util.service';
import { ApiResponse } from './api-response';
import { User } from './user';

@Injectable()
export class AuthService {
  private apiBaseUrl = `${environment.apiBaseUrl}/auth`;

  constructor(
    private localStorage: LocalStorageService, //1
    private http: HttpClient,
    private router: Router,
    private utilService: UtilService,
  ) { }

  login(username: string, password: string): Promise<any> {
    return this.http.post<ApiResponse>(`${this.apiBaseUrl}/login`,{username:username, password:password})
              .toPromise()
              .then(this.utilService.checkSuccess)
              .then(response => {
                this.localStorage.set('token', response.data); //2
              })
              .catch(this.utilService.handleApiError);
  }

  me(): Promise<User> {
    return this.http.get<ApiResponse>(`${this.apiBaseUrl}/me`)
              .toPromise()
              .then(this.utilService.checkSuccess)
              .then(response => {
                this.localStorage.set('currentUser', response.data); //2
                return response.data as User
              })
              .catch(response =>{
                this.logout();
                return this.utilService.handleApiError(response);
              });
  }

  refresh(): Promise<any> {
    return this.http.get<ApiResponse>(`${this.apiBaseUrl}/refresh`)
              .toPromise()
              .then(this.utilService.checkSuccess)
              .then(response => {
                this.localStorage.set('token', response.data); //2
                if(!this.getCurrentUser()) return this.me();
              })
              .catch(response =>{
                this.logout();
                return this.utilService.handleApiError(response);
              });
  }

  getToken(): string{
    return this.localStorage.get<string>('token'); //2
  }

  getCurrentUser(): User{
    return this.localStorage.get<User>('currentUser'); //2
  }

  isLoggedIn(): boolean {
    var token = this.localStorage.get<string>('token'); //2
    if(token) return true;
    else return false;
  }

  logout(): void {
    this.localStorage.remove('token'); //2
    this.localStorage.remove('currentUser'); //2
    this.router.navigate(['/']);
  }
}

1. Angular 2 Local Storage service 를  import하고 constructor에 넣어줍니다.
2. Angular 2 Local Storage package 공식 문서(https://www.npmjs.com/package/angular-2-local-storage)에 표시된 방법으로 함수를 사용합니다.
예를 들어, localStorage.setItemthis.localStorage.set으로 바뀌었습니다. 이건 package가 이렇게 만들어져 있어서 그렇습니다.

// src/app/request-interceptor.service.ts

import { Injectable } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';

import { LocalStorageService } from 'angular-2-local-storage'; //1

@Injectable()
export class RequestInterceptor implements HttpInterceptor {

  constructor(
    private localStorage: LocalStorageService, //1
  ) { }

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    var token = this.localStorage.get<string>('token'); //2
    var newHeader: HttpHeaders = req.headers;
    newHeader = newHeader.set('Content-Type', 'application/json');
    if(token) newHeader = newHeader.set('x-access-token', token);
    const newReq = req.clone({headers: newHeader});
    return next.handle(newReq);
  }
}

1&2. 마찬가지로 local storage코드를 바꿔줍니다.

// src/app/auth.guard.ts

import { Injectable, Inject, PLATFORM_ID } from '@angular/core'; //1
import { isPlatformBrowser } from '@angular/common'; //3
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { AuthService } from './auth.service';

@Injectable()
export class AuthGuard implements CanActivate {

  constructor(
    private router: Router,
    private authService: AuthService,
    @Inject(PLATFORM_ID) private platformId: Object, //2
  ) { }

  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
    if(this.authService.isLoggedIn()){ //4
      return true;
    }
    else{
      if(isPlatformBrowser(this.platformId)){
        alert("Please login first");
        this.router.navigate(['login'],{ queryParams: { redirectTo: state.url } });
      }
      return false;
    }
  }
}

현재 앱 실행환경이 서버인지 클라이언트인지 알 수 있는 방법입니다.

1. Inject , PLATFORM_ID를 @angular/core로 부터 가져옵니다.
2. @Inject(PLATFORM_ID) private platformId: Object에서 PLATFORM_ID를 platformId로 inject해줍니다.
3. isPlatformBrowser를 @angular/common으로 부터 가져옵니다.
4. if(isPlatformBrowser(this.platformId))가 true이면 현재 실행환경이 클라이언트이며, false면 서버입니다. 즉 이 안에 클라이언트 앱에서만 해야 할 일을 적어주면 됩니다.

위 코드가 잘 이해가 안되더라도 그냥 사용법만 정확하게 익히시면 됩니다.

실행 결과

Angular 2/기본사이트 만들기사이트는 Node JS API/JWT JSON Web Token 로 로그인 REST API 만들기의 API를 사용합니다. 먼저 nodemon을 사용해서 로그인 API 서버를 실행합시다.

nodemon

Angular-CLI 명령어 사용해서 Angular 사이트를 실행합시다. 

ng serve --open

서버앱에서 할일을 바꾸어주었는데 지금은 서버앱이 없으므로 바뀐 내용이 없습니다.

마치며..

이번강의는 Angular Universal 적용을 위한 준비과정이며 다음 강의를 통해 실제로 Angular Universal을 적용해 보겠습니다.

댓글

댓글쓰기

이 글에 댓글을 다시려면 SNS 계정으로 로그인하세요. 자세히 알아보기

UP