팁: 전문적인 내용을 확인하기 전에 핵심 가이드를 읽어보는 것도 좋습니다.
시그널이 무엇인가요?
시그널(signal) 은 값을 감싸는 래퍼(wrapper)인데, 시그널은 그 값이 변경될 때 해당 값을 읽는 소비자에게 알림을 보냅니다. 시그널은 기본 자료형부터 복잡한 데이터 구조까지 다양한 값을 담을 수 있습니다.
시그널의 값을 읽으려면 시그널의 게터 함수를 호출하면 되고, Angular는 이 호출을 통해 시그널이 어디에서 사용되는지 추적합니다.
시그널은 값을 쓸 수 있거나(writable) 읽기 전용(read-only) 입니다.
값을 쓸 수 있는 시그널(Writable signals)
값을 쓸 수 있는 시그널의 값을 변경하려면 시그널의 내부 메서드를 직접 실행하면 됩니다.
초기값을 인자로 전달해 signal 함수를 호출하면 값을 쓸 수 있는 시그널을 생성할 수 있습니다:
const count = signal(0);
// 시그널은 그 자체로 게터 함수입니다. 값을 읽으려면 실행하세요.
console.log('The count is: ' + count());
시그널의 값을 변경하려면 .set() 메서드를 실행하면 됩니다:
count.set(3);
아니면 .update() 메서드를 사용해서 이전 값을 참조하는 방식으로 변경할 수 있습니다:
// 값을 1 증가시킵니다.
count.update((value) => value + 1);
값을 쓸 수 있는 시그널은 WritableSignal 타입입니다.
값을 쓸 수 있는 시그널을 읽기 전용으로 전환하기
WritableSignal이 제공하는 asReadonly() 메서드를 실행하면 이 시그널의 읽기 전용 시그널을 참조할 수 있습니다.
시그널 값을 변경하며 사용하지만, 수정은 막으려고 하는 경우에 사용하면 됩니다:
@Service()
export class CounterState {
// Private writable state
private readonly _count = signal(0);
readonly count = this._count.asReadonly(); // public readonly
increment() {
this._count.update((v) => v + 1);
}
}
@Component({/* ... */})
export class AwesomeCounter {
state = inject(CounterState);
count = this.state.count; // 값을 읽을 수 있지만 변경할 수는 없습니다.
increment() {
this.state.increment();
}
}
읽기 전용 시그널은 원래 시그널의 값을 그대로 반영하지만, set()이나 update() 메서드를 사용할 수는 없습니다.
중요: 읽기 전용 시그널은 객체 내부의 값 변경을 방지하지는 않습니다.
computed() 시그널(Computed signals)
computed() 시그널(Computed signal) 은 다른 시그널에서 값을 도출하는 읽기 전용 시그널입니다.
computed() 시그널은 computed() 함수에 값을 계산하는 함수를 전달해 생성할 수 있습니다:
const count: WritableSignal<number> = signal(0);
const doubleCount: Signal<number> = computed(() => count() * 2);
위 코드에서 doubleCount 시그널은 count 시그널에 영향을 받습니다.
count 시그널의 값이 변경되면 doubleCount의 값도 변경되며, Angular는 이 변화를 감지합니다.
computed() 시그널은 지연 연산되며 연산된 결과는 캐싱됩니다
doubleCount는 처음 실행하기 전까지 실제 연산을 수행하지 않습니다.
그리고 계산된 값은 캐싱되며, 다음 doubleCount를 실행하면 다시 연산하지 않고 캐싱된 값을 바로 반환합니다.
이후에 count 시그널이 변경되면 Angular는 이를 감지하고 doubleCount에 캐싱된 값이 더 이상 유효하지 않다는 것을 판단하기 때문에, 이후에 doubleCount를 실행하면 실제 연산이 실행됩니다.
따라서 배열 필터링과 같이 계산이 많은 경우라면 computed() 시그널을 사용하는 것이 성능 면에서 유리합니다.
computed() 시그널은 값을 지정할 수 없습니다
computed() 시그널에는 직접 값을 설정할 수 없습니다.
doubleCount.set(3);
이렇게 코드를 작성하면 컴파일 오류가 발생합니다.
doubleCount는 WritableSignal 타입이 아니기 때문입니다.
computed() 시그널의 의존성은 동적으로 변경됩니다
계산 과정에서 실제로 읽은 시그널만 추적합니다.
예를 들어 다음 computed() 시그널에서는 showCount 시그널이 true일 때만 count 시그널의 값을 읽습니다:
const showCount = signal(false);
const count = signal(0);
const conditionalCount = computed(() => {
if (showCount()) {
return `The count is ${count()}.`;
} else {
return 'Nothing to see here!';
}
});
이제 showCount 시그널의 값이 false일 때 conditionalCount 시그널의 값을 읽으면 count 시그널은 실행되지도 않고 "Nothing to see here" 메시지가 반환됩니다.
이 말은, 나중에 count 시그널이 변경되더라도 conditionalCount는 연산을 다시 하지 않는다는 것을 의미합니다.
그리고 showCount 시그널의 값이 true 일 때 conditionalCount 시그널의 값을 읽으면, 파생 값을 계산하는 함수가 다시 실행되면서 showCount가 true인 분기를 타게 되고, count 시그널의 값을 문자열에 담아 반환합니다.
그리고 count 시그널이 변경되면 conditionalCount 시그널에 캐싱된 값도 유효하지 않은 것으로 판단합니다.
시그널의 의존성은 동적으로 변경됩니다.
이후에 showCount 시그널의 값이 false가 되면, conditionalCount 시그널은 더 이상 count 값이 변경되는 것을 감지하지 않습니다.
반응 컨텍스트
반응 컨텍스트(reactive context) 는 Angular가 시그널을 모니터링하고 의존성을 관리하는 실행 상태를 의미합니다. 값을 보내는 시그널을 프로듀서(producer) 라고 하며, 시그널에서 값을 읽는 코드를 소비자(consumer) 라고 합니다.
Angular가 반응 컨텍스트로 관리하는 경우는 이렇습니다:
effect,afterRenderEffect콜백을 실행할 때computed()시그널 값을 계산할 때linkedSignal시그널 값을 계산할 때resource의params함수를 평가할 때- 컴포넌트 템플릿을 렌더링할 때 (호스트 프로퍼티도 포함합니다)
이 작업 중에 Angular는 별도 연결을 생성하는데, 추적하는 시그널이 변경되면 소비자를 다시 실행합니다.
반응 컨텍스트에서 assert 사용하기
Angular는 코드가 반응 컨텍스트 밖에서 실행되는지 확인하는 헬퍼 함수 assertNotInReactiveContext를 제공합니다.
반응 컨텍스트 안에서 호출하면 오류가 발생하며, 호출하는 함수의 참조를 인자로 전달하면 오류 메시지가 올바른 API 진입점을 가리킵니다.
일반적인 반응 컨텍스트 오류 메시지보다 문제를 해결하는 방법을 명확하게 안내할 수 있습니다.
import {assertNotInReactiveContext} from '@angular/core';
function subscribeToEvents() {
assertNotInReactiveContext(subscribeToEvents);
// 진행해도 안전합니다. 구독 코드는 여기에 작성합니다.
}
추적 의존성과 관계없이 값 읽기
드문 경우지만, computed() 함수나 effect와 같은 반응형 함수 안에서 의존성을 추가하지 않고 코드를 실행할 수 있습니다.
예를 들면, currentUser 시그널의 값이 변경될 때 counter 시그널의 값을 로그로 출력한다고 합시다.
그렇다면 이런 이펙트 함수를 작성할 수 있습니다:
effect(() => {
console.log(`User set to ${currentUser()} and the counter is ${counter()}`);
});
이렇게 구현하면 currentUser 시그널이나 counter 시그널 둘 중에 하나가 변경될 때마다 로그가 출력됩니다.
하지만 이펙트 함수가 currentUser 시그널이 변경될 때만 반응해야 하고, counter 시그널이 변경되는 것은 감지하지 않아야 하는 경우는 어떻게 해야 할까요?
이런 경우라면 untracked 함수 안에 게터 함수를 전달하면 됩니다:
effect(() => {
console.log(`User set to ${currentUser()} and the counter is ${untracked(counter)}`);
});
untracked 함수는 의존성 관계가 아닌 외부 코드를 실행해야 할 때도 유용합니다:
effect(() => {
const user = currentUser();
untracked(() => {
// `logginvService`는 `user` 시그널을 읽지만, 종속성 관계는 아닙니다.
this.loggingService.log(`User set to ${user}`);
});
});
비동기 연산의 반응 컨텍스트
반응 컨텍스트는 동기 방식으로 실행되는 코드에서만 활성화됩니다. 그래서 비동기 코드 이후에 읽는 시그널은 값을 제대로 추적할 수 없습니다.
effect(async () => {
const data = await fetchUserData();
// 반응 컨텍스트를 여기에서 놓칩니다. - theme()는 추적하지 않습니다.
console.log(`User: ${data.name}, Theme: ${theme()}`);
});
시그널을 놓치지 않으려면, await 앞에서 시그널을 읽으면 됩니다.
await 함수에 전달되는 인자들은 동기 방식으로 평가되기 때문입니다:
effect(async () => {
const currentTheme = theme(); // await 전에 참조합니다.
const data = await fetchUserData();
console.log(`User: ${data.name}, Theme: ${currentTheme}`);
});
effect(async () => {
// 이 방식도 동작합니다: 시그널은 함수 인자로 전달되어 await 전에 값을 참조했습니다.
await renderContent(docContent());
});
고급 파생
computed() 함수는 읽기 전용 파생 시그널을 생성하지만, 시그널 상태에 따라 값을 써야 하는 경우가 있습니다.
자세한 내용은 linkedSignal을 활용한 의존 상태 관리 문서를 참고하세요.
signal, computed, input 등 모든 시그널 API는 동기 방식으로 동작합니다.
하지만 애플리케이션은 종종 비동기 작업을 수행합니다.
이때 resource 함수를 사용하면 비동기 데이터를 애플리케이션의 시그널 기반 코드에 통합하면서도 데이터에 동기 방식으로 접근할 수 있습니다.
자세한 내용은 resource를 활용한 비동기 반응성
반응형 API가 아닌 코드를 실행하는 경우
상태 변화에 반응할 때는 동기 또는 비동기 파생 값을 사용하는 것이 좋습니다. 다만 시그널 변경에 맞춰 비반응형 API를 실행해야 하는 경우에는 effect나 afterRenderEffect를 사용하세요. 자세한 내용은 비반응형 API의 부수 효과 문서를 참고하세요.
OnPush 컴포넌트에서 시그널 읽기
OnPush 컴포넌트 템플릿에서 시그널 값을 읽으면 Angular는 해당 시그널을 이 컴포넌트의 의존성으로 추적합니다.
그리고 시그널의 값이 변경되면, Angular는 다음 변경 감지 사이클에서 컴포넌트를 갱신할 수 있게 컴포넌트를 마킹합니다.
OnPush 컴포넌트에 대해 자세하게 알아보려면 컴포넌트 서브트리 건너뛰기 문서를 참고하세요.
고급 주제
시그널 동일값 평가 함수
시그널을 생성할 때 이 시그널에 동일값인지 판단하는 함수를 전달할 수 있습니다. 이 함수는 시그널에 새 값이 설정될 때 이전 값과 같은 값인지 검사하는 함수입니다.
import {signal} from '@angular/core';
import isEqual from 'lodash/isEqual';
const data = signal(['test'], {equal: isEqual});
// 서로 다른 배열 인스턴스이지만, 깊은 동등성 함수는
// 두 값이 같다고 판단하므로 시그널 갱신을 트리거하지 않습니다.
data.set(['test']);
동일값 평가 함수는 값을 쓸 수 있는(writable) 시그널과 computed() 시그널에 모두 사용할 수 있습니다.
참고: 시그널이 사용하는 기본 평가 함수는 Object.is() 비교를 사용합니다.
시그널 타입 검사
isSignal 함수를 사용하면 어떤 객체가 시그널 객체인지 확인할 수 있습니다:
const count = signal(0);
const doubled = computed(() => count() * 2);
isSignal(count); // true
isSignal(doubled); // true
isSignal(42); // false
좀 더 구체적으로 값을 쓸 수 있는 시그널인지 확인하려면 isWritableSignal을 사용하면 됩니다.
const count = signal(0);
const doubled = computed(() => count() * 2);
isWritableSignal(count); // true
isWritableSignal(doubled); // false
RxJS와 시그널 함께 사용하기
시그널과 RxJS를 함께 사용하는 방법을 알아보려면 RxJS와 Angular 시그널 함께 사용하기 문서를 참고하세요.
시그널 디버깅하기
Angular DevTools는 컴포넌트나 디렉티브의 시그널 그래프를 시각화하여 시그널, computed() 시그널, 이펙트 함수, 기타 반응형 노드가 서로 어떻게 의존하는지 보여줍니다.
그래프를 열고 읽고 검사하는 방법은 시그널 그래프 디버깅하기 문서를 참고하세요.