Signals로 바꾸는 반응형 상태 관리: Preact Signals와 SolidJS 실전 적용

프론트엔드

Signals반응형 상태Preact SignalsSolidJS상태 관리

이 글은 누구를 위한 것인가

  • 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 표준이 안정화될 때를 기다리는 것도 방법이다.