16.7 Angular Signals — 새 반응형 시스템, Signals vs RxJS 비교
Signals란?
Signals는 Angular 16에서 도입된 새로운 반응형 상태 관리 시스템입니다. 값을 보유하고, 그 값이 변경될 때 관심 있는 곳에 자동으로 알림을 보내는 특별한 래퍼입니다.
Angular 팀이 Signals를 도입한 이유:
- Zone.js 의존도 감소: 현재 Angular는 Zone.js로 변경 감지를 수행하는데, Signals 기반으로 더 세밀한 변경 감지가 가능
- 직관적인 상태 관리: RxJS보다 단순한 API
- 성능 향상: 변경된 Signal을 사용하는 컴포넌트만 재렌더링
버전별 진화
| 버전 | 상태 |
|---|---|
| Angular 16 | Signals 실험적 도입 |
| Angular 17 | Signals 안정화, input(), output() 추가 |
| Angular 17.1 | Signal-based inputs 안정화 |
| Angular 18 | linkedSignal, Resource API (실험적) |
| Angular 19 | linkedSignal 안정화, Resource API 개선 |
기본 API — signal(), computed(), effect()
signal() — 변경 가능한 상태
import { signal } from '@angular/core';
// 기본 Signal 생성
const count = signal(0);
const name = signal('Angular');
const items = signal<string[]>([]);
const user = signal<User | null>(null);
// 값 읽기 (함수 호출)
console.log(count()); // 0
console.log(name()); // 'Angular'
// 값 변경: set()
count.set(5);
name.set('Signals');
// 값 업데이트: update() — 이전 값 기반
count.update(n => n + 1);
items.update(list => [...list, '새 항목']);
// 읽기 전용 Signal 노출 (asReadonly)
const readonlyCount = count.asReadonly();
// readonlyCount.set() → 타입 오류!
computed() — 파생 상태 (읽기 전용)
import { signal, computed } from '@angular/core';
const price = signal(10000);
const quantity = signal(3);
const discountRate = signal(0.1); // 10%
// 파생 computed — price, quantity, discountRate가 변경될 때 자동 재계산
const subtotal = computed(() => price() * quantity());
const discount = computed(() => subtotal() * discountRate());
const total = computed(() => subtotal() - discount());
console.log(total()); // 27000
price.set(20000);
console.log(total()); // 54000 — 자동 업데이트!
// computed는 쓰기 불가
// total.set(999); → 타입 오류
effect() — 부수 효과 처리
import { signal, computed, effect } from '@angular/core';
import { Component, OnInit } from '@angular/core';
@Component({ ... })
export class MyComponent {
theme = signal<'light' | 'dark'>('light');
userName = signal('');
constructor() {
// effect: Signal 값이 변경될 때 자동 실행
effect(() => {
// theme() 읽기 → theme가 의존성으로 자동 등록
document.body.classList.toggle('dark-theme', this.theme() === 'dark');
console.log('테마 변경됨:', this.theme());
});
// localStorage 동기화
effect(() => {
const name = this.userName();
if (name) {
localStorage.setItem('username', name);
}
});
// 정리(cleanup) 함수 등록
effect((onCleanup) => {
const timer = setInterval(() => console.log('tick'), 1000);
onCleanup(() => clearInterval(timer)); // effect 재실행 전 정리
});
}
}
상태 업데이트 — set(), update(), mutate()
import { signal } from '@angular/core';
// 기본 타입
const counter = signal(0);
counter.set(10); // 직접 설정
counter.update(n => n + 1); // 이전 값 기반 업데이트
// 배열
const todos = signal<Todo[]>([]);
// 항목 추가
todos.update(list => [...list, newTodo]);
// 항목 수정
todos.update(list =>
list.map(t => t.id === id ? { ...t, completed: true } : t)
);
// 항목 삭제
todos.update(list => list.filter(t => t.id !== id));
// 객체
const user = signal({ name: 'Alice', age: 30 });
// 부분 업데이트
user.update(u => ({ ...u, age: 31 }));
input() — Signal 기반 입력 (Angular 17.1+)
import { Component, input, computed } from '@angular/core';
interface Product {
id: number;
name: string;
price: number;
stock: number;
}
@Component({
selector: 'app-product-card',
standalone: true,
template: `
<div [class.out-of-stock]="isOutOfStock()">
<h3>{{ product().name }}</h3>
<p class="price">{{ formattedPrice() }}</p>
<p class="stock">재고: {{ product().stock }}개</p>
@if (showActions()) {
<button [disabled]="isOutOfStock()">
{{ isOutOfStock() ? '품절' : '장바구니 추가' }}
</button>
}
</div>
`
})
export class ProductCardComponent {
// 필수 input signal
product = input.required<Product>();
// 선택적 input signal (기본값)
showActions = input(true);
currency = input('KRW');
// computed — input signal에서 파생
isOutOfStock = computed(() => this.product().stock === 0);
formattedPrice = computed(() =>
new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: this.currency()
}).format(this.product().price)
);
}
output() — 이벤트 방출 (Angular 17.1+)
import { Component, output, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div>
<button (click)="decrement()">-</button>
<span>{{ count() }}</span>
<button (click)="increment()">+</button>
<button (click)="reset()">초기화</button>
</div>
`
})
export class CounterComponent {
count = signal(0);
// output() 함수로 이벤트 정의
countChanged = output<number>();
resetted = output<void>();
increment() {
this.count.update(n => n + 1);
this.countChanged.emit(this.count());
}
decrement() {
this.count.update(n => n - 1);
this.countChanged.emit(this.count());
}
reset() {
this.count.set(0);
this.resetted.emit();
}
}
model() — 양방향 Signal 바인딩 (Angular 17.2+)
import { Component, model } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-toggle',
standalone: true,
template: `
<label>
<input
type="checkbox"
[checked]="checked()"
(change)="checked.set($any($event.target).checked)"
>
{{ checked() ? '켜짐' : '꺼짐' }}
</label>
`
})
export class ToggleComponent {
// model(): 부모에서 읽고 쓸 수 있는 양방향 signal
checked = model(false);
}
// 부모 컴포넌트에서 사용
@Component({
standalone: true,
imports: [ToggleComponent],
template: `
<app-toggle [(checked)]="isDarkMode" />
<p>다크 모드: {{ isDarkMode() ? '활성' : '비활성' }}</p>
`
})
export class ParentComponent {
isDarkMode = signal(false);
}
toSignal(), toObservable() — RxJS 연동
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { signal, computed } from '@angular/core';
import { interval } from 'rxjs';
import { map } from 'rxjs/operators';
@Component({ ... })
export class InteropComponent {
// Observable → Signal
// tick은 매 초마다 업데이트되는 Signal
tick = toSignal(interval(1000), { initialValue: 0 });
// HTTP 응답을 Signal로
users = toSignal(
inject(HttpClient).get<User[]>('/api/users'),
{ initialValue: [] as User[] }
);
// Signal → Observable
searchTerm = signal('');
// searchTerm Signal을 Observable로 변환 → RxJS 연산자 사용 가능
searchResults$ = toObservable(this.searchTerm).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term =>
term ? inject(HttpClient).get<Product[]>(`/api/search?q=${term}`) : of([])
)
);
// 다시 Signal로
searchResults = toSignal(this.searchResults$, { initialValue: [] });
}
linkedSignal — 연결된 Signal (Angular 19)
linkedSignal은 다른 Signal에 의존하면서도 독립적으로 수정할 수 있는 Signal입니다.
import { signal, linkedSignal } from '@angular/core';
const options = signal(['사과', '바나나', '오렌지']);
// options가 변경되면 자동으로 첫 번째 옵션으로 재설정
// 하지만 사용자가 직접 선택할 수도 있음
const selectedOption = linkedSignal(() => options()[0]);
console.log(selectedOption()); // '사과'
selectedOption.set('바나나'); // 사용자가 직접 선택
console.log(selectedOption()); // '바나나'
options.set(['딸기', '포도', '키위']); // options 변경
console.log(selectedOption()); // '딸기' — 자동 리셋
Signals vs RxJS 선택 기준
| 기준 | Signals | RxJS |
|---|---|---|
| 학습 난이도 | 낮음 | 높음 |
| 코드 가독성 | 높음 | 중간 (연산자 체인) |
| 동기/비동기 | 주로 동기 | 비동기에 강함 |
| 스트림 처리 | 어려움 | 강함 |
| 에러 처리 | 단순 | 강력 |
| 취소(cancel) | 없음 | 지원 |
| 시간 기반 | 직접 구현 | debounce, throttle 등 내장 |
| 결합 | computed | combineLatest, forkJoin 등 |
언제 Signals를 쓸까?
// ✅ Signals 적합한 경우
// 1. 컴포넌트 로컬 상태
count = signal(0);
isOpen = signal(false);
// 2. 파생 상태
total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));
// 3. 서비스 공유 상태
@Injectable({ providedIn: 'root' })
export class AuthService {
private _user = signal<User | null>(null);
readonly user = this._user.asReadonly();
}
// 4. 단순 비동기 (toSignal 활용)
users = toSignal(inject(HttpClient).get<User[]>('/api/users'), { initialValue: [] });
언제 RxJS를 쓸까?
// ✅ RxJS 적합한 경우
// 1. 검색 자동완성 (debounce + switchMap)
searchResults$ = searchControl.valueChanges.pipe(
debounceTime(300),
switchMap(term => this.http.get(`/api/search?q=${term}`))
);
// 2. 여러 소스 결합
dashboardData$ = combineLatest([users$, orders$, stats$]).pipe(
map(([users, orders, stats]) => ({ users, orders, stats }))
);
// 3. WebSocket 실시간 데이터
messages$ = webSocket('wss://api.example.com').pipe(
retryWhen(errors => errors.pipe(delay(3000)))
);
// 4. 복잡한 비동기 워크플로우
submitOrder$ = cartItems$.pipe(
switchMap(items => this.validateItems(items)),
switchMap(validItems => this.processPayment(validItems)),
switchMap(paymentResult => this.createOrder(paymentResult))
);
실전 예제 — Signals로 만드는 쇼핑 카트
// services/cart.service.ts
import { Injectable, signal, computed } from '@angular/core';
export interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
image: string;
}
@Injectable({ providedIn: 'root' })
export class CartService {
private _items = signal<CartItem[]>([]);
// 읽기 전용 공개
readonly items = this._items.asReadonly();
// 파생 상태
readonly itemCount = computed(() =>
this._items().reduce((sum, item) => sum + item.quantity, 0)
);
readonly subtotal = computed(() =>
this._items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
readonly tax = computed(() => this.subtotal() * 0.1);
readonly total = computed(() => this.subtotal() + this.tax());
readonly isEmpty = computed(() => this._items().length === 0);
// 카트에 항목 추가
addItem(product: Omit<CartItem, 'quantity'>) {
this._items.update(items => {
const existing = items.find(i => i.id === product.id);
if (existing) {
// 이미 있으면 수량 증가
return items.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
);
}
// 새 항목 추가
return [...items, { ...product, quantity: 1 }];
});
}
// 수량 변경
updateQuantity(id: number, quantity: number) {
if (quantity <= 0) {
this.removeItem(id);
return;
}
this._items.update(items =>
items.map(i => i.id === id ? { ...i, quantity } : i)
);
}
// 항목 제거
removeItem(id: number) {
this._items.update(items => items.filter(i => i.id !== id));
}
// 카트 비우기
clearCart() {
this._items.set([]);
}
}
// cart/cart.component.ts
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CartService } from '../services/cart.service';
@Component({
selector: 'app-cart',
standalone: true,
imports: [FormsModule],
template: `
<div class="cart">
<h2>장바구니 ({{ cartService.itemCount() }}개)</h2>
@if (cartService.isEmpty()) {
<div class="empty-cart">
<p>장바구니가 비어있습니다.</p>
<a href="/products">쇼핑 계속하기</a>
</div>
} @else {
<div class="cart-items">
@for (item of cartService.items(); track item.id) {
<div class="cart-item">
<img [src]="item.image" [alt]="item.name" class="item-image">
<div class="item-info">
<h3>{{ item.name }}</h3>
<p class="price">{{ item.price | currency:'KRW' }}</p>
</div>
<div class="quantity-control">
<button (click)="cartService.updateQuantity(item.id, item.quantity - 1)">
-
</button>
<input
type="number"
[value]="item.quantity"
(change)="onQuantityChange(item.id, $event)"
min="1"
class="qty-input"
>
<button (click)="cartService.updateQuantity(item.id, item.quantity + 1)">
+
</button>
</div>
<p class="item-total">
{{ item.price * item.quantity | currency:'KRW' }}
</p>
<button (click)="cartService.removeItem(item.id)" class="remove-btn">
삭제
</button>
</div>
}
</div>
<!-- 주문 요약 -->
<div class="cart-summary">
<div class="summary-row">
<span>소계</span>
<span>{{ cartService.subtotal() | currency:'KRW' }}</span>
</div>
<div class="summary-row">
<span>부가세 (10%)</span>
<span>{{ cartService.tax() | currency:'KRW' }}</span>
</div>
<div class="summary-row total">
<span>합계</span>
<span>{{ cartService.total() | currency:'KRW' }}</span>
</div>
<div class="cart-actions">
<button (click)="cartService.clearCart()" class="clear-btn">
장바구니 비우기
</button>
<button (click)="checkout()" class="checkout-btn">
주문하기 ({{ cartService.total() | currency:'KRW' }})
</button>
</div>
</div>
}
<!-- 주문 완료 메시지 -->
@if (orderPlaced()) {
<div class="order-success">
주문이 완료되었습니다! 주문번호: {{ orderId() }}
</div>
}
</div>
`
})
export class CartComponent {
cartService = inject(CartService);
orderPlaced = signal(false);
orderId = signal('');
onQuantityChange(id: number, event: Event) {
const qty = Number((event.target as HTMLInputElement).value);
this.cartService.updateQuantity(id, qty);
}
checkout() {
// 주문 처리 로직
const orderId = `ORD-${Date.now()}`;
this.orderId.set(orderId);
this.orderPlaced.set(true);
this.cartService.clearCart();
}
}
고수 팁
팁 1: effect에서 Signal 쓰기 방지
// ❌ effect 안에서 signal 쓰기 → 무한 루프 위험
effect(() => {
const val = count();
count.set(val + 1); // 위험!
});
// ✅ computed 사용
const doubled = computed(() => count() * 2);
// ✅ 어쩔 수 없다면 allowSignalWrites 옵션
effect(() => {
// ...
}, { allowSignalWrites: true });
팁 2: 서비스의 Signal은 asReadonly()로 노출
@Injectable({ providedIn: 'root' })
export class UserService {
private _user = signal<User | null>(null);
// 외부에서는 읽기만, 변경은 서비스 메서드로만
readonly user = this._user.asReadonly();
setUser(user: User) {
this._user.set(user);
}
}
팁 3: Signals는 Zone.js 없이도 동작
Angular 18에서 zoneless 모드로 실험 가능합니다:
// app.config.ts
provideExperimentalZonelessChangeDetection()