
React? Svelte?
최근 사내 프로젝트에서 React 대신 SvelteKit을 도입하면서 두 개의 철학과 동작방식이 얼마나 다른지 실무를 통해 체감하게 됐어요. 이 글은 그 경험을 바탕으로 React와 Svelte의 렌더링 방식, 상태 관리, 반응성, 바인딩 문법 등을 비교 정리한 글이에요.
각 특징을 실용적인 관점에서 이해하고 싶은 분들, 또는 React 경험자가 Svelte로 개발할 때 어떤 점에 주의해야 하는지 알고 싶은 분들에게 도움이 되었음 좋겠어요.
철학의 차이: 명시 / 추상
React는 모든 것을 명시하라 라는 철학을 지녀요. 상태 관리, side effect, 최적화 등 대부분의 흐름을 개발자가 직접 정의하고 제어하도록 설계되어 있죠. 예를 들어 상태 변경은 useState
, useReducer
등을 통해 명시적으로 선언해야 하고,
side effect는 useEffect
로 직접 지정해야만 실행돼요. 이러한 구조 덕분에 코드만 읽어도 상태 변화의 흐름이 모두 드러나서 예측 가능성이 높고 디버깅 시에도 흐름을 추적하기 쉬운 장점이 있어요.
Svelte는 적게 쓰고 많이 처리하자 라는 철학을 지녀요. 개발자가 적은 코드를 작성해도 원하는 기능을 얻을 수 있도록, 컴파일 타임에 자동으로 많은 작업을 하죠. 내부적으로 복잡한 작업을 대신 처리해주는 만큼, 겉으로 보이는 코드만으로 동작을 모두 이해하긴 어렵지만, 생산성과 간결함 측면에서는 확실한 장점이 있어요.
React
function Counter() {
const [count, setCount] = useState(0);
const increment = () => setCount((prev) => prev + 1);
return (
<>
<button onClick={increment}>+1</button>
<p>{count}</p>
</>
);
}
- 개발자가 상태 훅을 직접 호출하고 이벤트 핸들러를 선언해요.
- 의도는 명확하지만 코드량이 증가해요.
Svelte
<script>
let count = $state(0);
const increment = () => count += 1;
</script>
<button onclick={increment}>+1</button>
<p>{count}</p>
- 상태 선언, 이벤트 바인딩을 위와 같이 해요.
- Svelte 컴파일러가 변경 추적과 DOM 패치를 자동 처리해요.
상태 관리: 명시적 set / 암묵적 재할당
React
React에서는 상태를 관리하기 위해 useState
훅을 명시적으로 사용해야 해요. 값을 변경 할 땐 setState
를 통해 변경을 알리고, 이대도 단순히 값을 바꾸는 것이 아니라 불변성을 지키며 새로운 참조를 만들어야 하죠.
React는 Object.is(prev, next)
로 이전 상태와 새 상태를 비교해 변경 여부를 판단하기 때문에, 같은 참조를 다시 설정하면 UI가 갱신되지 않아요. 그래서 배열이나 객체 상태를 다룰 땐 항상 불변성을 지켜야하고, 새로운 참조로 만들어서 넘겨야 해요.
const [todos, setTodos] = useState([]);
// 잘못된 방식(같은 참조)
todos.push(newTodo);
setTodos(todos); // Object.is(prev, next) -> true -> 렌더링 X
// 올바른 방식 (새로운 참조)
setTodos([...todos, newTodo]);
Svelte
Svelte는 단순한 변수 재할당만으로도 상태 변화를 감지해요. 컴파일 타임에 어떤 값이 DOM에 영향을 미치는지 분석하고, 해당 지점만 정확하게 업데이트 코드를 생성하죠.
그래서 todos = todos;
처럼 재할당만으로 변화가 발생했음을 인식할 수 있어요.
<script>
let todos = $state([]);
const addTodo = (newTodo) => {
todos.push(newTodo); // 배열 내부 수정
todos = todos; // 재할당 -> 변경 감지 유도
}
</script>
배열 조작: immutable / mutable
React
항목 추가, 삭제, 수정 시 모두 새 배열을 만들어야 하고, 중첩 속성 변경 시에도 깊은 복사를 수동으로 처리해야 해요.
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: '금동이 밥주기', completed: true },
{ id: 2, text: '금동이 산책시키기', completed: false },
]);
const addTodo = (text) => {
// 기존 배열 복사 -> 새 항목 추가
setTodos([
...todos,
{
id: Date.now(),
text,
completed: false,
},
]);
};
const removeTodo = (id) => {
// filter로 새 배열 생성
setTodos(todos.filter((todo) => todo.id !== id));
};
const toggleTodo = (id) => {
// map으로 새 배열 생성하면서 특정 항목만 수정
setTodos(
todos.map((todo) => {
todo.id === id ? { ...todo, completed: !todo.completed } : todo;
})
);
};
return <>...</>;
}
Svelte
Svelte에서는 배열 항목과 그 안의 중첩 객체도 직접 수정 가능해요. push
,splice
,pop
등 변이 메서드도 자유롭게 사용할 수 있고, 변경 내용은 자동으로 감지돼요.
<script>
let todo = $state([
{ id: 1, text: '금동이 밥주기', completed: false },
{ id: 2, text: '금동이 산책시키기', completed: true },
]);
function addTodo(text) {
// 직접 배열에 push - 변이(mutation) 가능
todos.push({
id: Date.now(),
text,
completed: false,
});
}
function removeTodo(id) {
// 원본 배열에서 직접 제거
const index = todos.findIndex(todo => todo.id === id);
if (index !== -1) {
todos.splice(index, 1);
}
}
function toggleTodo(id) {
// 해당 항목을 직접 수정
const todo = todos.find(todo => todo.id === id);
if (todo) {
todo.completed = !todo.completed;
}
}
function popLastTodo() {
// pop()도 직접 사용 가능
todos.pop();
}
</script>
<ul>
{each todos as todo}
<li class={todo.completed ? "completed" : ""}>
<span>{todo.text}</span>
<button onclick={() => toggleTodo(todo.id)}>토글</button>
<button onclick={() => removeTodo(todo.id)}>삭제</button>
</li>
{/each}
</ul>
<button onclick={popLastTodo}>마지막꺼 지우기</button>
React - 배열 요소 수정
배열의 중간 요소를 수정하려면 깊은 복사가 필요해요.
function ItemList() {
const [items, setItems] = useState([
{ id: 1, name: '금동1', count: 0, details: { category: 'A' } },
{ id: 2, name: '금동2', count: 0, details: { category: 'B' } },
{ id: 3, name: '금동3', count: 0, details: { category: 'A' } },
]);
}
// 특정 인덱스의 항목 수정
const updateItemAtIndex = (index, newCount) => {
setItems(
items.map((item, i) => (i === index ? { ...item, count: newCount } : item))
);
};
// 특정 인덱스의 중첩 속성 수정
const updateItemCategory = (index, newCategory) => {
setItems(
items.map((item, i) =>
i === index
? { ...item, details: { ...item.details, category: newCategory } }
: item
)
);
};
return (
<div>
{items.map((item, index) => (
<div key={item.id}>
<span>{item.name}: {item.count}</span>
<button onClick={() => updateItemAtIndex(index, item.count +)}>
증가
</button>
<span>카테고리: {item.details.category}</span>
<button onClick={() => updateItemCategory(index, item.details.category === "A", "B", "A")}>
토글 카테고리
</button>
</div>
))}
</div>
);
Svelte 배열 요소 수정
Svelte는 깊은 복사를 하지 않고 바로 수정해도 반응성이 유지돼요.
<script>
let items = $state([
{ id: 1, name: '금동1', count: 0, details: { category: 'A' } },
{ id: 2, name: '금동2', count: 0, details: { category: 'B' } },
{ id: 3, name: '금동3', count: 0, details: { category: 'A' } },
]);
function updateItemAtIndex(index, newCount) {
items[index].count = newCount;
}
function updateItemCategory(index, newCategory) {
items[index].details.category = newCategory;
}
</script>
<div>
{each items as item, index}
<div>
<span>{item.name}: {item.count}</span>
<button onclick={() => updateItemAtIndex(index, item.count + 1)}>
증가
</button>
<span>카테고리: {item.details.category}</span>
<button onclick={() => updateItemCategory(index, item.details.category === "A" ? "B" : "A")}>
토글 카테고리
</button>
</div>
{/each}
</div>
반응성 시스템: 렌더링 기반 / 데이터 기반
React
React의 반응성은 컴포넌트 단위로 이루어져요. 상태가 변경되면 해당 컴포넌트 함수 전체가 다시 실행되며, 함수 내부에서 정의된 모든 값과 핸들러 함수도 다시 생성돼요.
function AgeForm() {
const [age, setAge] = useState(0);
const handleChange = (event) => {
setAge(event.target.value);
};
return <input type="number" value={age} onChange={handleChange} />;
}
위 코드에서 age
상태가 바뀔 때마다 AgeForm
함수가 다시 호출되고, 그 안의 handleChange
도 매번 새롭게 정의돼요. 이 방식은 Virtual DOM 기반으로 diffing이 수행되기 때문에 실제로는 변경된 부분만 업데이트 되지만,
컴포넌트가 재실행된다는 점에서 불필요한 연산이 섞일 수 있죠. 성능 최적화를 위해 React는 useCallback
, useMemo
, memo
와 같은 별도의 API를 제공해요.
Svelte
Svelte의 반응성은 데이터 단위로 동작해요. 상태가 변경되더라도 컴포넌트 전체가 재실행되지 않으며, 변경된 값과 해당 값에 의존하는 표현식만 다시 평가돼요. 이벤트 핸들러도 최초 한번만 정의되고, 이후에도 그대로 유지돼요.
<script>
let age = $state(0);
const handleChange = (event) => age = event.target.value;
</script>
<input bind:value={age} />
Svelte 파생 상태
Svelte는 상태로부터 파생되는 값도 쉽게 정의할 수 있어요. $derived
는 다른 상태에 의존하는 값을 선언할 때 사용하며, 해당 상태가 변경될 때만 파생 값을 재계산해요.
<script>
// 반응형 상태
let score = $state(0);
// 파생 값
let doubled = $derived(score * 2);
</script>
<input type="number" bind:value={score} />
<p>두 배 점수: {doubled}</p>
여기서 score
는 독립적인 상태이고, doubled
는 score
에 의존하는 파생 값이에요. score
가 바뀌면 Svelte 컴파일러는 어떤 DOM 요소가 영향을 받는지 미리 분석해 해당 부분만 업데이트 하는 코드를 생성해요.
<script>
let hourly = $state(10000); // 첫 번째 실행
let weekly = $derived(daily * 5); // 세 번째 실행
let daily = $derived(hourly * 7); // 두 번째 실행
</script>
<p>일급: {daily.toLocaleString()}원</p>
<p>주급: {weekly.toLocaleString()}원</p>
이처럼 반응형 구문이 여러 개 있을 경우 코드의 순서와 관계없이 의존하는 값이 먼저 실행돼요. 코드 상으로는 hourly
-> weekly
-> daily
계산 코드가 실행되지만,
Svelte 컴파일러가 의존하는 값에 따라 순서대로 실행시키기 때문에 (weekly는 daily에 의존, daily는 hourly에 의존) hourly
-> daily
-> weekly
순으로 실행이 돼요. (topological order - 위상 정렬)
즉 어떤 값에 의존하는지를 Svelte가 분석한 뒤 그 순서대로 계산이 이루어지기 때문에, 개발자가 선언 순서를 크게 생각 안해도 돼요.
바인딩 문법과 폼 처리
React - form
React에서는 입력 필드마다 value
와 onChange
핸들러를 수동으로 연결해요. 코드가 복잡해지면 상태 변경마다 컴포넌트가 다시 렌더링되기 때문에 react-hook-form
과 같은 외부 라이브러리를 도입하는 경우도 있어요.
function SignupForm() {
const [formData, setFormData] = useState({
userForm: '',
email: '',
password: '',
confirmPassword: '',
agreeTerms: false,
});
const [errors, setErrors] = useState({});
const handleChange = (event) => {
const { name, value, type, checked } = event.target;
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value,
});
};
const validataForm = () => {
// 유효성 검사
return true;
};
const handleSubmit = (event) => {
event.preventDefault();
if (validateForm()) {
// 폼 제출 로직
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>이름:</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
/>
{errors.username && <p className="error">{errors.username}</p>}
</div>
{/* 다른 필드들... */}
<div>
<label>
<input
type="checkbox"
name="agreeTerms"
checked={formData.agreeTerms}
onChange={handleChange}
/>
동의합니다.
</label>
{errors.agreeTerms && <p className="error">{errors.agreeTerms}</p>}
</div>
<button type="submit">회원가입</button>
</form>
);
}
Svelte - form
Svelte에서는 bind:value
와 bind:checked
지시어를 사용해 양방향 바인딩을 처리할 수 있어요. 별도의 이벤트 핸들러 없이 입력값과 상태가 동기화 되죠.
<script>
let formData = $state({
username: '',
email: '',
password: '',
confirmPassword: '',
agreeTerms: false,
});
let errors = $state({});
function validateForm() {
// 유효성 검사
return true;
}
function handleSubmit() {
if (validateForm()) {
// 폼 제출 로직
}
}
</script>
<form onsubmit={handleSubmit}>
<div>
<label>이름:</label>
<!-- 양방향 바인딩 -->
<input type="text" bind:value={formData.username} />
{if errors.username}
<p class="error">{errors.username}</p>
{/if}
</div>
<!-- 다른 필드들... -->
<div>
<label>
<!-- 체크박스 바인딩 -->
<input type="checkbox" bind:checked={formData.agreeTerms} />
동의 합니다.
</label>
{if errors.agreeTerms}
<p class="error">{errors.agreeTerms}</p>
{/if}
</div>
<button type="submit">회원가입</button>
</form>
DOM 업데이트 방식: Virtual DOM / 컴파일 타임 최적화
React
return <h1>Hello, {name}</h1>;
React는 상태가 바뀔 때마다 전체 Virtual DOM 트리를 만들고, 이전 VDOM과 비교(diffing)해서 실제로 변경된 부분만 실제 DOM에 반영해요. 덕분에 개발자가 DOM 갱신을 직접 신경 쓰지 않아도 되고, 선언적 UI 작성이 가능하다는 장점이 있지만,
Virtual DOM 객체 생성 + 비교 연산이 기본이에요. 이 때문에 React 팀은 memo
,useCallback
,useMemo
와 같은 API를 제공해서 불필요한 리렌더링을 줄이도록 권장하죠.
Svelte
<h1>Hello, {name}</h1>
Svelte는 Virtual DOM을 사용하지 않아요. 대신 컴파일 타임에 상태 변경이 발생할 경우 어떤 DOM 요소를 어떻게 업데이트 할지 정확한 명령형 코드로 미리 생성해 둬요.
// 컴파일 결과 예시(개념만)
if (changed.name) {
h1.textContent = 'Hello, ' + name;
}
Virtual DOM 생성 및 비교 과정을 생략하고, 변경된 값이 영향을 미치는 부분만 직접 DOM을 수정하는 코드를 컴파일 시점에 생성해요. 런타임의 오버헤드가 작고, 좀 더 빠른 초기 렌더링과 업데이트 성능을 기대할 수 있죠.
최상위 엘리먼트 제약
React
컴포넌트는 하나의 반환 노드가 필요해요. <></>
(Fragment) 로 감싸거나 <div>
를 추가하죠. JSX는 함수 반환값이라서 자바스크립트 값 하나만 반환 가능해요.
function Hello() {
return (
<>
<h1>Hello</h1>
<p>Seoha</p>
</>
);
}
Svelte
형제 엘리먼트가 몇 개든 자유롭게 두면 돼요. 하나의 컴포넌트 파일에서 자유롭게 나열할 수 있고, 컴파일러가 이를 내부적으로 감싸거나 처리해요. Svelte가 HTML 기반 파싱을 하기 때문에, 자바스크립트 함수 반환 제약을 따르지 않기 때문이죠.
<h1>Hello</h1>
<p>Seoha</p>
마무리: 추상화를 어디에 맡길 것인가?
React, Svelte는 모두 훌륭하지만, 결국 누가 얼마나 책임질 것인가에 대한 철학이 달라요.
React
React는 가능한 많은 것을 개발자가 직접 제어하게 해요. 상태 선언, 값 변경, 이벤트 처리, 최적화까지 모두 명시적으로 작성해야 하죠. 이 방식의 장점은 예측 가능성이에요. 코드만 잘 읽어도 컴포넌트가 어떻게 동작할지 대부분 파악 가능하고, 디버깅도 비교적 쉬운편이죠.
하지만 그만큼 반복적인 코드가 많아질 수 있고, 성능 최적화도 일일이 신경 써야 한다는 점에서 피로도가 있을 수 있어요. 예를 들면 useCallback
,useMemo
,memo
같은 최적화 도구들을 적절히 써주지 않으면 리렌더링 비용이 쌓일 수 있죠.
Svelte
Svelte는 반대로 불필요한 코드는 줄이고, 내부적으로 처리하는 컴파일러 중심의 철학을 가져요. 상태를 let
으로 선언하고 값을 재할당하기만 해도 자동으로 반응성 처리가 되고, DOM 업데이트도 자동이죠. Virtual DOM을 사용하지 않기 때문에 기본적인 렌더링 성능이 뛰어난 편이에요. (사실 아직까지 크게 체감하진 못햇어요.)
하지만 동작이 워낙 잘 감춰져 있다 보니, “이게 왜 안되지?”, “이건 어떤 타이밍에 실행되는거지?” 같은 의문이 종종 생겨요. 즉 내부 동작을 완전히 이해하지 않고 쓰면 예측하지 못한 버그나 흐름 상의 햇갈림이 올 수 있죠.
결국 중요힌건
코드의 제어권을 내가 쥐고 명확하게 다룰 것인가, 아니면 컴파일러가 대신 해주게 맡길 것인가? 에 대한 선택이 React와 Svelte 사이의 결정적인 차이점이에요.
선택은 프로젝트의 규모, 팀의 역량, 성능에 대한 요구사항, 생태계 적합성 같은 요소에 따라 달라질 수 있죠. 어느쪽이 더 옳다고 말하긴 어렵지만, 두 철학을 정확히 이해하고 나면 어떤 상황에서 어떤 도구를 선택해야 할지 분명해질 거에요.