Skip to content

3. 서버 사이드 렌더링(SSR)


이 문서는 Angular Universal에 대해 소개한다. Angular Universal은 Angular 애플리케이션을 서버에서 실행하는 테크닉이다.


일반적으로 Angular 애플리케이션은 브라우저에서 실행된다. DOM에 페이지가 렌더링되고 사용자의 동작에 반응하는 것도 모두 브라우저에서 이루어진다. 하지만 이와 다르게 Angular Universal은 서버에 미리 정적으로 생성해둔 애플리케이션을 클라이언트가 실행하고, 그 이후에 클라이언트에서 앱을 다시 부트스트랩하는 테크닉이다. 이 방식을 사용하면 애플리케이션을 좀 더 빠르게 실행할 수 있기 때문에 사용자가 보는 애플리케이션 화면도 빠르게 띄울 수 있다.


서버 사이드 렌더링은 Angular CLI를 활용해도 간단하게 적용할 수 있다. Angular CLI 스키매틱 중 @nguniversal/express-engine를 활용하는 방법인데, 아래에서 자세하게 설명한다.


Note

  • Angular Universal을 사용하려면 활성 LTS나 유지보수 중인 LTS 버전으로 관리되는 Node.js가 필요하다.
  • 지원하는 버전을 확인하려면 package.json 파일의 engines 프로퍼티를 확인해 보자.


1. Unviersal 튜토리얼

이 문서에서는 히어로들의 여행 앱에 Universal을 적용해 본다.


이 앱은 Angular CLI로 컴파일할 때 AOT 컴파일러를 사용한다. 그리고 이렇게 빌드한 결과물은 Node.js Express 서버로 서비스해 보자.


서버 사이드 앱 모듈을 생성하려면 다음 명령을 실행해서 app.server.module.ts를 생성한다.


ng add @nguniversal/express-engine


그러면 다음과 같은 폴더 구조가 생성된다.


src/
  index.html                 애플리케이션 웹 페이지
  main.ts                    클라이언트 앱을 부트스트랩하는 파일
  main.server.ts             * 서버 앱을 부트스트랩하는 파일
  style.css                  앱 전역 스타일 파일
  app/ ...                   애플리케이션 코드
    app.server.module.ts     * 서버 사이드 애플리케이션 모듈
server.ts                    * Express 웹 서버
tsconfig.json                TypeScript 기본 환경설정 파일
tsconfig.app.json            TypeScript 브라우저용 환경설정 파일
tsconfig.server.json         TypeScript 서버용 환경설정 파일
tsconfig.spec.json           TypeScript 스펙용 환경설정 파일


이 중 * 표시가 된 파일이 새로 추가된 파일이다.


1) Universal 앱 실행하기

로컬 개발 환경에서 Angular 앱을 Universal로 렌더링하려면 다음 명령을 실행하면 된다.


npm run dev:ssr


명령을 실행하고 나면 브라우저를 열어서 http://localhost:4200/에 접속해 보자. 이전에 봤던 히어로들의 여행 대시보드 화면이 표시될 것이다.


이 앱은 네이티브 앵커 태그(<a>)를 사용하기 때문에 routerLinks도 이전과 마찬가지로 동작한다. 그래서 대시보드 화면에서 히어로 목록 화면으로 이동할 수 있고 이전 화면으로 돌아갈 수도 있다. 그리고 대시보드 화면에서 히어로를 클릭하면 히어로 상세정보 화면으로 이동할 수 있다.


이 상태에서 네트워크 속도를 제한하면 애플리케이션 코드를 다운로드하는 속도가 느려지기 때문에 다음과 같은 현상이 발생한다:


  • 히어로를 추가하거나 삭제할 수 없다.
  • 대시보드 화면에 있는 검색창이 동작하지 않는다.
  • 히어로 상세정보 화면에 있는 Back, Save 버튼이 동작하지 않는다.


네트워크 속도를 제한하면 routerLink를 클릭하는 것 이외의 기능이 동작하지 않는다. 이 때는 클라이언트 앱이 모두 부트스트랩되어 실행될 때까지 기다리거나 preboot와 같은 라이브러리를 활용해서 이벤트를 캐싱해놨다가 클라이언트 스크립트가 로드된 후에 다시 한 번 처리해야 한다.


서버에서 렌더링된 앱을 클라이언트용으로 전환하는 것은 간단하지만 실제로 운영되고 있는 앱이라면 이 앱을 확실하게 테스트해야 한다.


네트워크 속도를 제한하는 기능은 다음과 같이 적용한다:


1] Chrome 개발자 도구를 열어서 Network 탭으로 이동한다.

2] 메뉴바 오른쪽에 있는 Network Throttling 드롭다운을 클릭한다.

3] "3G" 속도를 선택한다.


이렇게 적용하면 서버 사이드용으로 렌더링된 앱의 초기 실행은 빠를 수 있지만 클라이언트 앱이 전부 로딩되려면 몇 초 기다려야 한다.


2. 서버 사이드 렌더링은 왜 필요한가요?

1] 검색 엔진 최적화(SEO)를 통해 웹 크롤러에 대응하기 위해

2] 모바일 장비와 저사양 장비에서 동작하는 성능을 끌어올리기 위해

3] 사용자에게 유효한 첫 페이지를 빠르게 표시하기 위해


1) 웹 크롤러 대응하기 (SEO)

Google, Bing, Facebook, Twitter와 같은 소셜 미디어 사이트는 웹 애플리케이션 컨텐츠를 수집하고 검색에 활용하기 위해 웹 크롤러를 사용한다. 그런데 이런 웹 크롤러는 진짜 사람이 하는 것처럼 애플리케이션 페이지를 효율적으로 이동하면서 원하는 내용을 수집하지는 못한다.


Angular Universal은 이런 경우에 사용한다. Angular Universal을 적용하면 애플리케이션을 정적으로 빌드해둘 수 있기 때문에 컨텐츠를 검색하기 쉽고, 링크를 연결할 수 있으며, JavaScript를 사용하지 않아도 페이지를 전환할 수 있다. 그리고 Universal을 적용하면 완전히 렌더링된 페이지를 서버에 준비하기 때문에 웹사이트의 미리보기 화면을 제공할 수도 있다.


웹 크롤러에 대응하는 과정은 검색 엔진 최적화(Serach Engine Optimization, SEO)라고도 한다.


2) 모바일 장비와 저사양 장비에서 동작하는 성능 끌어올리기

JavaScript를 지원하지 않는 디바이스가 존재하기도 하고 JavaScript를 실행하는 것이 오히려 사용자의 UX를 해치는 디바이스도 존재한다. 이런 경우에는 클라이언트에서 JavaScript를 실행하지 말고 서버에서 미리 렌더링된 앱을 보내서 간단하게 실행하는 것이 더 좋다. 앱을 이렇게 제공하면 원래 사용자에게 제공하려던 기능을 모두 제공할 수는 없겠지만, 앱을 전혀 사용할 수 없는 상황은 피할 수 있다.


3) 첫 페이지를 빠르게 표시하기

사용자를 사로잡으려면 첫 번째 화면을 빨리 표시하는 것이 아주 중요하다. 화면이 빠르게 뜰수록 사용자가 앱을 더 많이 사용할 수 있기 때문에 100ms만 줄여도 비즈니스에 도움이 될 수 있다. UX 측면에서도 사용자가 어떤 동작을 하기 전에 앱이 빠르게 뜨는 것이 무엇보다 중요하다.


Angular Universal을 사용하면 설치형 앱과 거의 비슷하게 동작하는 랜딩 페이지를 생성할 수 있다. 페이지는 HTML만으로 구성되기 때문에 JavaScript가 비활성화되어도 화면을 제대로 표시할 수 있다. 다만, JavaScript가 실행되지 않으면 브라우저 이벤트를 처리할 수 없기 때문에 네비게이션은 routerLink를 사용하는 방식으로 구현되어야 한다.


운영환경에서도 첫 페이지를 빠르게 표시하기 위해 페이지를 정적으로 렌더링해서 제공하는 경우가 많다. 그 이후에 온전한 버전의 Angular 앱을 로드하는 방법을 사용하기도 한다. 그러면 애플리케이션 첫 페이지를 빠르게 표시하면서도 앱에 구현한 기능을 온전히 사용자에게 제공할 수 있다.


3. Unviersal 웹 서버

Universal 웹 서버는 애플리케이션 페이지 요청을 받았을 때 Universal 템플릿 엔진으로 렌더링한 정적 HTML을 제공하는 역할을 담당한다. 이 서버는 일반적으로 브라우저에서 HTTP 요청을 받고 HTTP 응답을 내려주는데, 스크립트 파일이나 CSS, 이미지 파일과 같은 정적 애셋들도 함께 제공한다. 이 외에도 API로 통하는 데이터 요청은 Universal 웹 서버가 직접 처리하거나 프록시 역할을 하면서 다른 데이터 서버를 중개할 수도 있을 것이다.


이 문서에서는 널리 사용되는 Express 프레임워크를 사용해서 샘플 웹 서버를 구현해 보자.


Note

  • Angular Universal이 제공하는 renderModule() 함수를 실행할 수만 있다면 아무 웹 서버를 사용해도 Universal 앱을 제공할 수 있다.
  • 이 섹션에서는 웹 서버를 결정하는 기준에 대해서 조금 더 자세하게 알아본다.


Universal 애플리케이션은 (platform-browser 대신) Angular가 제공하는 platform-server 패키지를 사용한다. 이 패키지는 서버에서 DOM에 접근할 수 있는 기능이나 XMLHttpRequest와 같이 브라우저의 기능이 필요한 로직에 사용된다.


이 문서에서 다루는 것처럼 Node.js Express를 사용하는 서버라면 클라이언트에서 보내는 애플리케이션 페이지 요청을 NgUniversal이 제공하는 ngExpressEngine으로 전달한다. 그러면 Universal의 renderModuleFactory() 함수가 실행되면서 페이지를 구성한다.


renderModuleFactory() 함수는 HTML 템플릿 페이지(일반적으로 index.html)를 바탕으로 Angular 컴포넌트로 구성된 모듈을 생성하며, 라우팅 규칙에 맞게 컴포넌트를 화면에 표시한다. 이 때 라우팅 규칙은 클라이언트가 서버로 보낸 것이 사용된다.


클라이언트가 보낸 요청의 결과는 해당 라우팅 규칙과 연결된 애플리케이션 페이지가 된다. 그래서 renderModuleFactory() 함수는 템플릿의 <app> 태그에 뷰를 렌더링하며, 결과적으로 온전하게 HTML로 구성된 페이지가 생성된다.


이제 렌더링된 페이지를 클라이언트가 받으면 브라우저에 이 페이지가 표시된다.


1) 브라우저 API 활용하기

Universal platform-server 앱은 브라우저에서 실행되지 않기 때문에 브라우저 API를 직접 활용할 수 없다.


그래서 서버 사이드 페이지는 브라우저에만 존재하는 windowdocument, navigator, location과 같은 전역 객체를 참조할 수 없다.


Angular는 이런 객체를 참조해야 하는 상황을 대비해서 Location이나 DOCUMENT과 같은 추상클래스를 제공하기 때문에, 필요한 곳에 의존성으로 주입받아 사용하면 된다. 그리고 Angular가 제공하는 추상 클래스로 해결할 수 없다면 개발자가 직접 이 추상 클래스를 정의해야 한다.


이와 비슷하게, 마우스 이벤트나 키보드 이벤트도 서버 사이드 앱에는 존재하지 않는다. 서버에서 페이지를 렌더링하는데 컴포넌트를 표시하는 버튼을 누를 사용자가 없기 때문이다. 그렇다면 서버 사이드 앱은 클라이언트의 요청만으로 온전히 렌더링할 수 있는 로직으로 작성해야 한다. 이 방식은 앱을 라우팅할 수 있도록 구현한다는 측면에서도 활용할 수 있다.


결국 서버에서 렌더링된 페이지에서는 사용자가 링크를 클릭한다는 방식을 활용할 수 없기 때문에, 이와 유사한 UX를 제공할 수 있도록 구현방식을 수정해야 할 수도 있다.


2) Universal 템플릿 엔진


server.ts 파일에서는 ngExpressEngine() 함수가 중요하다.


server.ts
// Universal express 엔진 (https://github.com/angular/universal/tree/master/modules/express-engine)
server.engine(
  "html",
  ngExpressEngine({
    bootstrap: AppServerModule,
  })
);


ngExpressEngine() 함수는 Uiversal이 제공하는 renderModule() 함수를 랩핑한 함수이며, renderModuleFactory() 함수는 클라이언트의 요청을 서버가 렌더링한 HTML 페이지로 변경해서 요청하는 함수이다.


  • bootstrap: 서버에서 렌더링할 때 사용할 최상위 NgModule이나 NgModule 팩토리를 지정한다. 그래서 AppServerModule와 같은 모듈을 지정할 수 있다. 이 때 지정되는 모듈은 Universal 서버 사이드 렌더러와 Angular 애플리케이션을 연결하는 다리 역할을 한다.
  • extraProviders: 서버에서 렌더링할 때 필요한 의존성 프로바이더를 등록할 수 있으며, 생략할 수 있다. 서버 인스턴스가 어떤 환경에서 실행되는지 확인하는 용도로 활용할 수 있다.


ngExpressEngine() 함수는 렌더링된 페이지를 Promise 콜백 형태로 반환한다. 그리고 이 페이지를 어떻게 활용할 것인지는 서버에서 사용하는 엔진에 따라 달라진다. 단순하게 구현하면, Promise 콜백 형태로 전달된 페이지를 웹 서버로 반환하고 웹 서버가 HTTP 응답으로 클라이언트에 전달하면 된다.


Note

  • renderModule() 함수를 직접 사용하는 것보다는 ngExpressEngine() 랩핑 헬퍼를 사용하는 것이 편한다.


3) 요청으로 보내는 URL 필터링하기

웹 서버는 웹 페이지를 요청하는 것과 데이터를 요청하는 것을 구별할 수 있어야 한다.


하지만 최상위 주소 / 이외에는 이 요청이 어떤 용도로 사용되는 것인지 구분하기 어렵다. 브라우저가 사용하는 라우팅 경로가 /dashboardheroes, /detail:12와 같은 형식으로 존재할 수 있기 때문이다. 그런데 앱이 서버에서 모두 렌더링되어 서비스된다고 하면, 사용자가 클릭할 수 있는 모든 앱 링크는 페이지를 전환하는 URL에 해당하며 Angular 라우터가 모두 처리해야 한다고 간주할 수 있다.


다행히 애플리케이션 라우팅이 공통으로 활용할 수 있는 규칙이 있다. 파일 확장자가 없는 URL을 모두 라우팅 경로로 간주하는 방법이다. (데이터 요청도 확장자가 없지만, 이 경우에는 URL이 /api로 시작하기 때문에 쉽게 구분할 수 있다.) 앱에 필요한 정적 애셋(static asset)들은 모두 확장자가 존재한다. (ex. main.js, /node_modules/zone.js/bundles/zone.umd.js)


Angular 앱은 라우터를 사용하기 때문에 다음과 같은 3가지 요청을 쉽게 구분하고 적절한 방법으로 처리할 수 있다.


1] 데이터 요청: URL이 /api로 시작하는 경우

2] 앱 네비게이션: 파일 확장자가 없는 경우

3] 정적 애셋: 두 경우를 제외한 모든 경우


Node.js Express 서버는 미들웨어 파이프라인을 연결하는 방식으로 동작하기 때문에 클라이언트가 보낸 요청을 처리할 때 URL을 활용할 수 있다. 그래서 데이터 요청 URL을 처리하는 Node Express 서버의 파이프라인을 정의한다면 Express가 제공하는 app.get() 함수를 사용해서 다음과 같이 정의할 수 있다.


server.ts (데이터 URL)
// TODO: 보안 로직을 추가해야 합니다.
server.get("/api/**", (req, res) => {
  res.status(404).send("data requests are not yet supported");
});


Note

  • 튜토리얼 설정으로는 데이터 요청을 처리하지 않는다.
  • 지금 살펴보고 있는 튜토리얼은 "인-메모리 웹 API" 모듈을 사용하기 때문에, 서버로 보내야 하는 모든 HTTP 요청을 가로채서 메모리 안에서 처리한다.
  • 리모트 데이터 서버로 실제 요청을 보내려면 이 모듈을 제거하고 서버에 웹 API 미들웨어를 설정해야 한다.


다음 코드는 URL에 확장자가 없을 때 이 요청을 네비게이션 요청으로 처리하는 코드이다.


server.ts (네비게이션)
// 모든 요청이 Universal 엔진을 사용합니다.
server.get("*", (req, res) => {
  res.render(indexHtml, {
    req,
    providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }],
  });
});


4) 정적 파일 안전하게 제공하기

JavaScript 파일이나 이미지 파일, 스타일 파일과 같은 정적 애셋은 server.use() 하나로 간단하게 처리할 수 있다.


그리고 클라이언트가 이 파일들을 다운로드 받을 수 있는 권한을 지정하기 위해, 애셋 파일은 모두 /dist 폴더에 두는 것이 좋다.


아래 코드는 정적 애셋을 요청받았을 때 실행되는 Node.js Express 코드이다. 요청받은 파일은 /dist 폴더에서 찾아 보내는데, 이 파일이 존재하지 않으면 404 - NOT FOUND를 반환한다.


server.ts (정적 파일)
// 정적 파일은 /browser 주소로 제공합니다.
server.get(
  "*.*",
  express.static(distFolder, {
    maxAge: "1y",
  })
);


5) 서버에서 절대 URL 사용하기

튜토리얼에서 다룬 HeroServiceHeroSearchService는 Angular HttpClient 모듈을 사용해서 애플리케이션 데이터를 가져온다. 이 때 서비스는 api/heroes와 같은 상대 URL로 요청을 보낸다. 서버 사이드에서 렌더링되는 앱은 반드시 https://my-server.com/api/heroes와 같은 절대 HTTP URL을 사용해야 한다. 이 말은, 서버에서 사용되는 URL은 절대 주소로 변환되어야 하지만, 브라우저에서 실행될 때를 위해 상대 주소로도 남아 있어야 한다는 것을 의미한다.


이 과정은 @nguniversal/express-engine와 같이 @nguniversal/*-engine 형태로 제공되는 패키지를 활용하면 자동으로 처리할 수 있다. 직접 설정해야 하는 내용은 아무것도 없다.


하지만 이유가 있어어 @nguniversal/*-engine 패키지를 사용할 수 없다면 이 과정을 직접 처리해야 한다.


이 때 권장하는 방법은 항상 전체 URL을 renderModule(), renderModuleFactory()options 인자로 전달하는 것이다. 대상 AppServerModule에 어떤 것을 지정했는지에 따라 다르다. 이 옵션값은 앱이 수정되더라도 변경될 일이 거의 없기 때문에 관리하기도 편하다. https://my-server.com/dashboard라는 주소에 대응하는 앱을 서버에서 렌더링하는 상황이라면 options.urlhttps://my-server.com/dashboard와 함께 지정하는 방식이다.


이렇게 설정하면 서버에서 앱을 렌더링할 때 필요한 HTTP 요청이 모두 options.url를 사용해서 절대 URL로 변경된다.


6) 활용하면 좋은 스크립트

  • npm run dev:ssr: 이 커맨드는 자동으로 갱신되는 개발 서버를 띄우는 ng serve 명령과 비슷하지만, 서버 사이드 렌더링 기능이 추가되었다. 그래서 ng serve 명령보다는 다소 느리다.
  • ng build && ng run app-name:server: 이 명령을 실행하면 서버 스크립트를 빌드하고 애플리케이션을 운영 모드로 실행한다. 배포용으로 빌드할 때 활용하면 좋다.
  • npm run server:ssr: 이 명령을 실행하면 서버 스크립트를 실행하고 서버 사이드 렌더링이 지원되는 형태로 개발 서버를 실행한다. 그리고 이 명령은 ng run build:ssr이 만든 아티팩트를 활용하기 때문에 ng run build:ssr 명령이 제대로 실행되는 것을 확인한 후에 사용하자. server:ssr는 애플리케이션을 운영환경에서 제공하기 위한 것이 아니다. 로컬에서 서버 사이드 렌더링을 테스트할 때만 사용하자.
  • npm run prerender: 애플리케이션 화면을 사전 렌더링할 때 사용한다.

References