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