Skip to main content
Summary

반응성은 "값과 화면의 연결"이다

앞 장에서 버튼을 누르면 숫자가 올라가는 카운터를 만들었다. 코드는 짧았다.

<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: 상태 변화에 맞춰 외부 작업을 실행한다.

반응성의 핵심과 Svelte 5 runesAI 생성 이미지

$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 반응성의 핵심이다.

정리하면 다음 순서다.

  1. 사용자가 체크박스를 누른다.

  2. todos[index].done 값이 바뀐다.

  3. 그 값을 읽는 계산값과 화면이 다시 맞춰진다.

  4. 개발자는 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 반응성을 다루는 기본 습관이다.

참고

1Svelte $state 공식 문서

2Svelte $derived 공식 문서

3Svelte $effect 공식 문서

책 목록

쉽게 배우는 Svelte 개발: Svelte 5와 SvelteKit으로 시작하는 프론트엔드

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기