프론트엔드에서의 Signal 이해하기
회사에서 Angular 프레임워크를 사용하고 있는데, 최근 Angular v18을 사용하게 되면서 새롭게 알게 된 Signal에 대해 공부할 기회가 생겼다. Signal은 Angular뿐만 아니라 SolidJS, Preact, Vue와 같은 다른 라이브러리와 프레임워크에서도 채택된 개념이며, 작년 4월에는 Signal API가 JavaScript 표준이 되기 위한 첫 단계인 TC39 Stage 1에 들어섰다는 것을 알게 됐다.
Signal이란?
Signal은 상태 변화를 추적하고 그에 따라 UI를 업데이트하는 반응형 프로그래밍 패턴을 구현하는 방식이자 일종의 객체라고 볼 수 있다. TC39 제안 문서에서는 Signal에 대해 하나의 상태 단위나 계산된 결과를 나타내는 일급 데이터 타입(a first-class data type representing a cell of state or computation derived from other data, now often called "Signals")이라고 언급하고 있다.
다양한 UI 프레임워크들은 모델과 뷰 간의 변화를 추적하고, 이를 자동으로 업데이트하는 방식에 대해 여러 가지 방법들을 사용하고 고민해왔는데, 이를 효율적으로 하기 위한 하나의 방식이자 도구로 Signal을 적극적으로 채택하고 있는 것으로 이해하면 되겠다.
Signal의 구성 요소
// 상태 값을 0으로 초기화
const counter = new Signal.State(0);
// counter 값에 의존하는 계산
const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);
const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd");
// A library or framework defines effects based on other Signal primitives
declare function effect(cb: () => void): (() => void);
effect(() => element.innerText = parity.get());
// Simulate external updates to counter...
setInterval(() => counter.set(counter.get() + 1), 1000);
Signal을 가장 단순하게 보여주는 예시다. Signal 객체는 State와 Computed라는 역할을 하는 클래스나 구조체를 기반으로 하고, set과 get 메서드를 통해 데이터를 처리한다.
- State는 상태 값을 관리하는 역할을 하며,
get()
메서드로 값을 읽고,set()
메서드로 값을 설정할 수 있다. - Computed는 계산된 값을 표현하는 클래스로, 의존하는 State 또는 다른 Computed를 기반으로 계산된 값이다.
get()
을 사용하여 계산된 값을 읽어올 수 있다. - effect 함수는 Signal의 변화에 반응하는 사이드 이펙트를 정의하는 함수로, 단순히 Signal 값이 변경됐을 때 지정된 콜백을 실행시키는 역할을 한다.
UI 프레임워크나 라이브러리는, 위 예시에서의 effect함수와 같은 역할을 하는 메서드 등을 정의함으로써 state나 computed값에 따라 UI를 변경시킬 수 있다. State, Computed, effect라는 주요 구성 요소들을 통해 보면, 예시 코드는 다음과 같은 흐름으로 상태 및 UI를 변경시킨다.
counter
는Signal.State(0)
을 사용해 초기값 0으로 설정된다.- 매 초마다
counter.set(counter.get() + 1)
로counter
값을 1씩 증가시킨다 counter
값에 기반해isEven
이 업데이트된다.isEven
값에 기반해parity
가 업데이트된다.effect()
는parity
값이 변경될 때마다 실행된다.effect
는parity.get()
을 호출해 그 값을 화면에 반영하는 콜백 함수다.effect
함수 내에서element.innerText = parity.get()
을 실행하여,parity
값(짝수인지 홀수인지)를 화면에 표시한다.
Angular에서의 Signal 예시
위 예시에서의 'State'개념을 Angular에서는 'writable signal'이라고 표현하고, 'Computed'개념은 'computed signal'이라고 표현하고 있다.
Writable Signal : 위 예시에서의 'state' 개념과 동일하다.
- 생성 시 초기값을 지정하고 -
const count = signal(0)
- 호출함으로써 값을 받아올 수 있다 (get()) -
console.log('The count is: ' + count())
- set을 통해 값을 직접 지정하거나 -
count.set(3)
- update를 통해 값을 변경할 수 있다 -
count.update(value => value + 1)
Computed Signal : 위 예시에서의 'computed' 개념과 동일하다. 읽기 전용이다.
- computed 함수를 통해 계산된 시그널을 정의한다 -
const doubleCount: Signal<number> = computed(() => count() * 2)
- Computed Signals은 지연 평가(lazy evaluation)와 메모이제이션을 지원한다.
- 지연 평가 :어떤 값이나 계산이 필요할 때까지 해당 계산을 하지 않고, 그 값이 처음으로 요청될 때 계산을 시작하는 방식 (이 예시에서 doubleCount를 정의하는 함수는 처음 doubleCount가 사용되는 시점에서야 계산된다)
- 메모이제이션 : 함수가 동일한 입력값에 대해 반복적으로 호출될 때 그 결과를 캐시(저장)해두고, 이후 동일한 입력값이 들어오면 다시 계산하지 않고 저장된 값을 재사용하는 방식 (이 예시에서 계산된 doubleCount 값은 캐시된다. 이후 다시 doubleCount 값을 사용할 때는 재계산 없이 캐시된 값을 반환한다. 만약 count가 변경되면 Angular는 캐시된 값이 더 이상 유효하지 않음을 인식하고, 다음에 doubleCount를 읽을 때 재계산이 이루어진다)
Effect() : 하나 또는 그 이상의 시그널 값이 변경되면 실행되는 변경 감지 함수다. 다른 값의 영향을 받아 실행된다는 점에서 computed signal과 비슷하지만 사이드 이펙트를 실행시킨다는 점에서 다르다.
Angular의 변경 감지 사이클이 돌 때 컴포넌트 내에서 Signal이나 상태 변화를 자동으로 추적하고 UI를 업데이트하기 때문에, 대부분의 경우 Angular의 내장 메커니즘으로 충분히 상태 변화를 처리할 수 있어 이 effect 함수를 명시적으로 사용할 일은 많지 않다고 한다.
다만 UI 업데이트 외의 사이드 이펙트가 필요할 때(예: 시그널 값을 디버깅 툴에 로깅하거나, 로컬 스토리지에 저장하거나, 백그라운드에서 데이터베이스에 저장하거나, 캔버스/차트 등 외부 라이브러리에 렌더링할 때...) 이 함수가 좋은 솔루션이 될 수 있다고 한다.
시그널이라는 컨셉을 이해하려다보니 Javascript TC39 제안, Angular에 Signal 도입을 제안하는 글을 주로 참고했다.
어떤 기술을 왜 도입해야 하는지에 대한 초기 아이디어나 제안서를 읽어보는 경험은 처음이었는데, 아무래도 많은 이들에게 설득력이 있어야 하는 제안서다보니 친절하고 명료하게 작성됐다. 끄트머리에 이어지는 Q&A나 토론까지도 알찬 정말 좋은 문서라는 느낌을 받았다. 기술적인 배움도 얻을 수 있지만 제안과 문서화를 어떻게 해야 하는지 잘 보여주는 Best Practice와 같은 문서인 것 같아 언제든 참고하기 좋을 것 같다.