Angular 완벽 가이드 | Component·Service
이 글의 핵심
Angular로 엔터프라이즈 웹 앱을 구축하는 완벽 가이드. Component, Service, RxJS, Routing, Signals, Standalone Components까지 실전 예제로 정리. Start now.
이 글의 핵심
Angular로 엔터프라이즈 웹 앱을 구축하는 완벽 가이드입니다. Component, Service, RxJS, Routing, Signals, Standalone Components까지 실전 예제로 정리했으며, LView/틱 사이클 관점의 변경 감지, ChangeDetectorRef·Zone, EnvironmentInjector·ElementInjector·InjectionToken·팩토리, toSignal/toObservable·연산자 선택, strictTemplates·Ivy 지역성·빌드 체인, SSR·이미지·에러 핸들링·예산 등 프로덕션 심화 주제를 포함했습니다.
실무 경험 공유: 대규모 엔터프라이즈 앱을 Angular로 구축하면서, TypeScript 타입 안전성으로 버그를 70% 줄이고 코드 유지보수성을 크게 향상시킨 경험을 공유합니다.
들어가며: “React가 구조가 없어요”
실무 문제 시나리오
시나리오 1: 프로젝트 구조가 제각각이에요
React는 구조를 강제하지 않습니다. Angular는 명확한 구조를 제공합니다. 시나리오 2: 의존성 주입이 번거로워요
Context API가 복잡합니다. Angular는 DI 컨테이너를 제공합니다. 시나리오 3: 타입 안전성이 부족해요
JavaScript는 런타임 에러가 많습니다. Angular는 TypeScript 우선입니다.
1. Angular란?
핵심 특징
Angular는 Google이 만든 풀스택 프론트엔드 프레임워크입니다. 주요 장점:
- 완전한 프레임워크: 모든 기능 내장
- TypeScript 우선: 강력한 타입 안전성
- DI 컨테이너: 의존성 자동 주입
- RxJS: 반응형 프로그래밍
- CLI: 강력한 개발 도구
2. 프로젝트 생성
설치
npm install -g @angular/cli
ng new my-app
cd my-app
ng serve
브라우저에서 http://localhost:4200 열림
3. Component
생성
ng generate component user-list
# 또는
ng g c user-list
기본 구조
// src/app/user-list/user-list.component.ts
// 필요한 모듈 import
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-user-list',
templateUrl: './user-list.component.html',
styleUrls: ['./user-list.component.css']
})
export class UserListComponent implements OnInit {
users = [
{ id: 1, name: 'John', email: '[email protected]' },
{ id: 2, name: 'Jane', email: '[email protected]' },
];
ngOnInit(): void {
console.log('Component initialized');
}
deleteUser(id: number): void {
this.users = this.users.filter(u => u.id !== id);
}
}
<!-- src/app/user-list/user-list.component.html -->
<div>
<h2>Users</h2>
<ul>
<li *ngFor="let user of users">
{{ user.name }} ({{ user.email }})
<button (click)="deleteUser(user.id)">Delete</button>
</li>
</ul>
</div>
4. Service
생성
ng generate service services/user
HTTP 서비스
// src/app/services/user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface User {
id: number;
name: string;
email: string;
}
@Injectable({
providedIn: 'root'
})
export class UserService {
private apiUrl = 'https://api.example.com/users';
constructor(private http: HttpClient) {}
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl);
}
getUser(id: number): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`);
}
createUser(user: User): Observable<User> {
return this.http.post<User>(this.apiUrl, user);
}
updateUser(id: number, user: User): Observable<User> {
return this.http.put<User>(`${this.apiUrl}/${id}`, user);
}
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}
사용
import { Component, OnInit } from '@angular/core';
import { UserService, User } from '../services/user.service';
@Component({
selector: 'app-user-list',
templateUrl: './user-list.component.html',
})
export class UserListComponent implements OnInit {
users: User[] = [];
loading = false;
constructor(private userService: UserService) {}
ngOnInit(): void {
this.loadUsers();
}
loadUsers(): void {
this.loading = true;
this.userService.getUsers().subscribe({
next: (users) => {
this.users = users;
this.loading = false;
},
error: (err) => {
console.error(err);
this.loading = false;
},
});
}
}
5. Routing
설정
// src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { UsersComponent } from './users/users.component';
import { UserDetailComponent } from './user-detail/user-detail.component';
const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'users', component: UsersComponent },
{ path: 'users/:id', component: UserDetailComponent },
{ path: '**', redirectTo: '' },
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}
Navigation
<!-- app.component.html -->
<nav>
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">
Home
</a>
<a routerLink="/users" routerLinkActive="active">
Users
</a>
</nav>
<router-outlet></router-outlet>
프로그래매틱 Navigation
import { Router } from '@angular/router';
constructor(private router: Router) {}
goToUser(id: number): void {
this.router.navigate(['/users', id]);
}
6. Forms
Template-driven Forms
import { Component } from '@angular/core';
@Component({
selector: 'app-login',
template: `
<form #loginForm="ngForm" (ngSubmit)="onSubmit(loginForm)">
<input
name="email"
type="email"
[(ngModel)]="email"
required
email
/>
<input
name="password"
type="password"
[(ngModel)]="password"
required
minlength="6"
/>
<button type="submit" [disabled]="!loginForm.valid">
Login
</button>
</form>
`,
})
export class LoginComponent {
email = '';
password = '';
onSubmit(form: any): void {
console.log(form.value);
}
}
Reactive Forms
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'app-login',
template: `
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<input formControlName="email" type="email" />
<div *ngIf="loginForm.get('email')?.invalid && loginForm.get('email')?.touched">
Email is required
</div>
<input formControlName="password" type="password" />
<div *ngIf="loginForm.get('password')?.invalid && loginForm.get('password')?.touched">
Password must be at least 6 characters
</div>
<button type="submit" [disabled]="!loginForm.valid">
Login
</button>
</form>
`,
})
export class LoginComponent {
loginForm: FormGroup;
constructor(private fb: FormBuilder) {
this.loginForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]],
});
}
onSubmit(): void {
if (this.loginForm.valid) {
console.log(this.loginForm.value);
}
}
}
7. Signals (Angular 16+)
기본 사용
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<div>
<p>Count: {{ count() }}</p>
<p>Doubled: {{ doubled() }}</p>
<button (click)="increment()">Increment</button>
</div>
`,
})
export class CounterComponent {
count = signal(0);
doubled = computed(() => this.count() * 2);
increment(): void {
this.count.update(value => value + 1);
}
}
8. Standalone Components (Angular 14+)
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule, RouterModule],
template: `
<div>
<h2>Users</h2>
<ul>
<li *ngFor="let user of users">
<a [routerLink]="['/users', user.id]">{{ user.name }}</a>
</li>
</ul>
</div>
`,
})
export class UserListComponent {
users = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
];
}
9. 변경 감지(Change Detection)와 Zone.js 내부
Angular는 기본적으로 변경 감지(change detection) 를 통해 템플릿과 컴포넌트 상태를 동기화합니다. 클래식한 설정에서는 Zone.js 가 브라우저의 비동기 API(setTimeout, Promise, XMLHttpRequest, DOM 이벤트 등)를 패치(patch)하여, 비동기 작업이 끝날 때마다 Angular에 “애플리케이션에 무언가 바뀌었을 수 있음”을 알립니다. 그 결과 전역 타이머·XHR·이벤트 루프 와 연동된 흐름에서도 자동으로 변경 감지가 스케줄됩니다.
뷰(View)와 “더티” 플래그의 개념
런타임에서 Angular는 컴포넌트 트리와 대응하는 뷰(View) 구조를 유지합니다. 공개 API로 직접 다루지는 않지만, 내부적으로는 LView(로컬 뷰 데이터)·TView(템플릿 메타데이터) 같은 구조로 바인딩·자식 뷰·훅 정보를 보관합니다. 변경 감지 한 사이클은 대략 다음 순서로 이해할 수 있습니다.
- 트리거: Zone 마이크로태스크/매크로태스크 완료, 또는
ApplicationRef.tick()호출, 또는OnPush에서 입력·이벤트·markForCheck등으로 해당 뷰가 “검사 대상”으로 표시되는 경우. - 순회: 기본 전략에서는 컴포넌트 트리를 따라 바인딩을 평가하고 DOM을 갱신합니다.
OnPush는 해당 컴포넌트가 더티(dirty)로 표시된 경우 등에만 실제 검사 비용을 치릅니다. - 안정화:
NgZone.onStable등으로 “이번 틱에서 할 일이 끝났는지”를 관찰할 수 있습니다(차트·레이아웃 측정 등에 활용).
ChangeDetectorRef로 제어하는 수동 CD
markForCheck():OnPush컴포넌트에서 비동기 콜백 안에서 상태를 바꾼 뒤, 상위 체인에 “검사 필요” 를 전파합니다. Zone 밖에서 Observable을 직접 구독할 때 자주 씁니다.detectChanges(): 현재 컴포넌트와 그 자식 에 대해 즉시 동기적으로 변경 감지를 한 번 실행합니다. 단위 테스트나 서드파티 위젯과의 동기화가 필요할 때 사용하며, 남용하면 호출 순서 버그로 이어질 수 있습니다.detach()/reattach(): 해당 뷰를 전역 CD 사이클에서 일시적으로 제외 하거나 다시 포함합니다. 고비용 서브트리를 명시적으로 갱신할 때 쓰는 고급 패턴입니다.
기본(Default) vs OnPush
- Default: 동일 트리에서 이벤트·Zone 틱 등이 발생하면 하위까지 검사 비용이 커질 수 있습니다. 작은 앱에서는 단순하지만, 목록·테이블이 많아지면 병목이 됩니다.
ChangeDetectionStrategy.OnPush: 입력 참조 변경, 템플릿 이벤트,async파이프가 방출, Signals/관련 API 등으로 “이 컴포넌트가 바뀌었을 수 있음”이 명확할 때만 검사하는 쪽에 가깝습니다. 불변(immutable) 데이터 로@Input을 갱신하면 의도가 분명해집니다.
Zone 밖에서 무거운 작업하기
스크롤·mousemove·차트 렌더링처럼 초당 수십~수백 번 도는 콜백은 Zone을 타면 변경 감지가 과도하게 돌 수 있습니다. 이때 NgZone.runOutsideAngular() 안에서 처리하고, 화면 반영이 필요한 시점에만 NgZone.run()으로 다시 들어오는 방식이 일반적입니다.
import { Component, NgZone } from '@angular/core';
@Component({
selector: 'app-heavy',
template: `<div (mousemove)="onMove($event)"></div>`,
})
export class HeavyComponent {
private moveCount = 0;
constructor(private ngZone: NgZone) {}
onMove(_event: MouseEvent): void {
this.ngZone.runOutsideAngular(() => {
this.moveCount++;
if (this.moveCount % 30 === 0) {
// 화면 갱신이 필요할 때만 Zone 안으로
this.ngZone.run(() => {
/* 상태 업데이트 */
});
}
});
}
}
Zone-less·Signals와의 관계
최신 Angular는 Signals 기반의 세밀한 반응성과 함께, Zone에 덜 의존하는 방향으로 진화하고 있습니다. provideExperimentalZonelessChangeDetection() 같은 실험적 API는 Zone 패치 없이 CD를 스케줄하는 접근을 탐색합니다. 다만 기존 코드·서드파티는 여전히 Zone을 가정하는 경우가 많으므로, 하이브리드 로 이해하고 마이그레이션 계획을 세우는 것이 안전합니다.
실무 체크 포인트
async파이프 +OnPush: 파이프가 새 값을 방출하면 해당 컴포넌트가 더티 처리되어 자연스럽게 갱신됩니다.*ngFor+trackBy: 데이터 참조가 바뀌어도 행 정체성 이 유지되면 DOM 재생성이 줄어듭니다.ApplicationRef.isStable: 초기 라우팅·HTTP·이미지 로딩이 끝난 뒤 측정·분석 스크립트를 실행할 때 참고합니다.
10. 의존성 주입(DI) 계층과 해석 규칙
Angular DI는 인젝터 트리(injector tree) 로 모델링됩니다. 최상위 EnvironmentInjector(플랫폼·루트 부트스트랩) 아래에 엘리먼트 인젝터(ElementInjector) 가 컴포넌트·디렉티브마다 붙고, 필요 시 Injector 인스턴스 로 직접 조회할 수 있습니다. 의존성을 요청하면 가장 가까운 인젝터에서 먼저 찾고, 없으면 부모 방향으로 올라갑니다.
인젝터 종류를 나누어 이해하기
| 계층 | 역할 |
|---|---|
| Platform injector | platformBrowser() 등으로 한 번 구성되는 플랫폼 전역 (PLATFORM_ID 등). |
Root EnvironmentInjector | bootstrapApplication 또는 NgModule의 providers로 등록된 앱 루트 싱글턴. |
| Lazy route / importProvidersFrom | 지연 로딩된 기능 모듈이나 Route.providers가 자식 EnvironmentInjector 를 만듭니다. |
| ElementInjector | @Component({ providers }), @Directive({ providers }) 가 해당 DOM 서브트리 에만 노출. |
@Injectable({ providedIn: 'root' })는 루트 환경 인젝터 에 등록되며, 트리 쉐이킹으로 미사용 서비스는 번들에서 제거 되기 쉬운 편입니다.
providedIn과 스코프
| 방식 | 의미 |
|---|---|
@Injectable({ providedIn: 'root' }) | 애플리케이션 싱글턴(일반적 기본값). |
@Injectable({ providedIn: SomeModule }) | 해당 모듈이 로드될 때만 제공(레거시·특수 케이스). |
@NgModule({ providers: [...] }) | 모듈 인젝터에 바인딩(모듈 기반 앱). |
@Component({ providers: [...] }) | 해당 컴포넌트 서브트리 전용 인스턴스(자식은 공유, 형제는 별도). |
라우트 providers | Standalone 라우트에서 기능 단위 스코프 를 만들 때 유용합니다. |
토큰: InjectionToken과 멀티 프로바이더
문자열 DI 토큰은 충돌 위험이 있어, 실무에서는 InjectionToken<T> 를 권장합니다. 같은 추상 타입에 여러 구현을 등록 하려면 { provide: LOGGER, useClass: FileLogger, multi: true }처럼 multi: true 를 사용하고, 주입 시 InjectionToken 타입을 배열로 받습니다. HTTP 인터셉터·APP_INITIALIZER 등이 이 패턴을 사용합니다.
import { InjectionToken, Provider } from '@angular/core';
export const METRICS_SINK = new InjectionToken<(name: string) => void>('METRICS_SINK');
export const metricsProviders: Provider[] = [
{ provide: METRICS_SINK, useValue: (n: string) => console.log(n), multi: true },
];
팩토리·지연 초기화
useFactory와 deps는 환경에 따라 다른 구현 을 조립할 때 씁니다(예: 브라우저 vs SSR, 기능 플래그). useExisting은 별칭(alias)으로, 동일 인스턴스를 다른 토큰으로 노출할 때 사용합니다.
해석(decorator) 힌트
@Optional(): 토큰에 맞는 제공자가 없으면null을 주입합니다(@Optional()이 없으면 이 경우 런타임 에러).@Self(): 자기 자신 인젝터 에만 있어야 함(부모로 안 올라감).@SkipSelf(): 자기 인젝터는 건너뛰고 부모부터 검색.@Host(): 호스트 컴포넌트 인젝터 범위까지(디렉티브에서 상위 컴포넌트 주입 시 자주 사용).
import { Injectable, Optional, SkipSelf } from '@angular/core';
export const API_BASE = 'API_BASE';
@Injectable()
export class ApiService {
constructor(
@Optional() @SkipSelf() parent: ApiService | null,
// 부모 스코프 설정을 상속하거나, 루트에서 새로 구성하는 식으로 계층 API 설계 가능
) {}
}
inject()와 주입 컨텍스트
Angular 14+ inject() 함수는 생성자·필드 초기화·팩토리·라우트 컨텍스트 등 “주입 컨텍스트” 안에서만 안전하게 동작합니다. 임의의 함수 본문에서 호출하면 런타임 오류가 나므로, 팩토리·라우트 providers 와 함께 쓰는 패턴이 일반적입니다.
실무에서는 기능 단위 lazy route 마다 providers로 해당 기능만의 서비스 스코프 를 두어, 탭·마이크로프론트엔드·권한별 설정을 분리하는 패턴이 많습니다.
11. RxJS 연동 패턴
Angular는 HTTP·폼·라우터 등이 Observable 을 반환하므로, 템플릿과 서비스에서의 구독 관리 가 핵심입니다.
async 파이프: 메모리 누수 방지
컴포넌트에서 subscribe를 직접 호출하면 ngOnDestroy에서 해제를 잊기 쉽습니다. 템플릿에서는 async 파이프 가 구독·해제를 대신 처리합니다.
<ul>
<li *ngFor="let user of users$ | async">{{ user.name }}</li>
</ul>
takeUntilDestroyed (Angular 16+)
컴포넌트 생명주기에 맞춰 스트림을 끊는 표준 패턴입니다. 생성자 외 필드 초기화에서도 inject(DestroyRef)와 함께 쓸 수 있습니다.
import { Component } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({ /* ... */ })
export class TickerComponent {
constructor() {
interval(1000)
.pipe(takeUntilDestroyed())
.subscribe((n) => console.log(n));
}
}
Signals와 상호운용: toSignal / toObservable
Angular 16+ @angular/core/rxjs-interop 는 기존 Observable 자산과 Signals를 연결합니다.
toSignal(obs$, { initialValue }): Observable을 Signal로 바꿔 템플릿에서()호출만으로 표시합니다. 구독 해제는rxjs-interop이 관리합니다.toObservable(signal): Signal 변화를 Observable로 펼쳐 기존 Rx 연산자 체인 과 합칩니다.
이 조합은 서버 스트림·폼 스트림은 Rx로, 뷰 상태는 Signal로 나누는 하이브리드 아키텍처에 잘 맞습니다.
연산자 선택 가이드(실무)
| 상황 | 연산자 |
|---|---|
| 빠른 검색·파라미터 변경 | switchMap(이전 요청 취소), 보조로 debounceTime |
| 순서 보장이 중요한 다단계 저장 | concatMap |
| 병렬 다중 요청 | mergeMap(동시성 제한은 mergeMap+queue 패턴 등으로 조절) |
| 로그인 중 중복 클릭 방지 | exhaustMap(진행 중이면 새 클릭 무시) |
| 동일 값 반복 방지 | distinctUntilChanged |
| 완료·에러 시 로딩 플래그 | finalize |
| 사용자 메시지와 함께 복구 | catchError + EMPTY 또는 폴백 Observable |
도메인 패턴: 검색·캐시·파생 상태
switchMap: 빠른 타이핑 검색에서 이전 요청을 취소하고 최신만 유지.shareReplay({ bufferSize: 1, refCount: true }): 동일 데이터를 여러 구독자가 공유(HTTP GET 캐시에 자주 사용).refCount: true로 구독자 0일 때 연결 해제 에 가깝게 동작시키는 편이 안전합니다.combineLatest/combineLatestWith: 여러 Observable에서 파생 UI 상태 조합.
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, shareReplay } from 'rxjs';
interface User {
id: number;
name: string;
}
@Injectable({ providedIn: 'root' })
export class UserCacheService {
private http = inject(HttpClient);
private users$?: Observable<User[]>;
getUsers(): Observable<User[]> {
this.users$ ??= this.http
.get<User[]>('/api/users')
.pipe(shareReplay({ bufferSize: 1, refCount: true }));
return this.users$;
}
}
HTTP·라우터와의 선언적 연결
폼·라우터와 연결할 때는 valueChanges, router.events, ActivatedRoute.paramMap 등을 파이프로 연결해 선언적으로 흐름을 표현하는 것이 테스트와 유지보수에 유리합니다. 라우트 파라미터는 paramMap.pipe( switchMap(...) ) 패턴이 많고, 같은 컴포넌트 인스턴스가 재사용 되는 경우 router.events로 NavigationEnd를 필터링해 명시적으로 데이터를 다시 로드 하기도 합니다.
HttpInterceptor와 관찰 가능한 부수 효과
인터셉터에서 재시도·로깅·토큰 삽입 을 중앙집중화하면 컴포넌트 코드가 단순해집니다. 전역 로딩 스피너는 finalize와 함께 진행 중 요청 카운터 패턴으로 구현하는 경우가 많습니다.
12. AOT(Ahead-of-Time) 컴파일 내부
프로덕션 빌드는 기본적으로 AOT 를 사용합니다. JIT(Just-in-Time)는 개발 서버에서 템플릿을 브라우저가 해석하는 방식이었으나, 현재 워크플로는 빌드 시 템플릿·메타데이터를 TypeScript와 함께 컴파일 하는 쪽이 표준입니다.
Ivy(Angular 렌더링 엔진) 관점
- 컴포넌트 팩토리 가 정적 정보로 생성되어, 런타임 오버헤드가 줄어듭니다.
- 템플릿 타입 체크(strict template 등)로 템플릿-컴포넌트 불일치를 빌드 타임에 잡습니다.
- 사용하지 않는 디렉티브·파이프 는 번들에서 트리 쉐이킹 되기 쉬운 구조입니다.
- Ivy의 지역성(locality): 컴파일 결과가 파일 단위로 더 잘 쪼개져 증분 빌드·캐시 에 유리합니다.
템플릿 타입 체크 깊이
tsconfig의 angularCompilerOptions 로 검증 강도를 조절합니다.
strictTemplates: true: 템플릿에서 프로퍼티·이벤트 바인딩을 엄격히 검사합니다.strictNullInputTypes,strictAttributeTypes,strictSafeNavigationTypes등: 세부 규칙을 켜서 null 안전성 과 DOM 속성 오타를 조기에 발견합니다.fullTemplateTypeCheck: (이전 세대 옵션) 엄격 템플릿 검사를 켜는 데 사용되던 이름으로, 최신 문서는strictTemplates계열을 중심으로 설명합니다.
메타데이터·동적 패턴의 제약
AOT는 컴파일 시점에 알 수 있는 정적 정보 를 선호합니다. 문자열로 클래스 이름을 조립하거나, 런타임에만 결정되는 메타데이터에 의존하면 컴파일러가 생성물을 만들지 못할 수 있습니다. 지연 로딩 경로 는 import() 기반으로 작성하는 것이 번들 분할과도 잘 맞습니다.
빌드 체인과 최적화
ng build --configuration production: 일반적으로 esbuild/Vite 기반 번들러(CLI 버전에 따라)와 난독화·트리 쉐이킹 을 적용합니다.optimization,outputHashing,budgets는angular.json에서 프로덕션 파이프라인을 고정합니다.- 템플릿 공백:
preserveWhitespaces: false(기본에 가깝게 설정되는 경우가 많음)로 불필요한 텍스트 노드 를 줄여 DOM 비용을 낮출 수 있습니다.
개발자가 알아두면 좋은 포인트
- 동적
loadChildren과 lazy chunk 경계는 번들 분할과 직결됩니다. - 메타데이터 제한: 리플렉션에 의존하는 패턴은 AOT와 충돌할 수 있어, 공식 가이드의 제약 을 따르는 것이 안전합니다.
tsconfig의angularCompilerOptions(strictTemplates,strictInjectionParameters등)으로 AOT 검증 강도를 조절합니다.strictInjectionParameters는 생성자 주입 시그니처와 제공자가 맞지 않으면 빌드에서 실패시켜 런타임 DI 오류 를 줄입니다.
13. 프로덕션 최적화 패턴
빌드·예산(budgets)
angular.json의 budgets 로 초기/컴포넌트 스타일 번들 크기 상한을 두면, 회귀를 CI에서 차단할 수 있습니다. 초기 번들·지연 청크·스타일 을 나누어 한도를 두면, 라이브러리 추가로 인한 TTI 악화 를 조기에 발견합니다.
라우트 단위 지연 로딩
const routes: Routes = [
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
},
];
기능별로 chunk 를 나누면 첫 화면 TTI(상호작용 가능 시점)에 유리합니다. PreloadAllModules 또는 사용자 정의 프리로딩 전략 으로 “지연 로딩 + 백그라운드 프리페치” 균형을 맞출 수 있습니다.
렌더링·이미지·SSR
NgOptimizedImage: LCP 개선을 위해 우선순위·지연 로딩·크기 힌트 를 표준화합니다.- SSR(Angular Universal) + 하이드레이션: 검색·첫 페인트에 유리하지만, 상태 불일치 가 없도록 브라우저 전용 API 접근을 가드해야 합니다.
PLATFORM_ID로 분기합니다. provideHttpClient(withFetch()): Fetch 기반 전송으로 일부 환경에서 성능·취소 동작 이 나아질 수 있습니다(버전·환경에 따라 점검).
안정성·보안·운영
- 전역
ErrorHandler: 예외를 로깅·알림 파이프라인으로 보내 프로덕션 디버깅 을 가능하게 합니다. - CSP·신뢰할 수 있는 HTML:
DomSanitizer사용 전제를 문서화하고, 동적 HTML 삽입 을 최소화합니다. - 서비스 워커(
@angular/pwa): 오프라인·캐시 전략을 쓸 때 버전 스키마 와 함께 배포 파이프라인을 설계합니다.
변경 감지·목록 렌더링
OnPush+ 불변 업데이트.*ngFor의trackBy로 DOM 재생성 최소화.- 이미지·차트 등은 지연 로딩·
IntersectionObserver와 병행.
프로덕션 플래그와 산출물
ng build --configuration production은 일반적으로 최적화·압축·트리 쉐이킹 을 켭니다.- 소스맵은 디버깅용으로만 켜고, 배포 아티팩트에는 끄는 것이 일반적입니다(보안·용량).
- 환경별
environment.ts: API 베이스 URL·기능 플래그를 빌드 시 주입하고, 민감 값은 클라이언트에 넣지 않습니다(공개 저장소 기준).
팀 단위 관례
- 기능 폴더(feature) + 공유 UI 라이브러리 로 경계를 나누고, 스토리북·Visual regression 으로 회귀를 줄입니다.
- ESLint·스타일 가드 로
subscribe누락·any남용·테스트 누락을 막습니다.
14. 배포
빌드
ng build --configuration production
Docker
FROM node:20-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist/my-app /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
정리 및 체크리스트
핵심 요약
- Angular: 완전한 프론트엔드 프레임워크
- TypeScript 우선: 강력한 타입 안전성
- 변경 감지·Zone.js: 뷰/더티 플래그,
markForCheck·detectChanges·detach,OnPush·NgZone·실험적 Zone-less - DI 계층: EnvironmentInjector·ElementInjector,
providedIn,InjectionToken·multi,useFactory,inject()컨텍스트,@Optional/@SkipSelf/@Host - RxJS:
async파이프,takeUntilDestroyed,toSignal/toObservable,switchMap·exhaustMap·shareReplay, HTTP 인터셉터 - AOT(Ivy): strictTemplates 세부 옵션,
strictInjectionParameters, 지역성·지연 로딩·프로덕션 빌드 옵션 - 프로덕션: budgets, 프리로딩,
NgOptimizedImage, SSR/하이드레이션 가드,ErrorHandler, CSP·소스맵 정책 - Signals: 새로운 반응성 시스템
- Standalone: 간소화된 구조
구현 체크리스트
- Angular CLI 설치
- 프로젝트 생성
- Component 작성
- Service 구현
- Routing 설정
- Forms 구현
- 변경 감지 전략(
OnPush)·ChangeDetectorRef·Zone 밖 작업·trackBy검토 - DI 스코프(
providedInvs 라우트/컴포넌트providers)·토큰·멀티 프로바이더 설계 - RxJS 구독 해제·
async/toSignal·연산자·라우트paramMap패턴 적용 -
angularCompilerOptions·angular.jsonbudgets·lazy routes·프로덕션 빌드 점검 - 배포
같이 보면 좋은 글
- RxJS 완벽 가이드
- TypeScript 5 완벽 가이드
- React 18 심화 가이드
이 글에서 다루는 키워드
Angular, TypeScript, Frontend, RxJS, Signals, Zone.js, Change Detection, LView, OnPush, EnvironmentInjector, InjectionToken, DI, AOT, Ivy, strictTemplates, Enterprise, Web Framework
내부 동작과 핵심 메커니즘
이 글의 주제는 「Angular 완벽 가이드 | Component·Service·RxJS·Routing·Signals·Standalone」입니다. 앞선 튜토리얼을 구현·런타임 관점에서 다시 압축합니다. 구성 요소 간 책임 분리와 관측 가능한 지점을 기준으로 “입력이 어디서 검증되고, 핵심 연산이 어디서 일어나며, 부작용(I/O·네트워크·디스크)·동시성이 어디서 터지는가”를 한 장면으로 그리면 장애 분석이 빨라집니다.
처리 파이프라인(개념도)
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
경계에서의 지연·실패(시퀀스 관점)
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(프로세스·런타임·게이트웨이) participant D as 의존성(외부 API·DB·큐) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
알고리즘·프로토콜·리소스 관점 체크포인트
- 불변 조건(Invariant): 각 단계가 만족해야 하는 조건(버퍼 경계, 프로토콜 상태, 트랜잭션 격리, 파일 디스크립터 상한)을 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 동일 입력에 동일 출력이 보장되는 순수 층과, 시간·네트워크·스레드 스케줄에 의해 달라질 수 있는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화/역직렬화, 문자 인코딩, syscall 횟수, 락 경합, GC·할당, 캐시 미스처럼 누적 비용을 의심 목록에 넣습니다.
- 백프레셔: 생산자가 소비자보다 빠를 때(소켓 버퍼, 큐 깊이, 스트림) 어디서 어떤 신호로 속도를 줄일지 정의합니다.
프로덕션 운영 패턴
실서비스에서는 기능과 함께 관측·배포·보안·비용·규제가 동시에 요구됩니다.
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율/지연 분위수(p95/p99), 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시 계층·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션 호환성·플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·파일 디스크립터·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 가능한 한 프로덕션에 가깝게 맞추는 것이 재현율을 높입니다.
확장 예시: 엔드투엔드 미니 시나리오
「Angular 완벽 가이드 | Component·Service·RxJS·Routing·Signals·Standalone」을 실제 배포·운영 흐름으로 옮긴 체크리스트형 시나리오입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드 표를 API 또는 이벤트 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 한 화면(로그+메트릭+트레이스)에서 추적한다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지(또는 피처 플래그) 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값이 기대 범위인지 본다.
의사코드 스케치(프레임워크 무관)
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request) // 경계에서 거절
authorize(validated, ctx) // 권한·테넌트
result = domainCore(validated) // 순수에 가까운 규칙
persistOrEmit(result, idempotentKey) // I/O: 멱등·재시도 정책
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성 불안정, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정이 로컬과 다름 | 프로필·시크릿·기본값, 지역 리전 | 단일 소스(예: 스키마 검증된 설정)와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
자주 묻는 질문 (FAQ)
Q. Angular vs React, 어떤 게 나은가요?
A. Angular는 완전한 프레임워크입니다. React는 라이브러리입니다. 대규모 엔터프라이즈는 Angular, 유연성이 필요하면 React를 권장합니다.
Q. 학습 곡선이 가파른가요?
A. 네, Angular는 학습할 것이 많습니다. 하지만 구조화되어 있어 대규모 프로젝트에 유리합니다.
Q. AngularJS와 다른가요?
A. 네, 완전히 다릅니다. AngularJS는 레거시이고, Angular (2+)는 완전히 새로 작성되었습니다.
Q. 프로덕션에서 사용해도 되나요?
A. 네, Google, Microsoft, Forbes 등 많은 기업에서 사용합니다.
Q. OnPush만 쓰면 성능이 항상 좋아지나요?
A. 입력 참조가 자주 바뀌지 않거나 불변 업데이트와 맞물릴 때 효과가 큽니다. 반대로 매번 새 객체를 넘기면 오히려 추적 비용이 늘 수 있어, 데이터 흐름을 함께 설계해야 합니다.
Q. AOT와 JIT의 실무 차이는 무엇인가요?
A. 최신 Angular는 프로덕션에서 AOT가 기본입니다. 템플릿 오류를 빌드에서 잡고, 번들 크기와 시작 성능에 유리합니다. 개발 모드는 핫 리로드·소스맵 등에 초점을 둡니다.
Q. markForCheck와 detectChanges는 언제 구분해서 쓰나요?
A. OnPush에서 비동기로 상태를 갱신했을 때는 보통 markForCheck()로 “이번 틱에서 검사”를 예약합니다. 특정 서브트리만 즉시 동기 갱신해야 할 때는 detectChanges()를 쓰지만, 호출 범위가 넓어지면 순서 이슈가 생기기 쉬워 테스트·서드파티 연동 등 제한된 경우에 두는 편이 안전합니다.
Q. 같은 서비스를 루트와 컴포넌트 providers에 둘 다 두면 어떻게 되나요?
A. 가까운 ElementInjector 가 우선합니다. 자식 컴포넌트는 컴포넌트 스코프 인스턴스를 보고, 형제 컴포넌트는 각자의 providers가 없으면 상위·루트로 올라가며 별도 인스턴스 가 될 수 있습니다. 의도한 스코프인지 반드시 확인해야 합니다.