앵귤러 고급 강좌 #6 SSR — Angular Universal과 Hydration

10 분 소요

지금까지의 앵귤러 강좌에서 우리가 만든 앱들은 전부 CSR(Client-Side Rendering) 방식이었습니다. 기초 #2 ng new에서 프로젝트를 만들 때 Server-Side Rendering (SSR)?이라는 질문에 No로 답했던 걸 기억하실 겁니다. 그 선택의 결과물이 바로 우리가 지금까지 다뤄온 일반적인 SPA — 빈 HTML 껍데기를 먼저 받고, 자바스크립트 번들이 다운로드된 후에야 화면이 그려지는 그 방식입니다.

대부분의 사내 시스템,대시보드라면 이 방식으로도 충분합니다. 하지만 공개 웹사이트라면 이야기가 달라집니다. 검색엔진이 본문을 인덱싱해야 하고, 첫 화면이 빨리 떠야 사용자가 떠나지 않으며, 소셜 공유 미리보기에 제목과 이미지가 제대로 들어가야 합니다. 이런 요구를 풀어주는 것이 **SSR(Server-Side Rendering)**입니다.

이번 글에서는 모던 앵귤러(Angular 17+)의 통합 SSR 모델을 정리합니다. 옛 이름인 Angular Universal부터 Hydration, TransferState, 그리고 **Pre-rendering(SSG)**까지 한 번에 짚어보겠습니다.

CSR / SSR / SSG — 세 가지 렌더링 모델 #

먼저 용어를 분명히 해두겠습니다. 같은 앵귤러 앱이라도 화면이 만들어지는 시점과 장소에 따라 세 가지로 나뉩니다.

  • CSR (Client-Side Rendering) — 브라우저가 빈 HTML을 받고, JS 번들을 다운받아 실행한 뒤, 그제야 화면이 그려집니다. 우리가 지금까지 해온 방식입니다.
  • SSR (Server-Side Rendering) — 매 요청마다 서버에서 앱을 실행해 완성된 HTML을 만들어 보냅니다. 사용자는 첫 화면을 즉시 보고, JS는 그 위에 인터랙션을 덧붙입니다.
  • SSG (Static Site Generation, Pre-rendering) — 빌드 타임에 미리 모든 페이지를 HTML로 뽑아 둡니다. 요청이 오면 서버가 그 정적 파일을 그대로 내려주기만 하면 됩니다.
세 모델의 차이
CSR : 요청 → 빈 HTML + JS → 브라우저에서 그림
SSR : 요청 → 서버가 HTML 생성 → 보냄 → 브라우저에서 hydration
SSG : (빌드 시점에 HTML 생성) → 요청 → 정적 HTML 즉시 응답

중요한 건 셋 중 하나만 골라야 하는 게 아니라는 점입니다. 모던 앵귤러는 세 가지를 같은 빌드 안에서 라우트 단위로 섞어 쓸 수 있게 통합해 두었습니다. 블로그 본문 페이지는 SSG로, 사용자 대시보드는 SSR로, 인터랙션 위주의 관리자 화면은 CSR로 — 한 앱 안에서 자연스럽게 공존합니다.

Angular Universal의 위치 #

앵귤러로 SSR을 검색하면 Angular Universal이라는 이름이 자주 나옵니다. 이건 옛 이름입니다. 2017년경부터 SSR을 담당하던 별도 패키지(@nguniversal/*)였는데, Angular 17부터 SSR이 프레임워크 본체에 통합되면서 이름과 패키지 구조가 정리되었습니다.

Angular Universal의 변천
~Angular 16 : @nguniversal/express-engine 등 별도 패키지
Angular 17+ : @angular/ssr 한 패키지로 통합 (ng new --ssr)

새 프로젝트에서는 @angular/ssr만 알면 충분합니다. 다만 회사 코드베이스에서 @nguniversal/...을 마주칠 일이 아직 많으니, “옛 이름이구나” 정도는 알아두면 좋습니다. 내부 동작은 같은 계보입니다.

SSR 활성화 #

새 프로젝트라면 --ssr 플래그 한 번이면 끝입니다.

새 프로젝트에 SSR 켜기
ng new my-app --ssr

ng new 도중 SSR 질문에 No로 답해서 이미 만들어둔 프로젝트라면, 나중에 ng add로 추가할 수 있습니다.

기존 프로젝트에 SSR 추가
ng add @angular/ssr

이 명령어를 실행하면 다음과 같은 변화가 생깁니다.

SSR 활성화 후 추가/변경되는 것들
src/
├── main.server.ts         ← 서버용 진입점 (추가)
├── app/
│   ├── app.config.ts          ← 클라이언트 설정 (수정)
│   └── app.config.server.ts   ← 서버 설정 (추가)
└── server.ts              ← Express 서버 (추가)
angular.json              ← server 빌드 타깃 추가
package.json              ← serve:ssr:my-app 스크립트 추가

server.ts가 핵심입니다. Express 기반의 작은 Node 서버로, 들어오는 요청을 받아 앵귤러 앱을 서버에서 렌더링한 뒤 HTML로 응답합니다. 이 파일은 직접 수정해서 미들웨어를 추가하거나 다른 라우트를 끼워 넣을 수 있는 평범한 Express 앱입니다.

provideServerRendering / provideClientHydration #

Standalone 컴포넌트 시대의 앵귤러는 설정도 함수 호출의 모음입니다. SSR이 켜지면 클라이언트와 서버의 설정 파일이 한 쌍으로 만들어집니다.

src/app/app.config.ts (클라이언트)
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideClientHydration(),
    provideHttpClient(withFetch()),
  ],
};
src/app/app.config.server.ts (서버)
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';

import { appConfig } from './app.config';

const serverConfig: ApplicationConfig = {
  providers: [provideServerRendering()],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

각 함수가 하는 일은 이름에 그대로 드러납니다.

  • provideServerRendering() — 앱을 서버(Node)에서 렌더링할 수 있게 필요한 토큰들을 등록합니다.
  • provideClientHydration() — 브라우저가 서버에서 받아온 DOM 위에 hydration을 수행하도록 켭니다. 이게 빠지면 SSR이 켜져 있어도 클라이언트가 DOM을 통째로 다시 그립니다 — 가장 흔한 SSR 설정 실수입니다.
  • provideHttpClient(withFetch()) — 서버에서 XMLHttpRequest를 쓸 수 없으니 fetch 백엔드를 사용합니다. SSR 환경에서는 사실상 필수입니다.

Hydration이란 #

방금 등장한 **Hydration(하이드레이션)**이라는 단어를 잠깐 짚고 가겠습니다. 비유하면 이렇습니다.

서버가 마른 HTML을 그려서 보내주면, 클라이언트가 그 위에 JS의 물을 부어 살아있는 컴포넌트로 만든다.

서버에서 그린 정적인 DOM을 클라이언트가 그대로 이어받아, 그 DOM에 이벤트 핸들러를 붙이고 시그널,바인딩을 연결하는 과정이 hydration입니다.

Angular 16 이전에는 SSR이 켜져 있어도 클라이언트가 도착하면 DOM 전체를 통째로 다시 그렸습니다(destructive hydration). 화면이 한 번 깜빡이고, 사용자 입장에서는 “왜 다시 그려지지?” 싶은 어색함이 있었습니다.

Angular 16에서 Non-destructive hydration이 정식 안정화되면서 이 문제가 사라졌습니다. 클라이언트는 서버가 그린 DOM을 건드리지 않고, 그 위에 이벤트 리스너만 살짝 얹습니다. 깜빡임이 없고, 첫 페인트 이후 인터랙션 가능 시점(TTI)도 빨라집니다.

provideClientHydration()을 켜는 것만으로 이 모드가 활성화됩니다.

Hydration이 켜진 상태에서는 서버에서 그린 DOM과 클라이언트가 그릴 DOM이 정확히 같아야 합니다. 서버에서는 Date.now()같이 매번 값이 바뀌는 표현식이 들어가면 클라이언트에서 다시 계산할 때 결과가 달라져 hydration mismatch가 발생할 수 있습니다. 이런 동적 값은 클라이언트 전용 컴포넌트로 분리하거나, 시간 표시는 [ngClass]나 directive로 클라이언트에서만 적용하는 식으로 풀어야 합니다.

TransferState — 같은 데이터 두 번 받지 않기 #

SSR을 켜고 나면 미묘한 문제가 하나 보입니다. 서버가 HttpClient로 API를 호출해 데이터를 받아와 HTML을 그렸는데, 클라이언트가 hydration할 때 그 API를 또 한 번 호출하는 현상입니다.

서버가 이미 받은 응답을 HTML과 함께 클라이언트에 넘겨주고, 클라이언트는 처음에는 그 값을 그대로 쓰면 한 번이면 충분합니다. 이걸 깔끔하게 풀어주는 게 앵귤러의 TransferState API입니다.

가장 쉬운 방법은 withFetch()와 함께 **provideClientHydration(withHttpTransferCacheOptions(...))**을 쓰는 것입니다. 이 옵션을 켜면 SSR 중에 발생한 GET 요청의 결과를 HTML에 직렬화해 같이 내려보내고, 클라이언트는 그 캐시를 보고 재요청을 생략합니다.

src/app/app.config.ts
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withFetch()),
    provideClientHydration(
      withHttpTransferCacheOptions({
        includePostRequests: false,
        filter: (req) => !req.url.includes('/admin'),
      }),
    ),
  ],
};

직접 키–값을 넘기고 싶을 때는 TransferState 토큰을 주입해서 명시적으로 다룰 수도 있습니다.

src/app/services/posts.service.ts
import { inject, Injectable, makeStateKey, TransferState } from '@angular/core';
import { HttpClient } from '@angular/common/http';

const POSTS_KEY = makeStateKey<Post[]>('posts');

@Injectable({ providedIn: 'root' })
export class PostsService {
  private http = inject(HttpClient);
  private state = inject(TransferState);

  async loadPosts(): Promise<Post[]> {
    // 클라이언트 첫 실행 시 서버가 넣어둔 값이 있으면 재사용
    const cached = this.state.get(POSTS_KEY, null);
    if (cached) {
      this.state.remove(POSTS_KEY);
      return cached;
    }
    const posts = await firstValueFrom(this.http.get<Post[]>('/api/posts'));
    this.state.set(POSTS_KEY, posts); // 서버 측에서 넣으면 HTML에 함께 직렬화됨
    return posts;
  }
}

세부 구현은 다르지만, 둘 다 **“서버가 받은 데이터를 HTML 옆에 끼워 보내, 클라이언트가 다시 받지 않게 한다”**는 같은 목표를 가집니다.

서버에서 안 되는 것들 #

SSR을 켜는 순간부터 우리 컴포넌트는 두 번 실행됩니다. 한 번은 Node 서버에서, 한 번은 브라우저에서. Node에는 window도 없고 document도 없고 localStorage도 없습니다. 무심코 다음 같은 코드를 컴포넌트 최상위나 ngOnInit에 두면 서버 렌더링이 그 시점에 터집니다.

잘못된 예 — 서버에서 터지는 코드
ngOnInit() {
  const theme = localStorage.getItem('theme'); // ReferenceError: localStorage is not defined
  document.body.classList.add(theme ?? 'light');
}

해결책은 플랫폼 가드입니다. 앵귤러는 현재 실행 중인 플랫폼을 알려주는 토큰을 제공합니다.

src/app/theme.component.ts
import { Component, inject, OnInit, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Component({
  selector: 'app-theme',
  standalone: true,
  template: `<button (click)="toggle()">테마 전환</button>`,
})
export class ThemeComponent implements OnInit {
  private platformId = inject(PLATFORM_ID);

  ngOnInit() {
    if (isPlatformBrowser(this.platformId)) {
      const theme = localStorage.getItem('theme');
      document.body.classList.add(theme ?? 'light');
    }
  }

  toggle() {
    if (!isPlatformBrowser(this.platformId)) return;
    document.body.classList.toggle('dark');
  }
}

isPlatformBrowser로 감싸면 서버에서는 그 블록을 건너뜁니다. setTimeout, IntersectionObserver, 차트 라이브러리, 지도 라이브러리 — 브라우저에 의존하는 모든 코드는 이 가드 안으로 넣어야 한다고 생각하면 됩니다.

노트
서드파티 라이브러리가 import 시점에 곧바로 window를 건드리는 경우도 있습니다. 이런 라이브러리는 import('chart.js')처럼 동적 import로 묶고, isPlatformBrowsertrue일 때만 불러오는 식으로 격리해야 합니다. 정적 import에 window 참조가 들어 있으면 서버 빌드 자체가 실패할 수 있습니다.

Pre-rendering / SSG #

매 요청마다 서버가 같은 HTML을 만들어 응답하는 게 낭비처럼 느껴질 때가 있습니다. 블로그 글이나 문서 페이지처럼 내용이 거의 바뀌지 않는 페이지가 그렇습니다. 이런 페이지는 빌드 시점에 미리 HTML로 뽑아두는 편이 압도적으로 효율적입니다.

@angular/ssr은 이 Pre-rendering(SSG)을 1급 시민으로 다룹니다. app.routes.ts에 라우트를 정의하고, 각 라우트의 렌더링 전략을 함께 지정합니다.

src/app/app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender }, // 홈은 빌드 타임에 HTML 뽑기
  { path: 'about', renderMode: RenderMode.Prerender },
  {
    path: 'posts/:slug',
    renderMode: RenderMode.Prerender,
    getPrerenderParams: async () => {
      const posts = await loadAllPosts();
      return posts.map((p) => ({ slug: p.slug }));
    },
  },
  { path: 'dashboard/**', renderMode: RenderMode.Server }, // 대시보드는 매 요청 SSR
  { path: 'admin/**', renderMode: RenderMode.Client }, // 관리자는 CSR
];

RenderMode는 세 가지입니다.

  • Prerender — 빌드 시 HTML 생성 (SSG)
  • Server — 매 요청마다 서버가 렌더 (SSR)
  • Client — 클라이언트에서만 렌더 (CSR)

이렇게 라우트 단위로 섞어쓸 수 있다는 것이 모던 앵귤러 SSR의 큰 매력입니다. 같은 빌드, 같은 코드베이스 안에서 페이지의 성격에 맞춰 전략을 골라 쓰면 됩니다.

빌드
ng build

빌드가 끝나면 dist/my-app/browser/에 클라이언트 자산과 prerender된 정적 HTML이, dist/my-app/server/에 SSR용 Node 번들이 만들어집니다.

배포 #

ng build의 결과물을 어디에 올릴 것인가는 어느 라우트가 Server 모드인지에 따라 갈립니다.

전부 Prerender라면dist/my-app/browser/만 있으면 됩니다. 그냥 정적 호스팅이면 충분하니 Vercel, Netlify, Cloudflare Pages, 심지어 S3 + CloudFront 같은 평범한 정적 호스팅 어디에 올려도 동작합니다.

Server 모드 라우트가 있다면 — 서버에서 앵귤러를 실행할 런타임이 필요합니다. 선택지는 크게 두 갈래입니다.

  • Node 서버 (Express)ng build가 만들어낸 server.mjs를 그대로 띄웁니다. AWS EC2, GCP, Render, Railway, Fly.io 등 Node를 실행할 수 있는 환경이라면 어디든 배포할 수 있고, 어떤 미들웨어든 자유롭게 끼울 수 있습니다.
  • 서버리스 / 엣지 런타임 — Vercel, Netlify Functions, Cloudflare Workers처럼 요청 단위로 함수를 실행하는 환경. 콜드 스타트가 짧고 글로벌 엣지에서 응답할 수 있다는 장점이 있습니다. Angular CLI에서 ng add @angular/ssr --server-routing과 함께 빌더를 골라 배포 어댑터를 붙이는 방식이 표준화되고 있습니다.
로컬에서 SSR 서버 실행
npm run serve:ssr:my-app

배포 전에 항상 이 명령으로 로컬 SSR 동작을 한 번 확인하는 습관을 들이세요. ng serve(개발 모드)는 SSR이 항상 켜진 상태가 아니어서, 서버 전용 코드의 문제를 못 잡고 지나칠 수 있습니다.

마무리 #

이번 글에서는 모던 앵귤러의 SSR을 처음부터 끝까지 한 번에 훑었습니다.

  • CSR / SSR / SSG — Angular 17+에서 한 빌드 안에서 라우트 단위로 섞어쓸 수 있는 통합 모델
  • Angular Universal은 옛 이름. 지금은 @angular/ssr로 정리됨
  • **provideClientHydration()**과 non-destructive hydration으로 깜빡임 없는 첫 페인트
  • **TransferState / withHttpTransferCacheOptions**로 서버,클라이언트 중복 호출 제거
  • **isPlatformBrowser**로 window, document 같은 브라우저 전용 API 보호
  • **RenderMode**로 라우트마다 Prerender / Server / Client를 골라 쓰기

SSR을 켜는 일은 어렵지 않지만, “이 코드는 서버에서도 돌고 클라이언트에서도 돈다"는 사고방식의 전환이 필요합니다. 기초 #2에서 No로 넘어갔던 그 질문에 이제는 자신 있게 Yes라고 답할 수 있게 되었을 겁니다.

다음 글인 “앵귤러 고급 강좌 #7 성능 튜닝"에서는 OnPush 변경 감지, 트랙백, lazy loading, 번들 분석까지 — 실제 프로덕션 앵귤러 앱이 빠르게 동작하도록 다듬는 기법들을 차근차근 살펴보겠습니다.

X