$state가 마법이 아니라 Proxy라는 걸 알면, 왜 특정 패턴에서 반응성이 깨지는지 이해할 수 있습니다.

개념 정의

Svelte 5의 반응성은 Signal 패턴 과 JavaScript Proxy 를 결합하여 구현됩니다. $state로 선언된 변수는 내부적으로 Signal이 되고, 객체는 Proxy로 감싸져 속성 변경을 자동 추적합니다.

Signal이란

Signal은 값을 감싸는 반응형 래퍼입니다. 값을 읽으면 구독(subscribe)하고, 값을 쓰면 알림(notify)합니다.

JAVASCRIPT
// Signal의 개념적 구현 (실제 Svelte 내부는 더 복잡)
function createSignal(initialValue) {
  let value = initialValue;
  const subscribers = new Set();

  return {
    get() {
      // 현재 실행 중인 effect가 있으면 구독 등록
      if (currentEffect) subscribers.add(currentEffect);
      return value;
    },
    set(newValue) {
      value = newValue;
      // 모든 구독자에게 알림
      subscribers.forEach(fn => fn());
    }
  };
}

Proxy 기반 깊은 반응성

SVELTE
<script>
  // $state는 원시값에는 Signal을, 객체에는 Proxy를 적용합니다
  let count = $state(0);       // Signal
  let user = $state({          // Proxy
    name: '홍길동',
    address: {                 // 중첩 Proxy
      city: '서울'
    }
  });

  // Proxy 덕분에 중첩 속성 변경도 자동 감지
  user.address.city = '부산';  // UI 자동 업데이트!
</script>

반응성이 끊어지는 경우

SVELTE
<script>
  let user = $state({ name: '홍길동', age: 25 });

  // 1. 구조 분해 — 반응성 끊김!
  let { name, age } = user;
  // name은 이제 일반 문자열. user.name을 바꿔도 name은 안 바뀜

  // 2. 올바른 방법 — 직접 참조
  // 템플릿에서 {user.name}을 사용하세요

  // 3. 함수로 전달할 때 주의
  function processUser(u) {
    // u가 Proxy면 반응성 유지, 구조분해된 값이면 끊김
  }
</script>

<p>{user.name}</p>  <!-- 반응성 유지 -->
<p>{name}</p>       <!-- 반응성 끊김 — 초기값에 고정 -->

$state.raw vs $state의 차이 — 내부 동작

SVELTE
<script>
  // $state — Proxy로 감싸서 모든 속성 접근/변경을 추적
  let deep = $state({ items: [1, 2, 3] });
  // typeof deep === 'object' (실제로는 Proxy)
  // deep.items도 Proxy

  // $state.raw — Proxy 없음, 변수 자체의 재할당만 추적
  let raw = $state.raw({ items: [1, 2, 3] });
  // typeof raw === 'object' (순수 객체)

  // 성능 차이
  // deep: 모든 속성 접근에 Proxy trap 발동 (오버헤드 있음)
  // raw: 일반 객체 접근 속도 (빠름)
</script>

Fine-grained Reactivity

Svelte 5는 세밀한 반응성(Fine-grained Reactivity) 을 제공합니다. 컴포넌트 전체가 아니라, 변경된 값을 사용하는 특정 DOM 노드만 업데이트합니다.

SVELTE
<script>
  let firstName = $state('길동');
  let lastName = $state('홍');
  let age = $state(25);
</script>

<!-- firstName이 바뀌면 이 <p>만 업데이트 -->
<p>{firstName}</p>

<!-- lastName이 바뀌면 이 <p>만 업데이트 -->
<p>{lastName}</p>

<!-- age가 바뀌면 이 <p>만 업데이트 -->
<p>{age}세</p>

<!-- React는 셋 중 하나만 바뀌어도 전체 컴포넌트를 다시 렌더링합니다 -->

배열 반응성의 함정

SVELTE
<script>
  let items = $state([1, 2, 3]);

  // $state 배열은 push, splice 등 변경 메서드가 반응형으로 동작
  items.push(4);        // UI 업데이트됨
  items.splice(0, 1);   // UI 업데이트됨
  items[0] = 99;        // UI 업데이트됨

  // $state.raw 배열은 재할당만 감지
  let rawItems = $state.raw([1, 2, 3]);
  rawItems.push(4);                    // UI 안 바뀜!
  rawItems = [...rawItems, 4];         // UI 업데이트됨
</script>

$derived의 메모이제이션

SVELTE
<script>
  let items = $state([1, 2, 3, 4, 5]);
  let threshold = $state(3);

  // $derived는 의존하는 값이 바뀔 때만 재계산됩니다
  let filtered = $derived(items.filter(i => i > threshold));

  // items나 threshold가 안 바뀌면 filtered도 재계산 안 됨
  // React의 useMemo와 비슷하지만, 의존성 배열을 수동으로 관리할 필요 없음
</script>

면접 포인트

  • "Svelte의 반응성과 React의 반응성 차이는?": React는 컴포넌트 함수 전체를 다시 실행하고 Virtual DOM으로 diff합니다. Svelte는 Signal 기반으로 변경된 값을 사용하는 DOM 노드만 직접 업데이트합니다. 이를 Fine-grained Reactivity라 합니다.
  • "구조 분해하면 반응성이 끊어지는 이유는?": Proxy는 속성 접근을 가로채는데, 구조 분해는 그 순간의 값을 복사합니다. 복사된 값은 Proxy와 연결이 끊어져 이후 변경을 감지할 수 없습니다.

정리

Svelte 5의 반응성은 "Signal + Proxy = 자동 의존성 추적"입니다. React처럼 의존성 배열을 수동 관리할 필요 없고, Vue처럼 .value를 붙일 필요도 없습니다. 다만 Proxy의 특성상 구조 분해 시 반응성이 끊어지는 점은 반드시 이해해야 합니다.

댓글 로딩 중...