반응성 심화 — Proxy, Signal, 그리고 Fine-grained Reactivity
$state가 마법이 아니라 Proxy라는 걸 알면, 왜 특정 패턴에서 반응성이 깨지는지 이해할 수 있습니다.
개념 정의
Svelte 5의 반응성은 Signal 패턴 과 JavaScript Proxy 를 결합하여 구현됩니다. $state로 선언된 변수는 내부적으로 Signal이 되고, 객체는 Proxy로 감싸져 속성 변경을 자동 추적합니다.
Signal이란
Signal은 값을 감싸는 반응형 래퍼입니다. 값을 읽으면 구독(subscribe)하고, 값을 쓰면 알림(notify)합니다.
// 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 기반 깊은 반응성
<script>
// $state는 원시값에는 Signal을, 객체에는 Proxy를 적용합니다
let count = $state(0); // Signal
let user = $state({ // Proxy
name: '홍길동',
address: { // 중첩 Proxy
city: '서울'
}
});
// Proxy 덕분에 중첩 속성 변경도 자동 감지
user.address.city = '부산'; // UI 자동 업데이트!
</script>
반응성이 끊어지는 경우
<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의 차이 — 내부 동작
<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 노드만 업데이트합니다.
<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는 셋 중 하나만 바뀌어도 전체 컴포넌트를 다시 렌더링합니다 -->
배열 반응성의 함정
<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의 메모이제이션
<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의 특성상 구조 분해 시 반응성이 끊어지는 점은 반드시 이해해야 합니다.
댓글 로딩 중...