TC39 Signals Stage 1 분석: React·Vue·SolidJS 코드가 어떻게 바뀌는가
5년 동안 프론트엔드를 만지다 보면 상태 관리 라이브러리와 씨름한 시간이 전체 개발 시간의 절반은 되는 것 같다는 생각이 든다. Redux 보일러플레이트에 질려 MobX로 갔다가, Recoil이 나왔다고 갈아탔다가, 요즘은 Zustand로 눌러앉은 팀도 많다. 그런데 이 피로의 근원을 짚어보면, 결국 "JavaScript 언어 자체에 반응형 상태 기본 타입이 없다"는 사실에 다다른다.
TC39 Signals 제안은 바로 그 빈자리를 채우려는 시도다. TC39는 JavaScript 언어 표준을 관리하는 위원회고, Stage 1은 "공식 논의 시작"을 의미한다—채택 확정이 아니라 앞으로 같이 다듬어 보겠다는 선언이다. 2024년 4월 Stage 1을 통과한 이후 주요 프레임워크 팀들이 활발하게 위원회 작업에 참여하고 있는데, 어떤 프레임워크가 이 표준과 얼마나 가깝고 얼마나 먼지를 들여다보면 앞으로의 방향이 꽤 선명하게 보인다. 이 글에서는 TC39 Signals 스펙이 각 프레임워크의 현재 구현과 어떻게 다른지 뜯어보고, 표준으로 안착했을 때 우리 코드베이스가 실제로 어떻게 달라지는지를 살펴본다.
핵심 개념
Signal이란 무엇이고, 왜 지금 표준화인가
Signal은 값을 담는 반응형 셀이다. 값이 바뀌면 그 값에 의존하는 연산들이 자동으로 다시 계산된다. MobX의 observable, Vue의 ref, SolidJS의 createSignal이 모두 이 아이디어의 변형이다. 각 프레임워크가 5~10년에 걸쳐 독자적으로 같은 개념을 구현해왔다는 것 자체가, 이게 JavaScript 언어 수준에서 필요한 기본 타입이라는 신호다.
TC39 Signals의 공개 API는 크게 두 가지다.
| API | 역할 | 프레임워크 대응 |
|---|---|---|
Signal.State(value) |
읽고 쓸 수 있는 반응형 셀 | ref(), createSignal(), signal() |
Signal.Computed(callback) |
다른 Signal에서 파생되는 읽기 전용 연산 | computed(), createMemo() |
여기에 Signal.subtle 네임스페이스가 따로 있는데, 이건 프레임워크 제작자를 위한 저수준 API다. 쉽게 말해 "내부 구현자가 렌더링 스케줄링을 직접 제어하기 위한 영역"이라고 보면 된다. 일반 앱 개발자가 직접 건드릴 일은 거의 없다.
주목할 점은 effect()가 표준에 포함되지 않았다는 것이다. 가장 많이 쓰는 사이드이펙트 API가 빠져 있는 건 의도적인 설계 결정이다. 렌더링을 언제, 어떻게 배치할지는 프레임워크마다 전략이 다르기 때문에 언어 표준이 이를 강제하면 오히려 족쇄가 된다. 대신 Signal.subtle.Watcher를 통해 프레임워크가 직접 구현할 수 있는 기반만 제공한다.
import { Signal } from "signal-polyfill";
const counter = new Signal.State(0);
const isEven = new Signal.Computed(() => (counter.get() & 1) === 0);
const parity = new Signal.Computed(() => (isEven.get() ? "even" : "odd"));
// effect는 표준에 없으므로 Watcher로 직접 구현
const w = new Signal.subtle.Watcher(() => {
queueMicrotask(() => {
for (const s of w.getPending()) s.get();
w.watch();
});
});
// GC에 수거되지 않도록 반드시 변수에 할당해 참조를 유지해야 한다
const parityLogger = new Signal.Computed(() => console.log(parity.get()));
w.watch(parityLogger);
counter.set(1); // → "odd"
counter.set(2); // → "even"저도 처음엔 Signal.subtle 안에 effect()가 어딘가 숨어 있을 거라고 열심히 뒤졌는데, 진짜 없다. 의도적 설계다.
각 프레임워크는 지금 어디쯤 있는가
솔직히 처음 이 표를 봤을 때 "Angular가 TC39 제안에 직접 영향을 줬다고?"라는 생각이 들어서 이중으로 확인했다. 맞다. Angular Signals(v17~) 팀이 위원회에 참여했고, 거기다 SolidJS를 만든 Ryan Carniato까지 TC39 위원회에 적극 참여하면서 API 설계에 실질적으로 기여했다. 그래서 SolidJS가 "가장 원형에 가깝다"는 표현이 허풍이 아니다—설계자가 테이블에 앉아 있었으니까.
| 프레임워크 | 자체 구현 | TC39와의 관계 |
|---|---|---|
| SolidJS | createSignal / createMemo |
가장 원형에 가깝다. 제작자 Ryan Carniato가 TC39 위원회에 참여, 내부를 폴리필로 교체하는 실험 진행 중 |
| Angular v17+ | signal() / computed() |
TC39 제안에 직접 참여, API 설계에 영향을 줌 |
| Vue 3 | ref() / computed() |
Proxy 기반 구현, 위원회에 참여하며 상호운용성 논의 |
| Svelte 5 | $state / $derived (Runes) |
컴파일 타임 변환 우선, 런타임 API보다 컴파일러 최적화 중시 |
| Preact | signal() / computed() |
TC39 표준 조기 채택 의지 표명, API 근접 |
| React | — | React Compiler로 우회, TC39 직접 통합 계획 없음 |
React의 이탈이 눈에 띈다. "Signals won, React is living off its ecosystem"이라는 표현이 2025~2026년 커뮤니티 담론에서 자주 보이는 이유가 있다. React는 단방향 데이터 흐름과 불변 상태라는 철학을 고수하면서, React Compiler(구 React Forget)를 통해 빌드 타임에 자동으로 메모이제이션 코드를 삽입하는 방식으로 성능을 챙기는 전략을 택했다. Signals의 가변 반응형 셀 모델과는 철학 자체가 다르다. 개인적으론 React가 틀렸다고 생각하진 않지만, 생태계의 무게 중심이 Signals 쪽으로 확실히 기울고 있는 건 사실이다.
실전 적용
예시 1: 같은 기능, 프레임워크별 문법 비교
카운터와 그 두 배 값을 추적하는 단순한 예시인데, 이걸 나란히 놓으면 TC39 표준이 얼마나 자연스러운 공통 분모가 되는지 체감된다.
// 1. SolidJS (현재)
import { createSignal, createMemo } from "solid-js";
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
// 2. Vue 3 (현재)
import { ref, computed } from "vue";
const count = ref(0);
const doubled = computed(() => count.value * 2);
// 3. Angular v17+ (현재)
import { signal, computed } from "@angular/core";
const count = signal(0);
const doubled = computed(() => count() * 2);
// 4. TC39 Signals 표준 (미래)
const count = new Signal.State(0);
const doubled = new Signal.Computed(() => count.get() * 2);| 항목 | SolidJS | Vue 3 | Angular | TC39 표준 |
|---|---|---|---|---|
| 상태 읽기 | count() |
count.value |
count() |
count.get() |
| 상태 쓰기 | setCount(n) |
count.value = n |
count.set(n) |
count.set(n) |
| 파생 값 | createMemo |
computed |
computed |
Signal.Computed |
| 함수 호출 방식 | 튜플 반환 | 프록시 객체 | 단일 함수 | 클래스 인스턴스 |
API 구조를 보면 Angular와 SolidJS가 TC39 표준에 가장 가깝다. 이미 두 프레임워크를 쓰고 있다면 TC39로의 전환 경로가 상대적으로 완만할 거다. 실무 관점에서 보면 이게 꽤 의미 있는 차이다—Angular 팀이 표준을 설계하는 테이블에 앉아 있었으니 둘이 비슷한 건 당연하고, 그 표준이 확정되면 Angular 코드베이스의 마이그레이션 비용은 다른 프레임워크에 비해 훨씬 낮을 가능성이 높다. Vue는 .value 프록시 방식이라 사용감이 약간 다르고, Svelte는 아예 컴파일러가 $state를 런타임 Signal로 변환하는 방식이라 비교 자체가 살짝 다른 층위에 있다.
예시 2: 프레임워크 독립 비즈니스 로직
표준이 정착됐을 때의 가장 큰 변화는 이쪽이다. 실무에서 자주 맞닥뜨리는 상황인데, 같은 장바구니 로직을 React 앱과 Vue 앱에 동시에 써야 할 때 지금은 어떻게 하는가? Zustand 같은 외부 상태 관리자를 공통으로 쓰거나, 아예 로직을 두 번 짠다.
TC39 Signals 기반이면 이렇게 된다.
// cart.signals.js — 어떤 프레임워크에도 종속되지 않는 비즈니스 로직
import { Signal } from "signal-polyfill"; // 표준 채택 전까지는 폴리필 사용
export function createCart() {
const items = new Signal.State([]);
const total = new Signal.Computed(() =>
items.get().reduce((sum, item) => sum + item.price, 0)
);
const itemCount = new Signal.Computed(() => items.get().length);
const addItem = (item) => items.set([...items.get(), item]);
const removeItem = (id) => items.set(items.get().filter((item) => item.id !== id));
return { items, total, itemCount, addItem, removeItem };
}이 파일이 어떤 프레임워크도 import하지 않는다는 게 핵심이다. React도, Vue도, Angular도 없다. 비즈니스 로직이 완전히 이식 가능한 형태로 분리된다.
// React 어댑터
// useSyncExternalStore는 React 18+에서 외부 스토어를 React 렌더 사이클에 연결하는 훅이다.
// Signal이 바뀌면 onStoreChange를 트리거해 리렌더를 유발하는 구조—
// React의 불변 상태 모델과 Signals의 가변 모델을 연결하는 공식 브릿지라고 보면 된다.
import { useSyncExternalStore } from "react";
import { createCart } from "./cart.signals.js";
const cart = createCart();
function useSignal(signal) {
return useSyncExternalStore(
(onStoreChange) => {
const watcher = new Signal.subtle.Watcher(onStoreChange);
watcher.watch(signal);
return () => watcher.unwatch(signal);
},
() => signal.get()
);
}
function CartTotal() {
const total = useSignal(cart.total);
return <span>합계: {total}원</span>;
}// Vue 3 어댑터 — 훨씬 간결하다
import { customRef } from "vue";
import { createCart } from "./cart.signals.js";
const cart = createCart();
function signalRef(signal) {
return customRef((track, trigger) => {
const watcher = new Signal.subtle.Watcher(trigger);
watcher.watch(signal);
return {
get() {
track();
return signal.get();
},
set(value) {
signal.set(value);
trigger();
},
};
});
}어댑터 레이어는 여전히 필요하지만, 비즈니스 로직은 단 한 번만 작성하면 된다. 어댑터도 얇게—React 쪽은 useSyncExternalStore 구독, Vue 쪽은 customRef 래핑 정도다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 번들 크기 감소 | 브라우저 내장 후 프레임워크별 반응형 런타임이 번들에서 빠진다. MobX(~17KB), Vue reactivity 등 수십 KB가 네이티브 구현으로 대체될 수 있다 |
| 크로스 프레임워크 상태 공유 | 한 번 작성한 Signal 기반 로직을 React·Vue·Solid 어디서나 얇은 어댑터만으로 재사용할 수 있다 |
| 엔진 수준 최적화 | V8, SpiderMonkey 등 JavaScript 엔진이 Signal 그래프를 직접 이해하고 최적화할 수 있어 런타임 성능 향상 가능 |
| 학습 비용 통일 | Redux, MobX, Recoil, Zustand마다 다른 API를 따로 익힐 필요 없이 표준 하나로 수렴된다 |
| Web Components 통합 | Lit(Google)이 TC39 Signals 통합을 실험 중이라, 컴포넌트 경계를 넘는 반응형 상태 공유가 가능해진다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
effect() 미포함 |
가장 흔히 쓰는 사이드이펙트 API가 표준에 없음 | Signal.subtle.Watcher로 직접 구현하거나 프레임워크 래퍼에 위임 |
| Stage 1 불확실성 | API가 계속 변경될 수 있고, Stage 4(최종 채택)까지 2~3년 이상 소요 예상 | 프로덕션 직접 사용보다 signal-polyfill로 실험 수준 유지 |
| React와의 이질성 | 불변 상태 철학과 가변 반응형 셀 철학이 근본적으로 다름 | React 프로젝트는 어댑터 레이어가 복잡해질 수 있으므로 신중하게 도입 범위 결정 |
| Svelte와의 갭 | Svelte 5는 런타임 Signal 객체보다 컴파일 타임 최적화를 우선시함 | Svelte 프로젝트에서는 표준 Signal 직접 사용보다 Runes($state, $derived) 활용 권장 |
폴리필 의존 기간에 대해 한 가지 더 짚고 싶다. "Stage 4가 돼야 브라우저 구현이 시작된다"고 오해하는 경우가 있는데, 실제로는 브라우저 벤더들이 Stage 3 단계부터 구현을 시작한다. Stage 4 조건 자체가 "두 개 이상의 브라우저 구현이 완료돼야" 충족되기 때문이다. 그렇다고 지금 당장 폴리필 없이 쓸 수 있다는 얘기는 아니지만, 브라우저 지원 시점이 생각보다 앞당겨질 수 있다는 의미다.
실무에서 가장 흔한 실수
1. Stage 1 제안을 프로덕션에 직접 도입하는 것
signal-polyfill은 실험과 학습 목적으로 훌륭하지만, API 변경이 언제든 올 수 있다. 지금 당장 제품 코드에 new Signal.State()를 심는 건 시기상조다.
2. effect()를 표준 어딘가에 숨어 있다고 착각하는 것
위에도 썼지만 한 번 더—없다. Signal.subtle.Watcher를 이해하고 직접 래핑하거나, 프레임워크가 제공하는 effect()를 그대로 쓰는 게 맞다.
3. Svelte 프로젝트에 TC39 Signals를 무리하게 끼워 맞추는 것
Svelte 5의 Runes는 컴파일러가 변환하는 문법이라 런타임 Signal 객체와 레이어가 다르다. 두 방식을 억지로 섞으면 컴파일러 최적화가 깨질 수 있다.
마치며
TC39 Signals는 "어떤 프레임워크를 쓰느냐"가 아니라 "반응형 로직을 어디에 두느냐"라는 질문을 언어 레벨에서 다시 쓰는 시도다. 아직 Stage 1이고 최종 채택까지 수년이 걸리겠지만, Angular·Vue·SolidJS·Preact가 이미 유사한 개념을 프로덕션에서 돌리고 있고 SolidJS 제작자까지 TC39 테이블에 앉아 있다는 사실이 이 방향의 신뢰성을 충분히 말해준다. JavaScript에 반응형 상태 기본 타입이 생기는 건 이미 정해진 목적지다—다만 얼마나 걸리느냐의 문제다.
지금 바로 시작해볼 수 있는 3단계:
-
signal-polyfill을 설치해서 TC39 스펙을 직접 손으로 만져보는 것을 권장한다.npm install signal-polyfill한 줄이면 되고, 공식 GitHub(github.com/proposal-signals/signal-polyfill)에 실행 가능한 예시가 잘 정리돼 있다. -
현재 사용 중인 프레임워크의 Signal 구현(
ref,signal,createSignal)이 TC39 표준 API와 어떻게 다른지 위의 비교표를 보며 매핑해보면 좋다. 이미 Vue나 Angular를 쓰고 있다면 생각보다 거리가 가깝다는 걸 발견할 수 있다. -
프레임워크 독립 상태 로직을 하나 골라
Signal.State와Signal.Computed만으로 다시 작성해보는 연습이 도움이 된다. 완성도보다는 "표준 API만으로 이 로직이 표현되는가"를 확인하는 것이 목적이다. 어댑터를 얼마나 얇게 유지할 수 있는지 감을 잡을 수 있다.
참고 자료
- GitHub - tc39/proposal-signals: A proposal to add signals to JavaScript
- A TC39 Proposal for Signals — EisenbergEffect (Medium)
- GitHub - proposal-signals/signal-polyfill (공식 폴리필)
- What do TC39 Signals mean for you, specifically?
- Standardizing Signals in TC39 by Daniel Ehrenberg — GitNation
- 2026 Frontend Framework War: Signals Won, React Is Living Off Its Ecosystem — DEV Community
- The state of Solid.js in 2026: signals, performance, and growing influence
- Signals in Svelte, Solid, Vue, Angular, Qwik and VanillaJS — JavaScript in Plain English
- State of JavaScript 2025: Front-end Frameworks
- GitHub - transitive-bullshit/ts-reactive-comparison
- Signals – Lit (Web Components)