Angular 시그널
Angular 시그널 (Signals)
컴포넌트의 상태가 어디에서, 어떻게 쓰이는지 프레임워크가 알면 렌더링 갱신을 아주 정밀하게 최적화할 수 있어요. Angular 시그널(Signals)은 바로 그런 상태 추적을 가능하게 해주는 반응형 시스템이에요. 값 하나를 감싸서(래핑해서), 그 값이 바뀌면 관심 있는 소비자에게 알려주는 방식이죠.
시그널이란 무엇인가요
**시그널(signal)**은 값을 감싼 래퍼로, 그 값이 변하면 관심 있는 소비자에게 알려줘요. 시그널은 원시 값부터 복잡한 데이터 구조까지 어떤 값이든 담을 수 있죠.
시그널 값을 읽으려면 getter 함수처럼 호출해야 해요. 그래야 Angular가 시그널이 어디에서 쓰이는지 추적할 수 있어요. 시그널에는 쓰기 가능한(writable) 것과 읽기 전용(read-only)인 것이 있어요.
쓰기 가능한 시그널 (Writable signals)
쓰기 가능한 시그널은 값을 직접 갱신할 수 있는 API를 제공해요. signal() 함수에 초기 값을 넘겨서 만들 수 있어요.
const count = signal(0);
// 시그널은 getter 함수예요. 호출하면 값을 읽어요.
console.log('The count is: ' + count());
값을 바꾸려면 .set()으로 직접 설정하거나, .update()로 이전 값에서 새 값을 계산해요.
count.set(3);
// count를 1만큼 증가시켜요.
count.update((value) => value + 1);
쓰기 가능한 시그널의 타입은 WritableSignal이에요.
쓰기 가능한 시그널을 읽기 전용으로 바꾸기
WritableSignal은 asReadonly() 메서드로 읽기 전용 버전을 돌려줘요. 값의 노출은 하되 소비자가 직접 수정하지 못하게 하고 싶을 때 유용해요.
@Service()
export class CounterState {
// 외부에 공개하지 않는 쓰기 가능한 상태
private readonly _count = signal(0);
readonly count = this._count.asReadonly(); // 공개된 읽기 전용 값
increment() {
this._count.update((v) => v + 1);
}
}
@Component({
/* ... */
})
export class AwesomeCounter {
state = inject(CounterState);
count = this.state.count; // 읽을 수는 있지만 수정은 못 해요
increment() {
this.state.increment();
}
}
읽기 전용 시그널은 원본 쓰기 가능한 시그널의 변화를 그대로 반영하지만, set()이나 update()로는 수정할 수 없어요. 다만 읽기 전용이라고 해서 그 값 자체의 깊은 변경(deep mutation)까지 막아주는 내장 메커니즘이 있는 건 아니라는 점은 기억해 둘 필요가 있어요.
계산된 시그널 (Computed signals)
**계산된 시그널(computed signal)**은 다른 시그널에서 값을 파생해내는 읽기 전용 시그널이에요. computed() 함수에 파생 로직을 지정해서 만들어요.
const doubleCount = computed(() => count() * 2);
doubleCount는 count에 의존해요. count가 갱신되면 Angular는 doubleCount도 갱신해야 한다는 걸 알게 되죠.
계산된 시그널은 지연 평가되고 메모이즈돼요
doubleCount의 파생 함수는 그 값을 처음 읽는 순간까지 실행되지 않아요. 계산된 값은 캐시되고, 다시 읽으면 재계산 없이 캐시된 값이 돌아와요. count가 바뀌면 캐시가 무효해지고, 다음에 읽을 때 새 값이 계산돼요. 그래서 배열 필터링처럼 비용이 큰 파생 작업도 계산된 시그널에서 안전하게 수행할 수 있어요.
계산된 시그널은 쓰기 불가능해요
계산된 시그널에는 값을 직접 할당할 수 없어요. 아래 코드는 연산 오류가 나요.
doubleCount.set(3);
doubleCount가 WritableSignal이 아니기 때문이죠.
계산된 시그널의 의존성은 동적이에요
추적되는 건 파생 중에 실제로 읽은 시그널뿐이에요. 예를 들어 showCount가 true일 때만 count를 읽는 computed가 있다면, showCount가 false일 때는 count를 읽지 않고 "Nothing to see here!" 메시지를 돌려줘요. 즉 나중에 count가 바뀌어도 conditionalCount는 다시 계산되지 않아요. 나중에 showCount를 true로 바꾸고 다시 읽으면 파생이 재실행돼 count를 읽는 분기가 실행되고, 그때부터 count의 변화가 conditionalCount를 무효화해요. 의존성은 이처럼 추가될 수도 있고(파생 중), showCount를 다시 false로 바꿨을 때처럼 제거될 수도 있어요.
반응형 컨텍스트 (Reactive contexts)
반응형 컨텍스트는 Angular가 시그널 읽기를 감시해서 의존성을 확립하는 런타임 상태예요. 시그널을 읽는 코드가 소비자(consumer), 읽히는 시그널이 *생산자(producer)*예요.
Angular는 이런 시점에 자동으로 반응형 컨텍스트에 들어가요.
effect나afterRenderEffect의 콜백을 실행할 때computed시그널을 평가할 때linkedSignal을 평가할 때resource의 params나 loader 함수를 평가할 때- 컴포넌트 템플릿을 렌더링할 때(호스트 프로퍼티 바인딩 포함)
이런 작업 중에 Angular는 살아 있는 연결을 만들어요. 추적 중인 시그널이 바뀌면 Angular는 결국 해당 소비자를 다시 실행해요.
반응형 컨텍스트 확인하기
Angular는 assertNotInReactiveContext 헬퍼 함수를 제공해서 현재 코드가 반응형 컨텍스트 안에서 실행 중인지 단언하게 해줘요. 호출 함수의 참조를 넘겨주면, 단언이 실패할 때 오류 메시지가 올바른 API 진입점을 가리켜요.
의존성 추적 없이 읽기 (untracked)
드물지만 computed나 effect 같은 반응형 함수 안에서 시그널을 읽되 의존성은 만들고 싶지 않을 때가 있어요. 예를 들어 currentUser가 변할 때 counter 값을 로그로 찍고 싶다고 해볼게요. 두 시그널을 다 읽는 effect를 만들면, 둘 중 하나라도 변할 때마다 로그가 남겠죠.
effect(() => {
console.log(`User set to ${currentUser()} and the counter is ${counter()}`);
});
counter만 변해도 로그를 남기고 싶지 않다면, getter를 untracked()로 감싸서 추적을 막을 수 있어요. untracked는 effect가 의존성으로 취급하면 안 되는 외부 코드를 호출할 때도 유용해요.
반응형 컨텍스트와 비동기 작업
반응형 컨텍스트는 동기 코드 구간에서만 활성화돼요. 비동기 경계를 넘어가면 이후의 시그널 읽기는 의존성으로 추적되지 않아요.
// 피해야 할 패턴
effect(async () => {
const data = await fetchUserData();
// 여기서는 반응형 컨텍스트가 사라져서 theme()가 추적되지 않아요
console.log(`User: ${data.name}, Theme: ${theme()}`);
});
모든 시그널 읽기가 추적되게 하려면 await 이전에 읽어두세요. 함수 인자로 전달하는 것도 인자가 동기적으로 평가되므로 괜찮아요.
// 권장 패턴
effect(async () => {
const currentTheme = theme(); // await 이전에 읽기
const data = await fetchUserData();
console.log(`User: ${data.name}, Theme: ${currentTheme}`);
});
// 이것도 동작해요 — 함수 인자로 전달하면 await 이전에 읽힘
effect(async () => {
await renderContent(docContent());
});
고급 파생 (Advanced derivations)
computed가 단순한 읽기 전용 파생을 다룬다면, 다른 시그널에 의존하는 쓰기 가능한 상태가 필요할 때는 linkedSignal을 볼 수 있어요. 또 모든 시그널 API(signal, computed, input 등)는 동기적이지만, 애플리케이션은 비동기 데이터를 다뤄야 하는 경우가 많죠. Resource를 쓰면 비동기 데이터를 시그널 기반 코드에 녹이면서도 값에는 동기적으로 접근할 수 있어요.
OnPush 컴포넌트에서 시그널 읽기
OnPush 컴포넌트 템플릿에서 시그널을 읽으면 Angular는 그 시그널을 컴포넌트의 의존성으로 추적해요. 시그널 값이 변하면 Angular는 컴포넌트를 자동으로 mark해서 다음 변경 감지 때 갱신되게 해줘요.
시그널 동등성 함수
시그널을 만들 때 옵션으로 동등성(equality) 함수를 제공할 수 있어요. 이 함수로 새 값이 이전 값과 정말 다른지 판단하죠.
import isEqual from 'lodash/isEqual';
const data = signal(['test'], {equal: isEqual});
// 다른 배열 인스턴스이지만 깊은 동등성 함수가 값이 같다고 판단해서,
// 시그널은 아무 갱신도 트리거하지 않아요.
data.set(['test']);
동등성 함수는 쓰기 가능한 시그널과 계산된 시그널 양쪽에 제공할 수 있어요. 기본적으로 시그널은 참조 동등성(Object.is() 비교)을 써요.