Signal은 Angular 16에서 새롭게 도입된 반응형 프로그래밍 모델로, 상태 변화를 추적하기 위한 로직을 간소화하고 성능을 높이는 데 사용됩니다.
기존의 RxJS Observable과 달리, Signal은 값 자체를 캡슐화하여 값의 변경을 직접 감지하고 관련된 종속성을 자동으로 업데이트합니다.
Signal은 특정 변수(상태)를 감싸며, 이 값을 읽는 위치를 자동으로 추적합니다. 해당 값이 변경되면 해당 값을 읽는 모든 구독처가 자동으로 업데이트됩니다.
const count = signal(0);
effect(() => {
console.log(`Count is ${count()}`);
});
count.update(n => n + 1);
// count() 값을 읽는 곳이 자동으로 갱신
Writable Signal은 상태를 직접 수정할 수 있는 기본 단위로, 애플리케이션 내 데이터 흐름을 간소화하고 예측 가능하게 만듭니다. 값의 변경을 즉시 추적하기 때문에, 반응형 렌더링과 같은 기능을 빠르고 직관적으로 구현할 수 있습니다.
Signal을 생성할 때 초기값을 지정하면, 해당 값의 변화를 자동으로 추적하고 필요한 곳에 반영됩니다:
const count = signal(0);
set() 메서드는 Signal에 새로운 값을 직접 할당합니다. 값이 서로 다르면 업데이트가 일어나지만, 내부 구조까지 완전히 동일하다면(배열·객체 포함) 업데이트가 일어나지 않습니다.
const items = signal(['apple']);
// 새로운 값이므로 업데이트 발생
items.set(['banana']);
// 값이 동일하므로 업데이트 없음
items.set(['banana']);
const fruit = signal({ name: 'apple', color: 'red' });
// 내부 구조가 동일하므로 업데이트 없음
fruit.set({ name: 'apple', color: 'red' });
update() 메서드는 이전 값을 기반으로 새 값을 계산해 적용합니다.
count.update(prev => prev + 1);
mutate() 메서드는 일부만 수정해야 할 때 더 효율적입니다.
const todos = signal([{ title: 'Learn signals', done: false }]);
todos.mutate(list => {
list[0].done = true;
});
Computed는 writable이 아니므로 set 할 수 없습니다.
대신 내부에 writable Signal 값을 구독하여 해당 값이 변경되었을 때 로직을 처리한 결과 값을 얻을 수 있습니다.
const count: WritableSignal<number> = signal(0);
const doubleCount: Signal<number> = computed(() => count() * 2);
// count()는 doubleCount의 것이 아니므로 computed를 통해 가져와야 합니다.
Effects는 Signal 값의 변화를 구독하고 값이 바뀔 때마다 특정 로직을 실행하는 기능입니다.
단순히 부수적인 작업(로그 출력, 서버 호출 등)에 사용해야 하며, state 변경을 포함시키면 값 변화를 유발하는 로직과 종속성이 꼬여서 예기치 못한 반복 실행이나 무한 루프가 발생할 수 있습니다.
예시:
const count = signal(0);
effect(() => {
// count의 값이 변할 때마다 실행되지만, 새 state를 설정하지 않습니다.
console.log(`The current count is: ${count()}`);
});
count.set(1); // count가 변경되어 콘솔 메시지가 다시 실행됩니다.
위 예제처럼 Effects는 새로운 값을 리턴하거나 state를 직접 수정하지 않고, 단지 변화에 반응하는 역할만 수행해야 합니다.
effect 함수는 어떤 부수 효과(side effect)를 설정하고, 이 부수 효과가 더 이상 필요하지 않을 때 OnCleanup을 사용해서 정리(cleanup)하는 메커니즘을 제공합니다.
effect((onCleanup) => {
const user = currentUser();
const timer = setTimeout(() => {
console.log(`1 second ago, the user became ${user}`);
}, 1000);
onCleanup(() => {
clearTimeout(timer);
});
});
위 코드에서 onCleanup 콜백은 effect가 정리될 때 호출됩니다.
즉, effect가 더 이상 필요하지 않게 되면 onCleanup에 전달된 함수가 실행되어 setTimeout으로 설정된 타이머를 정리합니다. 이는 메모리 누수나 불필요한 타이머 실행을 방지하는 데 유용합니다.
clearTimeout(timer)는 타이머를 즉시 취소합니다. 따라서 setTimeout으로 설정된 콜백은 실행되지 않습니다.
effect(() => {
const user = currentUser();
// clearTimeout이 바로 실행되어 타이머가 즉시 취소됩니다.
const timer = setTimeout(() => {
console.log(`1 second ago, the user became ${user}`);
}, 1000);
clearTimeout(timer); // 이 코드는 타이머를 즉시 취소합니다.
});
Untracked는 Signal이나 Computed의 변화를 추적하지 않도록 하는 기능입니다.
일반적으로 effect는 내부에서 사용되는 Signal의 변화를 자동으로 추적하고, 해당 Signal의 값이 변경될 때마다 Effect를 다시 실행합니다.
그러나 Untracked를 사용하면 특정 코드 블록 내에서 의도적으로 의존성을 생성하지 않고, Signal의 변화에 반응하지 않도록 할 수 있습니다.
이 기능은 특정 상황에서 유용하게 활용될 수 있습니다. 예를 들어, Signal의 현재 값을 읽지만 그 값을 변경하더라도 Effect가 다시 실행되지 않도록 하고 싶을 때 사용할 수 있습니다.
const counter = signal(0);
const currentUser = signal('Guest');
effect(() => {
console.log(`User set to '${currentUser()}' and the counter is ${untracked(counter)}`);
});
// 초기 출력
// User set to 'Guest' and the counter is 0
// currentUser의 값을 변경
currentUser.set('Alice'); // Effect가 실행되어 로그가 출력됩니다.
// 출력: User set to 'Alice' and the counter is 0
// counter의 값을 변경
counter.set(1); // 이 때 Effect는 실행되지 않음
// 출력 없음
Observable을 Signal로 변환합니다.
import { Component } from '@angular/core';
import { interval } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
template: `{{ counter() }}`,
})
export class Ticker {
counterObservable = interval(1000);
counter = toSignal(this.counterObservable, { initialValue: 0 });
}
toSignal은 Angular에서 Reactive Programming을 보다 효과적으로 구현하기 위해 Observable을 Signal로 변환하는 메서드입니다. 이러한 변환의 이점은 다음과 같습니다:
반응형 데이터 관리
Signal은 상태 변화를 자동으로 추적하고, 해당 변화에 따라 UI를 업데이트하는 데 최적화되어 있습니다. Observable은 비동기 데이터 흐름을 관리하는 데 유용하지만, Signal은 상태의 변화를 더 명확하게 표현하고 관리할 수 있도록 도와줍니다.
예를 들어, toSignal을 사용하면 Observable로부터 생성된 Signal이 상태 변화를 감지하고, 그에 따라 UI를 자동으로 갱신합니다. 이는 코드의 가독성을 높이고, 데이터 흐름을 단순화합니다.
UI와 상태의 일관성 유지
Signal은 UI와 상태 간의 일관성을 유지하는 데 유리합니다. Signal을 사용하면 상태가 변경될 때마다 관련된 UI 요소가 자동으로 업데이트됩니다. 이는 사용자 경험을 향상시키고, 상태 관리에 대한 복잡성을 줄입니다.
예를 들어, 위의 코드에서 counterObservable이 1초마다 값을 방출하면, counter Signal도 업데이트되고 UI가 자동으로 갱신됩니다.
성능 최적화
Signal은 불필요한 재계산을 방지하는 최적화된 구조를 가지고 있습니다. Signal의 값이 변경될 때만 관련된 컴포넌트가 리렌더링되므로, 성능을 향상시킬 수 있습니다. Observable을 Signal로 변환함으로써 이러한 최적화를 쉽게 적용할 수 있습니다.
간편한 상태 업데이트
Signal은 상태 업데이트를 간편하게 처리할 수 있게 해줍니다. Signal의 값은 직접적으로 설정하거나 업데이트할 수 있으며, 이 과정은 매우 직관적입니다. 반면, Observable은 다양한 연산자와 구독 관리가 필요하므로, 상태 업데이트가 복잡해질 수 있습니다.
Angular의 반응형 생태계와의 통합
Angular에서는 Signal과 Observable 모두를 사용할 수 있지만, toSignal을 사용하여 두 가지를 결합함으로써 더 일관된 반응형 프로그래밍 패턴을 유지할 수 있습니다. 이는 Angular의 의존성 주입 및 컴포넌트 생명 주기와 함께 원활하게 작동합니다.
위의 예제를 다시 살펴보자면,
import { Component } from '@angular/core';
import { interval } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
template: `{{ counter() }}`,
})
export class Ticker {
counterObservable = interval(1000);
counter = toSignal(this.counterObservable, { initialValue: 0 });
}
위의 코드에서 interval(1000)을 사용해 1초마다 값을 방출하는 Observable을 생성하고, 이를 toSignal을 통해 Signal로 변환합니다. 이 변환의 결과로:
toSignal을 사용하여 Observable을 Signal로 변환하면 반응형 데이터 관리, UI와 상태의 일관성 유지, 성능 최적화, 간편한 상태 업데이트, Angular의 반응형 생태계와의 통합 등 여러 가지 이점을 누릴 수 있습니다.
toSignal의 옵션들은 Signal을 구성하는 데 다양한 방식으로 활용될 수 있습니다.
{
initialValue: any,
requireSync: true, // BehaviorSubject처럼 동작
manualCleanup: boolean // true, 자동 클린업 사용 안 함
}
Signal이 처음 생성될 때 사용할 초기값을 설정합니다. 이 값은 Observable이 방출하기 전에 Signal의 초기 상태를 정의합니다.
import { Component } from '@angular/core';
import { interval } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
template: `{{ counter() }}`,
})
export class Ticker {
counterObservable = interval(1000);
counter = toSignal(this.counterObservable, { initialValue: 0 });
constructor() {
// 초기값은 0이므로, UI에 0이 표시됩니다.
}
}
이 예제에서 initialValue를 0으로 설정하여 Signal이 처음 생성될 때 UI에 0이 표시됩니다. Observable이 첫 번째 값을 방출하기 전에 초기값을 통해 사용자에게 기본 상태를 보여줄 수 있습니다.
이 옵션이 true로 설정되면 Signal이 BehaviorSubject와 같이 동작합니다. 즉, Signal은 마지막으로 방출된 값을 항상 유지하고, 구독자가 Signal을 처음 구독할 때 최신 값을 즉시 받을 수 있습니다.
import { Component } from '@angular/core';
import { interval } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
template: `{{ counter() }}`,
})
export class Ticker {
counterObservable = interval(1000);
counter = toSignal(this.counterObservable, { initialValue: 0, requireSync: true });
constructor() {
// 사용자가 처음 컴포넌트를 로드할 때 최신 카운트 값을 즉시 받습니다.
}
}
이 예제에서 requireSync를 true로 설정하면, 사용자가 컴포넌트를 처음 로드할 때 마지막으로 방출된 카운트 값을 즉시 받을 수 있습니다. 이는 사용자에게 더 나은 경험을 제공합니다.
이 옵션이 true로 설정되면 Signal이 자동으로 클린업을 수행하지 않도록 설정합니다. 즉, Signal이 더 이상 필요하지 않을 때 개발자가 수동으로 정리 작업을 수행해야 합니다.
import { Component } from '@angular/core';
import { interval } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
template: `{{ counter() }}`,
})
export class Ticker {
counterObservable = interval(1000);
counter = toSignal(this.counterObservable, { initialValue: 0, manualCleanup: true });
ngOnDestroy() {
// 수동으로 클린업 작업을 수행해야 합니다.
console.log('Ticker 컴포넌트가 파괴되었습니다. 클린업을 수행합니다.');
}
}
이 예제에서 manualCleanup을 true로 설정하면, Signal이 더 이상 필요하지 않을 때 개발자가 수동으로 클린업을 수행해야 합니다. 이는 리소스 관리를 세밀하게 조정해야 할 때 유용합니다.
toObservable은 Signal을 Observable로 변환하는 메서드입니다. 이 변환은 Angular의 반응형 프로그래밍 패턴을 활용하여 Signal의 변화를 Observable 구독자에게 전달할 수 있게 해줍니다. 이로 인해 Signal의 상태 변화를 더욱 유연하게 처리할 수 있습니다.
import { Component, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
@Component({...})
export class SearchResults {
query: Signal<string> = inject(QueryService).query; // QueryService로부터 Signal을 주입받음
query$ = toObservable(this.query); // Signal을 Observable로 변환
results$ = this.query$.pipe(
switchMap(query => this.http.get('/search?q=' + query)) // 쿼리 값에 따라 API 요청
);
}
const mySignal = signal(0);
const obs$ = toObservable(mySignal); // Signal을 Observable로 변환
obs$.subscribe(value => console.log(value)); // Observable 구독
// Signal의 값을 변경
mySignal.set(1); // 출력: 1
mySignal.set(2); // 출력: 2
mySignal.set(3); // 출력: 3
toObservable은 ReplaySubject처럼 동작합니다. 즉, 구독자가 Signal을 구독할 때, 구독자가 구독하기 전에 방출된 마지막 값을 받을 수 있습니다. 따라서 Signal의 초기 값이 필요할 때 유용합니다.