| 서론
안녕하세요, 팡일입니다.
이번 글에서는 Angular CSR 기반 프로젝트에서 특정 페이지만 SSG(Prerender)로 전환한 경험을 정리해보았습니다.
기존 프로젝트는 전체가 CSR 구조로 구성되어 있었기 때문에, 브라우저에서 JavaScript를 실행한 이후에야 화면이 렌더링되는 방식이었습니다.
이로 인해 초기 HTML이 비어 있는 상태로 전달되었고, 특히 SEO가 중요한 랜딩 페이지에서는 크롤링과 초기 렌더링 성능 측면에서 한계를 느끼게 되었습니다.
이를 해결하기 위해 모든 페이지를 SSR로 전환하는 대신, SEO가 중요한 홈 페이지만 SSG로 전환하고, 나머지 페이지는 CSR을 유지하는 전략을 선택했습니다.
이 접근을 통해 초기 렌더링 속도와 SEO를 개선하면서도, 기존 CSR 구조의 단순성과 개발 편의성을 함께 유지할 수 있었습니다.
| 왜 CSR에서 SSG로 전환했나?
기존 Angular CSR 구조에서는 다음과 같은 흐름으로 동작합니다.
- 서버 → index.html + JavaScript 번들 전달
- 브라우저 → JavaScript 실행
- Angular 부트스트랩 이후 화면 렌더링
즉, 실제 화면을 구성하는 HTML은 대부분 클라이언트에서 생성됩니다.
이 구조는 다음과 같은 한계를 가지고 있었습니다.
- 초기 HTML이 비어 있어 콘텐츠를 즉시 확인할 수 없음
- Googlebot이 JavaScript 실행 이전에는 콘텐츠를 제대로 인식하기 어려움
- First Contentful Paint(FCP)가 지연되어 초기 표시 속도가 느림
특히 홈 페이지처럼 정적인 랜딩 페이지의 경우, 사용자에게 빠르게 정보를 전달하고 검색 엔진에 노출되는 것이 중요한데, CSR 방식은 이러한 목적에 비해 비효율적이라고 판단했습니다.
이러한 문제를 해결하기 위해 선택한 전략이 바로 SSG(Prerender)입니다.
SSG는 빌드 시점에 HTML을 미리 생성하여 제공하는 방식으로, 다음과 같은 장점을 가지고 있습니다.
- 빌드 시점에 완성된 HTML을 생성
- 요청 시 서버 부하 없이 정적 파일 제공
- 검색 엔진이 콘텐츠를 즉시 인식 가능 (SEO 친화적)
- 초기 렌더링 속도 개선
| CSR → SSG로 바뀌면서 생긴 구조 변화
1) Before (CSR)
- 클라이언트가 모든 HTML 생성
- <app-root></app-root> 상태로 시작
2) After (SSG + Hydration)
- 빌드 시 HTML 미리 생성
- 브라우저는 완성된 HTML을 먼저 받음
- 이후 Angular가 hydration으로 인터랙션 연결
hydration이란?
: hydration은 서버에서 미리 만들어진 HTML에 클라이언트 JavaScript가 연결되면서, 화면을 다시 그리지 않고 인터랙션만 활성화하는 과정입니다.
즉, 이 과정을 통해 구조가 이렇게 바뀝니다.
: Client Rendering → Server Pre-render + Hydration
| 핵심 설정: 라우트별 렌더 전략 분리
이번 작업의 핵심은 전체를 SSR로 전환하는 것이 아니라, 특정 라우트에만 SSG를 적용하는 것입니다.
즉, 페이지의 성격에 따라 렌더링 방식을 다르게 가져가는 전략입니다.
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender }, // 홈: SSG
{ path: 'project', renderMode: RenderMode.Client }, // 프로젝트: CSR
{ path: '**', renderMode: RenderMode.Client },
];
홈 페이지는 SEO와 초기 렌더링 속도가 중요한 정적 페이지이기 때문에 SSG를 적용하였고,
프로젝트 페이지는 사용자 인터랙션이 중심이 되는 화면이므로 CSR 방식을 유지했습니다.
이처럼 라우트 단위로 렌더링 전략을 분리함으로써 불필요한 서버 렌더링을 피하면서도, 필요한 영역에만 최적화를 적용할 수 있습니다.
| SSG 적용 시 생성되는 주요 파일
Angular에서 @angular/ssr를 추가하면 렌더링 구조가 확장되면서 서버 렌더링 관련 파일들이 생성됩니다.
- app.config.server.ts
→ 서버 렌더링 설정 연결 - app.routes.server.ts
→ 라우트별 렌더 모드 정의 (핵심) - main.server.ts
→ 서버에서 Angular 앱을 실행하는 엔트리 - server.ts
→ Express 기반 서버 진입점
| 구현 상세
1) 라우트별 렌더 전략 정의 - app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '',
renderMode: RenderMode.Prerender, // ✅ 홈 → SSG
},
{
path: 'project',
renderMode: RenderMode.Client, // ✅ CSR 유지
},
{
path: '**',
renderMode: RenderMode.Client,
},
];
홈 페이지는 SEO가 중요한 정적 랜딩 페이지이기 때문에 SSG로 처리하였고,
프로젝트 페이지는 사용자 인터랙션이 중심이 되는 화면이므로 기존 CSR 방식을 유지하였습니다.
이를 통해 전체를 SSR로 전환하지 않고, 필요한 라우트에만 렌더링 전략을 적용하는 구조로 설계하였습니다.
즉, 불필요한 서버 렌더링을 제거하면서도, 필요한 영역만 최적화할 수 있습니다.
Q. "app.routes.server.ts가 추가된거면, app.routes.ts는 안 쓰는걸까?"
1) app.routes.ts
: 앱의 기본 라우트 정의브라우저 라우팅(CSR)과 서버 렌더링 시 컴포넌트 매칭에 공통으로 쓰인다.
2) app.routes.server.ts“
: 서버에서 이 라우트를 어떤 모드로 렌더할지” 정책을 정의하며, Prerender / Server / Client 결정용으로 쓰인다.
즉, 어떤 페이지로 매핑할지는 app.routes.ts이고, 그 페이지를 서버에서 어떻게 처리할지는 app.routes.server.ts이다.
Q. 그렇다면, 어떤 흐름으로 라우트가 처리될까? 예를 들어서 "/" 경로로 진입했다고 가정해보자.
1. 서버가 app.routes.server.ts 정책 확인
2. /는 RenderMode.Prerender라서 미리 생성된 HTML을 응답
3. 그 HTML은 실제 라우트 매칭('' -> HomeComponent)을 app.routes.ts 기준으로 만들어진 결과
4. 브라우저에서 JS 로드 후 app.routes.ts로 다시 라우터 상태를 맞춤
5. provideClientHydration(...)가 서버/프리렌더 HTML에 이벤트를 붙여 hydration 완료
2) 서버 렌더링 설정 연결 - app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(withRoutes(serverRoutes)), // 🔥 핵심
],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
이 설정은 서버 렌더링 환경에서 애플리케이션이 어떻게 동작할지를 정의하는 부분입니다.
특히 아래 코드가 핵심 역할을 합니다.
provideServerRendering(withRoutes(serverRoutes))
이 한 줄을 통해 라우트별 렌더 전략이 실제 애플리케이션에 적용되며, 각 경로에 따라 SSG 또는 CSR 방식으로 렌더링이 분기됩니다.
3) 클라이언트 Hydration 설정 - app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
// 🔥 hydration 설정
provideClientHydration(withEventReplay()),
],
};
SSG가 적용되면 HTML은 서버에서 미리 생성되지만, 이후 사용자 인터랙션은 클라이언트에서 이어서 처리해야 합니다.
이때 hydration 설정을 통해 서버에서 생성된 HTML 위에 Angular 애플리케이션이 자연스럽게 이어 붙습니다.
즉, “HTML은 서버가 만들고, 인터랙션은 클라이언트가 이어받는다”는 구조입니다.
또한 withEventReplay() 옵션을 통해 hydration 과정에서 발생할 수 있는 이벤트 유실을 방지하여 UX 끊김을 최소화합니다.
4) 서버 부트스트랩 - main.server.ts
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { config } from './app/app.config.server';
const bootstrap = (context: BootstrapContext) =>
bootstrapApplication(App, config, context);
export default bootstrap;
이 파일은 서버 환경에서 Angular 애플리케이션을 실행하는 진입점입니다.
브라우저가 아닌 서버에서 앱을 부트스트랩하며, prerender(SSG) 과정에서 사용되는 서버 엔트리가 되는 파일입니다.
5) Express 서버 - server.ts (SSR 런타임, SSG-only에서는 선택 사항)
: ng add @angular/ssr를 실행하면 Angular가 SSR/SSG 공용 템플릿을 생성하면서 server.ts(Express 기반)도 함께 만듭니다.
그래서 파일이 생겼다고 해서 “반드시 SSR 서버를 운영해야 한다”는 의미는 아닙니다.
(1) 현재처럼 홈만 SSG(Prerender)로 쓰는 경우
- 빌드 시점에 HTML이 미리 생성되고
- 운영에서는 dist/browser 정적 파일만 서빙해도 동작합니다
- 즉, server.ts는 런타임에 사용하지 않을 수 있습니다
(2) 이 파일이 실제로 필요한 경우:
- 요청 시점 SSR(RenderMode.Server)을 사용할 때
- Node/Express 서버에서 Angular 응답을 직접 처리할 때
- 정적 파일 + SSR을 함께 운영하는 하이브리드 구성을 할 때
- 정리하면, server.ts는 “SSR 런타임을 위한 옵션 파일”이고,
이번 목적(SSG 중심 배포)에서는 필수라기보다 확장 대비용으로 생성된 파일입니다.
import {
AngularNodeAppEngine,
createNodeRequestHandler,
isMainModule,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import { join } from 'node:path';
const browserDistFolder = join(import.meta.dirname, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();
// 🔥 정적 파일 서빙
app.use(
express.static(browserDistFolder, {
maxAge: '1y',
index: false,
redirect: false,
}),
);
// 🔥 Angular SSR 처리
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) =>
response ? writeResponseToNodeResponse(response, res) : next(),
)
.catch(next);
});
// 🔥 서버 실행
if (isMainModule(import.meta.url) || process.env['pm_id']) {
const port = process.env['PORT'] || 4000;
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});
}
export const reqHandler = createNodeRequestHandler(app);
이 서버는 정적 파일 서빙과 Angular 렌더링을 함께 처리하는 구조입니다.
- /browser 경로의 파일은 정적으로 제공하고
- 그 외 요청은 Angular 엔진을 통해 렌더링합니다
즉, 정적 리소스와 SSR을 함께 사용하는 Hybrid 구조입니다.
그러나, 이번 프로젝트는 SSG 중심으로 구성했기 때문에, Express 기반 SSR 서버(server.ts)는 실제 배포에서 사용하지 않았습니다. 빌드 시 prerender로 생성된 정적 파일만을 배포하여 운영했습니다.
6) Angular 빌드 설정 - angular.json
{
"projects": {
"first-angular": {
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"server": "src/main.server.ts",
"outputMode": "server",
"ssr": {
"entry": "src/server.ts"
}
}
}
}
}
}
}
@angular/ssr를 추가하면 Angular CLI는 SSR/SSG 공용 템플릿 기준으로 빌드 옵션을 생성합니다.
그래서 아래 3가지 설정이 함께 들어옵니다.
- "server": "src/main.server.ts"
-> 서버 렌더링용 앱 엔트리 - "outputMode": "server"
-> 서버/브라우저 산출물을 함께 다루는 빌드 모드 - "ssr": { "entry": "src/server.ts" }
-> Node(Express) SSR 런타임 엔트리
그러나, 이 구성이 있어도 운영에서 반드시 SSR 서버를 띄워야 하는 것은 아닙니다. SSG-only라면 빌드 시 prerender를 수행한 뒤, 실제 배포는 dist/browser 정적 파일만 서빙해도 됩니다.
즉, 현재 설정은 “SSR도 가능한 공용 파이프라인”이며, SSG-only 운영에서는 일부(server, ssr.entry)가 런타임 기준으로는 확장 대비 성격의 설정입니다.
7) TypeScript 서버 설정 - tsconfig.app.json
{
"compilerOptions": {
"types": ["node"]
}
}
서버 코드에서 Express와 Node API를 사용하기 때문에 Node 타입을 추가하여 타입 안정성을 확보합니다.
또한 이를 통해 서버 관련 코드도 정상적으로 컴파일됩니다.
8) 전체 구조 요약
(1) Before (CSR)
Browser → JS 실행 → HTML 생성
브라우저가 모든 렌더링을 담당하며, 초기 HTML은 비어 있는 상태로 시작합니다.
(2) After (SSG + Hydration)
Build 시 HTML 생성 (SSG)
→ Browser HTML 받음
→ Angular hydration
빌드 시점에 HTML이 미리 생성되며, 브라우저는 완성된 HTML을 먼저 받아 빠르게 화면을 표시합니다.
이후 Angular가 hydration을 통해 인터랙션을 연결하는 구조로 동작합니다.
| SSG 적용 여부 검증 방법
SSG가 정상적으로 적용되었는지는 “JavaScript 없이도 HTML에 콘텐츠가 존재하는지”를 기준으로 확인할 수 있습니다.
1) 페이지 소스 확인
<app-root>
<!-- 실제 콘텐츠가 이미 존재 -->
</app-root>
: <app-root> 내부에 실제 콘텐츠가 포함되어 있다면, 이는 빌드 시점에 HTML이 미리 생성되었다는 의미이며 SSG가 적용된 상태입니다.
2) curl 테스트
curl -s https://your-domain.com/ | head -n 100
: JavaScript를 실행하지 않는 환경에서 HTML을 직접 요청했을 때, 본문 콘텐츠가 포함되어 있다면 SSG가 정상적으로 동작하고 있는 것입니다. 즉, JS 실행 없이도 콘텐츠가 보이면 성공입니다
3) hydration 로그
Angular hydrated ...
: 브라우저 콘솔에서 위와 같은 로그가 출력된다면, 서버에서 생성된 HTML 위에 클라이언트가 정상적으로 hydration을 수행했다는 의미입니다. 즉, 선렌더된 HTML + 클라이언트 인터랙션이 정상적으로 연결된 상태입니다.
| 결론
이번 작업에서 얻은 핵심 변화는 다음과 같습니다.
1) 개선된 점
- 홈 페이지 SEO 개선
- 초기 렌더링 속도 향상
- Googlebot 크롤링 안정성 확보
2) 아키텍처적으로 얻은 인사이트
- Angular도 충분히 SEO 친화적으로 설계 가능
- 전체 SSR이 아니라 “부분 SSG 전략”이 더 현실적
- 렌더링 전략을 라우트 단위로 나누는 것이 핵심
'💻 개발 > 🅰️ Angular' 카테고리의 다른 글
| [Angular] Angular 초심자 문법 가이드 (0) | 2026.03.19 |
|---|---|
| [Angular] Angular 프로젝트 시작하기 (처음부터 구조 이해까지) (0) | 2026.03.19 |
