반응성의 핵심과 Svelte 5 runes
반응성은 "값과 화면의 연결"이다
앞 장에서 버튼을 누르면 숫자가 올라가는 카운터를 만들었다. 코드는 짧았다.
<script>
let count = $state(0);
function increase() {
count += 1;
}
</script>
<button onclick={increase}>+1</button>
<p>현재 값: {count}</p>여기서 중요한 부분은 count += 1 다음에 document.querySelector나 textContent 같은 DOM 조작 코드가 없다는 점이다. 우리는 값만 바꿨고, Svelte가 그 값을 사용하는 화면을 다시 맞춰 준다.
이런 방식을 반응성이라고 부른다. 반응성은 "상태가 바뀌면 그 상태를 읽는 화면이나 계산값이 함께 갱신되는 성질"이다. Svelte 5에서는 이 반응성을 runes라는 문법으로 표현한다. 룬은 Svelte 컴파일러에게 "이 값은 상태다", "이 값은 계산된 값이다", "이 코드는 상태 변화에 맞춰 실행해야 한다"라고 알려 주는 표시다.
이번 장에서는 가장 많이 쓰는 세 가지 룬을 익힌다.
$state: 바뀔 수 있는 상태를 만든다.$derived: 상태를 바탕으로 계산된 값을 만든다.$effect: 상태 변화에 맞춰 외부 작업을 실행한다.
AI 생성 이미지
$state로 상태 만들기
$state는 화면과 연결되는 값을 만들 때 사용한다. 숫자, 문자열, 불리언처럼 단순한 값부터 객체와 배열까지 사용할 수 있다.
<script>
let count = $state(0);
let name = $state('Svelte');
let isOpen = $state(false);
</script>
<button onclick={() => count++}>클릭</button>
<input value={name} oninput={(e) => name = e.currentTarget.value} />
<button onclick={() => isOpen = !isOpen}>열고 닫기</button>
<p>{name}를 {count}번 클릭했습니다.</p>
<p>상태: {isOpen ? '열림' : '닫힘'}</p>$state(0)으로 만든 count는 특별한 객체처럼 보이지 않는다. 그냥 숫자처럼 읽고, count++처럼 바꾼다. Svelte 공식 문서도 $state로 만든 원시값은 별도의 .set()이나 .value 없이 일반 변수처럼 다룬다고 설명한다.1
이 점이 Svelte 코드의 장점이다. 상태를 바꾸기 위해 복잡한 API를 먼저 외울 필요가 없다. JavaScript 변수를 다루듯 값을 바꾸면 된다. 단, 값이 화면과 연결되어야 한다면 일반 let count = 0이 아니라 let count = $state(0)으로 선언해야 한다.
객체와 배열도 자연스럽게 바뀐다
실제 앱의 상태는 숫자 하나보다 복잡하다. 할 일 목록, 사용자 정보, 장바구니처럼 객체와 배열을 자주 다룬다.
<script>
let user = $state({
name: '지민',
level: 1
});
let todos = $state([
{ text: 'Svelte 문법 읽기', done: false },
{ text: '카운터 예제 만들기', done: true }
]);
function levelUp() {
user.level += 1;
}
function toggleTodo(index) {
todos[index].done = !todos[index].done;
}
function addTodo() {
todos.push({ text: '새 할 일', done: false });
}
</script>
<h2>{user.name}님의 레벨: {user.level}</h2>
<button onclick={levelUp}>레벨 올리기</button>
<ul>
{#each todos as todo, index}
<li>
<button onclick={() => toggleTodo(index)}>
{todo.done ? '완료' : '진행 중'}
</button>
{todo.text}
</li>
{/each}
</ul>
<button onclick={addTodo}>할 일 추가</button>눈여겨볼 코드는 두 줄이다.
todos[index].done = !todos[index].done;
todos.push({ text: '새 할 일', done: false });객체의 안쪽 속성을 직접 바꾸고, 배열에 push로 항목을 추가했다. 그래도 화면은 갱신된다. Svelte 5의 $state는 단순 객체와 배열을 깊게 반응하는 프록시로 만들기 때문이다. 프록시는 값을 읽거나 쓸 때 Svelte가 변화를 알아차릴 수 있게 해 주는 JavaScript 기능이다. 그래서 todos[0].done 같은 안쪽 값이 바뀌어도 그 값을 사용하는 화면이 다시 계산된다.1
다만 한 가지 주의할 점이 있다. 반응형 객체를 구조 분해하면 연결이 끊길 수 있다.
let todo = $state({ text: '공부하기', done: false });
let { done } = todo;
todo.done = true;
console.log(done); // 처음 꺼낸 값 그대로일 수 있다done은 todo.done을 계속 바라보는 값이 아니라, 구조 분해하던 순간 꺼낸 값이다. 화면에서 계속 바뀌는 값을 보여 주고 싶다면 done만 따로 꺼내기보다 todo.done처럼 원래 상태를 직접 읽는 편이 안전하다.
$derived로 계산된 값 만들기
상태가 있으면 그 상태에서 계산되는 값도 생긴다. 예를 들어 장바구니에는 상품 목록이 있고, 총액은 상품 목록에서 계산된다.
<script>
let items = $state([
{ name: '키보드', price: 50000, quantity: 1 },
{ name: '마우스', price: 30000, quantity: 2 }
]);
let total = $derived(
items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
</script>
<ul>
{#each items as item}
<li>{item.name}: {item.price}원 × {item.quantity}</li>
{/each}
</ul>
<p>총액: {total}원</p>total은 직접 수정하는 값이 아니다. items가 바뀌면 그 결과로 달라지는 값이다. 이런 값에는 $derived를 사용한다.
만약 마우스 수량을 늘리면 어떻게 될까?
items[1].quantity += 1;items가 바뀌고, total은 새로운 items를 기준으로 다시 계산된다. 우리는 "총액을 다시 계산해서 total에 넣어야 한다"는 절차를 직접 작성하지 않는다. 총액이 무엇에서 나오는지만 선언한다.
$derived는 짧은 계산식에 잘 어울린다.
let count = $state(1);
let doubled = $derived(count * 2);
let message = $derived(`현재 값은 ${count}입니다`);여기서 $derived 안에는 계산 결과가 되는 표현식을 넣는다. let doubled = $derived(() => count * 2)처럼 함수를 넣으면 "두 배 값"이 아니라 함수 자체를 값으로 만든 셈이어서 의도와 다르다. 여러 줄 계산이 필요할 때는 아래처럼 $derived.by를 사용한다.
<script>
let products = $state([
{ name: '노트북', price: 1200000, selected: true },
{ name: '가방', price: 80000, selected: false },
{ name: '마우스', price: 30000, selected: true }
]);
let selectedProducts = $derived(
products.filter((product) => product.selected)
);
let selectedTotal = $derived.by(() => {
let sum = 0;
for (const product of selectedProducts) {
sum += product.price;
}
return sum;
});
</script>
<p>선택한 상품 수: {selectedProducts.length}</p>
<p>선택 상품 총액: {selectedTotal}원</p>$derived 안에서 동기적으로 읽은 상태는 의존성으로 추적된다. 위 코드에서는 products가 바뀌면 selectedProducts가 영향을 받고, selectedProducts가 바뀌면 selectedTotal도 다시 계산될 수 있다. 공식 문서에서는 $derived(expression)이 짧은 계산에 적합하고, 여러 줄 계산에는 $derived.by(() => ...)를 사용할 수 있다고 설명한다.2
중요한 규칙은 하나다. $derived 안에서는 값을 계산만 해야 한다. 다른 상태를 바꾸거나, 서버에 요청을 보내거나, localStorage에 저장하는 일을 넣지 않는다.
나쁜 예를 보자.
let count = $state(0);
let doubled = $derived.by(() => {
count += 1; // 계산 중에 상태를 바꾸면 안 된다
return count * 2;
});이런 코드는 Svelte가 허용하지 않거나, 허용된다고 해도 흐름을 매우 헷갈리게 만든다. 계산된 값은 질문에 답하는 코드여야 한다. "현재 count의 두 배는 무엇인가?"에 답하는 정도가 좋다. 무언가를 실행해야 한다면 다음에 볼 $effect를 생각한다.
$effect는 외부와 맞출 때만 쓴다
$effect는 상태가 바뀐 뒤 어떤 작업을 실행할 때 사용한다. 대표적인 예는 브라우저 API나 외부 라이브러리와 맞추는 일이다.
<script>
let name = $state('Svelte');
$effect(() => {
document.title = `${name} 배우는 중`;
});
</script>
<input value={name} oninput={(e) => name = e.currentTarget.value} />name이 바뀌면 문서 제목도 바뀐다. 여기서 문서 제목은 Svelte 마크업 안의 값이 아니다. 브라우저의 document.title이라는 외부 세계에 있는 값이다. 이런 경우에는 $effect가 어울린다.
또 다른 예는 localStorage에 저장하는 코드다.
<script>
let memo = $state('');
$effect(() => {
localStorage.setItem('memo', memo);
});
</script>
<textarea value={memo} oninput={(e) => memo = e.currentTarget.value}></textarea>$effect는 컴포넌트가 브라우저에 표시된 뒤 실행되며, 안에서 동기적으로 읽은 반응형 값을 의존성으로 삼는다. 그리고 의존성이 바뀌면 DOM 업데이트 이후 다시 실행된다. 공식 문서는 $effect를 DOM 조작, 캔버스, 네트워크 요청, 분석 로그, 외부 라이브러리 호출 같은 부수 효과에 사용하는 도구로 설명한다.3
그러나 $effect는 자주 쓸수록 좋은 도구가 아니다. 특히 계산된 값을 만들기 위해 쓰면 코드가 복잡해진다.
<script>
let count = $state(0);
let doubled = $state(0);
$effect(() => {
doubled = count * 2;
});
</script>이 코드는 동작할 수 있지만 의도가 돌아간다. doubled는 외부 작업이 아니라 count에서 계산되는 값이다. 그러면 아래처럼 쓰는 편이 낫다.
let count = $state(0);
let doubled = $derived(count * 2);기준은 간단하다.
화면에 보여 줄 계산값이면
$derived브라우저 API, 외부 라이브러리, 저장소, 네트워크처럼 바깥과 맞추는 일이면
$effect
$effect 안에서 상태를 바꾸는 코드도 조심해야 한다. 어떤 상태를 읽고, 그 상태를 다시 바꾸면 반복 실행이 생길 수 있다. 처음에는 편해 보여도 나중에는 "왜 이 코드가 다시 실행되지?"를 추적하기 어려워진다.
필요하다면 정리 함수도 반환할 수 있다.
<script>
let seconds = $state(0);
$effect(() => {
const id = setInterval(() => {
seconds += 1;
}, 1000);
return () => {
clearInterval(id);
};
});
</script>
<p>{seconds}초 지났습니다.</p>반환한 함수는 effect가 다시 실행되기 전이나 컴포넌트가 사라질 때 호출된다. 타이머, 구독, 이벤트 리스너처럼 정리가 필요한 작업에 사용한다. 위 예제에서는 effect가 타이머를 만들고, 컴포넌트가 사라질 때 그 타이머를 정리한다.
깊은 반응성을 체감하는 작은 예제
이번에는 할 일 앱의 일부를 만들어 보자. 완료한 항목 수와 남은 항목 수는 배열에서 계산한다.
<script>
let todos = $state([
{ text: '개발 환경 만들기', done: true },
{ text: '컴포넌트 구조 익히기', done: true },
{ text: 'runes 이해하기', done: false }
]);
let doneCount = $derived(
todos.filter((todo) => todo.done).length
);
let remainingCount = $derived(todos.length - doneCount);
function toggle(index) {
todos[index].done = !todos[index].done;
}
</script>
<p>완료: {doneCount}개 / 남음: {remainingCount}개</p>
<ul>
{#each todos as todo, index}
<li>
<label>
<input
type="checkbox"
checked={todo.done}
onchange={() => toggle(index)}
/>
{todo.text}
</label>
</li>
{/each}
</ul>체크박스를 누르면 todos[index].done만 바뀐다. 하지만 그 작은 변화가 doneCount, remainingCount, 화면의 체크 상태까지 이어진다. 이 흐름이 Svelte 반응성의 핵심이다.
정리하면 다음 순서다.
사용자가 체크박스를 누른다.
todos[index].done값이 바뀐다.그 값을 읽는 계산값과 화면이 다시 맞춰진다.
개발자는 DOM 갱신 절차를 직접 작성하지 않는다.
예전 Svelte 문법을 만났을 때
Svelte 5 이전 자료를 보면 지금과 다른 문법이 나온다. 특히 아래 두 가지를 자주 만난다.
<script>
let count = 0;
$: doubled = count * 2;
</script>Svelte 4에서는 컴포넌트 최상위의 let 변수가 반응형 상태처럼 동작했고, $: 문법으로 반응형 계산을 만들었다. Svelte 5의 runes 방식으로 읽으면 이렇게 바꿔 이해할 수 있다.
<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>또 이런 코드도 볼 수 있다.
<script>
let count = 0;
$: if (count > 10) {
console.log('10보다 큽니다');
}
</script>runes 방식에서는 외부 작업이므로 $effect에 가깝다.
<script>
let count = $state(0);
$effect(() => {
if (count > 10) {
console.log('10보다 큽니다');
}
});
</script>새 프로젝트에서는 Svelte 5 문법을 기준으로 쓰면 된다. 예전 문법은 오래된 블로그 글이나 예제 코드를 읽기 위한 번역표처럼 생각하자.
예전
let count = 0→ 지금은let count = $state(0)예전
$: doubled = count * 2→ 지금은let doubled = $derived(count * 2)예전
$: { ... }중 외부 작업 → 지금은$effect(() => { ... })
세 가지 룬을 구분하는 감각
처음에는 $state, $derived, $effect가 비슷해 보일 수 있다. 하지만 역할은 분명하다.
$state는 원본 데이터다. 사용자가 입력하거나, 버튼을 눌러 바꾸거나, 서버에서 받아 와 저장하는 값이다.
$derived는 원본 데이터에서 계산되는 값이다. 총액, 필터 결과, 완료 개수, 표시용 문장처럼 "다른 상태를 읽어서 나오는 값"이다.
$effect는 Svelte 바깥과 맞추는 코드다. 문서 제목 변경, 로컬 저장소 저장, 타이머 정리, 외부 라이브러리 호출처럼 화면 선언만으로 해결되지 않는 작업이다.
이 구분만 잡아도 Svelte 코드는 훨씬 단순해진다. 상태는 $state로 만들고, 계산은 $derived로 빼고, 꼭 필요한 외부 작업만 $effect에 둔다. 이것이 Svelte 5 반응성을 다루는 기본 습관이다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.