Angular SSR 및 증분 수화: 2025년 서버 측 렌더링
Il 서버 측 렌더링(SSR) Angular에서는 급격한 변화를 겪었습니다. 외부적이고 실험적인 패키지인 Angular Universal에서 이제 SSR이 하나의 기능이 되었습니다. 프레임워크 코어에 통합된 첫 번째 클래스입니다. Angular 19+의 도입 점진적인 수화 게임의 규칙이 바뀌었습니다. 더 이상 수분을 공급할 필요가 없습니다. 전체 애플리케이션을 한 번에, 그러나 사용자가 실제로 사용하는 부분만.
이 시리즈의 여섯 번째 기사에서는 현대적인 각도 SSR 아키텍처를 살펴보겠습니다. Angular의 기본 구성부터 고급 증분 수화 전략까지, 이벤트 재생, 정적 사전 렌더링 및 SEO 최적화. 우리는 이러한 기술이 어떻게 작동하는지 살펴보겠습니다. 구체적으로 나에게 영향을 미친다 핵심 웹 바이탈 마치 애플리케이션을 구성하는 것과 같습니다 최대 성능을 얻으려면 각도를 사용하세요.
이 기사에서 배울 내용
- Angular에서 서버측 렌더링이 작동하는 방식과 Angular Universal의 진화
- 전체 애플리케이션 하이드레이션 메커니즘: DOM 재사용 및 이벤트 재생
- Full Hydration의 한계와 TTI(Time to Interactive)에 미치는 영향
- 트리거를 사용한 증분 수화
@defer: 뷰포트, 상호 작용, 유휴, 타이머 - 완전한 SSR 구성:
app.config.server.ts, 익스프레스 서버, 공급자 - 이벤트 재생 및 jsaction과의 통합으로 수화 중 상호 작용 캡처
- 정적 페이지 생성을 위한 사전 렌더링(SSG)
- 메타 태그, 구조화된 데이터 및 크롤러 렌더링을 통한 SEO 최적화
- 실용적인 패턴:
isPlatformBrowser,TransferState, 브라우저 전용 API - 성능 전략: 부분 수화, 번들 최적화, 핵심 웹 바이탈
최신 Angular 시리즈 개요
| # | Articolo | 집중하다 |
|---|---|---|
| 1 | 각도 신호 | 세분화된 응답성 |
| 2 | 영역 없는 변경 감지 | Zone.js 삭제 |
| 3 | 새로운 템플릿 @if, @for, @defer | 현대적인 제어 흐름 |
| 4 | 독립형 구성 요소 | NgModule이 없는 아키텍처 |
| 5 | 신호 형태 | 신호가 포함된 반응형 양식 |
| 6 | 현재 위치 - SSR 및 증분 수화 | 서버 측 렌더링 |
| 7 | Angular의 핵심 웹 바이탈 | 성능 및 지표 |
| 8 | 각도 PWA | 프로그레시브 웹 앱 |
| 9 | 고급 종속성 주입 | DI 트리 셰이크 가능 |
| 10 | Angular 17에서 21로 마이그레이션 | 마이그레이션 가이드 |
1. Angular의 서버 측 렌더링
서버 측 렌더링은 페이지의 HTML 생성으로 구성됩니다. 서버에서 브라우저 대신. 사용자나 크롤러가 페이지를 요청하면 서버는 다음을 실행합니다. Angular 애플리케이션은 완전한 HTML을 생성하여 클라이언트에 보냅니다. 브라우저가 표시됩니다. JavaScript가 다운로드되어 실행될 때까지 기다릴 필요 없이 즉시 콘텐츠를 다운로드할 수 있습니다.
진화: Angular Universal에서 Core까지
역사적으로 Angular의 SSR은 다음에 의해 관리되었습니다. 각도유니버설, 패키지
분리된 (@nguniversal/express-engine) 수동 구성이 필요함
중요합니다. Angular 17+에서는 SSR이 프레임워크에 직접 통합되었습니다.
@angular/ssr Angular Universal을 대체하고 CLI가 자동으로 생성됩니다.
필요한 구성 파일.
Angular에서 SSR의 진화
| 버전 | SSR 기술 | 형질 |
|---|---|---|
| 각도 2-16 | Angular Universal(@nguniversal) | 외부 패키지, 수동 설정, 수화 없음 |
| 각도 16 | Angular Universal + Hydration(개발자 미리보기) | 최초의 비파괴 수화, DOM 재사용 |
| 각도 17 | @angular/ssr(새 패키지) | CLI에 SSR 내장, 안정적인 하이드레이션, @defer 지원 |
| 각도 19 | @angular/ssr + 증분 수화 | 증분 수화, 이벤트 재생, 경로 수준 렌더링 |
| 각도 21 | @angular/ssr(안정적) | 안정적인 증분 수분 공급, SSG 개선, 최적화된 성능 |
SSR 흐름의 작동 방식
Angular의 SSR 요청 수명 주기는 다음과 같은 정확한 흐름을 따릅니다. 서버, 브라우저 및 수화 프로세스.
- HTTP 요청: 브라우저(또는 크롤러)가 URL을 요청합니다.
- 서버측 렌더링: Express는 요청을 받고 Angular는 앱을 실행하고 HTML을 생성합니다.
- HTML 보내기: 서버는 렌더링된 콘텐츠와 함께 완전한 HTML을 보냅니다.
- 첫 번째 만족스러운 페인트: 브라우저에 콘텐츠가 즉시 표시됩니다.
- JS 다운로드: 브라우저는 애플리케이션의 JavaScript 번들을 다운로드합니다.
- 수분 공급: Angular는 이벤트 리스너와 응답성을 연결하여 정적 HTML을 "다시 애니메이션"합니다.
- 대화형 애플리케이션: 앱이 완전히 작동하게 됩니다.
# Nuovo progetto con SSR abilitato
ng new my-ssr-app --ssr
# Aggiungere SSR a un progetto esistente
ng add @angular/ssr
# Il comando genera automaticamente:
# - server.ts (Express server)
# - src/app/app.config.server.ts (configurazione server)
# - Aggiorna angular.json con target "server"
2. 전체 도포 수화
La 수화 Angular가 정적 HTML을 변환하는 프로세스입니다. 브라우저의 대화형 애플리케이션에서 서버에 의해 생성됩니다. Angular 16 이전에는 프레임워크를 간단히 의해 파괴됨 서버의 HTML을 삭제하고 모든 것을 다시 렌더링했습니다. 브라우저에서 0(파괴적인 수화). 이로 인해 콘텐츠의 성가신 "플래시"가 발생했습니다. SSR의 많은 이점을 무효화했습니다.
비파괴적인 수화
Angular 16부터 수분 공급이 비파괴적인: 각도 서버에서 생성된 DOM 노드를 다시 생성하는 대신 재사용합니다. 이는 시각적 플래시를 제거합니다. 그리고 콘텐츠가 포함된 최대 페인트(LCP) 왜냐하면 브라우저는 레이아웃을 다시 실행할 필요가 없습니다.
// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
// Abilita la hydration non-destructive
provideClientHydration(),
]
};
DOM 재사용의 작동 방식
서버 측 렌더링 중에 Angular는 구조적 정보를 HTML로 직렬화합니다.
다음과 같은 특수 속성을 통해 ngSkipHydration 그리고 숨겨진 댓글. 언제
클라이언트는 HTML을 수신하고 프레임워크인 하이드레이션을 시작합니다.
- 새로운 DOM을 생성하는 대신 기존 DOM을 탐색합니다.
- 기존 DOM 노드에 이벤트 리스너 연결
- 구성 요소의 내부 상태를 초기화합니다.
- 서버의 DOM이 클라이언트가 출력하는 것과 일치하는지 확인하십시오.
수분 공급 불일치: 흔히 저지르는 실수
서버에서 생성된 HTML이 클라이언트가 기대하는 것과 일치하지 않으면 Angular는 오류가 발생합니다 수화 불일치. 가장 일반적인 원인은 다음과 같습니다.
- 액세스
window,documentolocalStorage서버 렌더링 중 - 사용
Date.now()oMath.random()서버와 클라이언트에서 서로 다른 값을 생성하는 것 - 직접 DOM 조작
ElementRef.nativeElement - 브라우저 전용 기능(예: 뷰포트 크기)을 기반으로 하는 조건부 콘텐츠
- DOM을 직접 수정하는 타사 라이브러리
3. 수분 공급의 문제
와 함께 전체 도포 수화, Angular는 수분을 공급해야 합니다 전체 응용 프로그램이 대화형이 되기 전에. 이는 심지어 구성 요소가 사용자는 페이지 하단의 바닥글이나 숨겨진 섹션이 어떻게 나타나는지 결코 볼 수 없습니다. 로딩 시 수화물을 공급하여 직접적인 영향을 미칩니다. TTI(상호작용 시간).
핵심 웹 바이탈에 대한 완전 수화의 영향
| 미터법 | SSR 없음(CSR) | SSR + 완전 수분 공급 | 문제 |
|---|---|---|---|
| 첫 번째 콘텐츠가 포함된 페인트(FCP) | 2.5초 | 0.8초 | 크게 개선됨 |
| 콘텐츠가 포함된 최대 페인트(LCP) | 3.2초 | 1.2초 | 크게 개선됨 |
| TTI(상호작용 시간) | 3.5초 | 3.0초 | 거의 개선되지 않음: 전체 앱에 수분을 공급해야 함 |
| 총 차단 시간(TBT) | 800ms | 650ms | 여전히 높음: 수분 공급은 오랜 작업입니다. |
| 다음 페인트(INP)와의 상호작용 | 120ms | 180ms | 악화됨: 수분 공급 중 놓친 이벤트 |
Full Hydration의 역설은 초기 렌더링 지표(FCP, LCP)를 향상시킨다는 것입니다. 그러나 상호작용성 지표를 해결하지 못하고 때로는 악화시킵니다. 이유는 간단합니다. 수백 개의 구성 요소를 하이드레이션하는 것은 메인 스레드를 차단하는 CPU 집약적인 작업입니다.
완전한 수분공급은 비용이 많이 들기 때문에
- 모든 자바스크립트를 다운로드하세요: 결코 표시되지 않는 구성 요소의 경우에도 브라우저는 코드를 다운로드하고 구문 분석해야 합니다.
- 각 구성요소의 실행: Angular는 각 구성요소를 초기화하고, 종속성 주입 트리를 생성하고, 바인딩을 연결해야 합니다.
- 메인 스레드 차단: 수화는 동시에 발생하여 사용자 상호 작용을 차단합니다.
- 모바일에서의 자원 낭비: CPU와 대역폭이 제한된 장치가 가장 큰 어려움을 겪습니다.
4. 점진적인 수분 공급
La 점진적인 수화 Full Hydration 문제를 해결합니다.
성분에 수분을 공급하기 위해 주문형, 필요할 때만. 개발자로 소개됩니다
Angular 19의 미리보기는 Angular 21에서 안정화되었습니다. 이 기능은 차단을 기반으로 합니다.
@defer Angular 구문 템플릿에 이미 존재합니다.
작동 방식
아이디어는 간단하지만 강력합니다. 구성 요소를 블록으로 감싼 것입니다. @defer 그들이 온다
서버에서는 정상적으로 렌더링되지만(사용자는 콘텐츠를 볼 수 있음) 클라이언트에서는
수분 공급이 온다 연기됨 특정 트리거가 올 때까지
활성화되었습니다. 그 동안 서버의 정적 DOM은 계속 표시되지만 대화형은 아닙니다.
<!-- Questa sezione viene renderizzata sul server ma idratata
solo quando entra nel viewport dell'utente -->
@defer (hydrate on viewport) {
<app-product-list [category]="selectedCategory" />
}
<!-- Questo componente viene idratato quando l'utente
interagisce con esso (click, focus, hover) -->
@defer (hydrate on interaction) {
<app-comment-section [postId]="post.id" />
}
<!-- Questo viene idratato quando il browser e idle,
cioe non sta eseguendo altri task -->
@defer (hydrate on idle) {
<app-sidebar-recommendations />
}
<!-- Questo viene idratato dopo un timer specifico -->
@defer (hydrate on timer(3s)) {
<app-analytics-widget />
}
<!-- Mai idratato: resta HTML statico per sempre -->
@defer (hydrate never) {
<app-static-footer />
}
<!-- Idratato immediatamente (equivale a full hydration) -->
@defer (hydrate on immediate) {
<app-critical-cta />
}
수화 트리거 사용 가능
| 트리거 | 통사론 | 활성화되면 | 사용 사례 |
|---|---|---|---|
| 뷰포트 | hydrate on viewport |
구성요소가 보이는 뷰포트에 들어갑니다. | 스크롤 없이 볼 수 있는 콘텐츠, 제품 목록 |
| 상호 작용 | hydrate on interaction |
요소를 클릭하고 집중하고 터치하세요. | 양식, 버튼, 대화형 영역 |
| 게으른 | hydrate on idle |
브라우저에 대기 중인 작업이 없습니다. | 보조 위젯, 사이드바 |
| 시간제 노동자 | hydrate on timer(Ns) |
N초 로딩 후 | 분석, 추적, 지연된 위젯 |
| 즉각적인 | hydrate on immediate |
로딩하자마자 | 스크롤 없이 볼 수 있는 부분의 중요 구성 요소 |
| 절대 | hydrate never |
안 함: 정적 HTML을 유지합니다. | 바닥글, 순수하게 정보를 제공하는 콘텐츠 |
증분 수화 활성화
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import {
provideClientHydration,
withIncrementalHydration
} from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
// Abilita hydration con supporto incrementale
provideClientHydration(
withIncrementalHydration()
),
]
};
증분 수화 요구 사항
- 참조된 구성요소는 다음과 같아야 합니다. 독립형 (NgModule에 의존할 수 없음)
- 기능
withIncrementalHydration()또한 필요합니다provideClientHydration() - 안에 내용물은
@defer여전히 서버에서 렌더링됩니다. 트리거는 단지 확인만 합니다. 언제 클라이언트에 수분이 공급됩니다 - 트리거
@placeholdere@loadingdi@deferSSR에서는 사용되지 않습니다. 서버는 전체 콘텐츠를 직접 표시합니다.
5. SSR 구성 완료
Angular의 프로덕션 준비 SSR 설정에는 여러 파일이 포함됩니다. 보자 각 구성 요소의 전체 구조와 역할.
서버측 구성
// src/app/app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { provideServerRoutesConfig } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [
// Abilita il rendering lato server
provideServerRendering(),
// Configurazione route-level rendering
provideServerRoutesConfig(serverRoutes),
]
};
// Merge della config client con quella server
export const config = mergeApplicationConfig(appConfig, serverConfig);
경로 수준 렌더링 구성
// src/app/app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '',
renderMode: RenderMode.Prerender, // SSG: generato al build time
},
{
path: 'blog',
renderMode: RenderMode.Prerender, // Pagina statica
},
{
path: 'blog/:slug',
renderMode: RenderMode.Server, // SSR: generato ad ogni richiesta
},
{
path: 'dashboard',
renderMode: RenderMode.Client, // CSR: solo lato client
},
{
path: '**',
renderMode: RenderMode.Server, // Default: SSR per tutto il resto
},
];
익스프레스 서버
// server.ts
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr/node';
import express from 'express';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import bootstrap from './src/main.server';
export function app(): express.Express {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');
const commonEngine = new CommonEngine();
// Serve file statici dalla cartella browser
server.get('*.*', express.static(browserDistFolder, {
maxAge: '1y', // Cache aggressiva per asset statici
index: false,
}));
// Tutte le altre route passano per Angular SSR
server.get('**', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [
{ provide: APP_BASE_HREF, useValue: baseUrl }
],
})
.then(html => res.send(html))
.catch(err => next(err));
});
return server;
}
function run(): void {
const port = process.env['PORT'] || 4000;
const server = app();
server.listen(port, () => {
console.log(`Server SSR in ascolto su http://localhost:${port}`);
});
}
run();
SSR 파일 구조
| 파일 | 역할 |
|---|---|
src/app/app.config.ts |
클라이언트 구성: 라우터, 하이드레이션, 공급자 |
src/app/app.config.server.ts |
서버 구성: ProvideServerRendering, serverRoutes |
src/app/app.routes.server.ts |
각 경로(Prerender, Server, Client)에 대한 renderMode를 정의합니다. |
server.ts |
SSR 요청을 처리하는 Express 서버 |
src/main.server.ts |
서버 측 부트스트랩의 진입점 |
6. 이벤트 재생
기존 SSR의 가장 실망스러운 문제 중 하나는 소위 말하는 SSR입니다. "불쾌한 계곡": 사용자는 렌더링된 페이지를 보고 상호 작용을 시작합니다(버튼 클릭, 양식 작성). 하지만 수분 공급이 아직 완료되지 않아 애플리케이션이 응답하지 않습니다. 이벤트가 온다 잃어버린 사용자는 해당 작업을 반복해야 합니다.
이벤트 재생 작동 방식
Angular는 이 문제를 다음과 같이 해결합니다.이벤트 재생, 라이브러리를 기반으로
jsaction Google에 의해. 메커니즘은 세 단계로 작동합니다.
- 포착: 서버에서 HTML에 삽입된 작은 인라인 스크립트는 Angular가 준비되기 전에 모든 사용자 이벤트를 포착합니다.
- 완충기: 이벤트는 필요한 모든 정보(이벤트 유형, 대상, 타임스탬프)와 함께 대기열에 저장됩니다.
- 다시 하다: 구성요소 하이드레이션이 완료되면 이벤트가 올바른 순서로 다시 전달됩니다.
// app.config.ts
import {
provideClientHydration,
withEventReplay,
withIncrementalHydration
} from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
// Event Replay: cattura gli eventi durante la hydration
withEventReplay(),
// Incremental Hydration: hydrate on demand
withIncrementalHydration(),
),
]
};
이벤트 재생: 증분 수화를 사용한 동작
증분 수화를 사용하면 이벤트 재생이 더욱 중요해집니다. 언제
사용자가 수분 공급이 연기된 구성 요소를 클릭합니다(예: hydrate on interaction),
다음과 같은 일이 발생합니다:
- 이벤트는 jsaction에 의해 캡처됩니다.
- 클릭 트리거는 구성 요소의 수화를 활성화합니다.
- Angular는 구성 요소의 게으른 코드를 다운로드하고 이를 수화합니다.
- 캡처된 이벤트는 이제 대화형 구성 요소로 다시 전달됩니다.
- 사용자는 상당한 지연을 인식하지 못합니다.
7. 사전 렌더링(SSG)
Il 사전 렌더링 (또는 정적 사이트 생성(SSG))은 페이지의 HTML을 생성합니다. 알 빌드 시간 모든 요청 대신. 페이지는 HTML 파일로 저장됩니다. 서버 없이 CDN 또는 웹 서버에서 직접 정적으로 제공됩니다. Node.js가 실행 중입니다.
SSG와 SSR을 사용하는 경우
SSG 대 SSR: 선택 가이드
| 표준 | SSG(사전 렌더링) | SSR(서버) |
|---|---|---|
| 콘텐츠 | 정적이며 거의 변경되지 않음 | 동적, 사용자별 개인화 |
| 성능 | 매우 빠름(CDN에서 제공되는 파일) | 서버 렌더링 시간에 따라 다름 |
| 하부 구조 | 정적 호스팅만 해당(Firebase, Netlify, S3) | Node.js 서버 실행 중 |
| 확장성 | 훌륭함(CDN이 로드를 처리함) | 서버 확장 필요 |
| 업데이트 | 재구축 및 배포 필요 | 콘텐츠는 항상 업데이트됩니다 |
| 일반적인 사용 사례 | 블로그, 랜딩 페이지, 문서, 포트폴리오 | 전자상거래, 대시보드, 소셜 미디어 |
사전 렌더링 구성
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
// Pagine statiche: generate al build time
{
path: '',
renderMode: RenderMode.Prerender,
},
{
path: 'about',
renderMode: RenderMode.Prerender,
},
// Route parametriche: elenco esplicito di path
{
path: 'blog/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
// Restituisce un array di oggetti con i parametri
return [
{ slug: 'angular-ssr-hydration' },
{ slug: 'angular-signals-guida' },
{ slug: 'zoneless-change-detection' },
];
},
},
// Route dinamiche: SSR a runtime
{
path: 'product/:id',
renderMode: RenderMode.Server,
},
// Dashboard: solo client-side
{
path: 'admin/**',
renderMode: RenderMode.Client,
},
];
RenderMode: 세 가지 전략
- RenderMode.Prerender(SSG): 빌드 시 생성된 HTML로 정적 콘텐츠에 이상적입니다. 매우 빠르며 런타임 시 서버가 필요하지 않습니다.
- 렌더모드.서버(SSR): 서버의 각 요청으로 생성된 HTML입니다. 개인화되거나 자주 변경되는 콘텐츠에 필요
- 렌더모드.클라이언트(CSR): 서버 렌더링이 없으며 HTML은 전적으로 브라우저에 의해 생성됩니다. 보안 영역이나 대화형 대시보드에 유용합니다.
8. SSR을 이용한 SEO
SSR은 검색 엔진 크롤러가 읽을 수 있기 때문에 SEO에 매우 중요합니다. JavaScript를 실행하지 않고도 서버에서 생성된 HTML을 직접 사용할 수 있습니다. 하지만 렌더링은 만으로는 충분하지 않습니다. 구조화된 메타 태그가 필요하며 JSON-LD 데이터는 올바른 관리입니다. 표준 URL의
제목과 메타 서비스가 포함된 동적 메타 태그
// seo.service.ts
import { Injectable, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { DOCUMENT } from '@angular/common';
@Injectable({ providedIn: 'root' })
export class SeoService {
private title = inject(Title);
private meta = inject(Meta);
private document = inject(DOCUMENT);
updatePageMeta(config: {
title: string;
description: string;
url: string;
image?: string;
type?: string;
}): void {
// Title tag
this.title.setTitle(config.title);
// Meta description
this.meta.updateTag({
name: 'description',
content: config.description
});
// Open Graph tags
this.meta.updateTag({ property: 'og:title', content: config.title });
this.meta.updateTag({ property: 'og:description', content: config.description });
this.meta.updateTag({ property: 'og:url', content: config.url });
this.meta.updateTag({ property: 'og:type', content: config.type || 'website' });
if (config.image) {
this.meta.updateTag({ property: 'og:image', content: config.image });
}
// Twitter Card
this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' });
this.meta.updateTag({ name: 'twitter:title', content: config.title });
this.meta.updateTag({ name: 'twitter:description', content: config.description });
// Canonical URL
this.setCanonicalUrl(config.url);
}
addStructuredData(data: object): void {
const script = this.document.createElement('script');
script.type = 'application/ld+json';
script.textContent = JSON.stringify(data);
this.document.head.appendChild(script);
}
private setCanonicalUrl(url: string): void {
let link: HTMLLinkElement | null =
this.document.querySelector('link[rel="canonical"]');
if (!link) {
link = this.document.createElement('link');
link.setAttribute('rel', 'canonical');
this.document.head.appendChild(link);
}
link.setAttribute('href', url);
}
}
구조화된 데이터(JSON-LD)
// In un componente articolo blog
@Component({ /* ... */ })
export class ArticlePageComponent {
private seo = inject(SeoService);
ngOnInit(): void {
const article = this.articleData();
this.seo.updatePageMeta({
title: `${article.title} | Federico Calo`,
description: article.summary,
url: `https://federicocalo.dev/blog/${article.slug}`,
image: 'https://federicocalo.dev/assets/og-image.jpg',
type: 'article',
});
// Structured Data per Google
this.seo.addStructuredData({
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.summary,
author: {
'@type': 'Person',
name: 'Federico Calo',
url: 'https://federicocalo.dev',
},
datePublished: article.date,
image: 'https://federicocalo.dev/assets/og-image.jpg',
publisher: {
'@type': 'Organization',
name: 'Federico Calo Dev',
url: 'https://federicocalo.dev',
},
});
}
}
Angular SSR 애플리케이션을 위한 SEO 체크리스트
- 페이지당 고유한 제목 태그: 최대 60자, 주요 키워드 포함
- 메타 설명: 150-160자, 흥미로운 페이지 요약
- 오픈 그래프 및 트위터 카드: 소셜 미디어에서 적절한 미리보기를 위해
- 표준 URL: 중복 콘텐츠 문제 방지
- JSON-LD 구조화된 데이터: 기사, 사람, 조직, 이동 경로 목록
- 사이트맵.xml: 다국어 사이트의 경우 hreflang을 사용하여 자동 생성됨
- 로봇.txt: 중요한 페이지를 크롤링할 수 있도록 구성됨
- 제목 계층 구조: 페이지당 H1 하나만, 논리적 순서대로 H2-H6
9. SSR의 실제 패턴
SSR을 사용하려면 서버와 브라우저 환경의 차이점에 주의해야 합니다.
많은 브라우저 API(예: window, localStorage, document.querySelector)
서버 측 렌더링 중에는 사용할 수 없습니다. 관리해야 할 필수 패턴은 다음과 같습니다.
이러한 상황.
isPlatformBrowser 및 isPlatformServer
import { Component, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
@Component({
selector: 'app-analytics',
standalone: true,
template: `<div #trackingContainer></div>`
})
export class AnalyticsComponent {
private platformId = inject(PLATFORM_ID);
ngOnInit(): void {
if (isPlatformBrowser(this.platformId)) {
// Sicuro: eseguito solo nel browser
this.initGoogleAnalytics();
this.trackPageView();
}
if (isPlatformServer(this.platformId)) {
// Eseguito solo sul server
console.log('Rendering lato server per:', this.route);
}
}
private initGoogleAnalytics(): void {
// Accesso sicuro a window e document
const gtag = (window as any).gtag;
if (gtag) {
gtag('config', 'G-XXXXXXXXXX');
}
}
private trackPageView(): void {
// localStorage disponibile solo nel browser
const views = localStorage.getItem('pageViews') || '0';
localStorage.setItem('pageViews', String(Number(views) + 1));
}
}
TransferState: 중복 HTTP 요청 방지
서버가 페이지를 렌더링하기 위해 API 요청을 하면 클라이언트는 이를 수행하지 않습니다. 같은 요청을 반복해야 합니다. 그만큼 전송 상태 당신이 할 수 있습니다 HTML에 데이터를 삽입하여 서버에서 클라이언트로 데이터를 전송합니다.
import {
Injectable, inject, PLATFORM_ID,
TransferState, makeStateKey
} from '@angular/core';
import { isPlatformServer } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
interface Product {
id: number;
name: string;
price: number;
}
const PRODUCTS_KEY = makeStateKey<Product[]>('products');
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private transferState = inject(TransferState);
private platformId = inject(PLATFORM_ID);
getProducts(): Observable<Product[]> {
// 1. Controlla se i dati sono già nel TransferState
const cached = this.transferState.get(PRODUCTS_KEY, null);
if (cached) {
// Dati trasferiti dal server: usali e rimuovili
this.transferState.remove(PRODUCTS_KEY);
return of(cached);
}
// 2. Nessun dato in cache: fai la richiesta HTTP
return this.http.get<Product[]>('/api/products').pipe(
tap(products => {
if (isPlatformServer(this.platformId)) {
// 3. Sul server: salva nel TransferState
this.transferState.set(PRODUCTS_KEY, products);
}
})
);
}
}
HttpClient 및 자동 TransferState
Angular 18부터 시작하여, provideClientHydration() 자동으로 포함
HTTP 요청 캐싱을 통해 withHttpTransferCacheOptions().
즉, 대부분의 경우 수동으로 관리할 필요가 없습니다.
HTTP 호출을 위한 TransferState: Angular가 이를 수행합니다.
afterNextRender 및 afterRender
렌더링 후 브라우저에서만 실행해야 하는 코드의 경우 Angular는 다음을 제공합니다. SSR 안전하고 영역 없는 호환이 가능한 두 개의 전용 API.
import { Component, afterNextRender, afterRender, ElementRef, viewChild } from '@angular/core';
@Component({
selector: 'app-chart',
standalone: true,
template: `<canvas #chartCanvas width="800" height="400"></canvas>`
})
export class ChartComponent {
canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('chartCanvas');
constructor() {
// Eseguito UNA VOLTA dopo il primo render nel browser
// NON eseguito sul server
afterNextRender(() => {
const ctx = this.canvas().nativeElement.getContext('2d');
this.initChart(ctx!);
});
// Eseguito OGNI VOLTA che Angular rende il componente
// Utile per sincronizzare con librerie DOM esterne
afterRender(() => {
this.updateChartDimensions();
});
}
private initChart(ctx: CanvasRenderingContext2D): void {
// Inizializza un chart con Canvas API
// Sicuro: eseguito solo nel browser
}
private updateChartDimensions(): void {
// Aggiorna dimensioni basate sul viewport
}
}
10. 성능 최적화
SSR 및 증분 수화는 핵심 웹 바이탈에 직접적인 영향을 미칩니다. 보자 성과를 극대화하기 위한 구체적인 전략.
콘텐츠 유형별 수분 공급 전략
수분 공급 전략 맵
| 페이지 영역 | 전략 | 동기 부여 |
|---|---|---|
| Navbar 및 Hero 섹션 | hydrate on immediate |
스크롤 없이 볼 수 있는 부분 위에서 즉각적인 상호작용 필요 |
| 주요 콘텐츠(기사, 제품) | 완전한 수분 공급(@defer 없음) | 핵심 내용, LCP 요소 가능성 있음 |
| 댓글 섹션 | hydrate on viewport |
스크롤 없이 볼 수 있는 부분, 스크롤을 통해서만 표시됨 |
| 문의 양식 | hydrate on interaction |
대화형이지만 첫 번째 로드 시 중요하지 않음 |
| 사이드바 권장사항 | hydrate on idle |
보조, 브라우저가 무료일 때 수분 공급 |
| 분석/추적 위젯 | hydrate on timer(5s) |
보이지 않습니다. 기다릴 수 있습니다. |
| 바닥글 및 법적 링크 | hydrate never |
순전히 유익하며 상호작용이 필요하지 않습니다. |
핵심 웹 바이탈에 미치는 영향
벤치마크: 50개 이상의 기사가 포함된 블로그 애플리케이션
| 미터법 | CSR 전용 | SSR + 완전 수분 공급 | SSR + 증분 수화 |
|---|---|---|---|
| FCP | 2.4초 | 0.8초 | 0.7초 |
| LCP | 3.1초 | 1.1초 | 0.9초 |
| TTI | 3.8초 | 3.2초 | 1.5초 |
| TBT | 850ms | 620ms | 180ms |
| INP | 110ms | 170ms | 65ms |
| CLS | 0.15 | 0.02 | 0.01 |
| 초기 JS | 320KB | 320KB | 85KB |
증분 수화를 통한 번들 최적화
<!-- Layout pagina prodotto ottimizzato per Core Web Vitals -->
<!-- CRITICO: hydration immediata (above-the-fold) -->
<app-navbar />
<app-product-hero [product]="product()" />
<!-- VISIBILITA: hydration su viewport -->
@defer (hydrate on viewport) {
<app-product-details [product]="product()" />
}
@defer (hydrate on viewport) {
<app-product-reviews [productId]="product().id" />
}
<!-- INTERAZIONE: hydration su input utente -->
@defer (hydrate on interaction) {
<app-add-to-cart [product]="product()" />
}
<!-- BACKGROUND: hydration quando il browser e libero -->
@defer (hydrate on idle) {
<app-related-products [categoryId]="product().categoryId" />
}
<!-- DIFFERITO: hydration dopo timer -->
@defer (hydrate on timer(5s)) {
<app-user-tracking [productId]="product().id" />
}
<!-- STATICO: mai idratato -->
@defer (hydrate never) {
<app-footer />
}
영향 측정
// performance-monitor.service.ts
import { Injectable, inject, PLATFORM_ID, afterNextRender } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Injectable({ providedIn: 'root' })
export class PerformanceMonitorService {
private platformId = inject(PLATFORM_ID);
constructor() {
afterNextRender(() => {
this.observeWebVitals();
});
}
private observeWebVitals(): void {
if (!isPlatformBrowser(this.platformId)) return;
// Largest Contentful Paint
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.startTime, 'ms');
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
// First Input Delay
const fidObserver = new PerformanceObserver((list) => {
const entries = list.getEntries() as PerformanceEventTiming[];
entries.forEach(entry => {
console.log('FID:', entry.processingStart - entry.startTime, 'ms');
});
});
fidObserver.observe({ type: 'first-input', buffered: true });
// Cumulative Layout Shift
const clsObserver = new PerformanceObserver((list) => {
let clsScore = 0;
list.getEntries().forEach((entry: any) => {
if (!entry.hadRecentInput) {
clsScore += entry.value;
}
});
console.log('CLS:', clsScore);
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
}
}
SSR로 피해야 할 일반적인 실수
- 액세스
window눈에 띄지 않는 곳: 항상 사용isPlatformBrowseroafterNextRender - 수화 불일치: 서버와 클라이언트 간에 서로 다른 콘텐츠를 생성하지 마세요(예:
Date.now(), 무작위 데이터) - 브라우저 전용 라이브러리 가져오기: Chart.js 또는 Leaflet과 같은 라이브러리는 브라우저에서 동적 가져오기를 통해 가져와야 합니다.
- TransferState가 사용되지 않음: TransferState(또는 Angular 18+의 자동 캐시)가 없으면 HTTP 호출이 두 번 이루어집니다.
- 모든 것의 수분 공급: 증분 수분 공급을 사용할 수 있으면 전체 앱에 수분을 공급하는 것은 낭비입니다. 미국
hydrate never정적 콘텐츠의 경우 - CLS 무시: 웹 글꼴, 크기가 없는 이미지, 동적으로 로드되는 콘텐츠로 인해 레이아웃이 변경됩니다.
요약 및 다음 단계
Angular의 SSR은 질적으로 큰 도약을 이루었습니다. Angular Universal에서 패키지로 통합된 고성능의 유연한 솔루션 외부에 있습니다. 점진적인 수화 최고의 초기 렌더링을 결합할 수 있는 진정한 혁명을 나타냅니다. 즉각적이고 타겟화된 상호작용이 가능합니다.
이 기사의 주요 개념
- 각도의 SSR: 코어에 통합됨
@angular/ssr, Angular Universal을 대체합니다. - 완전 수분 공급: 서버 DOM을 재사용하지만 모든 것을 한 번에 수화하여 TTI에 영향을 줍니다.
- 점진적인 수분 공급: 트리거를 사용하여 필요에 따라 구성 요소에 수분 공급
@defer(뷰포트, 상호 작용, 유휴, 타이머, 없음) - 이벤트 리플레이: 수분 섭취 중 사용자 상호작용을 포착하고 이후 재배송
- 사전 렌더링(SSG): CDN에서 제공하는 정적 페이지의 빌드 시 HTML 생성
- 렌더 모드: Prerender, Server 및 Client 간의 경로별 선택
- SEO: 가시성을 극대화하기 위한 메타 태그, 오픈 그래프, JSON-LD 구조화된 데이터
- SSR 패턴:
isPlatformBrowser,TransferState,afterNextRender - 성능: 점진적 수화는 TTI를 50% 이상, TBT를 70% 이상 감소시킵니다.
시리즈의 다음 기사에서는 Angular의 핵심 웹 바이탈, FCP, LCP, CLS, INP 및 TTI를 측정, 모니터링 및 최적화하는 방법을 심층적으로 살펴봅니다. 모든 카테고리에서 90점 이상의 Lighthouse 점수를 달성합니다.







