Svelte 5 Runes: 컴파일러가 반응성을 직접 추적하면 스토어의 자리가 좁아진다
Svelte 4를 쓰다 보면 어느 순간 이런 코드를 짜게 됩니다.
// stores.ts
import { writable } from 'svelte/store';
export const count = writable(0);<!-- Counter.svelte -->
<script>
import { count } from './stores';
</script>
<button on:click={() => $count++}>{$count}</button>코드 자체는 나쁘지 않습니다. 그런데 $count가 스토어 자동구독인지, Rune인지, 그냥 변수인지 — 맥락을 알아야만 구분이 됩니다. 팀이 커질수록 "이 $가 뭐였더라?" 하는 순간이 늘어납니다.
Svelte 5 공식 릴리스 포스트("Svelte 5 is alive")는 2024년 10월 22일 발행되면서 이 문제를 정면으로 다뤘습니다. Runes는 반응성을 런타임이 아닌 언어 수준 키워드로 표현하는 방식이고, $state, $derived, $effect가 그 핵심입니다. 2026년 기준 Svelte 공식 문서는 신규 코드에서 Runes를 우선 권장합니다.
이 글은 Svelte 4 스토어에 익숙한 분들이 Runes의 동작 방식과 스토어와 갈라지는 지점을 코드 수준에서 이해할 수 있도록 썼습니다. 마이그레이션을 당장 할 계획이 없더라도, 왜 이 방향으로 바뀌었는지 파악하는 것 자체가 도움이 될 겁니다.
반응성의 위치가 달라졌다
Svelte 4: 컴파일러가 .svelte 안에서만 본다
Svelte 4 컴파일러는 .svelte 파일의 <script> 블록 안에서만 반응형 변수를 추적할 수 있었습니다. let count = 0으로 선언하면 그 파일 안에서는 자동으로 반응형이지만, 같은 상태를 다른 컴포넌트와 공유하려는 순간 파일 경계를 넘어야 합니다. 그래서 svelte/store가 필요했습니다.
한 줄로 정리하면 이렇습니다. Svelte 4에서는 .svelte 내부 상태는 let으로, 파일 경계를 넘는 공유 상태는 writable()로 — 두 방식이 병존했고 각각 문법도, 구독 해제 방식도 달랐습니다. "반응성"과 "파일 공유"가 분리된 개념이었죠.
Svelte 5: 컴파일러가 .svelte.ts까지 본다
Svelte 5 Runes는 .svelte, .svelte.js, .svelte.ts 파일 모두에서 동작합니다. 컴파일러가 이 파일들을 정적 분석해 업데이트 경로를 코드로 생성하되, 내부적으로는 signals 기반의 런타임 반응성 인프라를 함께 사용합니다. 단순 표현식은 컴파일 타임에 의존성이 결정되고, 동적 접근처럼 정적으로 알기 어려운 부분은 런타임 signals가 추적하는 하이브리드 구조입니다.
$state, $derived, $effect는 함수처럼 생겼지만 실제로는 컴파일러 심볼입니다. 변수에 할당하거나 import할 수 없고, 컴파일러가 이 키워드를 발견하는 순간 해당 변수의 읽기·쓰기를 추적하는 코드로 변환합니다.
세 가지 Rune, 어디에 쓰는가
$state — 반응형 상태 선언
Svelte 4에서 let count = 0이 암묵적으로 반응형이었다면, Svelte 5는 명시적으로 선언합니다.
<script lang="ts">
let count = $state(0);
let user = $state({ name: 'Alice', score: 0 });
</script>
<button onclick={() => count++}>클릭: {count}</button>
<p>{user.name}: {user.score}</p>객체를 $state로 감싸면 Svelte가 Proxy를 씌워 깊은 반응성(deep reactivity)을 제공합니다. user.score++만 해도 해당 부분만 업데이트됩니다. Vue의 ref() 역시 Proxy를 사용한다는 점에서는 겹치지만, Svelte는 컴파일러가 사전에 업데이트 코드를 생성한다는 지점에서 갈라집니다.
$derived — 파생 계산, 순수하게만
$: 라벨이 계산과 사이드 이펙트를 모두 담당했던 것과 달리, $derived는 순수 계산만 하도록 설계되어 있습니다.
<script lang="ts">
let count = $state(0);
let doubled = $derived(count * 2);
let isEven = $derived(count % 2 === 0);
</script>의존하는 상태가 바뀌면 자동으로 재계산됩니다. 표현식이 단순하면 컴파일러가 의존성을 정적으로 파악하고, items[index].name처럼 동적 프로퍼티 접근이 섞인 경우에는 런타임 signals가 실제 접근된 값을 추적합니다.
$derived 안에서 다른 $state를 변경하려 하면 Svelte 컴파일러가 이를 감지해 경고를 남기고, 복잡한 표현식은 $derived.by()를 쓰라고 안내합니다. 다만 쉼표 연산자나 함수 호출로 우회한 사이드 이펙트까지 컴파일러가 전부 잡아내지는 못하므로, "컴파일러가 막아주니 괜찮다"고 방심하지는 않는 편이 좋습니다.
다단계 파생이 필요하면 변수를 분리해서 선언합니다.
<script lang="ts">
let items = $state<string[]>([]);
let filtered = $derived(items.filter(i => i.length > 3));
let sorted = $derived(filtered.toSorted());
let summary = $derived(`${sorted.length}개 항목`);
</script>조금 장황해 보이지만, 각 단계의 계산이 명확히 분리되어 있어 디버깅이 편합니다.
$effect — 사이드 이펙트는 여기에
DOM 조작, 로깅, 외부 API 호출처럼 부수 효과가 있는 코드는 $effect에 넣습니다.
<script lang="ts">
let count = $state(0);
$effect(() => {
document.title = `카운트: ${count}`;
return () => {
document.title = '앱';
};
});
</script>$effect 안에서 읽은 반응형 상태가 바뀔 때마다 실행됩니다. 정리 함수(cleanup)를 반환하면 다음 실행 전, 또는 컴포넌트 소멸 시 자동으로 호출됩니다.
솔직히 처음에 $:를 쓰던 습관 때문에 $derived와 $effect의 구분이 헷갈렸습니다. 간단한 구분법은 이렇습니다.
| 질문 | 사용할 Rune |
|---|---|
| 다른 상태에서 값을 계산하나? | $derived |
| 상태 변화에 반응해 뭔가를 실행하나? | $effect |
| 사이드 이펙트가 포함되나? | 반드시 $effect |
스토어 없이 상태 공유하기 — 다만 함정이 있다
.svelte.ts 파일에서 Runes를 쓸 수 있게 되면서 스토어 없이도 모듈 수준 반응형 상태를 공유할 수 있게 됐습니다. 그런데 여기서 초보자가 가장 많이 걸리는 함정이 있습니다.
동작하지 않는 패턴 — primitive 직접 export:
// counter.svelte.ts — 이 방식은 반응성이 끊깁니다
let count = $state(0);
export function increment() {
count++;
}
export { count };JavaScript 모듈은 primitive 값을 값으로 복사해서 export합니다. 컴포넌트가 import { count } 하는 순간 반응성 연결이 끊기고, increment()를 호출해도 UI가 갱신되지 않습니다. 스토어를 대체하려다 조용히 반응성을 잃는 흔한 실수입니다.
동작하는 패턴 — 객체·클래스로 감싸기:
// counter.svelte.ts
class Counter {
count = $state(0);
increment() {
this.count++;
}
}
export const counter = new Counter();<!-- Component.svelte -->
<script lang="ts">
import { counter } from './counter.svelte';
</script>
<button onclick={() => counter.increment()}>{counter.count}</button>$state가 클래스 필드로 들어가면 인스턴스 프로퍼티 접근을 통해 반응성이 유지됩니다. getter 형태로 노출하는 것도 같은 원리로 동작합니다.
$ 접두어 자동구독 문법이 사라지고, 팀에 Svelte를 소개할 때 "이 $가 스토어 구독이에요, 저 $는 Rune이에요"라고 설명하던 부분이 없어진 것 자체는 큰 편의입니다. 다만 위 함정 때문에 "그냥 export만 하면 된다"는 인상은 위험합니다.
트레이드오프 — 좋은 것만 있지 않습니다
장단점 요약
| 항목 | Svelte 4 스토어 | Svelte 5 Runes |
|---|---|---|
| 반응성 위치 | .svelte 내부 전용 |
.svelte.ts까지 확장 |
| 파일 간 공유 | writable 필수 |
객체·클래스 export로 가능 |
| 명시성 | let이 암묵적 반응형 |
$state 명시 선언 |
| 구독 관리 | $ 접두어 또는 subscribe |
불필요 |
| SSR 상태 누출 위험 | 스토어도 동일 주의 필요 | 모듈 수준 Rune 공유 시 주의 |
| RxJS 연동 | 직접 연동 가능 | 래핑 레이어 필요 |
| StartStopNotifier | 네이티브 지원 | 재현 어려움 |
실무에서 자주 만나는 실수
1. SSR 환경에서 모듈 수준 상태 공유
.svelte.ts 최상위에서 let count = $state(0)을 선언하면 서버에서는 모든 요청이 같은 모듈 인스턴스를 공유합니다. 사용자 간 상태 누출이 발생할 수 있습니다.
컴포넌트 내부에 선언한 $state는 안전한데, 이유는 컴포넌트 인스턴스마다 스크립트 블록이 다시 실행되어 상태가 요청별로 독립적으로 생성되기 때문입니다. 요청 스코프에서 공유가 필요하면 SvelteKit의 setContext/getContext 조합을 씁니다.
<!-- +layout.svelte — 요청별 스코프에서 상태 생성 -->
<script lang="ts">
import { setContext } from 'svelte';
class Counter {
count = $state(0);
}
setContext('counter', new Counter());
</script><!-- Child.svelte -->
<script lang="ts">
import { getContext } from 'svelte';
const counter = getContext<{ count: number }>('counter');
</script>
<button onclick={() => counter.count++}>{counter.count}</button>이렇게 두면 서버에서 요청마다 새 인스턴스가 만들어져 사용자 간 상태가 섞이지 않습니다.
2. 서드파티 라이브러리와의 마찰
스토어 인터페이스(subscribe, set, update)를 기대하는 서드파티 라이브러리와 Runes를 연동할 때는 래핑 레이어가 필요합니다. 이 경우 스토어를 그대로 유지하는 편이 더 실용적일 수 있습니다. Svelte 5는 기존 스토어와 Runes가 같은 프로젝트에서 공존할 수 있으므로, 빅뱅 재작성 없이 점진적으로 전환하는 것이 현실적입니다.
3. RxJS·StartStopNotifier처럼 스토어가 더 잘 맞는 자리
writable(value, start)의 두 번째 인자인 StartStopNotifier는 첫 구독자가 붙을 때 리소스를 열고 마지막 구독자가 떠날 때 닫는 패턴을 자연스럽게 표현합니다. WebSocket 연결이나 RxJS 스트림 브릿지에서 특히 유용한데, Runes로 이 수명주기를 재현하려면 $effect cleanup과 참조 카운팅을 직접 조합해야 하고 코드가 오히려 복잡해집니다.
마이그레이션을 검토 중이라면
2026년 기준으로 Svelte 5 마이그레이션 가이드는 npx sv migrate svelte-5 명령으로 기계적 변환을 안내하고, 수동 검토가 필요한 지점에 주석을 남기도록 되어 있습니다. 전체를 한 번에 바꾸지 않아도 됩니다.
정리하자면, Runes로 넘어가면서 "스토어가 사라진다"는 표현은 절반만 맞습니다. 지역 상태와 모듈 간 단순 공유의 자리는 $state가 가져갔지만, RxJS 스트림 연동이나 StartStopNotifier 기반 자원 관리처럼 스토어가 여전히 더 자연스러운 지점이 남아 있습니다. 갈라지는 지점을 정확히 파악하는 것이 이번 전환의 핵심이고, 이 판단이 서면 두 방식이 공존하는 코드베이스도 부담 없이 운영할 수 있습니다.
참고 자료
- Svelte 5 is alive — Svelte 공식 블로그
- What are runes? — Svelte 공식 문서
- Svelte 5 Migration Guide — Svelte 공식 문서
- Introducing runes — Svelte 공식 블로그
$derived— Svelte 공식 문서- Context API — Svelte 공식 문서
- Migrating Svelte Stores to Runes — closingtags.com
- Refactoring Svelte stores to $state runes — Loopwerk
- Understanding Svelte 5 Runes: $derived vs $effect — HTML All The Things
- Exploring the magic of runes in Svelte 5 — LogRocket Blog