[Angular 마스터하기] Day 17 - 성능 최적화, 빠른 앱 만들기
이제와서 시작하는 Angular 마스터하기 - Day 17 “성능 최적화로 사용자 경험을 향상시키세요! 🚀”
오늘 배울 내용
- OnPush와 signals를 함께 쓰는 이유
@for의track으로 리스트 렌더링 줄이기- 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. @for와 track
예전 Angular 예제에서는 *ngFor와 trackBy 함수를 많이 사용했습니다. 최신 템플릿 제어 흐름에서는 @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. 성능 측정 순서
최적화는 감으로 시작하면 오래 걸립니다. 먼저 느린 지점을 측정하고, 병목이 확인된 곳에만 개선을 적용하는 것이 안전합니다.
추천 순서:
- Chrome DevTools Performance 탭에서 느린 상호작용을 녹화합니다.
- Angular DevTools Profiler로 자주 갱신되는 컴포넌트를 확인합니다.
- 큰 리스트에는
@for ... track을 적용합니다. - 반복적으로 계산되는 값은
computed로 분리합니다. - 라우트 단위 Lazy Loading으로 초기 번들을 줄입니다.
- 이미지, 폰트, 외부 스크립트처럼 Angular 밖의 병목도 같이 확인합니다.
5. 실습: 느린 목록 개선하기
아래 순서로 간단한 목록 화면을 개선해보세요.
- 상품 100개를 렌더링하는 컴포넌트를 만듭니다.
- 처음에는
@for (product of products(); track product.id)없이 작성해봅니다. - 상품 이름 검색 기능을 추가합니다.
computed로 필터링 결과를 분리합니다.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 응답, 외부 스크립트도 함께 점검했나요?
📚 다음 학습
- 이전: Day 16: Signal 고급
- 다음: Day 18: 테스팅 기초
“최적화는 사용자 경험의 핵심입니다!” 🚀
![[Angular 마스터하기] Day 17 - 성능 최적화, 빠른 앱 만들기](/assets/img/posts/angular/angular-day-17.png)