16.8 실전 고수 팁 — 변경 감지 전략, HttpClient, NgRx 상태 관리 개요
변경 감지 전략
Angular는 **변경 감지(Change Detection)**를 통해 데이터 변경 시 DOM을 업데이트합니다.
Default vs OnPush
import { Component, ChangeDetectionStrategy } from '@angular/core';
// Default: 모든 이벤트 후 컴포넌트 트리 전체를 검사
@Component({
selector: 'app-default',
template: `...`
})
export class DefaultComponent {}
// OnPush: 아래 조건일 때만 재렌더링
// 1. Input 참조가 변경될 때
// 2. 컴포넌트/자식에서 이벤트 발생 시
// 3. async 파이프로 받은 Observable이 방출할 때
// 4. Signal 값이 변경될 때
// 5. markForCheck() 수동 호출 시
@Component({
selector: 'app-optimized',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `...`
})
export class OptimizedComponent {}
OnPush + Signals (최적 조합)
import { Component, ChangeDetectionStrategy, signal, computed, input } from '@angular/core';
@Component({
selector: 'app-product-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, // 성능 최적화
template: `
<div class="product-card" [class.selected]="isSelected()">
<h3>{{ product().name }}</h3>
<p>{{ formattedPrice() }}</p>
<button (click)="toggleSelect()">
{{ isSelected() ? '선택 해제' : '선택' }}
</button>
</div>
`
})
export class ProductCardComponent {
product = input.required<{ id: number; name: string; price: number }>();
private _isSelected = signal(false);
readonly isSelected = this._isSelected.asReadonly();
// computed는 OnPush와 완벽하게 연동
formattedPrice = computed(() =>
new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' })
.format(this.product().price)
);
toggleSelect() {
this._isSelected.update(v => !v);
}
}
HttpClient 완전 활용
기본 설정
// app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor, errorInterceptor, loadingInterceptor } from './interceptors';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([
authInterceptor, // 토큰 자동 첨부
errorInterceptor, // 전역 오류 처리
loadingInterceptor // 로딩 상태 관리
])
)
]
};
HTTP 인터셉터 (함수형, Angular 15+)
// interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getToken();
if (token) {
const authReq = req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`)
});
return next(authReq);
}
return next(req);
};
// interceptors/error.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
switch (error.status) {
case 401:
// 인증 만료 → 로그인 페이지
router.navigate(['/login'], {
queryParams: { returnUrl: router.url }
});
break;
case 403:
router.navigate(['/403']);
break;
case 404:
console.error('리소스를 찾을 수 없습니다:', req.url);
break;
case 500:
console.error('서버 오류:', error.message);
break;
}
return throwError(() => error);
})
);
};
// interceptors/loading.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { finalize } from 'rxjs/operators';
import { LoadingService } from '../services/loading.service';
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
const loadingService = inject(LoadingService);
// 로딩 상태 무시할 URL 패턴
const skipLoading = req.headers.has('X-Skip-Loading');
if (skipLoading) return next(req);
loadingService.show();
return next(req).pipe(
finalize(() => loadingService.hide())
);
};
HttpClient 타입 안전 요청
// services/api.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
private baseUrl = environment.apiUrl;
get<T>(path: string, params?: Record<string, any>): Observable<T> {
let httpParams = new HttpParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
httpParams = httpParams.set(key, String(value));
}
});
}
return this.http.get<T>(`${this.baseUrl}${path}`, { params: httpParams });
}
post<T>(path: string, body: unknown): Observable<T> {
return this.http.post<T>(`${this.baseUrl}${path}`, body);
}
put<T>(path: string, body: unknown): Observable<T> {
return this.http.put<T>(`${this.baseUrl}${path}`, body);
}
patch<T>(path: string, body: unknown): Observable<T> {
return this.http.patch<T>(`${this.baseUrl}${path}`, body);
}
delete<T>(path: string): Observable<T> {
return this.http.delete<T>(`${this.baseUrl}${path}`);
}
}
환경 설정
// src/environments/environment.ts (개발)
export const environment = {
production: false,
apiUrl: 'http://localhost:3000/api',
wsUrl: 'ws://localhost:3000',
featureFlags: {
newCheckout: true,
darkMode: true
}
};
// src/environments/environment.production.ts (프로덕션)
export const environment = {
production: true,
apiUrl: 'https://api.myapp.com',
wsUrl: 'wss://api.myapp.com',
featureFlags: {
newCheckout: true,
darkMode: false
}
};
// angular.json에 파일 교체 설정
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
}
]
}
}
NgRx 상태 관리 개요
NgRx는 Redux 패턴을 Angular에 적용한 상태 관리 라이브러리입니다. 복잡한 앱에서 전역 상태를 예측 가능하게 관리합니다.
[Action 발생] → [Reducer에서 상태 변경] → [Store 업데이트]
↑ ↓
[Effect에서 사이드이펙트 처리] [Selector로 상태 읽기]
설치
ng add @ngrx/store@latest
ng add @ngrx/effects@latest
ng add @ngrx/entity@latest # 컬렉션 관리
ng add @ngrx/devtools@latest # Redux DevTools 연동
Action 정의
// store/products/product.actions.ts
import { createAction, props } from '@ngrx/store';
import { Product } from '../../models/product.model';
// 상품 목록 조회
export const loadProducts = createAction('[Product List] Load Products');
export const loadProductsSuccess = createAction(
'[Product API] Load Products Success',
props<{ products: Product[] }>()
);
export const loadProductsFailure = createAction(
'[Product API] Load Products Failure',
props<{ error: string }>()
);
// 상품 추가
export const addProduct = createAction(
'[Product Form] Add Product',
props<{ product: Omit<Product, 'id'> }>()
);
export const addProductSuccess = createAction(
'[Product API] Add Product Success',
props<{ product: Product }>()
);
// 상품 삭제
export const deleteProduct = createAction(
'[Product List] Delete Product',
props<{ id: number }>()
);
Reducer 정의
// store/products/product.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { Product } from '../../models/product.model';
import * as ProductActions from './product.actions';
export interface ProductState extends EntityState<Product> {
isLoading: boolean;
error: string | null;
}
export const adapter: EntityAdapter<Product> = createEntityAdapter<Product>();
export const initialState: ProductState = adapter.getInitialState({
isLoading: false,
error: null
});
export const productReducer = createReducer(
initialState,
on(ProductActions.loadProducts, state => ({
...state,
isLoading: true,
error: null
})),
on(ProductActions.loadProductsSuccess, (state, { products }) =>
adapter.setAll(products, { ...state, isLoading: false })
),
on(ProductActions.loadProductsFailure, (state, { error }) => ({
...state,
isLoading: false,
error
})),
on(ProductActions.addProductSuccess, (state, { product }) =>
adapter.addOne(product, state)
),
on(ProductActions.deleteProduct, (state, { id }) =>
adapter.removeOne(id, state)
)
);
// Selector helpers
export const { selectAll, selectIds, selectEntities, selectTotal } =
adapter.getSelectors();
Effect 정의 (사이드이펙트 처리)
// store/products/product.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { switchMap, map, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { ProductService } from '../../services/product.service';
import * as ProductActions from './product.actions';
@Injectable()
export class ProductEffects {
private actions$ = inject(Actions);
private productService = inject(ProductService);
loadProducts$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductActions.loadProducts),
switchMap(() =>
this.productService.getProducts().pipe(
map(products => ProductActions.loadProductsSuccess({ products })),
catchError(error =>
of(ProductActions.loadProductsFailure({ error: error.message }))
)
)
)
)
);
addProduct$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductActions.addProduct),
switchMap(({ product }) =>
this.productService.createProduct(product).pipe(
map(newProduct => ProductActions.addProductSuccess({ product: newProduct })),
catchError(error =>
of(ProductActions.loadProductsFailure({ error: error.message }))
)
)
)
)
);
}
Selector 정의
// store/products/product.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ProductState, selectAll } from './product.reducer';
export const selectProductState = createFeatureSelector<ProductState>('products');
export const selectAllProducts = createSelector(selectProductState, selectAll);
export const selectProductsLoading = createSelector(
selectProductState,
state => state.isLoading
);
export const selectProductsError = createSelector(
selectProductState,
state => state.error
);
// 파생 selector
export const selectExpensiveProducts = createSelector(
selectAllProducts,
products => products.filter(p => p.price > 100000)
);
export const selectProductById = (id: number) => createSelector(
selectProductState,
state => state.entities[id]
);
컴포넌트에서 Store 사용
// components/product-list.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { AsyncPipe } from '@angular/common';
import * as ProductActions from '../store/products/product.actions';
import * as ProductSelectors from '../store/products/product.selectors';
@Component({
standalone: true,
imports: [AsyncPipe],
template: `
@if (isLoading$ | async) {
<div class="loading">로딩 중...</div>
}
@if (error$ | async; as error) {
<div class="error">{{ error }}</div>
}
<ul>
@for (product of products$ | async ?? []; track product.id) {
<li>
{{ product.name }}
<button (click)="delete(product.id)">삭제</button>
</li>
}
</ul>
<button (click)="load()">새로고침</button>
`
})
export class ProductListComponent implements OnInit {
private store = inject(Store);
products$ = this.store.select(ProductSelectors.selectAllProducts);
isLoading$ = this.store.select(ProductSelectors.selectProductsLoading);
error$ = this.store.select(ProductSelectors.selectProductsError);
ngOnInit() {
this.store.dispatch(ProductActions.loadProducts());
}
delete(id: number) {
this.store.dispatch(ProductActions.deleteProduct({ id }));
}
load() {
this.store.dispatch(ProductActions.loadProducts());
}
}
성능 최적화 팁
1. 번들 분석 및 최적화
# 번들 크기 분석
ng build --stats-json
npx webpack-bundle-analyzer dist/my-app/browser/stats.json
# 소스맵 탐색기
npx source-map-explorer dist/my-app/browser/*.js
2. @defer로 지연 로딩
@Component({
template: `
<!-- 뷰포트에 들어올 때만 로드 -->
@defer (on viewport) {
<app-analytics-chart [data]="chartData" />
} @placeholder (minimum 200ms) {
<div class="chart-skeleton" style="height: 300px; background: #eee;"></div>
} @loading (minimum 200ms) {
<div class="loading-spinner">차트 로딩 중...</div>
} @error {
<p>차트를 불러오지 못했습니다.</p>
}
<!-- 즉시 로드 -->
@defer (on immediate) {
<app-heavy-table />
}
<!-- 타이머 후 로드 -->
@defer (on timer(3s)) {
<app-recommendation />
}
`
})
export class DashboardComponent {
chartData = [];
}
3. 이미지 최적화
<!-- NgOptimizedImage 사용 (Angular 15+) -->
<img
ngSrc="https://example.com/hero.jpg"
width="800"
height="400"
priority <!-- LCP 이미지 우선 로드 -->
alt="히어로 이미지"
>
<!-- 자동으로 srcset, lazy loading, preload 처리 -->
폴더 구조 베스트 프랙티스
src/app/
├── core/ ← 싱글톤 서비스, 가드, 인터셉터
│ ├── guards/
│ │ ├── auth.guard.ts
│ │ └── admin.guard.ts
│ ├── interceptors/
│ │ ├── auth.interceptor.ts
│ │ └── error.interceptor.ts
│ ├── services/
│ │ ├── auth.service.ts
│ │ └── logger.service.ts
│ └── core.providers.ts ← core providers 묶음
│
├── shared/ ← 공용 컴포넌트, 파이프, 디렉티브
│ ├── components/
│ │ ├── button/
│ │ ├── modal/
│ │ └── loading-spinner/
│ ├── directives/
│ │ └── highlight.directive.ts
│ ├── pipes/
│ │ └── format-date.pipe.ts
│ └── models/
│ ├── user.model.ts
│ └── product.model.ts
│
├── features/ ← 기능별 모듈 (각각 독립)
│ ├── auth/
│ │ ├── login/
│ │ ├── register/
│ │ └── auth.routes.ts
│ ├── products/
│ │ ├── product-list/
│ │ ├── product-detail/
│ │ ├── product.service.ts
│ │ └── products.routes.ts
│ └── admin/
│ └── admin.routes.ts
│
├── app.component.ts
├── app.config.ts
└── app.routes.ts
실전 에러 패턴과 해결책
에러 1: ExpressionChangedAfterItHasBeenCheckedError
// 문제: ngAfterViewInit에서 상태 변경 → 변경 감지 오류
ngAfterViewInit() {
this.isVisible = true; // ❌ 오류 발생
}
// 해결 1: setTimeout
ngAfterViewInit() {
setTimeout(() => this.isVisible = true);
}
// 해결 2: Signal 사용 (문제 없음)
isVisible = signal(false);
ngAfterViewInit() {
this.isVisible.set(true); // ✅
}
에러 2: NullInjectorError
// 문제: 서비스가 providers에 없음
// NullInjectorError: No provider for UserService!
// 해결 1: providedIn: 'root' 추가
@Injectable({ providedIn: 'root' })
export class UserService {}
// 해결 2: 컴포넌트 providers에 추가
@Component({
providers: [UserService]
})
에러 3: NG0100 — 메모리 누수
// 문제: 구독 해제 안 함
ngOnInit() {
this.dataService.getData().subscribe(d => this.data = d); // 누수!
}
// 해결: takeUntilDestroyed
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
ngOnInit() {
this.dataService.getData().pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(d => this.data.set(d));
}
에러 4: Template Parse Errors
<!-- 문제: JSX와 혼동 — Angular 템플릿은 < > 그대로 사용 -->
<!-- 문자열 비교 -->
@if (count > 0) { ✅ 가능
@if (count > 0) { ❌ 불필요한 이스케이프
<!-- 문제: 잘못된 바인딩 -->
<div [style]="color: red"> ❌
<div [style.color]="'red'"> ✅
<div [ngStyle]="{ color: 'red' }"> ✅
Angular DevTools 활용
Chrome/Firefox에서 Angular DevTools 설치 후:
- Components 탭: 컴포넌트 트리, Input/Output 검사
- Profiler 탭: 변경 감지 사이클 측정, 느린 컴포넌트 식별
- Injector Tree: DI 계층 구조 시각화
배포 최적화
# 프로덕션 빌드 (기본 최적화 포함)
ng build --configuration production
# SSR 빌드 (SEO 최적화)
ng build --configuration production --ssr
# 빌드 결과 확인
ls dist/my-app/browser/
# main.abc123.js ← 캐시 버스팅 해시
# styles.def456.css
# index.html
nginx 배포 설정 (SPA 라우팅)
# /etc/nginx/sites-available/my-angular-app
server {
listen 80;
server_name myapp.com;
root /var/www/my-angular-app/browser;
index index.html;
# 정적 파일 캐싱 (해시 포함 파일)
location ~* \.(js|css|png|jpg|gif|ico|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Angular SPA 라우팅 — 모든 요청을 index.html로
location / {
try_files $uri $uri/ /index.html;
}
}
단위 테스트 — Jest 마이그레이션
Angular 기본 테스트는 Karma + Jasmine이지만, Jest가 더 빠르고 편리합니다.
# Jest로 마이그레이션
ng add jest-preset-angular
# 또는 Vitest
// 기본 컴포넌트 테스트
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
let component: CounterComponent;
let fixture: ComponentFixture<CounterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent] // Standalone 컴포넌트는 imports에
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('초기 카운트는 0이어야 한다', () => {
expect(component.count()).toBe(0);
});
it('increment 클릭 시 카운트가 증가해야 한다', () => {
const button = fixture.nativeElement.querySelector('[data-testid="increment"]');
button.click();
fixture.detectChanges();
expect(component.count()).toBe(1);
});
it('컴포넌트가 생성되어야 한다', () => {
expect(component).toBeTruthy();
});
});
정리 — Angular 19 핵심 체크리스트
| 항목 | 권장 방식 |
|---|---|
| 컴포넌트 | Standalone, OnPush |
| 상태 관리 | Signals (로컬), NgRx (전역) |
| 데이터 바인딩 | input()/output()/model() |
| 비동기 | RxJS + AsyncPipe 또는 toSignal() |
| HTTP | HttpClient + 인터셉터 |
| 라우팅 | loadComponent + 가드 |
| DI | inject() 함수 |
| 테스트 | Jest + Angular Testing Library |
| 성능 | @defer, OnPush, Lazy Loading |
| 폴더 구조 | core/shared/features |