이 글은 누구를 위한 것인가
- React의 리렌더링 성능 문제를 겪고 있는 개발자
- Signals가 무엇인지, 왜 주목받는지 이해하고 싶은 팀
- Preact Signals를 기존 React 앱에 도입하는 방법이 필요한 엔지니어
들어가며
React의 상태 관리 모델은 강력하지만 한계가 있다. 상태가 바뀌면 해당 컴포넌트 전체가 재렌더링되고, useMemo/useCallback/memo로 수동 최적화해야 한다. React Compiler가 이를 자동화해주지만, 근본적인 모델은 같다.
Signals는 다른 접근을 한다. 값이 바뀔 때 그 값을 사용하는 정확한 지점만 업데이트한다. 컴포넌트 경계가 없다. Angular, Vue, Svelte, SolidJS가 이 모델을 사용하고 있고, TC39에서 JavaScript 표준 제안이 진행 중이다.
이 글은 bluefoxdev.kr의 프론트엔드 상태 관리 진화 를 참고하고, Signals 실전 적용 관점에서 확장하여 작성했습니다.
1. Signals 핵심 개념
[React useState vs Signals]
useState:
상태 변경
↓
컴포넌트 함수 전체 재실행
↓
Virtual DOM diffing
↓
DOM 업데이트 (변경된 부분만)
Signals:
signal 값 변경
↓
이 signal을 구독하는 정확한 위치만 직접 업데이트
(Virtual DOM, diffing 없음)
결과:
- 업데이트 그래뉼러리티가 훨씬 세밀
- 메모이제이션 불필요
- 대규모 목록 업데이트에서 성능 차이 극명
2. Signals 기본 API
// 3가지 기본 프리미티브
// 1. signal: 읽기/쓰기 가능한 반응형 값
const count = signal(0);
count.value; // 읽기 (구독 발생)
count.value = 1; // 쓰기 (구독자에게 알림)
// 2. computed: 다른 signal에서 파생되는 읽기 전용 값
const doubled = computed(() => count.value * 2);
// count가 바뀔 때만 재계산 (구독 자동)
// 3. effect: 부수 효과 (signal 변경 시 자동 실행)
effect(() => {
console.log(`count: ${count.value}`);
// count.value를 읽으므로 자동 구독
});
// 실행:
count.value++;
// 자동으로: effect 실행, doubled 재계산, 구독자에게 알림
3. Preact Signals + React
Preact Signals를 React 앱에서 사용할 수 있다. React 렌더 사이클 바깥에서 상태를 관리한다.
3.1 설치
npm install @preact/signals-react
3.2 기본 사용
import { signal, computed, effect } from '@preact/signals-react';
import { useSignals } from '@preact/signals-react/runtime';
// 모듈 레벨 전역 signal (React state와 달리 컴포넌트 외부)
const cartItems = signal<CartItem[]>([]);
const cartTotal = computed(() =>
cartItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
// 로컬 side effect
effect(() => {
localStorage.setItem('cart', JSON.stringify(cartItems.value));
});
// React 컴포넌트에서 사용
function CartIcon() {
useSignals(); // @preact/signals-react에서 필요
return (
<div className="cart-icon">
<span>🛒</span>
{/* cartItems.value.length가 바뀔 때만 이 부분 리렌더 */}
<span className="badge">{cartItems.value.length}</span>
</div>
);
}
function CartTotal() {
useSignals();
return (
<div>
합계: {cartTotal.value.toLocaleString()}원
</div>
);
}
function AddToCartButton({ product }: { product: Product }) {
const handleAdd = () => {
cartItems.value = [...cartItems.value, {
id: product.id,
price: product.price,
quantity: 1
}];
};
return <button onClick={handleAdd}>담기</button>;
}
3.3 로컬 Signal (컴포넌트 내)
import { useSignal, useComputed } from '@preact/signals-react';
function SearchBox() {
useSignals();
// useState 대신 useSignal (동일 라이프사이클, 더 세밀한 업데이트)
const query = useSignal('');
const isTyping = useSignal(false);
const debouncedQuery = useComputed(() => {
// 디바운스 로직
return query.value.trim();
});
return (
<div>
<input
value={query.value}
onChange={(e) => {
query.value = e.target.value;
isTyping.value = true;
}}
/>
{isTyping.value && <span>입력 중...</span>}
</div>
);
}
4. SolidJS - Signals-first 프레임워크
SolidJS는 처음부터 Signals를 중심으로 설계된 프레임워크다.
import { createSignal, createMemo, createEffect, For } from 'solid-js';
function TodoApp() {
const [todos, setTodos] = createSignal([]);
const [newTodo, setNewTodo] = createSignal('');
// createMemo: React의 useMemo와 유사하지만 signal 추적 자동
const completedCount = createMemo(() =>
todos().filter(t => t.completed).length
);
// createEffect: React의 useEffect와 유사하지만 deps 배열 없음
createEffect(() => {
document.title = `할 일 ${todos().length}개 (완료: ${completedCount()})`;
// todos()와 completedCount()를 자동 추적
});
const addTodo = () => {
if (!newTodo().trim()) return;
setTodos(prev => [...prev, { id: Date.now(), text: newTodo(), completed: false }]);
setNewTodo('');
};
return (
<div>
<h1>할 일 목록 ({completedCount()}/{todos().length})</h1>
<input
value={newTodo()}
onInput={(e) => setNewTodo(e.target.value)}
placeholder="새 할 일"
/>
<button onClick={addTodo}>추가</button>
{/* For: 효율적인 목록 렌더링 (key 불필요) */}
<For each={todos()}>
{(todo) => (
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() => setTodos(prev =>
prev.map(t => t.id === todo.id ? { ...t, completed: !t.completed } : t)
)}
/>
<span style={{ 'text-decoration': todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
</div>
)}
</For>
</div>
);
}
5. React vs Preact Signals vs SolidJS 성능 비교
[1000개 항목 목록에서 한 항목 업데이트]
React (최적화 없음):
→ 1000개 컴포넌트 재렌더
→ 가상 DOM diffing 1000회
→ 실제 DOM 1개 업데이트
React (최적화: memo + useMemo):
→ memo로 1개만 재렌더
→ 가상 DOM diffing 1회
→ 실제 DOM 1개 업데이트
Preact Signals (React 내):
→ Signal 구독자만 직접 업데이트
→ 가상 DOM 없음
→ 실제 DOM 1개 업데이트
SolidJS:
→ 컴포넌트 재실행 없음
→ 정확한 DOM 노드만 직접 업데이트
→ 실제 DOM 1개 업데이트
6. TC39 Signals 제안 현황
// TC39 Signals Proposal (Stage 1 - 2026년 기준)
// 아직 표준 아님, 향후 문법 변경 가능
// 제안된 API (아직 실험적)
import { Signal } from 'signal-polyfill';
const counter = new Signal.State(0);
const doubled = new Signal.Computed(() => counter.get() * 2);
// Watcher (effect 역할)
const watcher = new Signal.subtle.Watcher(() => {
queueMicrotask(() => {
// 변경 감지 시 실행
watcher.watch();
});
});
watcher.watch(counter, doubled);
counter.set(counter.get() + 1);
console.log(doubled.get()); // 2
마무리
Signals는 반응형 프로그래밍의 "정답"에 더 가깝다. 변경된 것만, 변경된 곳에만 업데이트하는 것은 직관적으로도 효율적이다.
React 앱에 바로 Preact Signals를 도입할 수 있지만, 팀이 React 패러다임에 익숙하다면 React Compiler로도 상당 부분 최적화가 된다. 완전히 Signals-first를 원한다면 새 프로젝트에서 SolidJS를 검토하거나, TC39 표준이 안정화될 때를 기다리는 것도 방법이다.