Preact Signals의 반응성 모델: signal·computed·effect, batch와 untracked, React 통합

이 글의 핵심

Preact Signals는 값(signal)·파생(computed)·부수 효과(effect)를 하나의 반응성 그래프로 묶습니다. 이 글에서는 읽기·쓰기 규칙, batch와 untracked, @preact/signals-react로 React와 엮는 방법, 모듈 수준 상태 패턴과 할 일 미니 앱을 다룹니다.

이 글의 핵심

Preact Signals는 값 단위로 의존성을 추적하는 반응성 모델입니다. signal로 원천 상태를 두고, computed로 파생 값을 만들고, effect로 DOM·네트워크·로그 같은 부수 효과를 연결합니다. 상태가 바뀌면 컴포넌트 전체를 다시 실행하는 대신, 그 시그널을 실제로 읽은 곳만 다시 실행되도록 설계되었습니다. 세부 API는 패키지 버전에 따라 조금씩 다를 수 있으므로 Preact Signals 문서를 함께 확인하는 것이 좋습니다.


Preact Signals가 해결하려는 문제

컴포넌트 리렌더 중심 모델의 한계

Virtual DOM 기반 UI에서는 상태가 바뀌면 그 상태를 가진 컴포넌트와 하위 트리가 다시 실행되고, 새 트리를 이전 트리와 비교합니다. 상위 컴포넌트에 둔 상태 하나가 바뀌어도 하위 컴포넌트 함수들이 다시 실행되므로, 이를 피하려고 memo, useMemo, useCallback을 곳곳에 붙이게 됩니다.

Signals는 렌더 단위가 아니라 의존성 그래프 단위로 이 문제에 접근합니다. 어떤 코드가 어떤 시그널을 읽었는지 기록해 두었다가, 그 시그널이 바뀌면 그 구독자만 다시 실행합니다. 시그널을 여러 컴포넌트 아래로 전달해도, 값을 실제로 읽는 컴포넌트나 텍스트 노드만 갱신됩니다.

API 표면

API는 작습니다. 상태는 signal, 파생은 computed, 부수 효과는 effect, 제어용으로 batch와 untracked가 전부입니다. 다만 의존성이 코드에 명시되지 않고 실행 중 읽기로 정해지기 때문에, 언제 구독이 생기고 언제 끊기는지를 이해하고 있어야 예상과 다르게 동작할 때 원인을 찾을 수 있습니다.


핵심 개념: 시그널 그래프

원천, 파생, 효과

사용자 입력, API 응답, 타이머처럼 외부에서 들어오는 값은 signal로 둡니다. 합계, 필터된 목록, 유효성 메시지처럼 다른 상태에서 계산되는 값은 computed로 만듭니다. 상태가 바뀔 때 localStorage에 저장하거나 분석 이벤트를 보내는 일은 effect에 둡니다. 이렇게 나누면 상태가 어디서 오고 어디로 나가는지가 코드 구조에 드러납니다.

읽기가 구독을 만든다

computed나 effect의 콜백, 또는 Preact 컴포넌트 렌더링처럼 추적 중인 컨텍스트 안에서 .value를 읽는 순간 의존성이 등록됩니다. 의존성은 실행할 때마다 새로 수집되므로, 조건문 때문에 이번 실행에서 읽지 않은 시그널은 구독에서 빠집니다.

이 규칙 때문에 생기는 흔한 실수는 시그널 값을 일반 변수에 복사해 두고 그 변수를 쓰는 것입니다. const n = count.value;를 effect 바깥에서 실행하고 effect 안에서 n을 쓰면, effect는 count를 읽은 적이 없으므로 count가 바뀌어도 다시 실행되지 않습니다.

쓰기와 전파

signal.value = next로 값을 쓰면, 이전 값과 ===로 비교해 같으면 아무 일도 일어나지 않습니다. 다르면 그 시그널에 의존하는 computed들이 “오래됨” 표시를 받고, 그 끝에 연결된 effect들이 쓰기 직후 동기적으로 다시 실행됩니다. 별도의 틱이나 마이크로태스크로 미뤄지지 않습니다.

computed는 지연 평가됩니다. 의존성이 바뀌었다고 바로 다시 계산하지 않고, 누군가 .value를 읽을 때 의존성이 실제로 바뀌었는지 확인한 뒤에만 다시 계산합니다. 아무도 읽지 않는 computed는 계산되지 않습니다. 또 다시 계산한 결과가 이전과 같으면 그 computed에 의존하는 쪽에는 변경이 전파되지 않습니다.


signal: 반응형 상태의 기본 단위

signal은 .value로 읽고 쓰는 셀입니다.

import { signal } from '@preact/signals';

const count = signal(0);

// 읽기: 추적 중인 컨텍스트 안이면 구독이 생김
console.log(count.value);

// 쓰기
count.value = 1;
count.value++; // 읽기 후 쓰기

변경 감지가 === 비교이므로, 객체나 배열을 넣고 내부를 변경하면 참조가 같아 아무도 알림을 받지 못합니다. todos.value.push(item) 대신 todos.value = [...todos.value, item]처럼 새 참조를 할당해야 합니다. 필드가 독립적으로 자주 바뀐다면 필드마다 시그널을 따로 두는 방법도 있습니다.

구독을 만들지 않고 현재 값만 읽고 싶다면 count.peek()를 씁니다.

Preact JSX에서의 최적화

Preact에서는 시그널을 읽는 방식에 따라 갱신 범위가 달라집니다. 컴포넌트 본문이나 JSX에서 {count.value}처럼 값을 읽으면 그 컴포넌트가 시그널을 구독하고, 값이 바뀌면 컴포넌트가 다시 렌더링됩니다. 반면 {count}처럼 시그널 객체 자체를 JSX에 넘기면 Preact는 해당 텍스트 노드만 시그널에 바인딩해 값이 바뀔 때 그 텍스트만 직접 고칩니다. 이 경우 컴포넌트 함수는 다시 실행되지 않습니다. DOM 속성에 시그널을 넘기는 경우(<input value={text} />)에도 비슷하게 속성만 갱신됩니다.

모듈 수준 signal

시그널은 컴포넌트 바깥에서 만들 수 있으므로, 모듈 최상단에 두고 여러 컴포넌트가 같은 상태를 import해 쓰는 패턴이 흔합니다. Context나 Provider 없이 공유 상태를 만들 수 있다는 장점이 있지만, 모듈 상태는 프로세스 전체에서 하나라서 SSR 서버에서는 여러 요청이 같은 값을 공유하게 됩니다. 테스트 사이에도 상태가 남습니다. 요청이나 테스트마다 격리가 필요하다면 아래에서 설명할 팩토리 패턴이 필요합니다.


computed: 파생 상태

computed는 다른 시그널에서 계산되는 읽기 전용 값입니다. 의존 시그널이 바뀌지 않으면 캐시된 값을 그대로 돌려줍니다.

import { signal, computed } from '@preact/signals';

const first = signal('Ada');
const last = signal('Lovelace');

const fullName = computed(() => `${first.value} ${last.value}`);

// fullName.value를 읽는 쪽은 first나 last가 바뀔 때만 갱신됨

computed 콜백은 순수하게 유지해야 합니다. 앞에서 말했듯 computed는 읽힐 때만, 그리고 의존성이 바뀌었을 때만 실행되므로, 안에 API 호출이나 로그를 두면 언제 몇 번 실행될지 예측하기 어렵습니다. 콜백 안에서 다른 시그널에 값을 쓰는 것도 피해야 합니다. 외부로 나가는 작업은 effect로 옮깁니다.


effect: 구독 기반 부수 효과

effect는 생성 즉시 한 번 실행되고, 그 뒤로는 읽은 시그널이 바뀔 때마다 다시 실행됩니다.

import { signal, effect } from '@preact/signals';

const theme = signal<'light' | 'dark'>('light');

const dispose = effect(() => {
  document.documentElement.dataset.theme = theme.value;
});

theme.value = 'dark'; // effect가 동기적으로 다시 실행됨

정리와 메모리

effect()는 구독을 끊는 dispose 함수를 반환합니다. 콜백이 함수를 반환하면 그 함수는 다음 실행 직전과 dispose 시점에 호출되므로, 이벤트 리스너 해제나 타이머 취소, AbortController.abort()를 여기에 둡니다.

const userId = signal('u1');

effect(() => {
  const controller = new AbortController();
  fetch(`/api/users/${userId.value}`, { signal: controller.signal })
    .then((r) => r.json())
    .then((data) => { /* ... */ })
    .catch(() => {});
  return () => controller.abort(); // userId가 바뀌면 이전 요청 취소
});

모듈 수준에서 앱 전체 수명 동안 살아야 하는 effect는 정리하지 않아도 되지만, 특정 화면이나 기능을 위해 만든 effect를 정리하지 않으면 그 화면을 떠난 뒤에도 계속 실행되고 클로저가 잡은 객체도 해제되지 않습니다. 컴포넌트 안에서는 useSignalEffect를 쓰면 언마운트 시 자동으로 정리됩니다.

effect 안에서 자신이 읽는 시그널에 다시 쓰면 무한 루프가 되며, Preact Signals는 이를 감지하면 Cycle detected 오류를 던집니다.


batch와 untracked

batch

시그널에 쓸 때마다 effect가 동기적으로 실행되므로, 여러 시그널을 연달아 바꾸면 중간 상태마다 effect가 돕니다. batch로 묶으면 콜백이 끝날 때 한 번만 effect가 실행됩니다.

import { signal, computed, effect, batch } from '@preact/signals';

const a = signal(0);
const b = signal(0);
const sum = computed(() => a.value + b.value);

effect(() => console.log(sum.value)); // 0

batch(() => {
  a.value = 1;
  b.value = 2;
}); // 3 한 번만 출력. batch가 없으면 1, 3 두 번 출력

배치 안에서도 computed를 읽으면 최신 값이 계산되므로, 배치 중에 파생값이 오래된 값으로 보이지는 않습니다.

untracked

effect나 computed 안에서 일부 시그널을 읽되 구독은 만들고 싶지 않을 때 untracked(() => ...)나 signal.peek()를 씁니다. 예를 들어 userId가 바뀔 때만 로그를 보내고, 로그에 함께 넣을 currentPage 값은 읽기만 하고 싶은 경우입니다.

effect(() => {
  const id = userId.value;              // 추적
  const page = currentPage.peek();      // 추적하지 않음
  sendLog({ id, page });
});

추적을 끊은 시그널은 바뀌어도 반응하지 않으므로, 나중에 코드를 고치는 사람이 갱신이 안 되는 버그로 착각하기 쉽습니다. 왜 추적하지 않는지 주석으로 남겨 두는 편이 좋습니다.


React와의 통합 (@preact/signals-react)

React는 시그널의 존재를 모르기 때문에, 컴포넌트가 시그널을 읽었다는 사실을 React에 알려 주는 장치가 없으면 시그널이 바뀌어도 화면이 갱신되지 않습니다. @preact/signals-react 2.x는 이 연결을 두 가지 방식으로 제공합니다.

권장 방식은 @preact/signals-react-transform Babel 플러그인입니다. 빌드 시점에 컴포넌트를 변환해 시그널 읽기를 추적하게 만듭니다. 플러그인을 쓸 수 없는 환경이라면 시그널을 읽는 컴포넌트마다 최상단에서 useSignals()를 호출합니다.

import { useSignals } from '@preact/signals-react/runtime';
import { signal } from '@preact/signals-react';

const count = signal(0);

export function Counter() {
  useSignals();

  return (
    <>
      <p>Value: {count.value}</p>
      <button type="button" onClick={() => count.value++}>
        +1
      </button>
    </>
  );
}

@preact/signals-react는 코어 API(signal, computed, effect, batch)를 다시 내보내므로, React 프로젝트에서는 이 패키지에서만 import하도록 통일하는 것이 좋습니다. 같은 프로젝트에 @preact/signals-core가 버전이 다르게 두 벌 설치되면 추적이 서로 연결되지 않는 문제가 생길 수 있습니다. 컴포넌트 안에서 시그널을 만들 때는 signal() 대신 useSignal, useComputed, useSignalEffect를 써야 렌더링마다 새 시그널이 만들어지지 않습니다.

통합 시 유의점

useSignals를 쓰는 방식에서는 호출을 빠뜨린 컴포넌트가 시그널을 읽으면 갱신되지 않는 버그가 생깁니다. 렌더 prop처럼 다른 컴포넌트에 넘긴 콜백 안에서 시그널을 읽는 경우에도 추적이 그 콜백을 실행하는 컴포넌트에 걸리므로 의도와 다를 수 있습니다. Babel 플러그인의 기본 모드는 시그널을 쓰는 것으로 보이는 컴포넌트만 변환하는데, 감지하지 못하는 패턴이 있다면 mode: 'all' 옵션이나 수동 useSignals() 호출로 보완합니다.

Zustand, Redux, TanStack Query와 함께 쓸 때는 같은 도메인 상태를 시그널과 외부 스토어에 이중으로 두지 않아야 합니다. 두 곳의 값을 동기화하는 코드는 거의 반드시 어긋나는 순간이 생깁니다. 서버 데이터는 TanStack Query에, 순수 클라이언트 UI 상태는 시그널에 두는 식으로 책임을 나누는 편이 안전합니다.


상태 관리 패턴

모듈 스코프 스토어

작은 앱에서는 signals.ts 한 파일에 signal과 computed를 모아 두고 컴포넌트에서 import해 씁니다. 보일러플레이트가 거의 없지만, 앞에서 말한 것처럼 SSR 요청 간 격리와 테스트 간 초기화가 되지 않습니다.

도메인별 팩토리

createCartStore(), createAuthStore()처럼 시그널과 액션을 만들어 반환하는 팩토리 함수를 두고, 앱 루트에서 한 번 만들어 Context로 내려 주는 방식은 테스트와 SSR에 유리합니다. 테스트마다 팩토리를 새로 호출하면 서로 영향을 주지 않는 상태를 얻을 수 있고, 서버에서는 요청마다 새 스토어를 만들 수 있습니다.

리스트와 식별자

리스트 상태를 표현하는 방법은 크게 두 가지입니다. 배열 전체를 하나의 시그널로 두면 코드가 단순하지만, 항목 하나가 바뀌어도 배열 참조가 바뀌어 배열을 읽는 모든 곳이 갱신됩니다. 항목마다 시그널을 두면(예: signal<Array<Signal<Todo>>>) 항목 하나를 바꿀 때 그 항목을 읽는 컴포넌트만 갱신되지만, 직렬화와 정렬 같은 작업이 번거로워집니다. 항목 수가 수백 개 이하라면 배열 하나로 시작해도 대개 충분하고, 프로파일링에서 리스트 갱신이 병목으로 드러날 때 항목 단위로 쪼개는 편이 낫습니다.


성능과 번들 크기

시그널이 유리한 경우는 자주 바뀌는 값이 트리 깊은 곳 일부에서만 쓰일 때입니다. 특히 Preact에서 시그널을 JSX에 직접 넘기면 컴포넌트 렌더링 없이 텍스트 노드만 갱신됩니다. 반대로 값이 바뀔 때마다 어차피 큰 트리를 다시 그려야 하는 화면에서는 기존 상태 관리와 차이가 크지 않습니다. 성능 차이는 화면 구조에 따라 달라지므로 프로파일러로 확인해야 합니다.

computed와 effect는 각각 구독 노드와 클로저를 만들기 때문에, 짧게 살다 사라지는 위젯에서 effect를 만들고 정리하지 않으면 메모리가 계속 늘어납니다.

코어 패키지는 수 KB 수준으로 작은 편이지만, 실제 번들에 들어가는 크기는 @preact/signals-core, 프레임워크 통합 패키지, 변환 런타임의 조합에 따라 다릅니다. rollup-plugin-visualizer 같은 도구로 프로젝트 번들을 직접 확인하는 것이 정확합니다.


실전: 할 일 미니 앱

필터, 통계, 로컬 저장을 포함한 작은 예시입니다. todos 배열을 하나의 signal로 두고, computed로 보이는 목록과 통계를 만들고, effect로 localStorage에 동기화합니다.

import { signal, computed, effect } from '@preact/signals';

type Todo = { id: string; text: string; done: boolean };

const STORAGE_KEY = 'preact-signals-todo-demo';

function loadInitial(): Todo[] {
  if (typeof localStorage === 'undefined') return [];
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return [];
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? (parsed as Todo[]) : [];
  } catch {
    return [];
  }
}

const todos = signal<Todo[]>(loadInitial());
const filter = signal<'all' | 'active' | 'completed'>('all');

const visibleTodos = computed(() => {
  const list = todos.value;
  switch (filter.value) {
    case 'active':
      return list.filter((t) => !t.done);
    case 'completed':
      return list.filter((t) => t.done);
    default:
      return list;
  }
});

const stats = computed(() => {
  const list = todos.value;
  const total = list.length;
  const completed = list.filter((t) => t.done).length;
  return { total, completed, active: total - completed };
});

if (typeof localStorage !== 'undefined') {
  effect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos.value));
  });
}

function addTodo(text: string) {
  const t = text.trim();
  if (!t) return;
  todos.value = [...todos.value, { id: crypto.randomUUID(), text: t, done: false }];
}

function toggle(id: string) {
  todos.value = todos.value.map((todo) =>
    todo.id === id ? { ...todo, done: !todo.done } : todo
  );
}

function remove(id: string) {
  todos.value = todos.value.filter((todo) => todo.id !== id);
}

export function TodoApp() {
  return (
    <section>
      <header>
        <h1>할 일</h1>
        <p>
          전체 {stats.value.total} · 완료 {stats.value.completed} · 남음 {stats.value.active}
        </p>
      </header>

      <form
        onSubmit={(e) => {
          e.preventDefault();
          const form = e.currentTarget as HTMLFormElement;
          const input = form.elements.namedItem('title') as HTMLInputElement;
          addTodo(input.value);
          input.value = '';
        }}
      >
        <input name="title" placeholder="새 작업" />
        <button type="submit">추가</button>
      </form>

      <div>
        {(['all', 'active', 'completed'] as const).map((key) => (
          <button
            key={key}
            type="button"
            data-active={filter.value === key}
            onClick={() => {
              filter.value = key;
            }}
          >
            {key === 'all' ? '전체' : key === 'active' ? '진행' : '완료'}
          </button>
        ))}
      </div>

      <ul>
        {visibleTodos.value.map((todo) => (
          <li key={todo.id}>
            <label>
              <input type="checkbox" checked={todo.done} onChange={() => toggle(todo.id)} />
              <span>{todo.text}</span>
            </label>
            <button type="button" onClick={() => remove(todo.id)}>
              삭제
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}

todos에 새 배열을 할당하면 localStorage effect가 즉시 실행되고, TodoApp은 렌더링 중에 stats.value와 visibleTodos.value를 읽었으므로 다시 렌더링됩니다. addTodo처럼 시그널 하나에 한 번만 쓰는 함수는 batch로 감쌀 필요가 없습니다. batch는 여러 시그널에 연달아 쓸 때만 의미가 있습니다.

이 예제는 컴포넌트가 .value를 직접 읽기 때문에 할 일이 바뀔 때마다 TodoApp 전체가 다시 렌더링됩니다. 통계 텍스트만 갱신하고 싶다면 stats에서 숫자별 computed를 따로 만들어 {totalCount}처럼 시그널 자체를 JSX에 넘기면 됩니다. 또 effect가 쓰기마다 전체 목록을 직렬화하므로 항목이 많아지면 저장을 디바운스하는 편이 좋습니다.


같이 보면 좋은 글