Svelte 5 Runes 완벽 가이드 — 반응성 시스템 재설계
이 글의 핵심
Runes는 Svelte 5에서 반응성을 “컴파일러가 이해하는 특별한 구문”으로 명시하는 방식입니다. $state·$derived·$effect의 역할, 레거시 스토어·라벨 기반 반응성과의 차이, 마이그레이션 절차, SvelteKit과의 조합, 성능 튜닝까지 한 번에 정리합니다.
이 글의 핵심
Svelte 5의 Runes(룬)는 반응성을 “암묵적인 마법”이 아니라 호출 가능한 API로 드러내는 설계 전환입니다. 이전 버전에서 let 변수에 대한 할당이 컴파일러에 의해 자동으로 반응성에 연결되던 모델에서, 이제는 $state·$derived·$effect 등으로 의도를 명시합니다. 그 결과 컴포넌트 경계·모듈 스코프·클래스 필드 등에서 반응성 규칙이 일관되게 적용되고, 타입 추론과 도구 지원이 개선됩니다.
이 글에서는 Runes의 기본 개념과 핵심 API, Svelte 3·4의 반응성 모델과의 차이, 실무 마이그레이션 전략, 성능 최적화 패턴, Todo·폼·API 연동 예제, SvelteKit에서의 사용법을 순서대로 다룹니다. 이미 Svelte 경험이 있다고 가정하며, 프로덕션 코드를 Runes 중심으로 재구성하려는 엔지니어를 주 독자로 합니다.
1. Runes란 무엇인가
Runes는 Svelte 5에서 반응형 상태·파생 값·부수 효과를 선언하기 위한 컴파일 타임에 특별히 처리되는 함수 호출입니다. 이름은 고대 문자 “룬”에서 따왔으며, “의미 있는 기호”라는 취지로 반응성의 선언 지점을 코드상에서 눈에 띄게 만드는 역할을 합니다.
Runes는 일반 JavaScript 함수처럼 보이지만, Svelte 컴파일러가 이를 해석해 구독 그래프와 업데이트 스케줄링에 맞는 코드로 변환합니다. 따라서 Runes는 .svelte 파일의 <script> 또는 *.svelte.js 런 모드 컨텍스트에서 사용하도록 설계되어 있습니다.
1.1 $state — 반응형 상태의 원천
$state는 반응형 셀(cell) 을 만듭니다. 원시값·객체·배열 모두 가능하며, 객체와 배열은 프록시를 통해 속성 단위로 추적됩니다.
<script>
let count = $state(0);
let user = $state({ name: 'Ana', score: 0 });
function increment() {
count += 1;
user.score += 1;
}
</script>
<button onclick={increment}>
{user.name}: {count} / score {user.score}
</button>
count와 user에 대한 읽기·쓰기가 템플릿이나 다른 Runes 내부에서 일어나면, 컴파일러는 해당 의존성을 등록하고 값이 바뀔 때 필요한 DOM 업데이트만 수행합니다. 이는 이전 세대에서 최상위 let에 대해 수행되던 자동 반응성과 목표는 같지만, 이제는 $state 호출로 범위가 명시된다는 점이 다릅니다.
1.2 $derived — 파생 상태와 메모이제이션
$derived는 다른 반응형 값으로부터 파생되는 값을 표현합니다. 의존성이 변하지 않으면 불필요한 재계산을 피하는 데 유리합니다.
<script>
let first = $state('');
let last = $state('');
let fullName = $derived(`${first} ${last}`.trim());
let items = $state(/** @type {number[]} */ ([]));
let sum = $derived.by(() => items.reduce((a, b) => a + b, 0));
</script>
간단한 표현식에는 $derived(expr) 형태를, 의존 관계가 복잡하거나 비용이 큰 계산에는 $derived.by(() => ...) 를 사용하는 것이 일반적입니다. 후자는 함수 본문 안에서 어떤 반응형 값을 읽었는지에 따라 정확히 추적됩니다.
1.3 $effect — 부수 효과와 동기화
$effect는 반응형 의존성이 변할 때 부수 효과(side effect) 를 실행합니다. DOM 조작, 로깅, 외부 시스템과의 동기화, 구독 설정 등에 사용합니다. 정리 함수가 필요하면 effect 콜백에서 함수를 반환합니다.
<script>
let query = $state('');
let controller;
$effect(() => {
controller?.abort();
controller = new AbortController();
const q = query;
const signal = controller.signal;
// 예: 검색어가 바뀔 때마다 요청 (실제 URL은 프로젝트에 맞게)
fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal })
.then((r) => r.json())
.then(console.log)
.catch((e) => {
if (e.name !== 'AbortError') console.error(e);
});
return () => controller.abort();
});
</script>
<input bind:value={query} />
주의: 파생 가능한 값을 $effect로 맞추는 것은 대체로 안티패턴에 가깝습니다. “상태 A에서 상태 B를 계산할 수 있는가?”가 예이면 $derived를 우선 고려해야 합니다. $effect는 진짜로 외부와 맞물리는 작업에 쓰는 것이 유지보수와 성능 모두에 유리합니다.
1.4 그 외 자주 쓰는 Runes
$props(): 부모가 넘긴 속성을 반응형으로 다룹니다. SvelteKit의data등도 여기에 포함됩니다.$bindable(): 자식에서 부모와 양방향 바인딩할 prop을 지정합니다 (bind:와 함께).$inspect/$inspect().with: 개발 중 의존성 추적·로깅용(프로덕션에서는 noop에 가깝게 동작).$state.raw: 깊은 반응성 없이 값을 보관할 때(대용량 데이터·외부 라이브러리 객체 등).
이들까지 포함하면 “Runes”는 단순히 세 개의 함수가 아니라 Svelte 5 반응성 계층 전체의 언어에 가깝습니다.
2. 기존 반응성 시스템과의 차이
2.1 컴파일러 “가정”에서 “명시”로
Svelte 3·4에서는 컴포넌트 최상위 스코프의 let 선언이 암묵적으로 반응형으로 취급되는 경우가 많았습니다. 이는 간결하지만, 중첩 함수·모듈 스코프·클래스 등에서 동일한 규칙을 기대하기 어렵고, 타입 추론·정적 분석에도 한계가 있었습니다.
Runes 모델에서는 “이 값이 반응형인가?”가 $state 등으로 선언되었는가로 결정됩니다. 팀 단위로 코드 리뷰 시에도 기준이 분명해지고, 재사용 로직을 *.svelte.js로 분리할 때 반응성 경계를 설계하기 쉬워집니다.
2.2 스토어(writable 등)와의 관계
writable·readable·derived 스토어는 여전히 지원됩니다. 특히 Svelte 외부 모듈과의 연동, 기존 코드베이스와의 공존 시 유용합니다. 다만 새 컴포넌트에서는 $state + $derived로 지역·공유 상태를 모델링하고, 정말 다중 구독·모듈 레벨 브로드캐스트가 필요할 때만 스토어를 도입하는 식으로 단순화할 수 있습니다.
마이그레이션 관점에서 보면, “스토어를 Runes로 완전 이전”과 “스토어는 유지하고 접착 계층만 Runes” 두 전략 모두 가능합니다. 후자는 리스크가 낮고, 전자는 장기적으로 일관성이 높습니다.
2.3 export let / $$props에서 $props()로
이전에는 export let propName으로 속성을 받았습니다. Svelte 5에서는 관례적으로 다음과 같이 씁니다.
<script>
let { title, count = 0 } = $props();
</script>
기본값·나머지 전개 등은 일반적인 구조 분해 규칙을 따르며, TypeScript를 쓰는 경우 제네릭·타입 단언으로 prop 타입을 명확히 할 수 있습니다.
3. Runes 마이그레이션 전략
3.1 공식 도구와 점진적 이전
Svelte 팀은 마이그레이션을 돕는 도구를 제공합니다. 프로젝트 전체를 한 번에 바꾸기보다, 기능 단위·라우트 단위로 나누어 적용하는 것이 안전합니다.
- 백업·브랜치 분리: 대규모 변경은 반드시 별도 브랜치에서 진행합니다.
- 의존성 정렬:
svelte·@sveltejs/kit등을 Svelte 5·Kit 2 권장 조합에 맞춥니다. svelte-migrate실행: 공식 마이그레이션 스크립트가 문법 변환을 자동화합니다. 결과 diff는 반드시 리뷰합니다.- 타입·테스트: UI 테스트·스냅샷·E2E가 있다면 마이그레이션 직후 실행해 회귀를 잡습니다.
자동 변환은 100% 완벽하지 않을 수 있으므로, $effect로 바뀐 부분이 과도하게 많지 않은지, $derived로 줄일 수 있는지를 특히 점검합니다.
3.2 팀 규칙 예시
- 새 파일은 Runes만 사용한다.
- 레거시 컴포넌트는 터치할 때마다 최소 범위로만 Runes로 옮긴다.
- 전역 스토어는 도메인 경계(인증, 테마, 장바구니 등)에만 둔다.
- 비동기 데이터는 가능하면 SvelteKit load(
+page.ts/+page.server.ts)로 먼저 모델링하고, 클라이언트 전용 동기화만 Runes로 둔다.
이렇게 하면 “상태가 어디서 오는가”가 서버 로드 데이터 vs 클라이언트 상호작용으로 나뉘어 추적이 쉬워집니다.
3.3 흔한 함정
$effect남용: 파생 값인데 effect로 복제하면, 실행 순서·무한 루프·불필요한 네트워크 호출이 생깁니다.- async effect: 앞서 언급했듯, effect 본문을
async로 두면 정리 함수와 섞여 버그가 되기 쉽습니다. - 객체 전체 재할당 vs 속성 갱신: 프록시 모델을 이해하지 못하면 불변 업데이트만 고집하거나, 반대로 참조 공유로 인한 의도치 않은 공유 버그가 납니다.
4. 성능 최적화 기법
4.1 $derived vs $effect
파생은 $derived, 외부와의 동기화는 $effect. 이 원칙만 지켜도 렌더·스케줄링 비용이 크게 줄어듭니다.
4.2 $state.raw와 대용량 데이터
리스트 아이템이 수만 개이고, 각 필드마다 반응성이 필요 없다면 $state.raw로 저장해 프록시 비용을 줄일 수 있습니다. 다만 이후 해당 값에 대한 “반응형 뷰”가 필요해지면 설계를 다시 검토해야 합니다.
4.3 $derived.by와 비용 큰 계산
필터·정렬·집계처럼 순수하고 비용이 큰 연산은 $derived.by 안에 두고, 입력 의존성만 명확히 하는 것이 좋습니다. 불필요한 중간 배열 할당을 줄이는 것도 같은 맥락입니다.
4.4 untrack과 구독 범위 줄이기
어떤 값은 “읽기만 하고 의존성으로 등록하고 싶지 않을” 때가 있습니다(예: 로깅용 스냅샷). 이때는 svelte/reactivity의 untrack 등을 사용해 의존성 그래프에서 제외할 수 있습니다. 남용하면 업데이트가 누락되므로, 문서화된 필요가 있을 때만 씁니다.
4.5 Svelte 컴파일 최적화와의 시너지
Svelte는 원래 컴파일 타임에 업데이트를 최소화하는 프레임워크입니다. Runes는 그 위에 의존성이 더 정확해지도록 돕습니다. 즉, “불필요한 $effect 제거”와 “정확한 $derived”가 곧 런타임 작업 감소로 이어집니다.
5. 실전 예제: Todo 앱
간단한 Todo로 목록·입력·필터 패턴을 살펴봅니다.
<script>
let nextId = 1;
let todos = $state(/** @type {{ id: number; text: string; done: boolean }[]} */ ([]));
let filter = $state(/** @type {'all' | 'active' | 'done'} */ ('all'));
let visible = $derived.by(() => {
if (filter === 'active') return todos.filter((t) => !t.done);
if (filter === 'done') return todos.filter((t) => t.done);
return todos;
});
let remaining = $derived(todos.filter((t) => !t.done).length);
function add(text) {
const t = text.trim();
if (!t) return;
todos = [...todos, { id: nextId++, text: t, done: false }];
}
function toggle(id) {
todos = todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
}
function remove(id) {
todos = todos.filter((t) => t.id !== id);
}
</script>
<form
onsubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
add(String(fd.get('text') ?? ''));
e.currentTarget.reset();
}}
>
<input name="text" placeholder="할 일" />
<button type="submit">추가</button>
</form>
<p>남은 작업: {remaining}</p>
<div class="filters">
{#each ['all', 'active', 'done'] as f}
<button type="button" class:active={filter === f} onclick={() => (filter = f)}>
{f}
</button>
{/each}
</div>
<ul>
{#each visible as todo (todo.id)}
<li>
<label>
<input type="checkbox" checked={todo.done} onchange={() => toggle(todo.id)} />
<span class:done={todo.done}>{todo.text}</span>
</label>
<button type="button" onclick={() => remove(todo.id)}>삭제</button>
</li>
{/each}
</ul>
<style>
.done {
text-decoration: line-through;
opacity: 0.7;
}
.filters button.active {
font-weight: bold;
}
</style>
filter와 todos가 바뀔 때만 visible이 다시 계산됩니다. Todo를 “객체 불변 업데이트”로 관리해 예측 가능한 상태 전이를 유지했습니다. 규모가 커지면 정규화된 구조·선택자 패턴을 고려할 수 있으나, 본 예제 수준에서는 배열+map/filter로 충분합니다.
6. 실전 예제: 폼 검증과 메시지
폼은 사용자 입력 상태와 검증 규칙에서 파생된 에러 메시지가 함께 등장합니다. 에러는 $derived로 두면 입력이 바뀔 때만 재평가됩니다.
<script>
let email = $state('');
let password = $state('');
let emailError = $derived(
email.length === 0 ? '이메일을 입력하세요.' : /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? '' : '형식이 올바르지 않습니다.'
);
let passwordError = $derived(
password.length < 8 ? '8자 이상 입력하세요.' : ''
);
let canSubmit = $derived(!emailError && !passwordError);
</script>
<label>
이메일
<input type="email" bind:value={email} autocomplete="email" />
</label>
{#if emailError}<p class="err">{emailError}</p>{/if}
<label>
비밀번호
<input type="password" bind:value={password} autocomplete="new-password" />
</label>
{#if passwordError}<p class="err">{passwordError}</p>{/if}
<button type="button" disabled={!canSubmit}>가입</button>
<style>
.err {
color: crimson;
font-size: 0.9rem;
}
</style>
서버와의 계약 검증(예: Zod)을 붙일 때는 클라이언트 $derived는 UX용, 서버는 최종 검증으로 이중화하는 것이 안전합니다.
7. 실전 예제: API 통합과 SvelteKit 데이터
클라이언트에서만 fetch하는 경우와, SvelteKit load로 초기 데이터를 넘기는 경우를 구분하는 것이 중요합니다.
7.1 +page.server.ts에서 데이터 로드
// src/routes/items/+page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const res = await fetch('/api/items');
if (!res.ok) throw new Error('목록을 불러오지 못했습니다.');
const items = await res.json();
return { items };
};
7.2 +page.svelte에서 Runes로 상호작용
<script>
let { data } = $props();
let items = $state(data.items);
let busy = $state(false);
let error = $state(/** @type {string | null} */ (null));
$effect(() => {
items = data.items;
});
async function refresh() {
busy = true;
error = null;
try {
const res = await fetch('/api/items');
if (!res.ok) throw new Error('갱신 실패');
items = await res.json();
} catch (e) {
error = e instanceof Error ? e.message : '알 수 없는 오류';
} finally {
busy = false;
}
}
</script>
{#if error}<p class="err">{error}</p>{/if}
<button type="button" onclick={refresh} disabled={busy}>
{busy ? '불러오는 중…' : '새로고침'}
</button>
<ul>
{#each items as item (item.id)}
<li>{item.title}</li>
{/each}
</ul>
data가 탐색으로 바뀔 때 로컬 items를 맞추기 위해 $effect로 동기화했습니다. 이 패턴은 “서버가 준 초기값 + 클라이언트에서 갱신 가능한 복사본”을 둘 때 자주 쓰입니다. 더 단순한 경우에는 items = data.items만으로 충분할 수도 있어, 실제로는 불변 업데이트 여부·폼 초기화 요구에 따라 선택합니다.
대안: 서버 액션·invalidate() 등으로 load를 다시 실행해 단일 출처를 data에만 두는 방식도 가능합니다. 팀이 선호하는 데이터 패턴에 맞추면 됩니다.
8. SvelteKit과의 통합 요약
- 라우팅·로드 함수:
+page.ts/+page.server.ts/+layout.ts등에서 데이터를 준비하고, 컴포넌트는$props()로data·params등을 받습니다. - 폼·액션: Runes로 입력 상태를 관리하고,
progressive enhancement가 필요하면 SvelteKit 폼 액션과 조합합니다. - 스토어 기반 유틸:
$app/stores의$page등은 기존처럼 사용할 수 있으나, 새 코드에서는getContext+ Runes나 $derived로 파생하는 등 팀 컨벤션을 맞추는 경우가 많습니다. (프로젝트별로 래퍼를 두면 일관성이 좋습니다.) - 환경 변수·서버 전용 코드: 서버 모듈과 클라이언트 번들 분리 규칙은 SvelteKit 그대로이며, Runes와 충돌하지 않습니다.
9. 정리
Svelte 5의 Runes는 반응성을 명시적 API로 바꾸어, 컴포넌트·모듈 전반에서 동일한 규칙을 적용하기 쉽게 만듭니다. $state로 상태를, $derived로 파생을, $effect로 외부 동기화를 표현하면 대부분의 UI 로직이 읽기 쉬운 데이터 흐름으로 정리됩니다. 기존 스토어·레거시 문법과의 병행 운용과 점진적 마이그레이션이 가능하므로, 대규모 코드베이스도 단계적으로 이전할 수 있습니다.
성능 측면에서는 $derived로 계산을 옮기고 $effect를 줄이는 것이 첫 번째 레버이며, 대용량 데이터에는 $state.raw·$derived.by·untrack 등을 상황에 맞게 고려하면 됩니다. SvelteKit과 함께 쓸 때는 서버 load를 진실의 원천으로 삼고, 클라이언트 Runes는 상호작용·낙관적 UI에 집중하는 구조가 유지보수에 유리합니다.
참고 및 다음 단계
- 공식 문서의 Runes·마이그레이션·SvelteKit 데이터 로딩 섹션을 최신 버전 기준으로 확인하십시오.
- 팀 내에서 Runes 스타일 가이드(이펙트 금지 규칙, 스토어 사용 기준 등)를 문서화하면 리뷰 비용이 줄어듭니다.
배포 전에는 변경 사항을 커밋하고 원격 저장소에 푸시한 뒤 npm run deploy를 실행하는 것이 이 프로젝트의 배포 절차입니다.