시그널의 핵심 반응성 메커니즘인 푸시-풀 기반 알고리즘을 살펴보고, computed의 자동 추적, 무효화, 지연 재평가가 어떻게 동작하는지 설명합니다.
우리는 Solid, Vue 같은 여러 현대 프런트엔드 프레임워크를 통해 수년간 프로덕션에서 Signals를 사용해 왔지만, 그 내부에서 어떻게 동작하는지 설명할 수 있는 사람은 많지 않습니다. 저는 그 원리를 파고들고 싶었고, 특히 반응성의 핵심 메커니즘인 푸시-풀 기반 알고리즘을 깊이 있게 살펴보고 싶었습니다. 이 주제는 정말 흥미롭습니다.
애플리케이션을, 그것을 지배하는 규칙 집합을 우리가 기술하는 하나의 세계라고 상상해 봅시다. 일단 규칙이 정의되면, 우리 프로그램은 더 이상 그것을 바꿀 수 없습니다.
예를 들어, 우리 세계에서는 어떤 y 값이든 반드시 2 * x와 같아야 한다고 결정합니다. 이 규칙을 정의하면, 그때부터 x가 바뀔 때마다 y는 자동으로 조정됩니다. 원하는 만큼 많은 규칙을 정의할 수 있습니다. z는 반드시 y + 1과 같아야 한다고 정함으로써, 규칙들이 서로 의존하게 만들 수도 있고, 이런 식으로 계속 이어질 수 있습니다.
이제 재생 버튼을 누르면 프로그램이 시작되고, 세계가 실행되며, 우리가 정의한 규칙들이 시간에 따라 적용됩니다. (우리의 런타임이라고 생각해도 됩니다.)
x
10
y = x * 2
20
z = y + 1
21
그다음에는 그저 관찰하면 됩니다. x를 바꾸고, y와 z가 우리가 세운 규칙을 따르기 위해 자동으로 조정되는 모습을 보면 됩니다. 이는 원본 셀이 바뀌면 의존 셀이 자동으로 갱신되는 스프레드시트와 같습니다. 다시 말해, 파생 값은 의존성의 변화에 대해 반응적입니다.
이러한 파생 값은 순수 함수처럼 동작합니다. 부작용이 없고, 가변 상태도 없습니다. 다음 예시에서 time은 계속 변하는 소스이고, rotation은 그로부터 파생됩니다. 사각형은 한 번 선언된 이 변환의 결과를 그저 반영할 뿐입니다.
time = 0.00
파생 값
rotation = f(time)
이런 “반응적 세계”는 갑자기 나타난 것이 아닙니다. 이 아이디어는 1970년대에 등장했고, Reactive Programming이라는 형태로 정식화되었습니다. 이것은 데이터 소스의 변화가 의존 계산 그래프를 통해 자동으로 전파되는 시스템을 설명하는 패러다임이며, 바로 이것이 Signals가 하는 일입니다.
따라서 Signals는 Reactive Programming 패러다임의 후계자라고 할 수 있으며, JavaScript에서의 초기 구현은 Knockout.js (2010) 같은 라이브러리와, 이후 브라우저에 반응형 아이디어를 가져온 RxJS (2012)와 함께 등장했습니다.
이제 Signals가 무엇인지에 대한 맥락이 좀 더 생겼으니, 이 시스템의 핵심에 있는 푸시-풀 기반 알고리즘으로 들어가 봅시다.
Signal은 읽고 수정할 수 있는 반응형 값을 나타내는 추상화입니다. 시그널이 바뀌면, 이 시그널에 의존하는 애플리케이션의 모든 부분이 자동으로 갱신됩니다. 저는 아주 기본적인 버전을 직접 구현해 보는 과정을 거쳤습니다.
기본 Signal 구현
우리는 Signals를 우리 세계의 규칙이 시작되는 지점, 즉 목표된 변경이 들어오는 원시적인 진입점으로 상상할 수 있습니다.
처음 제 생각은 이랬습니다. “좋아, 결국 getter와 setter가 있는 단순한 publish–subscriber 패턴이네.” 실제로 Signal 자체는 그런 식으로 동작합니다. 다만 함수가 읽고 수정할 수 있는 현재 상태에 대한 참조를 유지합니다. 이벤트 이미터를 써 본 적이 있다면, 이 패턴이 익숙할 것입니다.
Signal 사용 예시
이것이 바로 푸시 방식이며, 즉시 평가라고도 부릅니다. 시그널이 갱신되면 알림이 즉시 구독자에게 푸시 됩니다. 시그널을 갱신하면 모든 구독자에게 알림이 전달됩니다.
여기서 제가 일부러 “상태”가 아니라 “알림”이라는 용어를 쓰는 이유는, 푸시-풀 기반 알고리즘을 사용하는 Signals는 상태 값을 전달하지 않고, 자신들의 상태가 바뀌었다는 사실만 알리기 때문입니다. 이것은 같은 말이 아닙니다. 다음 섹션에서 캐시 무효화 를 자세히 다룰 것입니다. “노드” 사이를 움직이는 점은 오직 알림일 뿐이라는 점을 기억해 두세요. (다음 모듈에서는 Signal을 클릭해서 구독자에게 알림이 전달되는 모습을 볼 수 있습니다.)
signal
subscriber
subscriber
subscriber
이 더 복잡한 예시에서는 서로 의존하는 여러 “노드”가 있습니다. 이들 모두는 자기 자신의 상태가 바뀌었다고 각자의 구독자에게 알릴 수 있습니다.
signal
computed 1
computed 2
computed 3
computed 4
computed 5
computed 6
이 시점에서 우리는 푸시 기반 접근이 알림을 통해 아래 방향으로 전파된다는 점을 이해했습니다. 이제 풀 기반 접근이 재평가를 통해 위 방향으로 어떻게 전파되는지 살펴봐야 합니다. 이게 무슨 뜻일까요?
Signals에서 가장 중요한 측면 중 하나는 signal 함수 자체가 아니라 computed일지도 모릅니다. 이것들은 시그널이나 다른 computed를 바탕으로 값을 계산하는 반응형 파생 함수입니다. setter가 없는 시그널이라고 상상할 수 있습니다.
우선 시그널과 computed의 가장 큰 차이점은 computed가 지연적이라는 것입니다. 의존성 중 하나가 바뀔 때마다 computed는 무효화되지만, 갱신되지는 않습니다. 그리고 먼저 무효화된 경우에만, 읽힐 때 갱신됩니다. (이것이 우리의 캐시 시스템입니다.) 이것이 바로 풀 기반 알고리즘이라 부르는 것입니다.
둘째로, computed는 의존성을 자동으로 추적합니다. 실행 중에 접근한 시그널/computed의 변화에 스스로 구독합니다. 이것은 개발자들이 이 시스템에서 가장 “마법적”이라고 느끼는 측면 중 하나입니다. React에서는 useEffect나 useMemo의 의존성을 dependency array로 직접 지정해야 하기 때문입니다. 간단한 버전을 어떻게 구현할 수 있는지 봅시다.
가상의 computed 구현
여기서 주목할 점은 computed 객체의 value 프로퍼티에 접근하면 _internalCompute 함수가 실행된다는 것입니다. 이 함수는 계산을 다시 평가하고 캐시된 값을 갱신합니다. (지금은 실제로 캐시되지는 않지만, 이 부분은 뒤에서 다루겠습니다.)
Computed 사용 예시
이 코드, 익숙하죠? 이제 이 프로그램의 의존성 트리를 보고 알고리즘의 “풀” 측면에 집중해 봅시다. computed를 클릭하면 값을 읽을 때 점이 트리를 따라 위로 움직이는 모습을 볼 수 있습니다.
count
doubleCount
plusOne
우리는 읽히는 computed는 전체 트리를 알지 못한다는 사실을 관찰할 수 있습니다. 그것이 아는 것은 자신의 소스들(의존성)과 자신의 구독자들(의존 노드)뿐입니다.
더 복잡한 의존성 트리를 가진 같은 모듈도 확인해 보세요. 트리의 가장 아래 노드처럼, 하나의 computed 함수가 동시에 여러 의존성을 가질 때 어떤 일이 일어나는지도 볼 수 있습니다.
signal
computed 1
computed 2
computed 3
computed 4
computed 5
이 시점에서 이 시스템의 구현에 대해 몇 가지 질문이 남고, 바로 여기서 Signals는 더 복잡해지고 더 흥미로워집니다.
computed 함수는 자신의 소스와 자기 자신 사이의 연결을 어떻게 처리할까? (의존성 자동 추적)시그널과 computed 사이의 연결은 다소 마법적 입니다. 앞서 말했듯, React에서 하듯이 (그 지긋지긋한 dependency array와 함께) computed 값이 어떤 시그널에 의존하는지 명시적으로 선언할 필요가 없습니다. 시스템이 computed 함수 실행 중 접근된 시그널을 자동으로 추적합니다. 이 섹션에서는 바로 그 과정을 살펴봅니다.
count 시그널, doubleCount, plusOne computed가 있는 앞선 예시로 다시 돌아가 봅시다.
우리의 프로그램
자동 추적 메커니즘을 이해하려면, Signal 라이브러리의 구현을 자세히 들여다보는 것이 가장 좋습니다.
우리는 현재 실행 중인 computed와, 그 실행 중 접근하는 시그널/computed 사이의 통신을 가능하게 하는 전역 STACK을 사용해 의존성 자동 추적 시스템의 신비를 풀어냈습니다.
또한 computed가 언제 무효화되었는지 알기 위해 dirty 플래그를 사용하는 방식으로, 풀 기반 알고리즘에서 캐시 시스템이 어떻게 작동하는지도 살펴보았습니다.
위에서 설명했듯, 이제 푸시와 풀 메커니즘을 결합함으로써 시그널의 최종 흐름이 가능해집니다. 시그널이나 computed를 클릭해서 트리 안의 노드들이 무효화되고 재평가되는 모습을 관찰해 보세요. 함께 가지고 놀아 봅시다!
signal
computed 1
computed 2
computed 3
computed 4
computed 5
모든
setDirty호출은 동기적으로 일어난다는 점에 유의하세요. 점이 각 노드를 통과할 때마다 해당 노드는 무효화됩니다. 지연은 순전히 시각적 목적을 위한 것입니다.
이게 전부입니다! 이제 우리는 Signals의 핵심에 있는 푸시-풀 알고리즘을 전체적으로 이해하게 되었습니다. 여기서는 다루지 않겠지만, 대부분의 시그널 라이브러리가 같은 추적 메커니즘 위에 effect 함수도 제공한다는 점은 언급해 두고 싶습니다. 다만 그것은 알고리즘 자체보다는 API 설계에 더 가까운 주제입니다.
이 글은 알고리즘에 초점을 맞췄습니다. 따라서 Signals가 흥미로운 이유는 단지 어떤 UI를 갱신한다는 데 있는 것이 아니라, 반응형 그래프를 통해 변화를 어떻게 전파하는가에 있습니다.
이 조합은 이미 Solid, Vue, Preact, Angular, Svelte 등 많은 프레임워크에서 채택된 세밀한 반응성 시스템을 만들어 냅니다. 각 프레임워크는 저마다의 API 표면을 가지지만, 그 밑바탕의 논리는 동일합니다.
Signals라는 주제는 이미 수많은 글에서 다뤄졌고, 그것들은 제가 이 주제를 이해하는 데 큰 도움을 주었습니다. 하지만 제가 찾은 것들 중에는 푸시-풀 기반 알고리즘을 처음부터 직접 구현하는 과정을 깊이 있게 분석한 자료는 없었습니다. 이 주제를 깊이 탐구하기 위해 저는 제 나름의 Signal 시스템 구현을 만들었습니다. alien-signals, preact-signals, solidjs-signals에 비하면 분명 매우 순진한 구현이지만, 개념을 이해하기에는 충분히 기능적입니다.
또한 “곧” (아마도?) 이 시스템을 더 이상 수동으로 구현할 필요가 없어질 수도 있다는 점도 알아둘 만합니다. 이 모델이 JavaScript 자체에 네이티브하게 표준화되고 있기 때문입니다: TC39 proposal-signals (현재 Stage 1). 이것은 전체 JavaScript 생태계에 큰 진전이 될 것입니다. 각 프레임워크가 공통 기반 위에 설 수 있으면서도, 자신들에게 가장 잘 맞는 API를 선택할 자유를 유지할 수 있게 되기 때문입니다.
저는 이 글을 쓰고, 이를 위한 인터랙티브 모듈을 만드는 과정을 정말 즐겼습니다. 새로운 것을 배웠거나 읽는 것이 즐거우셨다면, 제 작업을 후원해 주세요 ☕️ 또는 Bluesky, LinkedIn에서 편하게 연락 주세요 👋
이 주제를 깊이 이해하는 데 도움을 준 이 팟캐스트 에피소드는 꼭 시간을 내어 들어보시길 강력히 추천합니다: Con Tejas Code w/ Kristen Maevyn and Daniel Ehrenberg의 How signals work
아티클
동영상 및 팟캐스트
라이브러리