JavaScript Signals로 바라보는 세밀한 반응성: TC39 제안과 Preact·Solid·Vue의 구현 비교

상태가 바뀌었는데 왜 화면 전체가 다시 그려지는가
우리 프론트엔드 개발자들은 오래전부터 이 질문을 마주해 왔습니다. 대형 이커머스 프로젝트에서 장바구니 수량 하나를 변경했을 때 헤더, 사이드바, 추천 섹션까지 전부 재렌더링되는 장면을 목격한 경험이 있을 것입니다.
Virtual DOM 기반 프레임워크들은 이 문제를 Reconciler와 Diffing 알고리즘으로 해결해 왔습니다. Signals는 다른 방향으로 문제를 풉니다. 어떤 상태가 변경됐을 때 그 상태를 실제로 읽고 있는 부분만 정확히 갱신합니다.
1. 기존 VDOM 기반 반응성의 한계
React의 렌더링 모델은 "상태 변경 → 컴포넌트 함수 재실행 → 새 JSX 트리 생성 → Diffing → DOM 패치"입니다. 이 파이프라인의 핵심 단위는 컴포넌트입니다.
React.memo, useMemo, useCallback은 기본 동작에서 특정 컴포넌트나 값을 의도적으로 제외시키는 도구들입니다. 문제는 이들이 "기본값이 전체 재렌더링인 세계"에서 작동하는 방어적 최적화라는 점입니다.
Signals 기반 시스템은 변경된 상태를 직접 구독하는 Effect나 Computed만 재실행하므로, 업데이트 비용이 변경된 상태의 구독자 수에만 비례합니다.
2. Signals 개념과 Observable 차이
Signal은 단일한 현재 값을 가집니다. 값을 읽으면 그 읽기 행위 자체가 암묵적으로 의존성을 등록합니다.
| 구분 | Signal | Observable |
|---|---|---|
| 값의 성격 | 단일 현재 값 | 시간적 이벤트 스트림 |
| 구독 방식 | reactive context 안에서 읽기 = 자동 구독 | 명시적 .subscribe() 호출 |
| 구독 해제 | 의존성 그래프가 자동 관리 | 명시적 .unsubscribe() |
| 동기/비동기 | 기본적으로 동기 | 비동기 스트림 우선 설계 |
| 대표 구현 | Preact Signals, SolidJS, Vue ref | RxJS, Angular Observables |
Signals의 핵심 프리미티브: State Signal, Computed Signal, Effect.
3. TC39 Signals 제안 현황
JavaScript 언어 표준 차원에서 Signals를 정의하려는 시도가 공식적으로 진행 중입니다. TC39 Signals Proposal은 2026년 5월 현재 Stage 1 상태입니다. 제안자 그룹에는 Angular, Ember, Preact, Qwik, RxJS, Solid, Svelte, Vue 팀의 엔지니어들이 공동으로 참여하고 있다는 점이 이 제안의 특별한 의미를 보여줍니다.
제안의 현재 API 스케치는 Signal.State와 Signal.Computed를 기본 클래스로 정의합니다.
Stage 1이라는 현재 상태는 "문제 공간을 탐색하고 있다"는 의미입니다. 그러나 주요 프레임워크들이 이미 수렴하는 방향으로 각자의 구현을 조율하고 있어, 실질적인 표준화는 명세보다 먼저 진행되고 있다고 볼 수 있습니다.
4. Preact Signals 구현
Preact Signals는 @preact/signals와 @preact/signals-react로 분리 배포됩니다.
import { signal, computed, effect } from '@preact/signals-react';
const cartItems = signal<{ id: string; quantity: number; price: number }[]>([]);
const couponCode = signal<string | null>(null);
const couponDiscount = signal<number>(0);
const subtotal = computed(() =>
cartItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const totalPrice = computed(() =>
subtotal.value * (1 - couponDiscount.value)
);
effect(() => {
const code = couponCode.value;
if (!code) {
couponDiscount.value = 0;
return;
}
validateCoupon(code).then((discount) => {
couponDiscount.value = discount;
});
});
function CartSummary() {
return (
<div>
<p>소계: {subtotal}</p>
<p>최종 금액: {totalPrice}</p>
</div>
);
}
Preact Signals의 가장 흥미로운 특성은 Signal 객체 자체를 JSX에 직접 전달할 수 있다는 점입니다. 위 예시에서 {subtotal}은 .value를 호출하지 않습니다. Preact 렌더러가 Signal 객체를 감지하고 해당 텍스트 노드만 Signal의 구독자로 등록합니다.
5. SolidJS createSignal 깊이
SolidJS의 Signals는 라이브러리가 아니라 프레임워크 렌더링 모델 그 자체입니다. Solid는 컴포넌트 함수를 한 번만 실행합니다.
import { createSignal, createMemo, createEffect, For } from 'solid-js';
function InventoryDashboard() {
const [inventory, setInventory] = createSignal<StockItem[]>([
{ sku: 'SKU-001', name: '무선 키보드', quantity: 45, threshold: 20 },
{ sku: 'SKU-002', name: '모니터 암', quantity: 8, threshold: 15 },
]);
const [filterBelowThreshold, setFilterBelowThreshold] = createSignal(false);
const displayedItems = createMemo(() => {
const items = inventory();
return filterBelowThreshold()
? items.filter((item) => item.quantity < item.threshold)
: items;
});
createEffect(() => {
const lowStock = inventory().filter((item) => item.quantity < item.threshold);
if (lowStock.length > 0) {
console.log(`[알림] 재고 부족: ${lowStock.length}건`);
}
});
return (
<For each={displayedItems()}>
{(item) => (
<div>
<span>{item.name}</span>
<span>{item.quantity}</span>
</div>
)}
</For>
);
}
createSignal이 반환하는 것은 [getter, setter] 튜플입니다. 값을 읽을 때 함수를 호출하는 이유는 함수 호출 시점에 Solid의 실행 컨텍스트가 "지금 이 Signal을 읽는 곳이 어디인지" 추적할 수 있기 때문입니다.
6. Vue 3 ref와의 비교
Vue 공식 반응성 API 문서에서 ref, computed, watch, watchEffect가 정의되어 있으며, 이것들은 각각 State Signal, Computed Signal, Effect에 정확히 대응합니다.
import { ref, computed, watchEffect, readonly } from 'vue';
const currentUser = ref<User | null>(null);
const allProjects = ref<Project[]>([]);
const accessibleProjects = computed(() => {
const user = currentUser.value;
if (!user) return [];
if (user.role === 'admin') return allProjects.value.filter((p) => !p.archived);
return allProjects.value.filter(
(p) => !p.archived && user.activeProjectIds.includes(p.id)
);
});
watchEffect(() => {
if (currentUser.value) {
fetchUserProjects(currentUser.value.id).then((projects) => {
allProjects.value = projects;
});
}
});
export const useUserStore = () => ({
currentUser: readonly(currentUser),
accessibleProjects,
setUser: (user: User) => { currentUser.value = user; },
});
Vue의 반응성 시스템이 특별한 이유는 컴파일 타임 최적화와 결합된다는 점입니다.
7. Effect와 Computed 동작 원리
Signals 시스템의 핵심은 의존성 자동 추적입니다. Effect나 Computed가 실행을 시작하면, 시스템은 자신을 "현재 활성 컨텍스트"로 전역 스택에 등록합니다.
다이아몬드 의존성 문제: Signal A에 의존하는 Computed B와 Computed C가 있고, Effect D가 B와 C 모두에 의존하는 경우, A가 변경되면 D가 두 번 실행될 수 있습니다. 고품질 Signals 구현은 이 문제를 위상 정렬 기반 배치 실행으로 해결합니다.
batch() API는 여러 Signal 갱신을 하나의 트랜잭션으로 묶는 도구입니다.
8. Angular Signals로의 전환
Angular 팀은 2023년 Angular 16부터 Signal을 점진적으로 도입하기 시작했습니다. 2026년 현재 Angular 18 이상에서는 signal(), computed(), effect()가 완전히 안정화된 API로 제공됩니다.
Signal 기반 컴포넌트(ChangeDetectionStrategy.OnPush + Signal)를 사용하면 Zone.js 없이도 해당 컴포넌트의 변경 감지가 정확하게 동작합니다.
RxJS와의 연동을 위한 toSignal(), toObservable() 헬퍼도 제공되어, 기존 Observable 기반 코드와 Signal 기반 코드를 점진적으로 혼용할 수 있습니다.
9. React에서 Signals 쓰기
React는 공식적으로 Signals를 지원하지 않습니다. 그러나 React 생태계에서 Signals를 활용하는 방법이 전혀 없는 것은 아닙니다.
@preact/signals-react는 React 컴포넌트 안에서 Preact Signals를 사용할 수 있는 공식 어댑터입니다.
더 근본적인 접근은 useSyncExternalStore를 이용해 자체 Signal 구독 어댑터를 만드는 방식입니다. React 18의 useSyncExternalStore는 외부 상태 저장소를 React의 렌더링 사이클에 안전하게 연결하기 위한 공식 API입니다.
React의 useTransition과 Concurrent 렌더링 패턴과 Signals를 결합하는 시나리오는 주의가 필요합니다. Concurrent 렌더링 환경에서 외부 상태 저장소의 값이 렌더링 도중 변경되면 "Tearing" 현상이 발생할 수 있습니다. 모던 React 상태 관리 생태계를 참고하세요.
10. 퍼포먼스 특성과 트레이드오프
| 항목 | Signals 기반 | VDOM 기반(React) |
|---|---|---|
| 초기 렌더링 비용 | 의존성 그래프 구축 비용 | 컴포넌트 트리 렌더링 비용 |
| 소규모 상태 업데이트 | 매우 빠름 | 컴포넌트 단위 재실행 |
| 광범위 상태 업데이트 | 의존성 그래프 전체 갱신 | Batch 업데이트 |
| 메모리 사용 | 의존성 그래프 유지 비용 | VDOM 트리 유지 비용 |
| 디버깅 투명성 | 암묵적 구독 | 명시적 deps 배열 |
| 생태계 성숙도 | 성장 중 | 매우 성숙 |
번들 크기: Preact Signals 코어는 약 1.5KB(gzip)입니다. SolidJS 전체 런타임은 약 7KB(gzip) 수준입니다. Vue 3 반응성 패키지는 약 10KB(gzip)입니다.
메모리 누수 위험: Effect가 제대로 정리되지 않으면 Signal을 읽는 클로저가 메모리에 남아 누수가 발생할 수 있습니다.
결론
- 상태 업데이트 패턴 확인: 좁은 범위에 집중된 업데이트라면 Signals가 즉각적인 성능 이득을 제공합니다.
- 프레임워크 선택과 분리: SolidJS는 완전한 세밀한 반응성 이점을 누릴 수 있습니다.
- Effect 클린업 전략 수립: 메모리 누수의 주된 원인이 됩니다.
- TC39 제안 모니터링: Stage 3 이상에 진입하면 프레임워크 간 호환성이 개선될 가능성이 높습니다.
- 팀 학습 비용 측정: 암묵적 의존성 추적은 강력하지만, 모델 이해가 필요합니다.