Solid.js Signals 완벽 가이드 — 세밀한 반응성(Fine-grained Reactivity)

Solid.js Signals 완벽 가이드 — 세밀한 반응성(Fine-grained Reactivity)

이 글의 핵심

Solid.js는 컴포넌트 함수를 “한 번만” 실행하고, Signals가 DOM 업데이트를 직접 구동합니다. 이 글에서는 createSignal·createMemo·createEffect의 추적 모델, Store의 불변 업데이트, onMount/onCleanup 라이프사이클, React와의 차이, 리스트·디바운스·배치(batch)까지 실무 관점으로 정리합니다.

이 글의 핵심

Solid.js의 반응성은 “컴포넌트 전체를 다시 그린다”가 아니라, 읽힌 시그널에 붙은 구독(subscription)만 갱신합니다. 이를 세밀한(fine-grained) 반응성이라 부르며, Virtual DOM diff를 기본 전제로 하지 않습니다. JSX는 초기 렌더 시 실제 DOM 조각과 업데이트 클로저를 연결하는 역할에 가깝고, 이후 상태 변화는 해당 노드만 다시 실행됩니다.

이 글에서는 핵심 철학을 짚은 뒤, createSignal·createMemo·createEffect 의 추적 규칙, createStore와 불변 업데이트, onMount·onCleanup 을 통한 라이프사이클, React와의 대조, 마지막으로 리스트·입력·배치 처리로 이어지는 실전 고성능 패턴을 정리합니다. API는 Solid 1.x 기준으로 서술하며, 프로젝트 설정에 따라 import 경로·JSX 변환 옵션은 공식 문서와 맞춰 주시기 바랍니다.


Solid.js의 핵심 철학

진실의 출처는 Signals

Solid에서 상태의 기본 단위는 시그널(Signal) 입니다. 시그널은 “값”이면서 동시에 구독 그래프의 노드입니다. 읽기(getter) 가 호출되면 현재 실행 중인 반응형 스코프(이펙트·메모·다른 파생 계산)가 그 시그널을 의존성으로 등록하고, 쓰기(setter) 는 해당 시그널에 연결된 구독자만 스케줄합니다.

컴포넌트는 한 번, 업데이트는 구독 단위

React의 함수 컴포넌트는 상태가 바뀔 때마다 함수 본문이 재실행됩니다. Solid의 컴포넌트 함수는 마운트 시 한 번 실행되는 것이 일반적인 멘탈 모델이며, 이후 변경은 JSX가 만든 세밀한 업데이트 함수가 담당합니다. 따라서 “리렌더링 최적화”를 위해 useCallback/useMemo참조 동일성을 맞출 필요가 상대적으로 적습니다. 대신 무엇을 읽었는지가 곧 최적화이므로, 의도치 않은 추적누락된 추적을 이해하는 것이 중요합니다.

Virtual DOM을 쓰지 않는다는 말의 의미

Solid는 “모든 변경을 DOM 속성 단위로 직접 만든다”는 뜻이 아니라, 컴포넌트 트리 전체를 매번 비교하지 않는다는 의미에 가깝습니다. 텍스트 노드 한 줄, 속성 하나, 이벤트 핸들러 연결 등은 시그널 단위로 연결됩니다. 그 결과 CPU 시간과 메모리 할당이 비교적 예측 가능해지고, 대규모 리스트에서도 변경된 행만 움직이도록 설계하기 쉽습니다.


createSignal: 읽기와 쓰기

createSignal[getter, setter] 튜플을 반환합니다. 반응형으로 값을 읽으려면 반드시 getter를 호출해야 합니다. JSX에서 {count()}처럼 쓰는 이유가 여기에 있습니다.

import { createSignal } from "solid-js";

function Counter() {
  const [count, setCount] = createSignal(0);

  return (
    <button type="button" onClick={() => setCount((c) => c + 1)}>
      {count()}
    </button>
  );
}

위 예에서 버튼의 텍스트는 count읽는 시점에 구독이 생성됩니다. setCount로 값이 바뀌면 그 텍스트 노드만 갱신되는 그림입니다. 반면 컴포넌트 함수 전체가 다시 돌지는 않습니다.

setter에는 또는 이전 값을 받는 함수를 넘길 수 있습니다. 여러 이벤트에서 동일 시그널을 갱신할 때는 함수형 업데이트가 경쟁 조건을 줄이는 데 도움이 됩니다.

읽기가 추적을 만든다

추적(tracking)은 “반응형 컨텍스트” 안에서 일어납니다. 대표적으로 createMemo, createEffect, JSX 표현식 내부 등입니다. 일반 변수에 시그널 값을 한 번 복사해 두고 그 변수만 읽으면, 이후 시그널이 바뀌어도 연결된 UI가 갱신되지 않을 수 있습니다. 반응형으로 쓰려면 getter를 읽는 지점을 JSX나 파생 계산 안에 두어야 합니다.

시그널의 이중 역할: 값과 그래프

시그널은 단순한 useState의 대체재가 아닙니다. React의 useState는 리렌더를 트리거하는 “셀”에 가깝고, Solid의 시그널은 렌더 단위가 아니라 구독 단위로 퍼집니다. 같은 시그널을 여러 DOM 조각이 읽으면 여러 구독이 생기며, 한 번의 set해당 구독자 전부를 깨울 수 있습니다.


createMemo: 파생 상태와 의존성

createMemo다른 반응형 값들로부터 계산된 값을 캐시합니다. 의존 시그널이 변하지 않으면 재계산을 건너뜁니다. React의 useMemo와 이름은 비슷하지만, 목적은 “리렌더 비용 절감”이 아니라 반응형 그래프에서 파생 노드를 명시하는 쪽에 가깝습니다.

import { createSignal, createMemo } from "solid-js";

function PriceTag() {
  const [price, setPrice] = createSignal(10000);
  const [taxRate, setTaxRate] = createSignal(0.1);

  const gross = createMemo(() => price() * (1 + taxRate()));

  return (
    <div>
      <p>금액: {gross().toLocaleString()}원</p>
      <button type="button" onClick={() => setPrice((p) => p + 1000)}>
        가격 인상
      </button>
    </div>
  );
}

grosspricetaxRate를 읽을 때마다 의존성이 연결됩니다. 둘 중 하나만 바뀌면 gross만 재평가되고, 그 gross를 읽는 하위 UI만 이어서 갱신됩니다.

메모를 쓰는 실무 기준

  • 여러 곳에서 동일한 중간 계산을 반복할 때: 한 번 정의해 두면 읽는 쪽이 모두 같은 캐시를 공유합니다.
  • 비용이 큰 순수 계산(정렬, 필터, 집계)일 때: 시그널 단위 무효화로 불필요한 재실행을 줄입니다.
  • 조건부로만 의존성을 읽는 경우: 분기에 따라 메모가 서로 다른 의존 집합을 갖게 되므로, 의도한 무효화 패턴인지 확인해야 합니다.

주의: 메모 안에서의 부수 효과

createMemo순수 계산을 담기에 적합합니다. 네트워크 요청, 로깅, DOM 직접 조작처럼 외부 세계에 영향을 주는 작업createEffect나 이벤트 핸들러로 옮기는 편이 안전합니다. 메모 안에서 부수 효과를 넣으면, 의존성 변화 타이밍과 실행 순서에 묶여 디버깅이 어려워질 수 있습니다.


createEffect와 부수 효과

createEffect추적되는 읽기를 기준으로 콜백을 실행합니다. React의 useEffect처럼 “렌더 이후”에 가깝게 동작하지만, 의존성 배열을 수동으로 관리하지 않습니다. 콜백이 읽은 시그널·스토어 필드가 곧 의존성입니다.

import { createSignal, createEffect, onCleanup } from "solid-js";

function ChatTitle() {
  const [roomId, setRoomId] = createSignal("lobby");

  createEffect(() => {
    const id = roomId();
    const ac = new AbortController();

    fetch(`/api/rooms/${id}/title`, { signal: ac.signal })
      .then((r) => r.json())
      .then(() => {
        /* 상태 반영 등 */
      })
      .catch(() => {
        /* 취소·에러 처리 */
      });

    onCleanup(() => ac.abort());
  });

  return null;
}

roomId가 바뀔 때마다 이펙트가 다시 실행되기 전에 onCleanup이 먼저 호출되어 이전 요청을 취소할 수 있습니다. React의 useEffect 반환 정리 함수와 역할이 비슷합니다.

언제 이펙트를 쓰고 언제 이벤트를 쓰는가

  • 사용자 액션에만 반응: onClick 등 이벤트 핸들러가 명확합니다.
  • 상태 A가 바뀔 때마다 동기화(구독, 폴링, 외부 스토어와의 동기화): createEffect가 적합합니다.
  • 마운트 시 한 번만: onMount를 쓰면 의도가 분명해집니다.

무한 루프를 피하려면

이펙트 본문에서 같은 시그널을 읽고 다시 쓰는 패턴은 조건 없이 반복되면 재실행 루프에 빠질 수 있습니다. “상태를 이펙트로 미러링”하기보다, 가능하면 createMemo로 파생하거나, 쓰기 전에 값이 실제로 변했는지를 가드하는 편이 안전합니다.


Store와 불변성

복잡한 객체·중첩 구조에는 createStore가 유용합니다. 스토어는 프로xy 기반으로 동작하며, 필드 단위로 반응형을 유지합니다.

import { createStore } from "solid-js/store";

type User = { name: string; prefs: { theme: "light" | "dark" } };

function ProfileForm() {
  const [user, setUser] = createStore<User>({
    name: "anon",
    prefs: { theme: "light" },
  });

  return (
    <div>
      <input
        value={user.name}
        onInput={(e) => setUser("name", e.currentTarget.value)}
      />
      <button
        type="button"
        onClick={() =>
          setUser("prefs", "theme", (t) => (t === "light" ? "dark" : "light"))
        }
      >
        테마 토글
      </button>
    </div>
  );
}

setUser경로 기반 업데이트를 지원합니다. React에서 흔히 쓰는 스프레드로 새 객체를 만드는 불변 업데이트와도 잘 맞고, Solid는 내부적으로 변경된 경로만 추적합니다.

불변성을 지키는 이유

Solid가 세밀한 추적을 하더라도, 참조를 통째로 바꾸는 방식필드 단위 변경은 성능·디버깅 특성이 다릅니다. 특히 대형 배열·트리에서는 불필요한 상위 노드 무효화를 피하려면, 스토어 API가 제안하는 경로 업데이트와 함께 쓰는 편이 안정적입니다.

배열·리스트

배열을 다룰 때는 filter/map으로 새 배열을 할당하면서도, 스토어가 항목 단위로 diff·구독을 유지하는 패턴이 많습니다. 리스트 렌더링에서는 키(key) 를 안정적으로 두는 것이 React와 마찬가지로 중요합니다. Solid의 <For>는 변경된 항목만 옮기도록 돕는 컴포넌트로, 대량 데이터에 자주 쓰입니다.


컴포넌트 라이프사이클

Solid에는 “클래스형 컴포넌트의 생명주기 메서드”와 1:1로 대응하는 단일 API가 없습니다. 대신 반응형 스코프와 훅의 조합으로 표현합니다.

onMount

DOM이 붙은 뒤 한 번 실행해야 할 코드(초기 측정, 외부 라이브러리 인스턴스 생성, 포커스)에 사용합니다.

onCleanup

스코프가 재실행되거나 파괴될 때 호출됩니다. createEffect 안에서 쓰면 이펙트 단위로, onMount와 함께 쓰면 컴포넌트 언마운트 시 정리에 맞출 수 있습니다.

Suspense·비동기 리소스

createResource 등으로 데이터를 불러올 때는 로딩 경계와 결합됩니다. 서버 렌더링(Solid Start)을 쓰면 스트리밍·직렬화 정책까지 맞춰야 하므로, “클라이언트 전용 이펙트”는 clientOnly 같은 패턴이나 문서 권장 방식으로 가드하는 것이 좋습니다.

정리하면, Solid의 라이프사이클은 “렌더 페이즈 / 이펙트 페이즈 / 정리” 를 시그널 그래프로 표현한다고 이해하면 됩니다.


React vs Solid.js

렌더링 모델

관점React (일반적인 함수 컴포넌트)Solid.js
컴포넌트 함수 실행상태 변경 시 재실행보통 마운트 시 1회
업데이트 단위컴포넌트(및 자식) 트리시그널 구독 단위
메모이제이션useMemo/useCallback/memo로 최적화읽기 자체가 추적

훅 규칙

React는 훅 순서가 고정되어야 합니다. Solid도 조건문 안에서 반응형 훅을 마구 바꾸면 예기치 않은 구독 그래프가 될 수 있으므로, 동일한 규칙을 지키는 것이 안전합니다.

JSX와 children

React에서 children은 재실행마다 새로운 요소 트리로 들어오기 쉽습니다. Solid에서는 함수 자식이나 반응형 래퍼를 써서 세밀한 범위를 나누는 패턴이 문서·커뮤니티에 자주 등장합니다. “부모가 리렌더될 때마다 자식 props가 바뀐다”는 문제를 근본적으로 다르게 느끼게 됩니다.

생태계와 채용

React는 라이브러리·자료·도구가 압도적입니다. Solid는 번들 크기·런타임 성능에서 강점이 있으나, 팀 학습 비용서드파티 호환을 함께 따져야 합니다. 기존 글 solid-js-complete-guidesolid-start-complete-guide에서 풀스택·배포 관점을 이어서 볼 수 있습니다.


실전 고성능 UI 구현

1. 리스트: <For>와 안정적인 키

대량 리스트는 가상 스크롤과 함께 <For>항목 단위 업데이트를 유지합니다. 키가 불안정하면 DOM 재생성이 늘어나 이점이 줄어듭니다.

import { For } from "solid-js";

type Row = { id: string; label: string };

function RowList(props: { rows: () => Row[] }) {
  return (
    <ul>
      <For each={props.rows()}>{(row) => <li>{row.label}</li>}</For>
    </ul>
  );
}

each에 넘기는 배열이 시그널·메모에서 왔다면, 배열 참조가 바뀔 때만 목록 diff가 일어납니다. 항목 내부 필드만 바뀌는 경우에는 스토어·시그널 설계에 따라 행 컴포넌트만 갱신되도록 나눕니다.

2. 입력 디바운스와 파생

검색창에 타이핑할 때마다 API를 치면 비용이 큽니다. 입력 시그널디바운스된 시그널을 나누거나, createMemo와 타이머를 조합해 요청 시그널만 느리게 변하게 할 수 있습니다. 중요한 점은 어느 시그널이 구독을 일으키는지를 분리해, UI는 즉시 반응하고 네트워크는 느리게 반응하게 만드는 것입니다.

3. batch로 묶어서 갱신

여러 시그널을 연속으로 바꾸면 구독자가 중간 상태를 볼 수 있습니다. batch로 묶으면 한 번에 커밋되어 중간 프레임이 줄어듭니다.

import { batch, createSignal } from "solid-js";

function FormActions() {
  const [a, setA] = createSignal(0);
  const [b, setB] = createSignal(0);

  const applyPair = () => {
    batch(() => {
      setA(1);
      setB(2);
    });
  };

  return (
    <button type="button" onClick={applyPair}>
      a={a()} b={b()}
    </button>
  );
}

폼 리셋·트랜잭션 적용·애니메이션 프레임 동기화 등에 유용합니다.

4. untrack으로 의존성에서 제외

가끔 “읽기는 하되 구독은 만들지 않고 싶다”는 경우가 있습니다. 예를 들어 이벤트 핸들러 안에서 최신 시그널 값만 참조하거나, 로그용으로 읽을 때입니다. 이때 untrack을 사용합니다. 남용하면 반응형이 끊기므로, 문서의 권장 패턴을 따르는 것이 좋습니다.

5. 성능 측정

브라우저 Performance 패널에서 프레임 시간·레이아웃 스래싱을 보고, 시그널 설계를 조정합니다. Solid는 미세 업데이트에 강하지만, 거대한 파생 메모과도한 이펙트 체인은 여전히 비용이 됩니다.


정리

Solid.js의 Signals는 값과 구독 그래프를 하나로 묶어, 컴포넌트 재실행 없이 DOM과 부수 효과를 동기화합니다. createSignal 로 원천 상태를, createMemo 로 파생을, createEffect·onMount·onCleanup 으로 외부 세계와의 동기화를 표현하고, createStore 로 중첩 객체를 불변성 있게 다루면 실무 규모의 UI에도 확장할 수 있습니다. React와의 차이는 “최적화 기법의 종류”가 아니라 기본 추상화가 무엇을 다시 실행시키느냐에 있으므로, 팀 내에서 멘탈 모델을 합치는 것이 도입 성공의 열쇠입니다.

배포 전에는 git add·git commit·git pushnpm run deploy를 실행하는 워크플로를 권장합니다(프로젝트 CLAUDE.md 기준).


참고