Solid.js의 세밀한 반응성: 컴포넌트는 한 번만 실행되고 Signal이 DOM을 바꾸는 방식

이 글의 핵심

Solid에서는 컴포넌트 함수가 한 번만 실행되고 이후에는 시그널을 읽은 지점만 다시 계산됩니다. React처럼 본문이 매번 다시 돈다고 가정하고 값을 미리 꺼내 두면 화면이 갱신되지 않는 이유가 여기에 있고, 이펙트 무한 루프를 피하는 방법과 가상 DOM이 없다는 말의 실제 의미까지 React와 비교해 정리합니다.

이 글의 핵심

Solid.js는 상태가 바뀔 때 컴포넌트 전체를 다시 실행하지 않고, 그 상태를 읽은 지점(구독)만 다시 실행합니다. 이를 세밀한(fine-grained) 반응성이라 부르며, 가상 DOM 비교를 거치지 않습니다. JSX는 컴파일 시점에 실제 DOM을 만드는 코드와 각 동적 부분을 갱신하는 작은 함수로 바뀌고, 이후 상태 변화는 해당 함수만 다시 실행합니다.

이 글에서는 createSignal, createMemo, createEffect의 추적 규칙, createStore의 경로 업데이트, onMount와 onCleanup, React와의 차이, 그리고 리스트와 배치 갱신 패턴을 Solid 1.x 기준으로 설명합니다.


Solid.js의 반응성 모델

상태의 기본 단위는 시그널

Solid에서 상태의 기본 단위는 시그널(Signal)입니다. 시그널은 값이면서 동시에 구독 그래프의 노드입니다. getter가 호출되면 그 시점에 실행 중인 반응형 계산(이펙트, 메모, JSX 바인딩)이 그 시그널을 의존성으로 등록하고, setter가 호출되면 그 시그널을 구독하는 계산만 다시 실행되도록 예약됩니다.

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

React 함수 컴포넌트는 상태가 바뀔 때마다 함수 본문이 다시 실행됩니다. Solid의 컴포넌트 함수는 마운트할 때 한 번만 실행되어 DOM을 만들고 반응형 바인딩을 연결하는 설정 함수에 가깝습니다. 이후 변화는 JSX가 만든 바인딩이 처리합니다. 그래서 리렌더 최적화를 위해 useCallback이나 useMemo로 참조를 고정할 필요가 없고, 대신 무엇을 어디서 읽었는지가 곧 갱신 범위를 결정합니다.

가상 DOM을 쓰지 않는다는 말의 의미

Solid 컴파일러는 JSX의 정적인 부분을 HTML 템플릿으로 뽑아 한 번에 복제하고, 동적인 부분마다 실제 DOM 노드를 직접 갱신하는 코드를 만듭니다. {count()}라는 텍스트 바인딩이 있다면 그 텍스트 노드의 내용을 바꾸는 작은 함수가 생기고, 그 함수가 count를 구독합니다. 이전 트리와 새 트리를 비교하는 단계가 없으므로 갱신 비용은 트리 크기가 아니라 바뀐 바인딩 수에 비례합니다. 리스트처럼 구조 자체가 바뀌는 경우에만 <For> 같은 컴포넌트가 배열 항목 단위의 비교를 수행합니다.


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로 값이 바뀌면 그 텍스트 노드만 갱신되고, Counter 함수는 다시 실행되지 않습니다.

setter에는 새 값이나 이전 값을 받는 함수를 넘길 수 있습니다. 이전 값을 기준으로 계산할 때는 함수형 업데이트를 쓰면, 클로저에 붙잡힌 오래된 값을 쓰는 실수를 피할 수 있습니다. 또한 setter는 새 값이 이전 값과 ===로 같으면 구독자를 깨우지 않습니다. 매번 알림이 필요하면 createSignal(value, { equals: false })로 만듭니다.

읽기가 추적을 만든다

추적은 반응형 컨텍스트 안에서 getter를 호출할 때만 일어납니다. createMemo, createEffect, JSX 표현식 안이 대표적입니다. 컴포넌트 본문에서 const value = count();처럼 값을 한 번 꺼내 일반 변수에 담으면, 컴포넌트 본문은 다시 실행되지 않으므로 그 변수는 영원히 처음 값입니다. React 습관대로 값을 미리 꺼내 두면 화면이 갱신되지 않는 이유가 이것입니다.

같은 이유로 props를 구조 분해하면 반응성이 끊깁니다. Solid의 props 객체는 getter로 이루어져 있어서 props.name을 JSX 안에서 읽어야 추적되는데, function Card({ name })처럼 구조 분해하면 컴포넌트가 실행되는 순간의 값만 복사됩니다. props를 나누거나 기본값을 줄 때는 splitProps와 mergeProps를 씁니다.

하나의 시그널, 여러 구독

React의 useState는 컴포넌트 리렌더를 일으키는 상태 셀이지만, Solid의 시그널은 컴포넌트와 무관하게 구독 단위로 연결됩니다. 같은 시그널을 여러 DOM 바인딩과 메모가 읽으면 각각 구독이 생기고, 한 번의 set이 그 구독자 전부를 다시 실행합니다. 시그널은 컴포넌트 밖, 모듈 최상위에서 만들어도 그대로 동작하므로 간단한 전역 상태는 별도 라이브러리 없이 시그널로 만들 수 있습니다.


createMemo: 파생 상태와 의존성

createMemo는 다른 반응형 값으로부터 계산한 값을 캐시합니다. 의존하는 시그널이 바뀌지 않으면 다시 계산하지 않고, 다시 계산한 결과가 이전과 같으면 메모를 읽는 쪽도 깨우지 않습니다.

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

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

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

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

gross는 실행 중에 price와 taxRate를 읽으며 둘에 의존성을 등록합니다. 둘 중 하나가 바뀌면 gross가 다시 계산되고, gross를 읽는 텍스트 바인딩이 이어서 갱신됩니다.

메모를 쓸 만한 경우는 세 가지입니다. 같은 중간 계산을 여러 곳에서 읽을 때 한 번만 계산하게 하려는 경우, 정렬·필터·집계처럼 비용이 큰 순수 계산을 의존성이 바뀔 때만 실행하려는 경우, 그리고 값은 자주 바뀌지만 결과는 드물게 바뀌는 파생 값(예: count() > 10)으로 하위 갱신을 줄이려는 경우입니다. 단순히 () => a() + b()처럼 가벼운 계산이라면 메모 없이 함수로 둬도 충분합니다. 함수로 둔 파생 값은 읽는 쪽이 각자 다시 계산할 뿐 반응성은 그대로 유지됩니다.

의존성은 매 실행마다 새로 수집되므로, 메모 안의 if 분기에 따라 읽는 시그널이 바뀌면 의존 집합도 바뀝니다. 분기에서 읽지 않은 시그널이 바뀌어도 메모가 다시 계산되지 않는 것은 정상 동작입니다.

createMemo에는 순수 계산만 넣습니다. 네트워크 요청, 로깅, DOM 조작 같은 부수 효과는 createEffect나 이벤트 핸들러로 옮겨야 실행 시점과 횟수를 예측할 수 있습니다.


createEffect와 부수 효과

createEffect는 본문에서 읽은 시그널이 바뀔 때마다 다시 실행되는 부수 효과입니다. 첫 실행은 컴포넌트의 렌더링이 끝난 뒤로 미뤄지므로 React의 useEffect와 비슷한 시점에 동작하지만, 의존성 배열을 직접 관리하지 않습니다.

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

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

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

    fetch(`/api/rooms/${id}/title`, { signal: ac.signal })
      .then((r) => r.json())
      .then((data) => setTitle(data.title))
      .catch((err) => {
        if (err.name !== "AbortError") console.error(err);
      });

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

  return <h1>{title()}</h1>;
}

roomId가 바뀌면 이펙트가 다시 실행되기 전에 이전 실행에서 등록한 onCleanup이 먼저 호출되어 이전 요청을 취소합니다. 이펙트가 시그널을 읽는 것은 동기 실행 구간뿐이라는 점도 기억해야 합니다. .then 콜백이나 await 이후에 읽은 시그널은 추적되지 않습니다. 데이터를 가져오는 용도라면 로딩·에러 상태와 Suspense 연동을 함께 제공하는 createResource가 더 적합합니다.

이펙트와 이벤트 핸들러는 용도가 다릅니다. 사용자 행동에만 반응하는 일은 onClick 같은 핸들러에서 처리하고, 상태가 바뀔 때마다 외부 시스템(구독, 폴링, 서드파티 위젯)과 동기화해야 하는 일은 createEffect로, 마운트 직후 한 번만 할 일은 onMount로 표현하면 의도가 분명해집니다.

무한 루프를 피하려면

이펙트 본문에서 읽은 시그널에 다른 값을 다시 쓰면 그 변경이 이펙트를 또 실행시킵니다. 같은 값을 쓰면 setter의 동등성 검사 덕분에 멈추지만, 매번 새 객체나 증가한 값을 쓰면 끝나지 않습니다. 상태를 이펙트로 다른 시그널에 복사하는 패턴은 대부분 createMemo로 바꿀 수 있고, 꼭 이펙트에서 써야 한다면 읽기를 untrack으로 감싸 구독을 만들지 않거나 on(source, fn)으로 추적할 시그널을 명시합니다.


Store: 중첩 상태와 경로 업데이트

중첩된 객체나 배열에는 createStore가 적합합니다. 스토어는 Proxy로 감싼 객체로, 각 필드를 읽을 때 필드 단위 구독이 생깁니다. 스토어 객체 자체는 읽기 전용이고, 값은 setter로만 바꿉니다.

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>
      <p>현재 테마: {user.prefs.theme}</p>
    </div>
  );
}

setUser("prefs", "theme", ...)처럼 경로를 지정하면 user.prefs.theme을 읽는 바인딩만 갱신되고, user.name을 읽는 곳은 영향을 받지 않습니다. 이것이 React에서처럼 스프레드로 새 객체를 만들어 통째로 넣는 방식과의 차이입니다.

통째로 교체할 때는 reconcile

스토어에 새 객체를 넣으면 최상위는 얕게 병합되지만, 그 아래 중첩 객체는 참조가 바뀌므로 해당 경로를 읽던 곳이 모두 다시 실행됩니다. 서버에서 받은 새 데이터로 스토어를 갱신하는 경우처럼 불변 데이터를 받아야 한다면 reconcile로 감싸서, 이전 값과 비교해 실제로 바뀐 필드만 반영하게 합니다.

import { reconcile, produce } from "solid-js/store";

// 서버 응답으로 전체 갱신: 바뀐 필드만 알림
setUser(reconcile(serverUser));

// 여러 필드를 가변 스타일로 한 번에 수정
setUser(produce((u) => {
  u.name = "kim";
  u.prefs.theme = "dark";
}));

produce는 Immer처럼 초안 객체를 직접 수정하는 문법으로 여러 경로를 한 번에 바꿀 때 편합니다.

배열과 리스트

스토어 배열의 항목도 각각 프록시이므로, setTodos(3, "done", true)처럼 인덱스와 필드를 지정하면 그 항목의 done을 읽는 곳만 갱신됩니다. filter나 map으로 새 배열을 만들어 통째로 넣으면 배열 참조가 바뀌어 리스트 비교가 다시 일어나지만, 기존 항목 객체를 그대로 재사용하는 한 <For>는 해당 DOM을 유지합니다.


컴포넌트 라이프사이클

Solid에는 클래스 컴포넌트의 생명주기 메서드에 1:1로 대응하는 API가 없고, 반응형 스코프(owner)와 몇 가지 함수로 표현합니다.

onMount는 컴포넌트의 DOM이 만들어져 문서에 붙은 뒤 한 번 실행되며, 초기 크기 측정, 외부 라이브러리 인스턴스 생성, 포커스 이동에 씁니다. 서버 렌더링에서는 실행되지 않으므로 브라우저 전용 코드를 두기에도 적합합니다.

onCleanup은 자신이 속한 스코프가 다시 실행되거나 파괴될 때 호출됩니다. createEffect 안에서 등록하면 이펙트 재실행마다, 컴포넌트 본문에서 등록하면 컴포넌트가 제거될 때 호출됩니다.

createResource로 비동기 데이터를 읽으면 가장 가까운 <Suspense> 경계가 로딩 상태를 처리합니다. SolidStart로 서버 렌더링을 할 때는 리소스 결과가 직렬화되어 클라이언트로 넘어가므로, 브라우저에서만 동작해야 하는 컴포넌트는 clientOnly나 onMount 안으로 격리합니다.


React와 Solid.js 비교

렌더링 모델

관점React(함수 컴포넌트)Solid.js
컴포넌트 함수 실행상태 변경 시마다 재실행마운트 시 한 번
갱신 단위컴포넌트와 그 자식 트리시그널을 읽은 바인딩
메모이제이션useMemo, useCallback, memo로 최적화읽은 위치가 곧 갱신 범위
props매 렌더마다 새 값getter로 구성된 객체, 구조 분해 금지

훅 규칙이 없는 대신 소유 스코프가 있다

React의 훅은 호출 순서로 상태를 식별하므로 조건문 안에서 호출할 수 없습니다. Solid의 createSignal이나 createEffect는 호출 순서와 무관하게 동작하므로 조건문 안이나 루프 안에서 만들어도 됩니다. 대신 반응형 계산은 생성 시점의 소유 스코프(owner)에 붙어 그 스코프가 정리될 때 함께 정리됩니다. setTimeout 콜백이나 await 이후처럼 소유 스코프 밖에서 createEffect를 만들면 정리되지 않는 계산이 생기고, 개발 모드에서 “computations created outside a createRoot” 경고가 나옵니다. 이런 경우 getOwner로 스코프를 잡아 두었다가 runWithOwner로 실행하거나, 직접 createRoot를 만들어 정리 시점을 관리합니다.

제어 흐름 컴포넌트

React에서는 cond && <A />나 items.map(...)을 그대로 쓰지만, Solid에서도 이 문법은 동작하되 조건이나 배열이 바뀔 때마다 그 부분을 새로 만듭니다. <Show>, <For>, <Switch>/<Match> 같은 제어 흐름 컴포넌트를 쓰면 조건이 실제로 바뀔 때만 DOM을 만들고 버리며, 리스트는 항목 단위로 재사용합니다.

생태계

React는 라이브러리, 학습 자료, 채용 시장이 압도적으로 큽니다. Solid는 런타임이 작고 갱신 비용이 바뀐 바인딩 수에 비례한다는 장점이 있지만, React 전용 컴포넌트 라이브러리를 그대로 쓸 수 없으므로 팀의 학습 비용과 서드파티 의존성을 함께 따져야 합니다. 풀스택 구성은 SolidStart로 풀스택 앱 만들기에서 이어서 다룹니다.


실전 패턴

리스트: <For>와 <Index>

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>
  );
}

<For>는 React처럼 key prop을 받지 않고 배열 항목의 객체 참조로 항목을 식별합니다. 배열이 바뀌면 이전과 같은 참조의 항목은 DOM을 그대로 옮기고, 새 참조만 새로 만듭니다. 그래서 서버에서 받은 데이터를 매번 새 객체로 만들어 넣으면 내용이 같아도 모든 행이 다시 만들어지므로, 스토어와 reconcile(필요하면 key 옵션으로 식별 필드 지정)로 참조를 유지하는 것이 중요합니다. 원시값 배열이나 위치가 고정된 목록은 인덱스로 식별하는 <Index>가 더 적합합니다. 항목 수가 수천 개를 넘으면 렌더링 방식과 무관하게 DOM 노드 수 자체가 부담이므로 가상 스크롤을 함께 씁니다.

props를 rows()처럼 함수로 넘길 필요는 없습니다. 부모가 <RowList rows={rows()} />로 넘기면 컴파일러가 이를 getter로 바꾸므로, 자식에서 props.rows를 읽을 때마다 최신 값이 추적됩니다.

입력과 디바운스

검색창에 입력할 때마다 API를 호출하지 않으려면, 입력값 시그널과 요청용 시그널을 분리합니다. 입력 시그널은 매 키 입력마다 바뀌어 입력창과 글자 수 표시를 즉시 갱신하고, 이펙트에서 타이머로 일정 시간 입력이 멈추면 요청용 시그널에 값을 옮깁니다. createResource의 소스를 요청용 시그널로 두면, 화면은 즉시 반응하고 네트워크는 느리게 반응하는 구조가 됩니다.

batch로 묶어서 갱신

이벤트 핸들러에서 여러 시그널을 연달아 바꾸면 각 set마다 구독자가 실행되어 중간 상태가 계산될 수 있습니다. 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>
  );
}

위 예에서 batch가 없으면 a=1 b=0인 중간 상태로 텍스트 바인딩이 한 번 더 실행됩니다. 스토어 setter 한 번 호출이나 이펙트 내부의 쓰기는 이미 자동으로 묶이므로, batch는 주로 이벤트 핸들러나 비동기 콜백에서 여러 시그널을 함께 바꿀 때 씁니다.

untrack으로 의존성에서 제외

반응형 컨텍스트 안에서 값을 읽되 구독은 만들고 싶지 않을 때 untrack을 씁니다. 예를 들어 이펙트가 userId가 바뀔 때만 실행되어야 하는데 로그에 현재 filter 값도 남기고 싶다면, filter를 untrack(() => filter())로 읽습니다. 이벤트 핸들러는 원래 반응형 컨텍스트가 아니므로 그 안에서는 untrack 없이 읽어도 구독이 생기지 않습니다.

성능 측정

브라우저 개발자 도구의 Performance 패널로 프레임 시간과 강제 레이아웃을 확인하며 시그널 설계를 조정합니다. Solid는 작은 갱신에 강하지만, 큰 배열을 매번 정렬하는 메모나 이펙트끼리 서로를 연쇄적으로 깨우는 구조는 여전히 비용이 큽니다.


참고


같이 보면 좋은 글