본문으로 건너뛰기
Advertisement

16.7 Angular Signals — 새 반응형 시스템, Signals vs RxJS 비교

Signals란?

Signals는 Angular 16에서 도입된 새로운 반응형 상태 관리 시스템입니다. 값을 보유하고, 그 값이 변경될 때 관심 있는 곳에 자동으로 알림을 보내는 특별한 래퍼입니다.

Angular 팀이 Signals를 도입한 이유:

  • Zone.js 의존도 감소: 현재 Angular는 Zone.js로 변경 감지를 수행하는데, Signals 기반으로 더 세밀한 변경 감지가 가능
  • 직관적인 상태 관리: RxJS보다 단순한 API
  • 성능 향상: 변경된 Signal을 사용하는 컴포넌트만 재렌더링

버전별 진화

버전상태
Angular 16Signals 실험적 도입
Angular 17Signals 안정화, input(), output() 추가
Angular 17.1Signal-based inputs 안정화
Angular 18linkedSignal, Resource API (실험적)
Angular 19linkedSignal 안정화, 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 선택 기준

기준SignalsRxJS
학습 난이도낮음높음
코드 가독성높음중간 (연산자 체인)
동기/비동기주로 동기비동기에 강함
스트림 처리어려움강함
에러 처리단순강력
취소(cancel)없음지원
시간 기반직접 구현debounce, throttle 등 내장
결합computedcombineLatest, 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()
Advertisement