SolidJS·Angular·Vue·Svelte·Qwik가 Signals를 각자 다르게 구현한 이유 — 프론트엔드 반응성 API 비교 (2025)
프론트엔드 개발을 하다 보면 어느 순간 이런 생각이 듭니다. "버튼 하나 눌렀는데 왜 이 컴포넌트까지 다시 렌더링되지?" React를 쓰면서 useMemo, useCallback, React.memo를 습관처럼 달고, 의존성 배열을 틀릴 때마다 버그와 씨름한 경험, 다들 있으실 겁니다. 저도 처음엔 "이게 그냥 원래 이런 거구나" 하고 넘겼는데, SolidJS를 처음 접하고 나서 생각이 완전히 바뀌었습니다.
Signals는 상태 관리의 단위를 "컴포넌트"에서 "값" 자체로 내리는 아이디어입니다. 변경된 값과 직접 연결된 DOM 노드만 업데이트되기 때문에, 구조적으로 불필요한 재렌더링이 사라집니다. js-framework-benchmark 기준으로 SolidJS는 React 대비 DOM 업데이트 횟수가 수십 배 차이 나는 시나리오도 있고, Angular Zoneless 전환 후 UI 업데이트가 20~30% 빨라졌다는 측정치도 보고되고 있습니다. 2025년 현재 거의 모든 주요 프레임워크가 이 패턴을 채택했습니다.
이 글은 React를 어느 정도 써본 프론트엔드 개발자를 주요 독자로 상정합니다. 솔직히 말하면, React 경험 없이 읽으면 비교의 맥락이 잘 와닿지 않을 수 있습니다. 각 프레임워크가 Signals를 어떻게 구현했는지 비교하고, 실제 코드로 차이를 살펴보면서 프레임워크 선택과 실무 적용에 실질적인 판단 기준을 얻어가실 수 있을 겁니다.
핵심 개념
Signals가 뭔지, 딱 한 번만 정확히 짚고 넘어가기
Signals는 세 가지 요소로 구성됩니다.
| 요소 | 역할 |
|---|---|
| Signal | 값을 담는 반응형 컨테이너. 읽거나 쓸 때 구독·알림 발생 |
| Computed (Derived) | Signal 기반으로 파생되는 읽기 전용 값. 의존 Signal 변경 시 자동 재계산 |
| Effect | Signal 값 변경에 반응해 실행되는 사이드 이펙트 함수 |
핵심은 "런타임에 의존성을 자동으로 추적한다"는 점입니다. React처럼 [count]를 직접 적어줄 필요가 없습니다. Signal을 읽는 순간, 그 읽기 행위 자체가 구독으로 등록됩니다.
Virtual DOM vs. Fine-grained Reactivity: Virtual DOM 방식(React)은 상태가 변하면 컴포넌트 함수 전체를 다시 실행하고, 이전 가상 DOM과 비교(diffing)해 실제 DOM에 반영합니다. Signals 기반 Fine-grained Reactivity는 변경된 값을 구독하는 DOM 노드나 연산만 직접 업데이트합니다. 중간 비교 단계가 없습니다.
이제 개념을 알았으니 실제 코드로 한 번 나란히 놓고 봐볼까요. "카운터를 만들고, 두 배 값을 파생하고, 변화를 로그로 찍는" 동일한 로직입니다.
// SolidJS
import { createSignal, createMemo, createEffect } from 'solid-js';
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
createEffect(() => console.log(`count: ${count()}`));// Angular 19+
import { signal, computed, effect } from '@angular/core';
const count = signal(0);
const doubled = computed(() => count() * 2);
effect(() => console.log(`count: ${count()}`));// Vue 3
import { ref, computed, watchEffect } from 'vue';
const count = ref(0);
const doubled = computed(() => count.value * 2);
watchEffect(() => console.log(`count: ${count.value}`));<!-- Svelte 5 -->
<script>
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => console.log(`count: ${count}`));
</script>// Preact Signals (React에서도 사용 가능)
import { signal, computed, effect } from '@preact/signals-react';
const count = signal(0);
const doubled = computed(() => count.value * 2);
effect(() => console.log(`count: ${count.value}`));// Qwik
import { component$, useSignal, useComputed$, useTask$ } from '@builder.io/qwik';
export default component$(() => {
const count = useSignal(0);
const doubled = useComputed$(() => count.value * 2);
useTask$(({ track }) => {
track(() => count.value);
console.log(`count: ${count.value}`);
});
return <button onClick$={() => count.value++}>{count.value} / {doubled.value}</button>;
});다른 프레임워크와 달리 Qwik의 Signal은 서버에서 직렬화된 상태를 클라이언트에서 그대로 재개(Resumability)하기 위해 설계되어 있습니다. 서버-클라이언트 경계를 넘는 반응형 상태를 다룬다는 점에서, 나머지 프레임워크와는 설계 목적 자체가 조금 다릅니다.
Svelte Runes: Svelte 5에서 도입한
$state,$derived,$effect는 컴파일러가 처리하는 특수 문법입니다. 컴파일러가 보일러플레이트 없이 반응성 인프라 코드를 자동 생성하며, 실제 의존성 연결은 런타임에 이루어집니다. 덕분에 개발자는 깔끔한 문법을 유지하면서도 런타임 라이브러리 크기를 줄이는 효과를 얻습니다.
Signal 값을 읽는 방식의 차이
솔직히 이 부분이 처음에 가장 헷갈렸습니다. 프레임워크마다 읽기 문법이 달라서, 하나를 익히고 다른 프레임워크로 넘어가면 한동안 손가락이 엉킵니다.
| 프레임워크 | 읽기 방식 | 쓰기 방식 |
|---|---|---|
| SolidJS | count() — 함수 호출 |
setCount(1) |
| Angular | count() — 함수 호출 |
count.set(1) / count.update(fn) |
| Vue 3 | count.value — 프로퍼티 접근 |
count.value = 1 |
| Svelte 5 | count — 변수처럼 직접 |
count++ |
| Preact Signals | count.value — 프로퍼티 접근 |
count.value = 1 |
| Qwik | count.value — 프로퍼티 접근 |
count.value = 1 |
SolidJS와 Angular는 Signal을 읽을 때 함수를 호출합니다. 이 함수 호출이 곧 "저 이 Signal에 의존하고 있어요"라는 구독 신호입니다. Vue와 Preact는 .value 프로퍼티의 getter를 통해 같은 일을 합니다. Svelte는 컴파일러가 중간에서 처리해줘서 그냥 일반 변수처럼 쓸 수 있게 해줍니다.
실전 적용
예시 1: SolidJS — 컴포넌트가 한 번만 실행되는 세계
SolidJS는 프레임워크 전체가 Signals 위에 세워진 케이스입니다. React와 비슷하게 생겼지만, 동작 방식이 근본적으로 다릅니다. 처음 접했을 때 가장 충격적이었던 건 이겁니다. SolidJS 컴포넌트 함수는 앱 전체에서 딱 한 번만 실행됩니다.
import { createSignal } from 'solid-js';
function Counter() {
console.log('this line runs exactly once');
const [count, setCount] = createSignal(0);
return <button onClick={() => setCount(c => c + 1)}>{count()}</button>;
}버튼을 100번 눌러도 console.log는 한 번만 찍힙니다. React에서는 count가 바뀔 때마다 컴포넌트 함수 전체가 다시 실행되죠. 그 차이가 SolidJS의 본질입니다. 이제 조금 더 실용적인 예시로 넘어가 보겠습니다.
import { createSignal, createMemo, createEffect, For } from 'solid-js';
interface Todo {
text: string;
done: boolean;
}
function TodoList() {
const [todos, setTodos] = createSignal<Todo[]>([]);
const [filter, setFilter] = createSignal('all');
const filtered = createMemo(() => {
if (filter() === 'all') return todos();
return todos().filter(t => t.done === (filter() === 'done'));
});
createEffect(() => {
// only re-runs when todos changes, not when filter changes
console.log(`todo count: ${todos().length}`);
});
return (
<div>
<select onChange={e => setFilter(e.target.value)}>
<option value="all">전체</option>
<option value="done">완료</option>
</select>
<For each={filtered()}>
{todo => <div>{todo.text}</div>}
</For>
</div>
);
}| 포인트 | 설명 |
|---|---|
TodoList 함수 |
앱 실행 시 딱 한 번만 실행됩니다 |
createMemo |
filter 또는 todos 둘 중 하나라도 바뀌면 자동 재계산됩니다 |
createEffect |
todos()만 읽고 있으니 filter 변경에는 반응하지 않습니다 |
<For> |
배열 아이템 단위로 DOM을 업데이트합니다. 전체 목록을 다시 그리지 않습니다 |
예시 2: Angular Zoneless — Zone.js 없이 깔끔하게
Angular 18부터 실험적으로 제공하다가 19/20에서 기본 권장이 된 Zoneless 아키텍처입니다. 기존 Angular는 Zone.js가 비동기 이벤트를 가로채서 변경 감지를 트리거했습니다. 쉽게 말하면, 클릭 한 번에 뭔가 바뀔 수도 있으니까 Angular 전체가 "혹시 변경된 거 없어?"를 묻는 구조였습니다. Signals 기반으로 넘어오면서 그 역할이 Signal 자체로 이동했고, 변경된 Signal이 없으면 감지 자체를 건너뜁니다.
import { Component, signal, computed, effect } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
@Component({
selector: 'app-cart',
imports: [CurrencyPipe],
template: `
<div>
<p>상품 수: {{ itemCount() }}</p>
<p>총액: {{ totalPrice() | currency:'KRW' }}</p>
<button (click)="addItem()">추가</button>
</div>
`
})
export class CartComponent {
items = signal<{ name: string; price: number }[]>([]);
itemCount = computed(() => this.items().length);
totalPrice = computed(() =>
this.items().reduce((sum, item) => sum + item.price, 0)
);
constructor() {
effect(() => {
localStorage.setItem('cart', JSON.stringify(this.items()));
});
}
addItem() {
this.items.update(prev => [...prev, { name: '신규 상품', price: 10000 }]);
}
}| 포인트 | 설명 |
|---|---|
signal() |
Angular의 반응형 상태. set()으로 값을 교체하고 update(fn)으로 이전 값 기반 변경이 가능합니다 |
computed() |
items가 바뀔 때만 itemCount와 totalPrice를 다시 계산합니다 |
effect() |
생성자 안에서 선언하면 컴포넌트 소멸 시 자동으로 정리됩니다 |
| Zone.js 불필요 | provideExperimentalZonelessChangeDetection() 한 줄로 Zone.js를 제거할 수 있습니다 |
예시 3: Preact Signals로 React 앱에 Signals 접목하기
새 프레임워크로 마이그레이션할 여건이 안 된다면, 기존 React 앱에 @preact/signals-react를 붙이는 방법도 있습니다.
pnpm add @preact/signals-reactimport { signal, computed } from '@preact/signals-react';
// module top-level — reactive state that lives outside components
const cartItems = signal<string[]>([]);
const cartCount = computed(() => cartItems.value.length);
function CartBadge() {
// Signal object passed directly to JSX — DOM updates without re-rendering the component
return <span>{cartCount}</span>;
}
function AddButton() {
return (
<button onClick={() => {
cartItems.value = [...cartItems.value, '신규 아이템'];
}}>
장바구니 추가
</button>
);
}<span>{cartCount}</span> 형태로 Signal 객체를 JSX에 직접 넣으면, 컴포넌트 리렌더링 없이 Signal이 해당 DOM 노드만 직접 업데이트합니다. 이건 @preact/signals-react에서만 동작하는 특수 문법이고, 일반 React 컴포넌트에서 Signal 값을 읽으려면 .value를 통해야 합니다.
cartItems를 변경하면 CartBadge만 업데이트됩니다. AddButton은 리렌더링되지 않습니다.
주의: React의 Virtual DOM에 Signals를 후적(retrofit)하면 오히려 복잡도만 늘어날 수 있습니다. Preact Signals는 이 문제를 내부적으로 처리하지만, 대규모 앱에서는 아키텍처 수준의 설계가 필요합니다. "성능이 좋다고 하니까" 바로 붙이기보다는, 실제로 리렌더링 문제가 있는 컴포넌트 트리에 먼저 적용해보시는 것을 권장합니다.
예시 4: Svelte 5 Runes — 컴파일러가 다 알아서
Svelte 5는 $state, $derived, $effect를 컴파일러가 최적화된 반응성 코드로 변환해줍니다. 컴파일러가 보일러플레이트를 대신 생성하고 실제 의존성 연결은 런타임에 이루어지는데, 덕분에 개발자 입장에서는 마치 일반 변수를 쓰는 것처럼 직관적인 코드를 작성할 수 있습니다.
<script>
let count = $state(0);
let step = $state(1);
let doubled = $derived(count * 2);
let message = $derived(`현재 값: ${count}, 두 배: ${doubled}`);
$effect(() => {
if (count >= 10) {
console.warn('값이 너무 커졌습니다!');
}
});
</script>
<button onclick={() => count += step}>+{step}</button>
<input type="number" bind:value={step} />
<p>{message}</p>.svelte.js 파일에서도 동일한 Runes를 쓸 수 있어서, 컴포넌트 외부의 공유 반응형 로직도 자연스럽게 작성할 수 있습니다.
// store.svelte.js — shared reactive state outside components
export function createCounter(initial = 0) {
let count = $state(initial);
let doubled = $derived(count * 2);
return {
get count() { return count; },
get doubled() { return doubled; },
increment: () => count++,
reset: () => count = initial,
};
}장단점 분석
장점
솔직히 Signals를 써보고 나서 가장 크게 느낀 건 "이미 최적화가 되어 있다"는 감각입니다. useMemo를 달아야 하나, useCallback이 필요한가 고민하던 시간이 사라집니다.
| 항목 | 내용 |
|---|---|
| 세밀한 성능 최적화 | 변경된 값을 읽는 DOM과 연산만 업데이트됩니다. 불필요한 재렌더링이 구조적으로 제거됩니다 |
| 수동 최적화 불필요 | useMemo, useCallback, React.memo 같은 보일러플레이트 없이도 최적화됩니다 |
| 컴포넌트 외부 사용 가능 | 라이프사이클에 종속되지 않아 모듈 어디서든 반응형 상태를 만들 수 있습니다 |
| 번들 사이즈 | SolidJS 약 7.6KB, Preact Signals 약 1KB로 매우 가볍습니다 |
| 자동 의존성 추적 | 의존성 배열을 직접 관리하지 않아도 됩니다. 런타임이 알아서 추적합니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 멘탈 모델 전환 | 컴포넌트 중심 사고에서 값 중심 사고로 바꿔야 합니다 | 작은 단위(카운터, 폼 필드)부터 Signal로 리팩토링해가며 감각을 익혀가시면 좋습니다 |
| 프레임워크별 API 불일치 | SolidJS는 count(), Angular은 count(), Vue/Preact는 count.value, Svelte는 count로 모두 다릅니다 |
한 프레임워크에서 패턴을 완전히 익힌 후 다른 프레임워크로 확장하는 것이 좋습니다 |
| React 후적 문제 | React Virtual DOM에 Signals를 끼워 넣으면 평균 성능이 낮아질 수 있습니다 | 리렌더링 병목이 실제로 확인된 컴포넌트에만 선택적으로 적용하는 것을 권장합니다 |
| 디버깅 도구 미성숙 | DevTools 등 디버깅 생태계가 Virtual DOM 방식 대비 부족합니다 | effect 안에서 직접 로그를 찍어 의존성 추적 흐름을 확인할 수 있습니다 |
| TC39 표준 불확실성 | 아직 Stage 1으로 스펙 변동 가능성이 있습니다 | 각 프레임워크 고유 API 위주로 사용하고, 표준 폴리필은 실험용으로만 활용하는 것이 좋습니다 |
TC39 Signals Proposal: JavaScript 언어 표준에 Signals를 추가하려는 공식 제안입니다. Angular, Solid, Vue, Svelte, Preact, Qwik, MobX 등의 메인테이너들이 공동으로 설계에 참여하고 있습니다. 2024년 4월 Stage 1에 진입했으며, 폴리필은 이미 공개되어 있지만 프로덕션 적용은 아직 이릅니다.
실무에서 가장 흔한 실수
-
Effect 안에서 Signal을 조건부로 읽기 —
if (flag()) { count(); }처럼 조건 안에서 Signal을 읽으면,flag가false일 때count구독이 아예 등록되지 않아 의존성 추적이 깨집니다. 제가 처음createEffect안에서 조건부로 Signal을 읽다가 "값이 바뀌는데 왜 effect가 안 실행되지?"로 한참 헤맸던 상황이 정확히 이겁니다. 조건 밖에서 값을 먼저 읽어두는 것이 안전합니다. -
Computed를 Effect로 대체하기 —
effect(() => { derivedValue = count() * 2; })방식으로 파생 값을 만드는 경우가 있는데, 이렇게 하면 메모이제이션이 없어 매번 재실행됩니다. 읽기 전용 파생 값은 반드시computed/createMemo를 활용하는 것이 좋습니다. -
Signal을 너무 잘게 쪼개기 — 연관된 상태를 전부 별개 Signal로 분리하면 오히려 관리가 복잡해집니다. 함께 바뀌는 값들은 객체 Signal 하나로 묶어두는 편이 낫습니다.
signal({ x: 0, y: 0 })처럼요.
마치며
결국 어느 프레임워크든 Signal을 읽는 순간 구독이 일어납니다. 문법이 함수 호출이든, .value 프로퍼티든, 컴파일러 마법이든, 본질은 하나입니다. 이 패턴을 이해하면 코드가 왜 다시 렌더링되는지, 어디서 최적화해야 하는지 직관적으로 파악할 수 있게 됩니다.
지금 바로 시작해볼 수 있는 3단계:
-
SolidJS 공식 튜토리얼(solidjs.com/tutorial)을 30분 정도 둘러보시면 좋습니다. 브라우저에서 바로 실행되고, "컴포넌트가 한 번만 실행된다"는 감각을 빠르게 잡을 수 있습니다. 설치도 필요 없습니다.
-
현재 쓰는 프레임워크에서 Signal 기반 상태를 하나 만들어볼 수 있습니다. Vue라면
watchEffect나computed로 파생 값을 선언해보고, Angular라면ngOnChanges대신computed로 파생 값을 선언해보시면 차이가 바로 느껴집니다. -
React 프로젝트라면
@preact/signals-react를 설치해서(pnpm add @preact/signals-react) 리렌더링이 잦은 컴포넌트에 Signal을 붙여볼 수 있습니다. React DevTools의 "Highlight updates" 기능으로 전후를 비교해보시면 효과가 눈에 보입니다.
참고 자료
- TC39 Signals Proposal | GitHub
- A TC39 Proposal for Signals | EisenbergEffect (Medium)
- Fine-grained reactivity | SolidJS 공식 문서
- Signals in Modern Frontend: Preact, Solid.js, Qwik | DEV Community
- Optimizing JavaScript Delivery: Signals v React Compiler | RedMonk
- Reactivity Models Compared: React, Vue, Angular, Svelte | OpenReplay
- Vue Reactivity in Depth | Vue 공식 문서
- Preact Signals Guide | Preact 공식 문서
- Angular Reactivity with Signals | Angular GitHub Discussion
- Signals: Fine-grained Reactivity for JavaScript Frameworks | SitePoint