포스트

[Angular 마스터하기] Day 17 - 성능 최적화, 빠른 앱 만들기

[Angular 마스터하기] Day 17 - 성능 최적화, 빠른 앱 만들기

이제와서 시작하는 Angular 마스터하기 - Day 17 “성능 최적화로 사용자 경험을 향상시키세요! 🚀”

오늘 배울 내용

  • OnPush와 signals를 함께 쓰는 이유
  • @fortrack으로 리스트 렌더링 줄이기
  • Lazy Loading과 번들 분리
  • 성능 측정과 개선 순서

1. OnPush 변경 감지 전략

Angular는 기본적으로 컴포넌트 트리의 변경 가능성을 폭넓게 확인합니다. 작은 앱에서는 문제가 되지 않지만, 화면이 커지고 리스트가 많아지면 불필요한 렌더링이 체감될 수 있습니다.

ChangeDetectionStrategy.OnPush는 입력값, 이벤트, signal 변경처럼 Angular가 추적할 수 있는 순간에만 컴포넌트를 다시 확인하도록 범위를 줄입니다. 특히 signal, computed와 함께 쓰면 데이터 흐름이 명확해져 성능과 유지보수성이 같이 좋아집니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';

@Component({
  selector: 'app-optimized',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <section>
      <h2>{{ title() }}</h2>
      <p>완료율: {{ completionRate() }}%</p>

      <button type="button" (click)="completeOne()">하나 완료</button>
    </section>
  `
})
export class OptimizedComponent {
  title = signal('성능 최적화 예제');
  total = signal(10);
  completed = signal(3);

  completionRate = computed(() =>
    Math.round((this.completed() / this.total()) * 100)
  );

  completeOne() {
    this.completed.update(value => Math.min(value + 1, this.total()));
  }
}

핵심은 “무조건 OnPush를 켠다”가 아니라, 컴포넌트가 외부 상태를 임의로 바꾸지 않도록 데이터 흐름을 단순하게 만드는 것입니다. mutable 객체를 직접 수정하는 대신 새 배열이나 새 객체를 만들어 signal에 넣는 습관이 중요합니다.

1
2
3
4
5
// 피하기: 같은 배열을 직접 수정하면 변경 추적이 흐려질 수 있습니다.
items().push(newItem);

// 권장: 새 배열을 반환합니다.
items.update(current => [...current, newItem]);

2. @fortrack

예전 Angular 예제에서는 *ngFortrackBy 함수를 많이 사용했습니다. 최신 템플릿 제어 흐름에서는 @for 블록에 track 표현식을 직접 적는 방식이 더 간결합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { DecimalPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

type Product = {
  id: number;
  name: string;
  price: number;
};

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [DecimalPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <ul>
      @for (product of products(); track product.id) {
        <li>
          <strong>{{ product.name }}</strong>
          <span>{{ product.price | number }}원</span>
        </li>
      } @empty {
        <li>상품이 없습니다.</li>
      }
    </ul>

    <button type="button" (click)="discountFirst()">첫 상품 할인</button>
  `
})
export class ProductListComponent {
  products = signal<Product[]>([
    { id: 1, name: '키보드', price: 120000 },
    { id: 2, name: '마우스', price: 59000 }
  ]);

  discountFirst() {
    this.products.update(products =>
      products.map(product =>
        product.id === 1
          ? { ...product, price: product.price - 10000 }
          : product
      )
    );
  }
}

track product.id를 지정하면 Angular는 같은 상품을 같은 DOM으로 유지할 수 있습니다. 리스트 정렬, 필터링, 페이지네이션처럼 배열이 자주 바뀌는 화면에서는 체감 차이가 커집니다.


3. Lazy Loading

처음 접속할 때 필요하지 않은 화면은 나중에 불러오는 편이 좋습니다. 관리자 페이지, 설정 화면, 리포트 화면처럼 진입 빈도가 낮은 영역은 route 단위로 나누면 초기 번들을 줄일 수 있습니다.

1
2
3
4
5
6
7
8
9
10
// app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'admin',
    loadComponent: () => import('./admin/admin.component')
      .then(m => m.AdminComponent)
  }
];

여러 컴포넌트가 묶인 기능 영역이라면 loadChildren으로 route 파일을 분리할 수도 있습니다.

1
2
3
4
5
6
7
export const routes: Routes = [
  {
    path: 'reports',
    loadChildren: () => import('./reports/reports.routes')
      .then(m => m.REPORT_ROUTES)
  }
];

Lazy Loading을 적용한 뒤에는 브라우저 개발자 도구의 Network 탭에서 실제로 별도 chunk가 생성되는지 확인하세요. 번들 분리는 설정만 보는 것보다 결과 파일을 확인하는 습관이 더 정확합니다.


4. 성능 측정 순서

최적화는 감으로 시작하면 오래 걸립니다. 먼저 느린 지점을 측정하고, 병목이 확인된 곳에만 개선을 적용하는 것이 안전합니다.

추천 순서:

  1. Chrome DevTools Performance 탭에서 느린 상호작용을 녹화합니다.
  2. Angular DevTools Profiler로 자주 갱신되는 컴포넌트를 확인합니다.
  3. 큰 리스트에는 @for ... track을 적용합니다.
  4. 반복적으로 계산되는 값은 computed로 분리합니다.
  5. 라우트 단위 Lazy Loading으로 초기 번들을 줄입니다.
  6. 이미지, 폰트, 외부 스크립트처럼 Angular 밖의 병목도 같이 확인합니다.

5. 실습: 느린 목록 개선하기

아래 순서로 간단한 목록 화면을 개선해보세요.

  1. 상품 100개를 렌더링하는 컴포넌트를 만듭니다.
  2. 처음에는 @for (product of products(); track product.id) 없이 작성해봅니다.
  3. 상품 이름 검색 기능을 추가합니다.
  4. computed로 필터링 결과를 분리합니다.
  5. track product.id를 추가하고 렌더링 차이를 확인합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-searchable-products',
  standalone: true,
  imports: [FormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input
      [ngModel]="keyword()"
      (ngModelChange)="keyword.set($event)"
      placeholder="검색어">

    <p>검색 결과: {{ filteredProducts().length }}개</p>

    <ul>
      @for (product of filteredProducts(); track product.id) {
        <li>{{ product.name }}</li>
      }
    </ul>
  `
})
export class SearchableProductsComponent {
  keyword = signal('');
  products = signal(
    Array.from({ length: 100 }, (_, index) => ({
      id: index + 1,
      name: `상품 ${index + 1}`
    }))
  );

  filteredProducts = computed(() => {
    const keyword = this.keyword().trim();
    return this.products().filter(product => product.name.includes(keyword));
  });
}

📝 정리

최적화 체크리스트

  • 상태 변경 지점이 signal 또는 명확한 입력값으로 관리되나요?
  • 반복 렌더링에는 @for와 안정적인 track 값이 있나요?
  • 자주 계산되는 파생 데이터는 computed로 분리했나요?
  • 라우트 단위 Lazy Loading을 적용할 영역을 골랐나요?
  • DevTools로 실제 병목을 확인했나요?
  • 이미지, API 응답, 외부 스크립트도 함께 점검했나요?

📚 다음 학습


“최적화는 사용자 경험의 핵심입니다!” 🚀

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.