앵귤러 고급 강좌 #7 성능 튜닝 — Virtual Scroll, Image, Profiler
고급 강좌의 마지막 글입니다. 그동안 Change Detection, Signals, RxJS 깊이, DI, SSR/Hydration, 마이크로프론트엔드까지 — 앵귤러를 구성하는 큰 기둥들을 한 번씩 짚어왔습니다. 마지막으로 다룰 주제는 성능입니다.
성능 튜닝은 한두 가지 트릭의 모음이 아닙니다. 큰 앱이 느려질 때는 보통 한 군데가 아니라 여러 layer에 걸쳐 동시에 문제가 생겨 있습니다. 번들이 커서 첫 페인트가 느리고, 변경 감지가 너무 자주 돌아서 입력이 끊기고, 이미지가 무거워서 LCP가 늦어지는 — 이런 것들이 한꺼번에 겹쳐 있는 게 보통입니다. 그래서 이번 글은 트릭 모음집이 아니라 layer별로 어디에 어떤 도구를 적용할지를 지도처럼 정리하는 데 초점을 맞춥니다.
성능을 보는 세 가지 layer #
크게 세 layer로 나누면 머리가 정리됩니다.
- 빌드 layer — 사용자에게 어떤 코드를, 얼마나, 언제 보내느냐. 번들 크기, 코드 분할, lazy loading, defer block이 여기에 속합니다.
- 런타임 CD layer — 도착한 코드가 화면을 다시 그리는 빈도와 비용. Change Detection 전략, 시그널,
track,runOutsideAngular가 여기에 속합니다. - 자원 layer — 이미지, 폰트, 외부 스크립트 같은 정적 자원.
NgOptimizedImage, preconnect, 폰트 로딩 전략이 여기에 속합니다.
문제 신호별로 어느 layer를 먼저 봐야 하는지가 다릅니다. 첫 화면이 느리다 → 빌드/자원, 스크롤,입력이 끊긴다 → CD, 메모리가 계속 늘어난다 → CD/구독 누수. 이번 글은 이 세 layer를 한 바퀴씩 돌면서 도구와 패턴을 정리합니다.
Change Detection 다시 — OnPush + 시그널 회수 #
가장 큰 단일 레버는 여전히 Change Detection입니다. 고급 #1에서 본 내용을 한 줄로 줄이면 — OnPush를 기본값으로, 입력과 상태는 시그널로입니다.
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { User } from './user.model';
@Component({
selector: 'app-user-row',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="row">
<span>{{ user().name }}</span>
<span>{{ user().score }}</span>
<button (click)="select.emit(user().id)">선택</button>
</div>
`,
})
export class UserRowComponent {
user = input.required<User>();
select = output<string>();
}이 한 컴포넌트만 보면 평범하지만, 수천 개가 화면에 뜨는 리스트라고 상상해보면 차이가 큽니다. Default 전략이라면 키 입력 한 번에 트리 전체가 검사되지만, OnPush + 시그널 입력 조합은 변한 행만 검사 대상이 됩니다. 큰 앱에서 OnPush를 전면 적용했을 때 키 입력 지연이 눈에 띄게 줄어드는 가장 큰 이유가 이것입니다.
새 프로젝트라면 ng generate component 시 --change-detection=OnPush를 기본값으로 두거나, ESLint rule로 OnPush를 강제하는 컨벤션이 단단합니다.
큰 리스트 — @for의 track을 정확히
#
OnPush 다음으로 큰 리스트에서 효과가 큰 것은 track 키를 정확히 주는 것입니다. Angular 17부터 @for에서 track은 필수가 됐습니다.
@for (user of users(); track user.id) {
<app-user-row [user]="user" (select)="onSelect($event)" />
}track은 “이 항목이 같은 항목인지 알아보는 키"입니다. 잘못된 키를 주면 — 예를 들어 track $index — 배열 중간에 항목 하나만 추가돼도 그 뒤의 모든 행이 “다른 항목"으로 판단되어 DOM이 통째로 다시 만들어집니다. OnPush가 무력해지는 가장 흔한 원인이 이것입니다. 도메인 ID(user.id, post.slug 등)처럼 항목을 안정적으로 식별하는 값을 쓰는 게 정석입니다.
@for (user of users(); track $index) {
<!-- 중간에 추가/삭제될 때 모든 행 재생성 -->
<app-user-row [user]="user" />
}@for (user of users(); track user.id) {
<app-user-row [user]="user" />
}CDK Virtual Scroll — 수만 개 항목 #
OnPush + 정확한 track으로도 부족한 경우가 있습니다. 항목 수 자체가 수천~수만 개라면 DOM에 그 모든 행이 살아 있는 것 자체가 무겁습니다. 이때 꺼내는 도구가 CDK Virtual Scroll입니다.
ng add @angular/cdkimport { Component, signal } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { UserRowComponent } from './user-row.component';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [ScrollingModule, UserRowComponent],
template: `
<cdk-virtual-scroll-viewport itemSize="48" class="viewport">
<app-user-row *cdkVirtualFor="let user of users(); trackBy: byId"
[user]="user" />
</cdk-virtual-scroll-viewport>
`,
styles: [`.viewport { height: 600px; }`],
})
export class UserListComponent {
users = signal<User[]>(/* ... 50,000개 ... */);
byId = (_: number, u: User) => u.id;
}cdk-virtual-scroll-viewport는 화면에 보이는 영역 + 약간의 버퍼만 DOM에 띄우고 나머지는 제거합니다. 5만 개 항목이 있어도 실제 DOM에는 20~30개만 살아 있는 식입니다. 스크롤 성능이 거의 항목 수와 무관하게 일정해집니다.
itemSize는 각 항목의 픽셀 높이입니다. 고정 높이가 가장 빠르고, 가변 높이가 필요하면 AutoSizeVirtualScrollStrategy(@angular/cdk-experimental)를 쓸 수 있지만 비용이 더 듭니다. 가능하면 디자인 단계에서 행 높이를 통일하는 게 좋습니다.
track만으로 충분히 부드럽고, virtual scroll의 복잡도(스크롤 위치 복원, 항목 측정 등)를 들일 가치가 크지 않습니다.NgOptimizedImage — <img ngSrc>
#
이미지는 Lighthouse에서 LCP(Largest Contentful Paint) 점수를 가장 자주 까먹는 항목입니다. 앵귤러 16+에서는 NgOptimizedImage 디렉티브로 이 부분을 거의 자동화할 수 있습니다.
import { Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
@Component({
selector: 'app-hero',
standalone: true,
imports: [NgOptimizedImage],
template: `
<img ngSrc="/images/hero.webp"
width="1200" height="630"
priority
alt="대시보드 히어로 이미지" />
`,
})
export class HeroComponent {}평범한 <img src>와 다른 점은 다음과 같습니다.
- 자동 lazy loading — 화면 밖 이미지는 자동으로
loading="lazy".priority속성을 단 첫 화면 이미지만 즉시 로드 +fetchpriority="high" - CDN 로더 통합 —
provideImgixLoader('https://my-app.imgix.net')같은 provider를 설정하면width/height기반으로 적절한 사이즈를 자동 요청. CDN 도메인 preconnect도 자동 - 빌드/런타임 경고 —
width,height을 빠뜨리면 콘솔이 알려줍니다. CLS(레이아웃 시프트) 방지 차원
작은 변경이지만 효과는 큽니다. 이미지 위주의 화면이라면 <img>를 <img ngSrc>로 바꾸는 것만으로 LCP가 1초 이상 줄어드는 경우도 흔합니다.
Web Vitals와 Lighthouse #
성능을 “느낌"이 아니라 숫자로 다루려면 Web Vitals라는 공통 언어를 알아두는 게 좋습니다. 구글이 정의한 사용자 체감 성능 지표 세 가지가 있습니다.
- LCP(Largest Contentful Paint) — 가장 큰 콘텐츠가 보이는 시점. 2.5초 이하 권장
- INP(Interaction to Next Paint) — 클릭/입력 후 화면이 반응하는 데 걸리는 시간. 200ms 이하 권장(2024년부터 FID를 대체)
- CLS(Cumulative Layout Shift) — 레이아웃이 갑자기 튀는 누적량. 0.1 이하 권장
크롬 DevTools의 Lighthouse 탭으로 한 번에 측정할 수 있고, 실제 사용자 분포는 web-vitals npm 패키지로 자체 수집해서 분석 서버로 보내면 됩니다(onLCP, onINP, onCLS 콜백을 등록하고 navigator.sendBeacon으로 전송).
Lighthouse의 점수만 보지 말고 실제 사용자 환경에서 수집한 분포를 보는 게 핵심입니다. 개발 PC의 Lighthouse는 90점이지만 모바일 사용자의 75퍼센타일이 60점인 경우가 흔하기 때문입니다.
Angular DevTools Profiler #
런타임 CD 비용을 시각화하는 가장 좋은 도구는 Angular DevTools 크롬 확장의 Profiler 탭입니다. 녹화를 시작하고 화면을 조작한 뒤 정지하면, Change Detection 사이클별로 어떤 컴포넌트가 얼마나 걸렸는지 flame chart로 시각화됩니다. 같은 컴포넌트가 사이클마다 반복 검사되면 OnPush가 누락됐다는 신호입니다.
전형적인 사용 흐름 — Lighthouse에서 INP가 나쁘게 나오면, Profiler를 켜고 그 인터랙션을 재현해서 어느 컴포넌트가 사이클을 길게 잡고 있는지 찾아냅니다. 그 컴포넌트에 OnPush를 적용하거나, 입력을 시그널로 바꾸거나, 더 작은 자식으로 쪼개는 식으로 좁혀갑니다.
콘솔의 옛 친구 ng.profiler.timeChangeDetection()도 여전히 유효합니다. OnPush 적용 전후 평균 CD 시간을 비교하면 변경 효과가 정량적으로 보입니다.
> ng.profiler.timeChangeDetection()
ran 500 change detection cycles in 187 ms; 0.374 ms per check빌드 분석 — --stats-json + source-map-explorer
#
빌드 layer는 중급 #5 코드 분할에서 다뤘지만, 청크 안에 정확히 무엇이 들어 있는지 보는 두 가지 도구를 더 짚고 갑니다.
# 옵션 1 — stats.json
ng build --stats-json
npx esbuild-visualizer --metadata dist/my-app/stats.json --filename bundle.html
# 옵션 2 — source map 기반 (더 정확)
ng build --source-map
npx source-map-explorer dist/my-app/browser/*.js체크 포인트는 다음과 같습니다.
- main 청크에 차트,에디터,PDF,moment 같은 무거운 라이브러리가 보이면 lazy 청크로 옮길 후보
- 같은 라이브러리가 여러 청크에 중복으로 나타나면 중급 #5 흔한 실수에서 본 패턴 — shared 폴더로 정리
- Tree-shaking 안 되는 라이브러리(예:
import _ from 'lodash')는 modular import(import debounce from 'lodash-es/debounce')로 교체
CI에 번들 사이즈 회귀를 막는 단계를 두는 것도 좋은 투자입니다. angular.json의 budgets를 활용하면 일정 사이즈를 넘는 PR에서 빌드가 실패하게 만들 수 있습니다.
"budgets": [
{ "type": "initial", "maximumWarning": "300kb", "maximumError": "500kb" },
{ "type": "anyComponentStyle", "maximumWarning": "4kb" }
]고빈도 이벤트는 zone 밖으로 — runOutsideAngular
#
고급 #1에서 본 패턴이지만 성능 챕터에서 다시 한 번 강조할 가치가 있습니다. 스크롤,마우스무브,requestAnimationFrame 루프처럼 이벤트 빈도가 화면 갱신 빈도보다 높은 경우, 매 이벤트마다 변경 감지가 도는 건 큰 낭비입니다.
ngOnInit() {
this.zone.runOutsideAngular(() => {
let lastFrame = 0;
const onMove = (e: MouseEvent) => {
const now = performance.now();
if (now - lastFrame < 100) return; // 100ms throttle
lastFrame = now;
this.zone.run(() => this.pos.set({ x: e.clientX, y: e.clientY }));
};
window.addEventListener('mousemove', onMove);
this.destroyRef.onDestroy(() => window.removeEventListener('mousemove', onMove));
});
}매 프레임 발생하는 이벤트라도 변경 감지는 100ms에 한 번만 돕니다. Zoneless 모드에서는 이 패턴이 사라지지만(시그널을 호출해야만 신호가 가니까요) 안정화돼서 모든 프로젝트가 갈아탈 때까지는 한동안 유효합니다.
@defer block — 화면 밖 컴포넌트는 lazy로
#
Angular 17에서 들어온 **@defer**는 lazy loading을 라우트 단위에서 컴포넌트 단위로 한 단계 더 잘게 쪼갭니다.
<h1>대시보드</h1>
<app-summary />
@defer (on viewport) {
<app-heavy-chart />
} @placeholder {
<div class="skeleton">차트 로딩 중...</div>
} @loading (after 100ms) {
<app-spinner />
} @error {
<p>차트를 불러오지 못했습니다.</p>
}@defer는 안에 있는 컴포넌트와 그 의존성을 별도 청크로 분리합니다. 그리고 트리거 조건이 만족될 때까지 코드 자체를 받지 않습니다. 트리거는 다양합니다.
on viewport— 화면에 들어오면(IntersectionObserver 기반)on idle— 브라우저가 idle 상태가 되면(기본값)on timer(2s)— 일정 시간 후on interaction— 사용자가 클릭,hover하면on hover— hover만으로when condition()— 시그널/표현식이 true가 되면
라우트 단위 lazy loading은 페이지가 바뀌는 시점에만 효과가 있지만, @defer는 한 페이지 안에서도 첫 화면에 꼭 필요한 부분과 그렇지 않은 부분을 나눠 받게 해줍니다. 무거운 차트, 댓글 영역, 추천 섹션처럼 “스크롤해야 보이는 것들"이 전형적인 후보입니다.
@defer는 SSR/Hydration과도 매끄럽게 동작합니다(고급 #6에서 본 incremental hydration). 서버에서는 placeholder를 렌더하고, 클라이언트에서는 트리거에 맞춰 컴포넌트만 hydrate하는 식입니다. SSR을 쓰는 앱이라면 @defer가 lazy loading 그 이상의 의미를 갖게 됩니다.폰트와 외부 스크립트 #
마지막으로 자주 잊히는 자원 layer 두 가지입니다.
폰트 — 웹 폰트가 늦게 도착하면 텍스트가 한 번에 다시 그려지면서(FOIT/FOUT) CLS와 LCP를 동시에 까먹습니다. 다음 두 가지가 기본입니다.
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preload" as="font" type="font/woff2"
href="/fonts/pretendard-subset.woff2" crossorigin />@font-face {
font-family: 'Pretendard';
src: url('/fonts/pretendard-subset.woff2') format('woff2');
font-display: swap; /* 텍스트는 fallback으로 먼저 보여주고 폰트가 도착하면 교체 */
}서브셋팅(필요한 글리프만 포함)으로 한국어 폰트 사이즈를 1MB → 100KB대로 줄일 수 있습니다.
외부 스크립트 — Google Analytics, 채팅 위젯, A/B 테스트 SDK 같은 것들. <head>에 동기적으로 추가하면 첫 페인트를 그만큼 잡아먹습니다. async/defer를 쓰거나, 아예 첫 페인트 이후 requestIdleCallback으로 동적 삽입하는 패턴이 흔합니다. if (typeof window === 'undefined') return; 한 줄로 SSR 환경을 가드해주는 것도 잊지 마세요.
성능 튜닝 체크리스트 #
마지막에 한 페이지로 정리합니다.
빌드 layer
-
ng build --stats-json+ visualizer로 main 청크 점검 - 자주 안 쓰는 무거운 라이브러리는 lazy 청크로
- 페이지 안의 무거운 영역은
@defer로 -
angular.jsonbudgets로 회귀 차단
런타임 CD layer
- 새 컴포넌트는 OnPush 기본
- 입력,상태는 시그널 우선
-
@for의track은 안정적인 ID - 1,000+ 항목 리스트는 CDK Virtual Scroll
- 고빈도 이벤트는
runOutsideAngular(또는 Zoneless) - Angular DevTools Profiler로 정기 점검
자원 layer
-
<img>→<img ngSrc>로 (width,height필수, 첫 화면은priority) - 폰트는 preload +
font-display: swap+ 서브셋팅 - 외부 스크립트는
async/defer또는 idle 이후 로드
측정
- Lighthouse는 출발점, 실 사용자 데이터(
web-vitals)가 진실 - LCP / INP / CLS를 같이 본다
고급 강좌 회고 #
이걸로 앵귤러 고급 강좌 7편이 마무리됐습니다. 한 번 돌아보면:
- #1 Change Detection — Default/OnPush, NgZone, Zoneless
- #2 Signals 깊이 — computed, effect, input/output/model, linkedSignal
- #3 RxJS 깊이 — Subject 계열, 고차 연산자, 커스텀 오퍼레이터
- #4 Dependency Injection 심화 — 토큰, 계층, multi providers, 함수형 가드
- #5 NgRx 입문 — Store, Action, Reducer, Effect, SignalStore
- #6 SSR과 Hydration — Angular Universal, full/incremental hydration
- #7 성능 튜닝 — 빌드/CD/자원 세 layer + Profiler
기초가 “앵귤러로 화면을 만든다”, 중급이 “앵귤러를 실무에서 쓴다"였다면, 고급은 “앵귤러의 안쪽 메커니즘을 이해하고 큰 앱을 다룬다"였습니다. 각 글에서 다룬 도구들은 처음 만났을 때는 따로따로처럼 보이지만, 큰 앱에서 함께 일해보면 결국 하나의 그림으로 모입니다 — 시그널 기반 반응형 모델 + OnPush + 코드 분할 + SSR의 조합이 모던 앵귤러의 표준 골격입니다.
다음 시리즈 — 앵귤러 실전 강좌 #
기초 → 중급 → 고급까지 다지셨다면, 이제는 실제로 손을 움직여 작은 제품을 처음부터 끝까지 만들어보는 단계입니다. 다음 시리즈인 **“앵귤러 실전 강좌”**에서는 한 가지 도메인(작은 SaaS 형태의 어드민 대시보드)을 골라 다음과 같은 과정을 차례로 다뤄보려고 합니다.
- 대시보드 골격 — Standalone + Router + 레이아웃 컴포넌트로 큰 틀 잡기
- 인증 흐름 — 로그인/로그아웃, 토큰 저장, 라우트 가드, 인터셉터로 세션 갱신
- 폼 + API — Reactive Forms로 복잡한 입력을 다루고, HttpClient + Resource API로 백엔드와 연결
- 상태 관리 — 시그널 기반 store부터 시작해서 NgRx Signal Store까지, 도메인이 커질수록 어떻게 진화시키는지
- UI 라이브러리 — Angular Material 또는 PrimeNG로 디자인 시스템을 얹고 테마 만들기
- 테스트와 배포 — 단위,컴포넌트,E2E 테스트, GitHub Actions로 CI/CD, Cloudflare/Vercel로 배포
여기까지 따라오신 분들이라면 도구와 개념은 이미 손에 있습니다. 실전 시리즈는 그 도구들을 한 제품의 맥락 안에서 어떻게 조합하는지 — 실무에 가까운 결정을 매 단계 보여드리는 데 초점을 맞출 예정입니다. 그 사이에 앵귤러도 한두 차례 새 버전을 더 발표할 텐데, 새로 들어오는 기능은 시리즈 안에서 자연스럽게 녹여 다루겠습니다.
기초부터 고급까지 긴 여정을 함께해주셔서 감사합니다. 실전 시리즈에서 다시 뵙겠습니다.