앵귤러 고급 강좌 #7 성능 튜닝 — Virtual Scroll, Image, Profiler

12 분 소요

고급 강좌의 마지막 글입니다. 그동안 Change Detection, Signals, RxJS 깊이, DI, SSR/Hydration, 마이크로프론트엔드까지 — 앵귤러를 구성하는 큰 기둥들을 한 번씩 짚어왔습니다. 마지막으로 다룰 주제는 성능입니다.

성능 튜닝은 한두 가지 트릭의 모음이 아닙니다. 큰 앱이 느려질 때는 보통 한 군데가 아니라 여러 layer에 걸쳐 동시에 문제가 생겨 있습니다. 번들이 커서 첫 페인트가 느리고, 변경 감지가 너무 자주 돌아서 입력이 끊기고, 이미지가 무거워서 LCP가 늦어지는 — 이런 것들이 한꺼번에 겹쳐 있는 게 보통입니다. 그래서 이번 글은 트릭 모음집이 아니라 layer별로 어디에 어떤 도구를 적용할지를 지도처럼 정리하는 데 초점을 맞춥니다.

성능을 보는 세 가지 layer #

크게 세 layer로 나누면 머리가 정리됩니다.

  1. 빌드 layer — 사용자에게 어떤 코드를, 얼마나, 언제 보내느냐. 번들 크기, 코드 분할, lazy loading, defer block이 여기에 속합니다.
  2. 런타임 CD layer — 도착한 코드가 화면을 다시 그리는 빈도와 비용. Change Detection 전략, 시그널, track, runOutsideAngular가 여기에 속합니다.
  3. 자원 layer — 이미지, 폰트, 외부 스크립트 같은 정적 자원. NgOptimizedImage, preconnect, 폰트 로딩 전략이 여기에 속합니다.

문제 신호별로 어느 layer를 먼저 봐야 하는지가 다릅니다. 첫 화면이 느리다 → 빌드/자원, 스크롤,입력이 끊긴다 → CD, 메모리가 계속 늘어난다 → CD/구독 누수. 이번 글은 이 세 layer를 한 바퀴씩 돌면서 도구와 패턴을 정리합니다.

Change Detection 다시 — OnPush + 시그널 회수 #

가장 큰 단일 레버는 여전히 Change Detection입니다. 고급 #1에서 본 내용을 한 줄로 줄이면 — OnPush를 기본값으로, 입력과 상태는 시그널로입니다.

src/app/user-row.component.ts
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를 강제하는 컨벤션이 단단합니다.

큰 리스트 — @fortrack을 정확히 #

OnPush 다음으로 큰 리스트에서 효과가 큰 것은 track 키를 정확히 주는 것입니다. Angular 17부터 @for에서 track필수가 됐습니다.

src/app/user-list.component.html
@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 등)처럼 항목을 안정적으로 식별하는 값을 쓰는 게 정석입니다.

잘못된 예 — index 사용
@for (user of users(); track $index) {
  <!-- 중간에 추가/삭제될 때 모든 행 재생성 -->
  <app-user-row [user]="user" />
}
올바른 예 — 안정적인 ID
@for (user of users(); track user.id) {
  <app-user-row [user]="user" />
}

CDK Virtual Scroll — 수만 개 항목 #

OnPush + 정확한 track으로도 부족한 경우가 있습니다. 항목 수 자체가 수천~수만 개라면 DOM에 그 모든 행이 살아 있는 것 자체가 무겁습니다. 이때 꺼내는 도구가 CDK Virtual Scroll입니다.

설치
ng add @angular/cdk
src/app/user-list.component.ts
import { 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)를 쓸 수 있지만 비용이 더 듭니다. 가능하면 디자인 단계에서 행 높이를 통일하는 게 좋습니다.

Virtual scroll을 쓸 만한가는 의외로 단순한 기준입니다 — 항목이 보통 1,000개를 넘느냐. 그 아래라면 OnPush + 정확한 track만으로 충분히 부드럽고, virtual scroll의 복잡도(스크롤 위치 복원, 항목 측정 등)를 들일 가치가 크지 않습니다.

NgOptimizedImage — <img ngSrc> #

이미지는 Lighthouse에서 LCP(Largest Contentful Paint) 점수를 가장 자주 까먹는 항목입니다. 앵귤러 16+에서는 NgOptimizedImage 디렉티브로 이 부분을 거의 자동화할 수 있습니다.

src/app/hero.component.ts
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 코드 분할에서 다뤘지만, 청크 안에 정확히 무엇이 들어 있는지 보는 두 가지 도구를 더 짚고 갑니다.

stats.json + visualizer / source-map-explorer
# 옵션 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.jsonbudgets를 활용하면 일정 사이즈를 넘는 PR에서 빌드가 실패하게 만들 수 있습니다.

angular.json (budgets)
"budgets": [
  { "type": "initial", "maximumWarning": "300kb", "maximumError": "500kb" },
  { "type": "anyComponentStyle", "maximumWarning": "4kb" }
]

고빈도 이벤트는 zone 밖으로 — runOutsideAngular #

고급 #1에서 본 패턴이지만 성능 챕터에서 다시 한 번 강조할 가치가 있습니다. 스크롤,마우스무브,requestAnimationFrame 루프처럼 이벤트 빈도가 화면 갱신 빈도보다 높은 경우, 매 이벤트마다 변경 감지가 도는 건 큰 낭비입니다.

src/app/cursor-tracker.component.ts
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을 라우트 단위에서 컴포넌트 단위로 한 단계 더 잘게 쪼갭니다.

src/app/dashboard.component.html
<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는 한 페이지 안에서도 첫 화면에 꼭 필요한 부분과 그렇지 않은 부분을 나눠 받게 해줍니다. 무거운 차트, 댓글 영역, 추천 섹션처럼 “스크롤해야 보이는 것들"이 전형적인 후보입니다.

노트
Angular 19부터 @defer는 SSR/Hydration과도 매끄럽게 동작합니다(고급 #6에서 본 incremental hydration). 서버에서는 placeholder를 렌더하고, 클라이언트에서는 트리거에 맞춰 컴포넌트만 hydrate하는 식입니다. SSR을 쓰는 앱이라면 @defer가 lazy loading 그 이상의 의미를 갖게 됩니다.

폰트와 외부 스크립트 #

마지막으로 자주 잊히는 자원 layer 두 가지입니다.

폰트 — 웹 폰트가 늦게 도착하면 텍스트가 한 번에 다시 그려지면서(FOIT/FOUT) CLS와 LCP를 동시에 까먹습니다. 다음 두 가지가 기본입니다.

src/index.html
<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 />
src/styles.css
@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.json budgets로 회귀 차단

런타임 CD layer

  • 새 컴포넌트는 OnPush 기본
  • 입력,상태는 시그널 우선
  • @fortrack은 안정적인 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 형태의 어드민 대시보드)을 골라 다음과 같은 과정을 차례로 다뤄보려고 합니다.

  1. 대시보드 골격 — Standalone + Router + 레이아웃 컴포넌트로 큰 틀 잡기
  2. 인증 흐름 — 로그인/로그아웃, 토큰 저장, 라우트 가드, 인터셉터로 세션 갱신
  3. 폼 + API — Reactive Forms로 복잡한 입력을 다루고, HttpClient + Resource API로 백엔드와 연결
  4. 상태 관리 — 시그널 기반 store부터 시작해서 NgRx Signal Store까지, 도메인이 커질수록 어떻게 진화시키는지
  5. UI 라이브러리 — Angular Material 또는 PrimeNG로 디자인 시스템을 얹고 테마 만들기
  6. 테스트와 배포 — 단위,컴포넌트,E2E 테스트, GitHub Actions로 CI/CD, Cloudflare/Vercel로 배포

여기까지 따라오신 분들이라면 도구와 개념은 이미 손에 있습니다. 실전 시리즈는 그 도구들을 한 제품의 맥락 안에서 어떻게 조합하는지 — 실무에 가까운 결정을 매 단계 보여드리는 데 초점을 맞출 예정입니다. 그 사이에 앵귤러도 한두 차례 새 버전을 더 발표할 텐데, 새로 들어오는 기능은 시리즈 안에서 자연스럽게 녹여 다루겠습니다.

기초부터 고급까지 긴 여정을 함께해주셔서 감사합니다. 실전 시리즈에서 다시 뵙겠습니다.

X