Svelte 5 Runes 완벽 가이드 — 새로운 반응성 시스템

Svelte 5 Runes 완벽 가이드 — 새로운 반응성 시스템

이 글의 핵심

Runes는 Svelte 5에서 반응성을 컴파일러가 해석하는 명시적 API로 바꾼 설계입니다. 이 글에서는 $state·$derived·$effect·$props·$bindable의 역할과 한계, Svelte 3·4 대비 마이그레이션 전략, 성능 최적화, 컴포넌트 경계를 넘는 실전 상태 패턴을 체계적으로 정리합니다.

들어가며

Svelte 5의 Runes(룬)는 반응형 상태·파생 값·부수 효과·컴포넌트 계약을 특별한 식별자가 붙은 호출 형태로 표현하는 새로운 반응성 모델입니다. 이전 세대에서 컴포넌트 최상위 let과 할당이 암묵적으로 반응성에 묶이던 방식과 달리, Runes는 “무엇이 반응형인가”를 소스 코드에서 바로 읽을 수 있게 만듭니다. 그 결과 모듈 분리·타입 추론·코드 리뷰 기준이 분명해지고, 팀 단위로 상태 경계를 설계하기가 쉬워집니다.

이 문서는 이미 Svelte 3·4 또는 다른 프레임워크 경험이 있고, Svelte 5로 프로덕션 코드를 설계·이전하려는 엔지니어를 대상으로 합니다. API 나열에 그치지 않고, 각 Runes가 해결하는 문제·피해야 할 안티패턴·SvelteKit과 맞물릴 때의 관례까지 연결해 설명합니다.


1. Runes의 핵심 개념

1.1 컴파일 타임에 특별 취급되는 호출

Runes는 일반 함수처럼 보이지만, Svelte 컴파일러가 AST 수준에서 해석합니다. 따라서 “런타임에 진짜 함수가 호출되는가?”보다 중요한 것은, 컴파일러가 해당 호출을 구독 그래프·스케줄러·업데이트 코드로 변환한다는 점입니다. 이는 React의 Hook과 비슷하게 “호출 순서와 규칙”이 있지만, Svelte는 컴포넌트 인스턴스와 템플릿에 맞춰 의존성을 더 정적으로 분석할 수 있는 편입니다.

1.2 반응성의 단위: 읽기와 쓰기

Runes 모델에서 반응성은 대략 다음으로 요약됩니다.

  • 원천(source): $state로 만들어진 셀. 읽히면 구독이 걸리고, 쓰이면 의존자에게 전파됩니다.
  • 파생(derived): $derived로 표현된 값. 원천(또는 다른 파생)의 읽기를 통해 의존성이 수집됩니다.
  • 효과(effect): $effect로 등록된 콜백. 의존성이 바뀌면 나중에 실행되며, 정리(cleanup) 함수를 반환할 수 있습니다.

이 세 층을 구분해 두면, “상태 복제를 effect로 할 것인가, 파생으로 할 것인가” 같은 설계 논쟁이 코드 위치로 귀결됩니다.

1.3 런 모드와 .svelte.js

Runes는 런 모드(runes mode)가 활성화된 컨텍스트에서 일관되게 동작합니다. 컴포넌트 <script>는 기본적으로 이 모드에 해당하며, 재사용 로직을 *.svelte.js(또는 .svelte.ts)로 빼면 컴포넌트 밖에서도 동일한 규칙으로 상태를 캡슐화할 수 있습니다. 반면 일반 .js 파일에는 Svelte 컴파일러가 개입하지 않으므로, 그대로는 Runes를 사용할 수 없습니다. 이 구분이 상태 모듈을 어디에 둘지 결정하는 첫 번째 기준입니다.

1.4 레거시 모델과의 존재론적 차이

Svelte 3·4에서 흔히 쓰이던 최상위 let 기반 반응성과 export let props는, 5에서는 $state$props()로 의도가 드러나는 형태로 이전됩니다. 스토어(writable 등)는 여전히 유효하며, Runes와 공존할 수 있습니다. 다만 새 아키텍처에서는 “컴포넌트 내부·모듈 내부 상태는 Runes, 여러 구독자에게 브로드캐스트할 때만 스토어”처럼 역할을 나누는 편이 유지보수에 유리합니다.


2. $state — 반응형 상태의 원천

2.1 기본 사용법

$state는 초깃값을 받아 반응형 변수를 만듭니다. 원시값·객체·배열 모두 가능합니다.

<script>
	let count = $state(0);
	let user = $state({ name: 'Min', tier: 'free' });

	function increment() {
		count += 1;
	}
	function rename(next) {
		user.name = next;
	}
</script>

<p>{user.name} · {count}</p>
<button onclick={increment}>+1</button>

객체와 배열은 프록시를 통해 속성 단위로 추적됩니다. 따라서 user.name = next처럼 가변(mutating) 갱신이 가능하고, 템플릿은 변경된 필드에 맞춰 갱신됩니다. 팀 컨벤션에 따라 불변 업데이트(user = { ...user, name: next })를 고수할 수도 있으나, 성능 문제가 아니라 참조 공유와 예측 가능성을 위한 스타일 선택에 가깝습니다.

2.2 깊은 반응성과 참조 공유

같은 객체를 여러 $state에 넣거나, 외부에서 들어온 객체를 그대로 넣으면 의도치 않은 공유가 생길 수 있습니다. 반응형 모델을 설계할 때는 “이 객체의 소유권은 누구인가?”를 명확히 하는 것이 중요합니다. API 응답을 그대로 보관해야 한다면, 필요 시 복사하거나 $state.raw를 검토합니다.

2.3 $state.raw — 프록시 오버헤드 줄이기

대용량 배열·차트 데이터·외부 라이브러리 인스턴스처럼 속성 단위 반응성이 불필요한 값은 $state.raw로 보관할 수 있습니다. 이때 값은 깊은 추적 대상에서 제외되므로, 갱신 시 참조 자체를 바꾸는 방식(예: 배열 통째로 교체)으로 뷰를 동기화하는 경우가 많습니다.

<script>
	// 예: 수만 행 테이블 원본 — 셀 단위 반응성 불필요
	let rows = $state.raw(/** @type {Row[]} */ ([]));

	function replaceAll(next) {
		rows = next;
	}
</script>

2.4 $state.snapshot — 비반응형 스냅샷

외부 API나 로깅에 순수한 plain 객체가 필요할 때는 $state.snapshot으로 현재 상태의 스냅샷을 얻을 수 있습니다. 반응형 프록시를 그대로 넘기면 의도치 않은 추적이나 직렬화 이슈가 생길 수 있으므로, 경계에서 스냅샷을 떼는 패턴이 유용합니다.


3. $derived — 파생 상태와 메모이제이션

3.1 $derived와 $derived.by

$derived는 다른 반응형 값으로부터 계산되는 값을 선언합니다. 단순 표현식에는 인자 한 개 형태를, 의존 관계가 분기·루프 안에 숨거나 계산 비용이 크면 $derived.by(() => ...) 를 씁니다.

<script>
	let a = $state(1);
	let b = $state(2);
	let sum = $derived(a + b);

	let items = $state(/** @type {number[]} */ ([]));
	let total = $derived.by(() => items.reduce((x, y) => x + y, 0));
</script>

$derived.by의 함수 본문에서 읽힌 반응형 값만 의존성으로 등록되므로, 실제 데이터 흐름에 맞는 최소 의존성을 기대할 수 있습니다.

3.2 파생은 순수하게 유지하기

파생 블록에서 외부 시스템에 부수 효과를 내지 않는 것이 원칙입니다. 네트워크 요청·localStorage 쓰기·전역 싱글톤 갱신 등은 $effect나 이벤트 핸들러로 옮기고, $derived에는 계산 가능한 결과만 남깁니다. 이렇게 해야 테스트·리플레이·실행 순서 의존성 문제를 줄일 수 있습니다.

3.3 이전 세대의 reactive 문과 비교

Svelte 4 이하의 $: 문은 표현상 편리했지만, 복잡해지면 “이 줄이 언제 다시 실행되나?”를 추적하기 어려웠습니다. $derived값의 출처가 한눈에 들어오는 선언이라, 코드 리뷰에서 의존성 논쟁이 줄어드는 편입니다.


4. $effect — 부수 효과와 동기화

4.1 언제 effect를 쓰는가

$effect는 반응형 의존성이 변할 때 부수 효과를 실행합니다. 예를 들면 다음과 같습니다.

  • 외부 API와의 동기화(검색어 변경 시 요청)
  • 서드파티 위젯·캔버스·맵 인스턴스에 상태 반영
  • 구독·이벤트 리스너 등록과 해제
<script>
	let query = $state('');
	let controller;

	$effect(() => {
		controller?.abort();
		controller = new AbortController();
		const q = query;
		const { signal } = controller;

		fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal })
			.then((r) => r.json())
			.then((data) => {
				/* 결과 반영은 별도 $state에 */
			})
			.catch((e) => {
				if (e.name !== 'AbortError') console.error(e);
			});

		return () => controller.abort();
	});
</script>

파생 가능한 값을 effect로 맞추는 패턴은 대개 안티패턴입니다. “A만으로 B를 계산할 수 있는가?”가 참이면 $derived로 옮깁니다. effect는 외부와의 접점에 두는 것이 실행 횟수·디버깅 모두에 유리합니다.

4.2 cleanup 함수

effect 콜백이 함수를 반환하면, 다음 실행 전·컴포넌트 파괴 시 호출됩니다. 이를 통해 요청 취소·타이머 정리·구독 해제를 대칭적으로 맞출 수 있습니다. cleanup을 빼먹으면 메모리 누수·중복 요청·경쟁 상태(race)가 남습니다.

4.3 async effect를 피하는 이유

effect 콜백을 async로 선언하면 Promise를 반환하게 되고, 이는 cleanup과 혼동되기 쉽습니다. 비동기 작업은 내부에서 async 함수를 정의해 호출하되, effect 자체는 동기적으로 구독·취소 토큰·정리만 다루는 편이 안전합니다.

4.4 $effect.pre

DOM에 쓰이기 직전에 맞춰야 하는 로직(레이아웃 측정 직전 등)이 있으면 $effect.pre를 고려합니다. 일반 $effect와 실행 타이밍이 다르므로, 스크롤 위치 보정·캔버스 리사이즈처럼 요구사항이 명확할 때만 사용합니다. 남용하면 실행 순서를 추적하기 어려워집니다.


5. $props와 $bindable — 컴포넌트 계약

5.1 $props()로 속성 받기

export let 대신 $props()로 부모가 넘긴 속성을 구조 분해합니다. 기본값·나머지 전개(rest)도 일반 객체 구조 분해 규칙을 따릅니다.

<script>
	let { title, count = 0, ...rest } = $props();
</script>

<article {...rest}>
	<h2>{title}</h2>
	<p>{count}</p>
</article>

SvelteKit의 data prop 등도 같은 메커니즘으로 전달되며, 서버 로드 데이터와 클라이언트 상호작용 상태를 mental model 상에서 분리하기 쉽습니다.

5.2 $bindable과 양방향 바인딩

부모·자식 간 양방향 동기화가 필요한 prop에는 $bindable을 사용합니다. 자식에서는 bind:로 노출하고, 부모에서는 bind:propName으로 연결합니다.

<!-- Child.svelte -->
<script>
	let { value = $bindable('') } = $props();
</script>

<input bind:value={value} />
<!-- Parent.svelte -->
<script>
	import Child from './Child.svelte';
	let name = $state('');
</script>

<Child bind:value={name} />

이 패턴은 폼 필드·편집 가능한 제목 등 진짜로 양방향이 타당한 경우에만 쓰는 것이 좋습니다. 대부분의 데이터 흔들기는 단방향 데이터 흐름 + 콜백으로 충분한 경우가 많습니다.

5.3 타입스크립트와 함께 쓰기

제네릭·타입 별칭으로 props 타입을 한곳에 모아두면, 라우트·스토리북·테스트에서 재사용하기 좋습니다. $props의 형태는 프로젝트마다 ComponentProps 유틸을 두어 일관되게 맞추는 팀도 많습니다.


6. 마이그레이션 전략

6.1 점진적 이전이 기본

코드베이스 전체를 한 번에 바꾸기보다, 라우트·기능 단위로 Svelte 5 문법과 Runes를 도입하는 방식이 안전합니다. 공식 마이그레이션 도구(svelte-migrate 등)를 실행한 뒤에는 diff를 반드시 리뷰하고, 특히 $effect로 과변환된 구간이 없는지 확인합니다. 자동 변환은 파생으로 충분한 로직을 effect로 옮기기 쉽기 때문입니다.

6.2 팀 규칙 예시

  • 새 컴포넌트는 Runes만 사용한다.
  • 레거시 파일은 기능 수정 시에만 최소 범위로 전환한다.
  • 전역 스토어는 인증·테마·카트처럼 경계가 뚜렷한 도메인에만 둔다.
  • 서버에서 가져온 데이터는 SvelteKit load로 모델링하고, 클라이언트에서만 필요한 동기화·애니메이션을 Runes로 둔다.

6.3 스토어와의 공존

writable 스토어를 즉시 제거할 필요는 없습니다. 다만 새 코드에서 스토어를 추가하기 전에 “모듈 레벨 $state + 명시적 구독으로 대체 가능한가?”를 질문하면 중복 추상화를 줄일 수 있습니다. 기존 스토어를 Runes 컴포넌트에 연결할 때는 어댑터를 짧게 두고, 장기적으로는 Runes 기반 모듈로 흡수합니다.

6.4 자주 발생하는 회귀

  • effect로 상태 복제: 파생값을 또 다른 $state에 넣으며 무한 루프·깜빡임 발생.
  • 클로저에 낡은 값: effect 내부에서 오래된 스냅샷을 참조. 의존성 배열이 아니라 읽기 지점을 점검.
  • 객체 공유: 부모·자식이 같은 객체본을 mutate하며 버그. 필요 시 복사·불변 업데이트.

7. 성능 최적화

7.1 $derived와 $effect의 역할 분리

렌더링과 무관하게 자주 바뀌는 파생값을 effect에서 갱신하면 스케줄링 비용이 불필요하게 커집니다. “계산 가능한가?”를 먼저 묻고, 외부 I/O가 있을 때만 effect를 씁니다.

7.2 $state.raw와 대용량 데이터

프록시 추적이 과하면 CPU·메모리 비용이 누적됩니다. 셀 단위 반응성이 필요 없는 데이터는 raw로 두고, 뷰에 필요한 최소 파생만 $derived로 노출합니다.

7.3 untrack으로 의존성 제한

svelte/reactivityuntrack은 특정 읽기를 의존성 그래프에 포함하지 않을 때 사용합니다. 로깅·메트릭·디버그 출력처럼 “참고만 하고 재실행 트리거는 아니어야 하는” 읽기에 쓸 수 있습니다. 남용하면 업데이트 누락 버그로 이어지므로, 코드 주석으로 왜 untrack이 필요한지 남기는 것이 좋습니다.

7.4 리스트·키 안정성

{#each}에서 키를 안정적으로 쓰면 재생성 범위가 줄어듭니다. Runes가 DOM diff를 대체해 주는 것은 아니므로, 목록 렌더링 패턴은 이전과 동일하게 신경 써야 합니다.

7.5 컴파일러와의 시너지

Svelte는 원래 컴파일 타임에 업데이트를 최소화하는 프레임워크입니다. Runes는 의존성 추적을 더 정밀하게 만들어, 불필요한 effect 제거·정확한 파생이 곧 런타임 작업 감소로 이어지도록 돕습니다.


8. 실전 상태 관리 패턴

8.1 로컬 상태 우선

대부분의 UI 상태는 해당 컴포넌트의 $state로 충분합니다. 조기에 전역화하면 데이터 흐름이 흐려지고 테스트가 어려워집니다.

8.2 상태 끌어올리기(Lifting state)

형제 컴포넌트가 같은 데이터를 공유해야 하면 공통 부모$state를 올리고, 자식에는 props·콜백으로 전달합니다. Runes에서도 이 패턴은 유효하며, $bindable은 정말로 양방향이 필요할 때만 씁니다.

8.3 .svelte.js 모듈로 캡슐화

여러 컴포넌트에서 같은 로직을 쓰려면 팩토리 함수*.svelte.ts에 두고, 호출할 때마다 새로운 $state를 만들게 할 수 있습니다.

// counter.svelte.ts
export function createCounter(initial = 0) {
	let n = $state(initial);
	return {
		get value() {
			return n;
		},
		inc() {
			n += 1;
		}
	};
}

getter로 읽기·메서드로 쓰기를 노출하면, 외부에서 상태를 직접 할당하지 않고 불변성 경계를 지키기 쉽습니다.

8.4 모듈 싱글톤(신중하게)

앱 전역에서 하나만 있어야 하는 값(테마, 로케일)은 모듈 스코프 $state로 둘 수 있으나, 서버 사이드 렌더링에서는 요청 간 누출이 생기지 않도록 SvelteKit의 권장 패턴(컨텍스트·load 데이터)과 함께 설계해야 합니다. “브라우저 전역 싱글톤”이 모든 사용자에게 공유되는 메모리가 되는 환경은 없는지 항상 확인합니다.

8.5 서버 데이터 + 클라이언트 상호작용

SvelteKit에서는 load 함수가 가져온 데이터를 초기 표시에 쓰고, 이후 상호작용은 $state로 덮어쓰거나 병합합니다. “서버가 진실의 원천인 필드”와 “클라이언트만 아는 임시 UI 상태”를 타입 수준에서 분리해 두면, 동기화 버그를 줄일 수 있습니다.


9. 요약

Runes는 Svelte 5의 반응성을 명시적이고 추적 가능한 API로 바꾼 설계입니다. $state로 원천을 만들고, $derived로 순수 파생을 표현하며, $effect는 외부와의 동기화에 한정하는 것이 유지보수와 성능 모두에 유리합니다. $props$bindable은 컴포넌트 계약을 현대적인 JavaScript 구문으로 정리해 주며, 마이그레이션은 점진적·리뷰 가능한 단위로 진행하는 것이 안전합니다. 마지막으로 상태는 필요한 가장 가까운 위치에 두고, 전역·스토어·서버 로드 데이터의 경계를 팀 규칙으로 고정하면 Runes 기반 코드베이스가 장기적으로 읽기 쉬운 구조를 유지합니다.


참고 및 버전 고지

Svelte 5는 활발히 발전 중인 릴리스 계열입니다. 세부 API 이름·동작은 공식 문서와 릴리스 노트를 기준으로 하며, 프로젝트의 svelte 버전에 맞춰 타입 정의와 마이그레이션 도구를 재실행하는 것을 권장합니다.