Preact Signals 완벽 가이드 — 간단한 반응성

Preact Signals 완벽 가이드 — 간단한 반응성

이 글의 핵심

Preact Signals는 값(signal)·파생(computed)·부수 효과(effect)를 하나의 반응성 그래프로 묶습니다. 이 글에서는 읽기·쓰기 규칙, batch·untracked, @preact/signals-react로 React와 엮는 방법, 모듈 수준 상태와 성능·번들 특성, 할 일 미니 앱까지 실무 관점으로 정리합니다.

이 글의 핵심

Preact Signals는 “상태가 바뀌면 컴포넌트 전체를 다시 실행한다”는 그림만으로는 설명되지 않는, 값 단위로 의존성을 추적하는 반응성 모델입니다. signal로 원천 상태를 두고, computed로 파생 값을 만들며, effect로 DOM·네트워크·로그 같은 부수 효과를 연결합니다. Preact 공식 블로그에서 강조하듯, Virtual DOM 비용을 줄이기 위해 시그널이 바뀐 경로만 갱신하도록 설계된 경우가 많습니다.

이 글에서는 핵심 개념(구독·스케줄링) 을 짚은 뒤 signal·computed·effect 의 사용 규칙, batch·untracked, React와의 통합(@preact/signals-react), 상태 관리 패턴, 성능·번들 크기, 마지막으로 할 일 목록 미니 앱으로 흐름을 묶습니다. 패키지 버전에 따라 타입·세부 API가 소폭 다를 수 있으므로, 프로젝트의 package.jsonPreact Signals 문서를 함께 확인하시기 바랍니다.


Preact Signals가 해결하려는 문제

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

전통적인 Virtual DOM 기반 UI는 상태가 바뀌면 해당 상태를 쓰는 컴포넌트 트리가 다시 계산되는 경향이 있습니다. 규모가 커질수록 “불필요한 비교(diff)”와 “함수 재실행” 비용이 누적됩니다. Signals는 이 문제를 렌더 단위가 아니라 의존성 그래프 단위로 쪼개 접근합니다. 즉, 어떤 시그널이 읽혔는지를 기록해 두었다가, 그 시그널만 바뀌면 그 구독자만 다시 실행합니다.

“간단한 반응성”이 의미하는 것

여기서 “간단하다”는 API 표면이 단순하다는 뜻에 가깝습니다. 상태는 signal, 파생은 computed, 효과는 effect로 역할이 분리되어 멘탈 모델을 유지하기 쉽습니다. 반면 내부적으로는 스케줄러·배치·구독 정리 같은 주제가 따라오므로, 중급 이상에서는 언제 구독이 생기고 언제 끊기는지를 의식하는 것이 좋습니다.


핵심 개념: 시그널 그래프

원천과 파생

  • 원천(source): 사용자 입력·API 응답·타이머 등 외부에서 들어오는 값은 보통 signal로 둡니다.
  • 파생(derived): “합계”, “필터된 목록”, “유효성 메시지”처럼 다른 상태에서 계산되는 값computed가 적합합니다.
  • 효과: “상태가 바뀔 때 로컬 스토리지에 저장”, “추적 이벤트 전송” 등은 effect에 둡니다.

읽기가 구독을 만든다

반응형 시스템의 공통 규칙은 반응형 컨텍스트 안에서 시그널을 읽으면 구독이 연결된다는 점입니다. computedeffect의 콜백 본문, 혹은 프레임워크가 제공하는 추적 경로(Preact JSX 등)에서 .value를 읽는 순간 의존성이 등록됩니다. 일반 변수에 한 번 복사해 두고 그 변수만 읽으면 이후 갱신이 반영되지 않을 수 있으므로, 파생·표현 계산 안에서는 항상 시그널·computed를 통해 읽는 습관이 필요합니다.

쓰기와 스케줄링

시그널에 새 값을 쓰면(signal.value = next), 그 시그널에 연결된 computed 재계산effect 재실행이 스케줄됩니다. 구현체는 보통 중복 작업을 합치거나(batch) , 같은 틱 안에서 묶어 처리하려고 합니다. 그래서 “여러 번 쓰기를 해도 effect가 과도하게 여러 번 도는가?” 같은 질문에는 배치 정책을 함께 봐야 답할 수 있습니다.


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

signal.value로 읽고 쓰는 입니다. 객체를 넣을 수도 있지만, 참조 동일성이 유지되는 한 내부 변경은 감지되지 않을 수 있습니다. 객체 전체를 갈아끼우거나, 필드 단위로 쪼개 signal을 두는 방식이 안전합니다.

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

const count = signal(0);

// 읽기: 반응형 컨텍스트 안에서 읽히면 구독 대상이 됨
console.log(count.value);

// 쓰기
count.value = 1;
count.value++; // 읽기+쓰기 조합도 동일한 규칙

Preact 컴포넌트에서 JSX로 count.value를 직접 읽으면, 프레임워크 통합에 따라 해당 텍스트·속성만 갱신되는 경로로 최적화될 수 있습니다. 이는 “컴포넌트 함수가 매번 전부 다시 돈다”는 React의 기본 그림과는 다른 체감을 줍니다.

모듈 수준 signal

작은 앱·데모에서는 모듈 최상단에 signal을 두고 여러 컴포넌트가 같은 상태를 참조하는 패턴이 흔합니다. 전역 이벤트 버스처럼 쓰기 쉽지만, 테스트·SSR·코드 분할 시 초기화 시점을 명확히 해야 합니다. 규모가 커지면 컨텍스트·라우트 단위 스토어·도메인 모듈로 경계를 나누는 편이 유지보수에 유리합니다.


computed: 파생 상태와 메모이제이션

computed다른 시그널에 의존하는 읽기 전용 파생 값입니다. 의존 시그널이 바뀌지 않으면 불필요한 재계산을 건너뛰는 데 유리합니다. UI에서 “보여줄 문자열만”, “필터 결과만” 바꾸고 싶을 때 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 콜백은 순수하게 유지하는 것이 좋습니다. 여기서 API를 호출하거나 console.log를 남기면, 재실행 빈도에 따라 부수 효과가 불안정해집니다. 네트워크·스토리지·로그는 effect로 옮기고, computed표현할 값 계산에 집중시키는 편이 디버깅에 유리합니다.


effect: 구독 기반 부수 효과

effect는 읽은 시그널이 바뀔 때마다 콜백을 다시 실행합니다. DOM 조작, 구독 시작, 분석 이벤트 전송 등 “상태 변화에 따라 반복되어야 하는 작업” 에 사용합니다.

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

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

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

theme.value = 'dark';

정리(cleanup)와 메모리

effect는 장기 실행 앱에서 구독 누수의 주된 원인이 될 수 있습니다. 라우트를 벗어나거나 컴포넌트가 사라질 때 effect를 명시적으로 중지할 수 있는지(반환된 dispose 등)를 확인하고, 이벤트 리스너·타이머·fetch 중단은 반드시 정리 경로를 마련합니다. 작은 예제에서는 드러나지 않아도, 실서비스에서는 이 부분이 안정성을 가릅니다.


batch와 untracked: 제어의 두 레버

batch

여러 시그널을 연속으로 갱신할 때, 중간 상태마다 effect가 도는 것을 한 번에 묶고 싶다면 batch를 사용합니다. 폼에서 여러 필드를 한 번에 반영하거나, 리듀서 스타일 업데이트를 묶을 때 유용합니다.

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

const a = signal(0);
const b = signal(0);

batch(() => {
  a.value = 1;
  b.value = 2;
});

untracked

의존성 추적에서 일부 읽기를 제외하고 싶을 때 untracked가 필요합니다. 예를 들어 effect 안에서 “현재 사용자 ID만 추적하고, 로그용으로만 다른 시그널을 읽고 싶다” 같은 경우, 추적 범위를 좁혀 불필요한 재실행을 막을 수 있습니다. 반대로 추적을 끊었다가 갱신을 놓치는 버그가 생기기 쉬우므로, 사용 이유를 주석으로 남기는 것이 좋습니다.


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

React는 기본적으로 함수 컴포넌트를 상태 변경 시 재실행하는 모델이므로, Preact용으로 설계된 Signals를 그대로 가져오면 “시그널은 바뀌는데 화면이 안 바뀐다” 는 상황이 생길 수 있습니다. 이를 위해 @preact/signals-react는 컴포넌트가 시그널 구독을 알 수 있도록 브리지 훅을 제공합니다.

권장 흐름은 @preact/signals-react-transform Babel 플러그인으로 컴포넌트를 자동으로 반응형으로 만드는 것입니다. 변환을 쓰지 못하는 경우에만 useSignals 를 컴포넌트 최상단에서 호출합니다. 공식 README 기준 import는 @preact/signals-react/runtime 입니다.

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}</>
      </p>
      <button type="button" onClick={() => count.value++}>
        +1
      </button>
    </>
  );
}

React 통합 패키지는 @preact/signals와 동일한 코어 API를 재노출하므로, 프로젝트에서는 @preact/signals-react에서만 import 하도록 일원화하는 팀도 많습니다. 컴포넌트 내부에서만 새 signal·effect를 만들 때는 useSignal, useComputed, useSignalEffect 훅을 쓰는 편이 React의 훅 규칙과 잘 맞습니다.

통합 시 유의점

  • 훅 규칙: useSignals는 다른 훅과 같이 컴포넌트 최상위에서 호출합니다.
  • 렌더 props: 시그널을 클로저로 넘기면 추적이 끊길 수 있어, 공식 문서는 정적 분석 가능한 형태로 쪼개거나 Babel mode: all 등을 검토하라고 안내합니다.
  • 경계: signal을 어디까지 공유할지(모듈 전역 vs 컴포넌트 로컬)에 따라 테스트 난이도가 달라집니다.
  • 다른 상태 관리와 혼용: Zustand·Redux·React Query와 함께 쓸 때는 진실의 출처를 하나로 정하는 것이 중요합니다. 같은 도메인을 signal과 외부 스토어에 이중으로 두면 동기화 버그가 생기기 쉽습니다.
  • SSR: renderToString 등 서버 환경에서는 시그널 추적이 의미가 없을 수 있어, 공식 문서의 SSR 섹션을 함께 읽는 것이 좋습니다.

Babel 설정이 가능하면 플러그인 경로를 우선하고, 그렇지 않을 때 useSignals로 보완하면 됩니다.


상태 관리 패턴

1) 모듈 스코프 단일 스토어

작은 앱에서는 signals.ts 한 파일에 signal·computed를 모아 두고 UI는 가볍게 유지합니다. 장점은 보일러플레이트가 적다는 것이고, 단점은 의존 방향이 파일에 몰리면 리팩터링 비용이 커질 수 있다는 점입니다.

2) 도메인별 팩토리

createCartStore(), createAuthStore()처럼 팩토리 함수로 스토어를 만들고, 앱 루트에서 한 번만 인스턴스화하는 패턴은 테스트하기 좋습니다. 같은 팩토리를 테스트에서 여러 번 호출해 격리된 시나리오를 만들 수 있습니다.

3) 파생은 computed, I/O는 effect

“상태 → 표현”은 computed, “상태 → 외부 세계”는 effect로 나누면 데이터 흐름이 문서화됩니다. 팀원 온보딩 시에도 “화면에 필요한 값은 어디서 오나?”에 대한 답이 computed에 모입니다.

4) 리스트와 식별자

리스트 렌더링에서는 항목마다 signal을 두는 대신 배열·맵 하나를 signal로 두고 항목 컴포넌트가 필요한 필드만 읽게 할지, 정규화된 엔티티 맵을 둘지 등을 결정해야 합니다. 항목 수가 매우 많으면 구조 공유·선택적 구독 전략이 성능을 가릅니다.


성능 특징

세밀한 갱신

Signals 계열 라이브러리가 주장하는 강점은 변경된 값에 연결된 구독만 깨우는 것입니다. Preact 통합에서는 JSX에 시그널을 넘기는 방식 등으로 Virtual DOM 작업 자체를 줄이거나 우회하려는 최적화가 함께 소개되는 경우가 많습니다. 다만 항상 기존 대비 빠른 것은 아니며, 컴포넌트 구조·시그널 분할·리스트 크기에 따라 달라집니다.

할당과 가비지 컬렉션

computed·effect가 많아질수록 구독 객체·클로저가 늘어납니다. 단발성 화면이라면 문제없지만, 무한 스크롤·대시보드 위젯처럼 생명주기가 짧은 구독이 많아지면 정리 전략이 필요합니다.

디버깅

의존성이 암시적이라 “왜 이 effect가 다시 도는가?”를 추적하려면 개발자 도구·로깅 전략이 필요합니다. 팀에서는 effect에 이름 태그를 붙이거나, 개발 모드에서만 의존성 로그를 남기는 패턴을 도입하기도 합니다.


번들 사이즈

공식 자료에서는 Signals 코어가 gzip 기준 약 1.6KB 수준으로 언급된 바 있습니다(측정 조건·버전에 따라 달라질 수 있음). 실제 프로젝트에서는 @preact/signals, @preact/signals-core, React 통합 패키지가 각각 번들에 어떻게 들어가는지rollup-plugin-visualizer 등으로 확인하는 것이 정확합니다. 트리 쉐이킹이 잘 되도록 직접 import하고, 사용하지 않는 유틸은 제거합니다.


실전: 할 일 미니 앱

아래는 필터·통계·로컬 저장을 포함한 소형 예시입니다. todos 배열을 하나의 signal로 두고, computed로 남은 작업 수를 만들며, effectlocalStorage에 동기화합니다. 프로덕션에서는 오류 처리·스키마 마이그레이션·동시 편집 등을 추가해야 합니다.

import { signal, computed, effect, batch } 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) as Todo[];
    return Array.isArray(parsed) ? parsed : [];
  } 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 };
});

effect(() => {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos.value));
});

function addTodo(text: string) {
  const t = text.trim();
  if (!t) return;
  batch(() => {
    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);
}

/** Preact 컴포넌트 예시 — 프로젝트에 맞게 JSX 런타임을 조정하세요. */
export function TodoApp() {
  return (
    <section>
      <header>
        <h1>할 일</h1>
        <p>
          전체 {stats.value.total} · 완료 {stats.value.completed} · 남음 {stats.value.active}
        </p>
      </header>

      <form
        onSubmit={(e: Event) => {
          e.preventDefault();
          const form = e.target 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가 바뀌면 visibleTodos·stats·localStorage 동기화가 같은 변경에 맞춰 이어집니다. batch로 묶은 addTodo는 중간 상태 노출을 줄이려는 의도이며, 실제로 effect가 몇 번 도는지는 런타임 버전의 스케줄링 정책과 함께 확인하는 것이 좋습니다.


실무 체크리스트

  • 객체/배열 갱신: 불변 업데이트로 새 참조를 만들어 signal이 변화를 감지하게 할 것.
  • effect 남발 방지: 네트워크 호출·구독은 가능한 한 명시적 생명주기 안에 두고, 작은 computed로 나눌 수 있는지 먼저 검토할 것.
  • 테스트: computed는 입력 시그널을 바꾸고 출력만 검증하면 되므로 단위 테스트가 단순해지는 편입니다.
  • SSR: localStorage·window 접근은 브라우저 가드가 필요하며, 서버에서 실행되는 effect는 설계를 분리할 것.

마무리

Preact Signals는 작은 API 세트로 상태·파생·효과를 분리해, UI 코드의 의도를 드러내기 쉽게 만듭니다. Preact 생태계에서는 JSX와의 통합 이점이 크고, React에서는 @preact/signals-react로 리렌더 경계를 맞추는 것이 핵심입니다. 성능과 번들은 측정한 값이 가장 신뢰할 수 있으므로, 이 글의 패턴을 출발점으로 삼되 프로젝트에서 프로파일링을 병행하시기 바랍니다.

배포 전에는 git addgit commit·git push를 마친 뒤 npm run deploy를 실행하는 것이 이 저장소의 관례입니다.