Fine-Grained Reactivity 완벽 가이드: 차세대 반응형 프로그래밍의 모든 것
Virtual DOM의 한계를 넘어선 Fine-Grained Reactivity의 핵심 개념부터 실전 구현까지. SolidJS, Vue 3, Legend State 등 주요 FGR 프레임워크와 성능 최적화 기법을 상세히 다룹니다.
Fine-Grained Reactivity 완벽 가이드: 차세대 반응형 프로그래밍의 모든 것
📑 목차
📚 Chapter 1: Fine-Grained Reactivity의 이해
🔧 Chapter 2: 반응형 시스템 직접 구현하기
⚛️ Chapter 3: React의 리렌더링 문제와 FGR 솔루션
🚀 Chapter 4: FGR 프레임워크 총정리
- 4.1 SolidJS: FGR의 정점
- 4.2 Vue 3: Composition API와 반응성
- 4.3 Svelte: 컴파일 타임 반응성
- 4.4 MobX: 데코레이터 기반 반응성
- 4.5 Preact Signals: 경량 FGR 솔루션
📊 Chapter 5: 실전 패턴과 최적화
🎯 Chapter 6: FGR vs Virtual DOM - 성능 비교
🚨 Chapter 7: 주의사항과 트레이드오프
🎓 Chapter 8: 미래 전망
📝 마무리와 정리
🎯 들어가며: 웹 개발의 패러다임 전환
당신이 스프레드시트에서 =A1+B1이라는 공식을 작성했다고 상상해보세요. A1이나 B1의 값을 변경하면, 결과가 즉시 자동으로 업데이트됩니다. 어떤 버튼을 클릭하거나 페이지를 새로고침할 필요가 없죠.
하지만 React로 이와 같은 동작을 구현하려면 어떨까요?
// React에서는 이렇게 해야 합니다
function Calculator() {
const [a, setA] = useState(1);
const [b, setB] = useState(2);
const [sum, setSum] = useState(3); // 수동으로 관리해야 함
// a나 b가 변경될 때마다 수동으로 sum 업데이트
useEffect(() => {
setSum(a + b);
}, [a, b]);
return <div>{sum}</div>; // 전체 컴포넌트 리렌더링
}
**Fine-Grained Reactivity(FGR)**는 이 문제를 근본적으로 해결합니다. 스프레드시트처럼 값이 변경되면 연관된 부분만 자동으로 업데이트되는 시스템을 제공합니다.
현대 웹 개발의 도전과제
- 성능 병목: 수천 개 컴포넌트의 불필요한 리렌더링
- 복잡한 상태 관리: 수동 의존성 추적과 메모이제이션
- 예측 불가능한 업데이트: 비동기 배치 처리로 인한 혼란
- 메모리 오버헤드: Virtual DOM의 이중 트리 구조
FGR은 이 모든 문제를 한 번에 해결합니다.
📚 Chapter 1: Fine-Grained Reactivity의 이해
1.1 핵심 개념: 정밀한 반응성이란?
Fine-Grained Reactivity는 상태 변경이 정확히 필요한 부분만 업데이트하는 반응형 프로그래밍 패러다임입니다.
전통적인 Virtual DOM 방식과 비교해보겠습니다:
🚫 Virtual DOM 방식 (React)
- 상태 변경 → 2. 전체 컴포넌트 함수 재실행 → 3. 새로운 Virtual DOM 생성 → 4. 이전 Virtual DOM과 비교(diff) → 5. 변경된 부분만 실제 DOM 업데이트
✅ Fine-Grained Reactivity 방식
- 상태 변경 → 2. 변경된 상태를 구독하는 특정 DOM 노드만 직접 업데이트
이제 실제 예제로 확인해보겠습니다:
// 스프레드시트의 동작 방식
// A1 = 10
// B1 = 20
// C1 = A1 + B1 // 자동으로 30
// A1을 15로 변경 → C1이 자동으로 35로 업데이트
// FGR로 구현
const [a, setA] = createSignal(10);
const [b, setB] = createSignal(20);
const c = createMemo(() => a() + b());
console.log(c()); // 30
setA(15);
console.log(c()); // 35 (자동 업데이트!)
1.2 Push vs Pull: 반응형 모델의 두 가지 접근
Pull 모델 (React 방식)
function ReactComponent() {
const [count, setCount] = useState(0);
// 렌더링 시점에 값을 "당겨옴"
const doubled = count * 2; // 매번 계산
return <div>{doubled}</div>;
}
Push 모델 (FGR 방식)
function FGRComponent() {
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
// count 변경이 doubled로 자동으로 "밀어냄"
createEffect(() => {
console.log("Doubled:", doubled());
});
}
1.3 FGR의 3대 핵심 원시 타입
🔵 Signal: 반응형 값의 기본 단위
Signal은 getter와 setter를 가진 반응형 컨테이너입니다.
// Signal의 내부 구조 (간소화)
function createSignal<T>(initialValue: T) {
let value = initialValue;
const subscribers = new Set<{ execute: () => void }>();
const read = (): T => {
// 현재 실행 중인 Effect가 있으면 구독자로 등록
if (currentListener) {
subscribers.add(currentListener);
}
return value;
};
const write = (newValue: T | ((prev: T) => T)): void => {
value = typeof newValue === 'function'
? (newValue as (prev: T) => T)(value)
: newValue;
// 모든 구독자에게 변경 알림
for (const sub of [...subscribers]) {
sub.execute();
}
};
return [read, write] as const;
}
🟢 Effect: 자동 반응형 부수 효과
Effect는 Signal을 자동으로 추적하고 변경 시 재실행됩니다.
// Effect의 내부 구조
let currentListener: { execute: () => void; dependencies: Set<any> } | null = null;
function createEffect(fn: () => void) {
const execute = () => {
// 이전 의존성 정리
cleanup(running);
// 현재 Effect를 전역 컨텍스트에 설정
currentListener = running;
try {
fn(); // 함수 실행 중 Signal 읽기가 자동 추적됨
} finally {
currentListener = null;
}
};
const running = {
execute,
dependencies: new Set<any>()
};
execute(); // 초기 실행
return running;
}
function cleanup(running: { dependencies: Set<any> }) {
for (const dep of running.dependencies) {
dep.delete?.(running);
}
running.dependencies.clear();
}
🟡 Memo: 캐싱된 파생 값
Memo는 계산 비용이 높은 파생 값을 캐싱합니다.
function createMemo<T>(fn: () => T) {
const [signal, setSignal] = createSignal<T>(undefined as any);
createEffect(() => setSignal(fn()));
return signal;
}
// 사용 예제
const [count, setCount] = createSignal(0);
const expensive = createMemo(() => {
console.log("계산 중...");
return count() ** 2; // 제곱 계산
});
console.log(expensive()); // "계산 중..." → 0
console.log(expensive()); // 0 (캐싱됨)
setCount(5);
console.log(expensive()); // "계산 중..." → 25
🔧 Chapter 2: 반응형 시스템 직접 구현하기
2.1 완전한 반응형 라이브러리 구축
이제 실제로 작동하는 반응형 시스템을 처음부터 구현해봅시다.
// reactive.ts - 완전한 구현
type Context = Array<{ execute: () => void; dependencies: Set<any> }>;
const context: Context = [];
// 양방향 구독 관계 설정
function subscribe(running: { dependencies: Set<any> }, subscriptions: Set<any>) {
subscriptions.add(running);
running.dependencies.add(subscriptions);
}
// 의존성 정리
function cleanup(running: { dependencies: Set<any> }) {
for (const dep of running.dependencies) {
dep.delete(running);
}
running.dependencies.clear();
}
// Signal 구현
export function createSignal<T>(value: T) {
const subscriptions = new Set();
const read = () => {
const running = context[context.length - 1];
if (running) subscribe(running, subscriptions);
return value;
};
const write = (nextValue) => {
value = typeof nextValue === 'function'
? nextValue(value)
: nextValue;
for (const sub of [...subscriptions]) {
sub.execute();
}
};
return [read, write];
}
// Effect 구현
export function createEffect(fn) {
const execute = () => {
cleanup(running);
context.push(running);
try {
fn();
} finally {
context.pop();
}
};
const running = {
execute,
dependencies: new Set()
};
execute();
}
// Memo 구현
export function createMemo(fn) {
const [signal, setSignal] = createSignal();
createEffect(() => setSignal(fn()));
return signal;
}
2.2 고급 기능 추가
Batch 업데이트: 성능 최적화
let pending = false;
const queue = new Set();
function flush() {
if (pending) return;
pending = true;
queueMicrotask(() => {
for (const effect of queue) {
effect.execute();
}
queue.clear();
pending = false;
});
}
export function batch(fn) {
const prevQueue = queue;
queue = new Set();
fn();
const effects = queue;
queue = prevQueue;
for (const effect of effects) {
queue.add(effect);
}
if (!prevQueue.size) flush();
}
// 사용 예제
batch(() => {
setFirstName("Jane");
setLastName("Doe");
}); // 한 번만 Effect 실행
Untrack: 선택적 의존성 제외
export function untrack(fn) {
const prevContext = context;
context = [];
const result = fn();
context = prevContext;
return result;
}
// 사용 예제
createEffect(() => {
console.log("Count:", count()); // 추적됨
untrack(() => {
console.log("Debug:", debugInfo()); // 추적 안 됨
});
});
⚛️ Chapter 3: React의 리렌더링 문제와 FGR 솔루션
3.1 React의 근본적인 문제
React의 Context API를 사용한 전역 상태 관리의 문제점을 살펴봅시다:
// 문제가 되는 코드
const UserContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState({
name: "김개발",
age: 25,
theme: "dark",
preferences: { /* 복잡한 중첩 객체 */ }
});
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
// name만 사용하는 컴포넌트
function UserName() {
const { user } = useContext(UserContext);
console.log("UserName 리렌더링!"); // theme 변경해도 실행됨 😱
return <div>{user.name}</div>;
}
3.2 Legend State: React를 위한 완벽한 FGR 솔루션
Legend State는 가장 빠른 React 상태 관리 라이브러리로, Expo에서 공식 지원합니다. "성능, 간결함, 강력함"을 모두 갖춘 Signal 기반 상태 관리 솔루션입니다.
📊 성능 벤치마크
Legend State는 모든 주요 React 상태 관리 라이브러리를 압도합니다:
- Zustand보다 5배 빠름
- MobX보다 3배 빠름
- Redux Toolkit보다 10배 빠름
- Jotai보다 4배 빠름
🔧 핵심 기능
1. Observable 시스템
import { observable } from '@legendapp/state';
// 무한 중첩 가능한 Observable
const state$ = observable({
user: {
profile: {
name: "김개발",
bio: "프론트엔드 개발자",
skills: ["React", "TypeScript", "Node.js"]
},
settings: {
theme: "dark",
notifications: {
email: true,
push: false,
sms: false
}
}
},
todos: []
});
// 간단한 get/set 인터페이스
console.log(state$.user.profile.name.get()); // "김개발"
state$.user.profile.name.set("박개발");
// 배열 조작
state$.user.profile.skills.push("GraphQL");
state$.todos.push({ id: 1, text: "Legend State 학습", done: false });
// 함수형 업데이트
state$.user.settings.theme.set(prev => prev === "dark" ? "light" : "dark");
2. React 통합 - use$ 훅
import { observable, use$ } from '@legendapp/state';
const app$ = observable({
count: 0,
user: { name: "김개발", age: 25 },
todos: [],
filter: "all"
});
function SmartComponent() {
// 필요한 부분만 정확히 구독
const count = use$(() => app$.count.get());
const userName = use$(() => app$.user.name.get());
// 컴포넌트는 count나 user.name이 변경될 때만 리렌더링
console.log("Render SmartComponent");
return (
<div>
<h1>{userName}님, 안녕하세요!</h1>
<p>카운트: {count}</p>
<button onClick={() => app$.count.set(c => c + 1)}>증가</button>
</div>
);
}
// 더 간단한 문법: 직접 Observable 전달
function SimpleComponent() {
// Observable을 직접 JSX에 사용 가능
return (
<div>
<h1>{app$.user.name}님</h1>
<p>나이: {app$.user.age}</p>
</div>
);
}
3. Computed Observables (파생 상태)
import { observable, computed } from '@legendapp/state';
const store$ = observable({
items: [
{ id: 1, name: "노트북", price: 1500000, quantity: 2 },
{ id: 2, name: "마우스", price: 50000, quantity: 5 }
],
taxRate: 0.1,
discount: 0.05
});
// Computed observable 생성
const calculations$ = observable({
// 함수를 전달하면 자동으로 computed가 됨
subtotal: () => {
return store$.items.get().reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
},
tax: () => calculations$.subtotal.get() * store$.taxRate.get(),
discount: () => calculations$.subtotal.get() * store$.discount.get(),
total: () => {
const subtotal = calculations$.subtotal.get();
const tax = calculations$.tax.get();
const discount = calculations$.discount.get();
return subtotal + tax - discount;
}
});
// 사용
console.log(calculations$.total.get()); // 자동 계산
store$.items[0].quantity.set(3); // total 자동 재계산
4. 강력한 영속성 (Persistence)
import { observable, observablePersistLocalStorage } from '@legendapp/state';
import { persistObservable } from '@legendapp/state/persist';
import { ObservablePersistIndexedDB } from '@legendapp/state/persist-plugins/indexeddb';
// LocalStorage 영속성
const settings$ = observable({
theme: "dark",
language: "ko",
fontSize: 16
});
persistObservable(settings$, {
local: 'app_settings', // localStorage 키
pluginLocal: observablePersistLocalStorage
});
// IndexedDB 영속성 (대용량 데이터)
const cache$ = observable({
posts: [],
images: {},
userData: {}
});
persistObservable(cache$, {
local: 'app_cache',
pluginLocal: ObservablePersistIndexedDB
});
// React Native MMKV 지원
import { ObservablePersistMMKV } from '@legendapp/state/persist-plugins/mmkv';
const mobileSettings$ = observable({ /* ... */ });
persistObservable(mobileSettings$, {
local: 'mobile_settings',
pluginLocal: ObservablePersistMMKV
});
5. 원격 동기화 - "Local State = Remote State"
import { observable, syncedFetch, syncedKeel, syncedSupabase } from '@legendapp/state';
// 1. 기본 Fetch 동기화
const profile$ = observable(syncedFetch({
get: 'https://api.example.com/profile',
set: 'https://api.example.com/profile',
persist: {
name: 'profile',
plugin: observablePersistLocalStorage
},
retry: {
times: 3,
delay: 1000
},
debounce: 500 // 변경 사항 디바운싱
}));
// 2. Supabase 실시간 동기화
const todos$ = observable(syncedSupabase({
supabase,
collection: 'todos',
filter: (select) => select.eq('user_id', userId),
persist: { name: 'todos' },
realtime: true, // 실시간 업데이트
realtimeOptions: {
broadcast: { self: true }
}
}));
// 3. Keel 백엔드 동기화
const products$ = observable(syncedKeel({
list: keelClient.api.queries.listProducts,
create: keelClient.api.mutations.createProduct,
update: keelClient.api.mutations.updateProduct,
delete: keelClient.api.mutations.deleteProduct,
persist: { name: 'products' }
}));
// 사용 - 로컬처럼 사용하면 자동 동기화
todos$.push({ text: "새 할일", done: false }); // 자동으로 서버 동기화
products$[0].name.set("업데이트된 상품명"); // 자동으로 서버 업데이트
6. TypeScript 완벽 지원
import { observable, Observable } from '@legendapp/state';
interface User {
id: string;
name: string;
email: string;
preferences: {
theme: 'light' | 'dark';
notifications: boolean;
};
}
interface AppState {
user: User | null;
todos: Todo[];
isLoading: boolean;
}
// 타입 안전한 Observable
const state$ = observable<AppState>({
user: null,
todos: [],
isLoading: false
});
// 타입 추론 완벽 지원
const userName = state$.user.name.get(); // string | undefined
state$.user.preferences.theme.set('blue'); // TS Error: 'blue'는 허용되지 않음
// Observable 타입
type UserObservable = Observable<User>;
7. 고급 기능들
// when - 조건이 충족될 때까지 대기
import { when } from '@legendapp/state';
await when(() => state$.user.get() !== null);
console.log("사용자 로그인 완료!");
// observe - Observable 관찰
import { observe } from '@legendapp/state';
const dispose = observe(() => {
console.log("Count:", state$.count.get());
});
// onChange - 특정 Observable 변경 감지
state$.user.onChange((value, prev) => {
console.log("User changed from", prev, "to", value);
});
// batch - 여러 변경을 하나로 묶기
import { batch } from '@legendapp/state';
batch(() => {
state$.user.name.set("새이름");
state$.user.age.set(30);
state$.todos.push({ id: 1, text: "새 할일" });
}); // 한 번만 리렌더링
// peek - 추적 없이 값 읽기
const currentValue = state$.count.peek(); // Effect에서 추적되지 않음
8. React 컴포넌트 최적화 패턴
// Reactive 컴포넌트 - 가장 간단하고 빠른 방법
import { Reactive } from '@legendapp/state/react';
function OptimizedList() {
return (
<Reactive.div>
{/* 이 div는 todos가 변경될 때만 리렌더링 */}
{state$.todos.map(todo => (
<Reactive.li key={todo.id}>
<Reactive.input
type="checkbox"
checked={todo.done}
onChange={e => todo.done.set(e.target.checked)}
/>
{todo.text}
</Reactive.li>
))}
</Reactive.div>
);
}
// Show/Switch 컴포넌트
import { Show, Switch } from '@legendapp/state/react';
function ConditionalRender() {
return (
<>
<Show if={state$.user}>
{(user) => <UserProfile user={user} />}
</Show>
<Switch value={state$.view}>
{{
list: () => <ListView />,
grid: () => <GridView />,
default: () => <DefaultView />
}}
</Switch>
</>
);
}
// For 컴포넌트 - 최적화된 리스트 렌더링
import { For } from '@legendapp/state/react';
function TodoList() {
return (
<For each={state$.todos}>
{(todo$, index) => (
<TodoItem todo={todo$} index={index} />
)}
</For>
);
}
9. 실전 예제: 완전한 Todo 앱
import { observable, persistObservable, computed } from '@legendapp/state';
import { use$, Reactive, For, Show } from '@legendapp/state/react';
// 전역 상태 정의
const todos$ = observable({
items: [],
filter: 'all',
// Computed values
filtered: () => {
const items = todos$.items.get();
const filter = todos$.filter.get();
switch(filter) {
case 'active': return items.filter(t => !t.done);
case 'completed': return items.filter(t => t.done);
default: return items;
}
},
stats: () => {
const items = todos$.items.get();
return {
total: items.length,
active: items.filter(t => !t.done).length,
completed: items.filter(t => t.done).length
};
}
});
// 영속성 설정
persistObservable(todos$, {
local: 'todos_app',
pluginLocal: observablePersistLocalStorage
});
// 액션 함수들
const actions = {
addTodo: (text) => {
todos$.items.push({
id: Date.now(),
text,
done: false,
createdAt: new Date()
});
},
toggleTodo: (id) => {
const todo = todos$.items.find(t => t.id === id);
if (todo) todo.done.toggle();
},
deleteTodo: (id) => {
todos$.items.set(prev => prev.filter(t => t.id !== id));
},
clearCompleted: () => {
todos$.items.set(prev => prev.filter(t => !t.done));
}
};
// React 컴포넌트
function TodoApp() {
const stats = use$(todos$.stats);
return (
<div>
<h1>Todo App with Legend State</h1>
<TodoInput />
<Reactive.div>
필터: {todos$.filter}
<button onClick={() => todos$.filter.set('all')}>전체</button>
<button onClick={() => todos$.filter.set('active')}>진행중</button>
<button onClick={() => todos$.filter.set('completed')}>완료</button>
</Reactive.div>
<For each={todos$.filtered}>
{(todo$) => <TodoItem todo={todo$} />}
</For>
<div>
전체: {stats.total} |
진행중: {stats.active} |
완료: {stats.completed}
</div>
</div>
);
}
🎯 Legend State의 차별점
- 제로 보일러플레이트: 액션, 리듀서, 셀렉터 불필요
- 직관적 API: get/set만으로 모든 작업 가능
- 최고의 성능: 벤치마크 1위
- 완벽한 TypeScript: 100% 타입 안전
- 통합 영속성: LocalStorage, IndexedDB, MMKV 지원
- 원격 동기화: Supabase, Firebase, Keel 등 지원
- React Native 최적화: Expo 공식 추천
⚡ Legend State 성능 최적화 가이드
Legend State는 기본적으로 매우 최적화되어 있지만, 대규모 애플리케이션에서 더 나은 성능을 위한 고급 기법들이 있습니다.
1. Batch 처리로 리렌더링 최소화
import { batch } from '@legendapp/state';
// ❌ Bad: 1000번 리렌더링
function addManyItems() {
for (let i = 0; i < 1000; i++) {
state$.items.push({ id: i, name: `Item ${i}` });
}
}
// ✅ Good: 1번만 리렌더링
function addManyItemsBatched() {
batch(() => {
for (let i = 0; i < 1000; i++) {
state$.items.push({ id: i, name: `Item ${i}` });
}
});
}
// 실전 예제: 대량 데이터 업데이트
const bulkUpdate = () => {
batch(() => {
// 모든 변경사항이 한 번에 반영됨
state$.users.set(newUsers);
state$.settings.theme.set('dark');
state$.ui.loading.set(false);
state$.analytics.events.push(...newEvents);
});
};
2. 프록시 생성 최적화
// ❌ Bad: 불필요한 프록시 생성
const calculateSum = () => {
let sum = 0;
// 각 아이템마다 프록시 생성됨
state$.items.forEach(item => {
sum += item.data.value.get();
});
return sum;
};
// ✅ Good: Raw 데이터 직접 접근
const calculateSumOptimized = () => {
let sum = 0;
// get()으로 raw 데이터 접근 - 프록시 생성 없음
const items = state$.items.get();
items.forEach(item => {
sum += item.data.value;
});
return sum;
};
// ✅ Better: peek()으로 추적 없이 읽기
const calculateSumPeek = () => {
let sum = 0;
// peek()은 의존성 추적을 하지 않음
const items = state$.items.peek();
items.forEach(item => {
sum += item.data.value;
});
return sum;
};
3. 배열 렌더링 최적화
// ❌ Bad: 배열 변경 시 부모 컴포넌트도 리렌더링
function TodoList() {
const todos = use$(state$.todos);
return (
<div>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</div>
);
}
// ✅ Good: For 컴포넌트로 최적화
import { For } from '@legendapp/state/react';
function TodoListOptimized() {
return (
<div>
<For each={state$.todos} optimized>
{(todo$) => <TodoItem todo={todo$} />}
</For>
</div>
);
}
// 고급: 아이템별 메모이제이션
const TodoItem = memo(({ todo$ }) => {
const todo = use$(todo$);
return (
<div>
<Reactive.span>{todo$.text}</Reactive.span>
<Reactive.input
type="checkbox"
checked={todo$.done}
onChange={e => todo$.done.set(e.target.checked)}
/>
</div>
);
});
4. ID 기반 안정적인 참조
// ✅ 객체 배열에는 항상 고유 ID 사용
const todos$ = observable([
{ id: 1, text: "Learn Legend State", done: false },
{ id: 2, text: "Build awesome app", done: false }
]);
// ID를 통한 안정적인 Observable 참조
function TodoApp() {
const addTodo = (text) => {
todos$.push({
id: Date.now(), // 고유 ID 생성
text,
done: false
});
};
const updateTodo = (id, updates) => {
// ID로 특정 아이템 찾아 업데이트
const todo = todos$.find(t => t.id.peek() === id);
if (todo) {
Object.assign(todo, updates);
}
};
return (
<For each={todos$} optimized>
{(todo$, index) => (
// 안정적인 key로 React 최적화
<TodoItem key={todo$.id.peek()} todo={todo$} />
)}
</For>
);
}
5. 선택적 추적과 peek() 활용
// peek()을 사용한 조건부 렌더링 최적화
function SmartComponent() {
const isDebugMode = state$.debug.peek(); // 추적하지 않음
// debug 모드 변경 시 리렌더링되지 않음
if (isDebugMode) {
console.log('Debug info:', state$.data.peek());
}
// 실제로 추적이 필요한 값만 get() 사용
const importantData = use$(state$.importantData);
return <div>{importantData}</div>;
}
// 계산 최적화
const expensiveCalculation = createMemo(() => {
const config = state$.config.peek(); // 설정은 추적 안 함
const data = state$.data.get(); // 데이터만 추적
return processData(data, config);
});
🏗️ Legend State 아키텍처 패턴
1. 글로벌 상태 패턴 (Centralized)
// stores/globalState.ts
import { observable } from '@legendapp/state';
// 전체 앱 상태를 하나의 Observable로 관리
export const globalState$ = observable({
// 인증 관련
auth: {
user: null as User | null,
token: null as string | null,
isAuthenticated: false
},
// UI 상태
ui: {
theme: 'light' as 'light' | 'dark',
sidebarOpen: true,
modals: {
settings: false,
profile: false
},
notifications: [] as Notification[]
},
// 비즈니스 데이터
data: {
products: [] as Product[],
orders: [] as Order[],
customers: [] as Customer[]
},
// 설정
settings: {
language: 'ko',
timezone: 'Asia/Seoul',
notifications: {
email: true,
push: false
}
}
});
// Computed values
export const computed$ = observable({
// 파생 상태들
activeOrders: () => {
return globalState$.data.orders.get()
.filter(order => order.status === 'active');
},
totalRevenue: () => {
return globalState$.data.orders.get()
.reduce((sum, order) => sum + order.total, 0);
},
userDisplayName: () => {
const user = globalState$.auth.user.get();
return user ? `${user.firstName} ${user.lastName}` : 'Guest';
}
});
// Actions
export const actions = {
login: async (credentials: LoginCredentials) => {
const response = await api.login(credentials);
batch(() => {
globalState$.auth.user.set(response.user);
globalState$.auth.token.set(response.token);
globalState$.auth.isAuthenticated.set(true);
});
},
logout: () => {
batch(() => {
globalState$.auth.set({
user: null,
token: null,
isAuthenticated: false
});
});
},
toggleTheme: () => {
globalState$.ui.theme.set(prev =>
prev === 'light' ? 'dark' : 'light'
);
}
};
2. 모듈식 Atom 패턴 (Decentralized)
// stores/atoms/authAtom.ts
export const auth$ = observable({
user: null as User | null,
token: null as string | null,
// Atom 내부 computed
isAuthenticated: () => auth$.user.get() !== null,
// Atom 내부 actions
login: async (credentials: LoginCredentials) => {
const response = await api.login(credentials);
batch(() => {
auth$.user.set(response.user);
auth$.token.set(response.token);
});
},
logout: () => {
auth$.user.set(null);
auth$.token.set(null);
}
});
// stores/atoms/uiAtom.ts
export const ui$ = observable({
theme: 'light' as Theme,
sidebarOpen: true,
toggleTheme: () => {
ui$.theme.set(prev => prev === 'light' ? 'dark' : 'light');
},
toggleSidebar: () => {
ui$.sidebarOpen.set(prev => !prev);
}
});
// stores/atoms/productsAtom.ts
export const products$ = observable({
items: [] as Product[],
loading: false,
error: null as string | null,
// 파생 상태
featured: () => products$.items.get().filter(p => p.featured),
inStock: () => products$.items.get().filter(p => p.stock > 0),
// Actions
fetch: async () => {
products$.loading.set(true);
try {
const data = await api.getProducts();
products$.items.set(data);
} catch (error) {
products$.error.set(error.message);
} finally {
products$.loading.set(false);
}
}
});
3. 컴포넌트 레벨 상태 패턴
// hooks/useComponentState.ts
import { useObservable } from '@legendapp/state/react';
// 재사용 가능한 커스텀 훅
export function useFormState<T>(initialValues: T) {
const form$ = useObservable({
values: initialValues,
errors: {} as Record<keyof T, string>,
touched: {} as Record<keyof T, boolean>,
isSubmitting: false,
// Computed
isValid: () => {
const errors = form$.errors.get();
return Object.keys(errors).length === 0;
},
// Actions
setField: (field: keyof T, value: any) => {
form$.values[field].set(value);
form$.touched[field].set(true);
},
setError: (field: keyof T, error: string) => {
form$.errors[field].set(error);
},
reset: () => {
batch(() => {
form$.values.set(initialValues);
form$.errors.set({});
form$.touched.set({});
form$.isSubmitting.set(false);
});
},
submit: async (onSubmit: (values: T) => Promise<void>) => {
form$.isSubmitting.set(true);
try {
await onSubmit(form$.values.get());
form$.reset();
} finally {
form$.isSubmitting.set(false);
}
}
});
return form$;
}
// 컴포넌트에서 사용
function LoginForm() {
const form$ = useFormState({
email: '',
password: ''
});
const handleSubmit = () => {
form$.submit(async (values) => {
await auth$.login(values);
});
};
return (
<form onSubmit={handleSubmit}>
<Reactive.input
value={form$.values.email}
onChange={e => form$.setField('email', e.target.value)}
className={form$.errors.email && 'error'}
/>
<Show if={form$.errors.email}>
{(error) => <span className="error-message">{error}</span>}
</Show>
<Reactive.button
disabled={form$.isSubmitting || !form$.isValid}
type="submit"
>
{form$.isSubmitting ? 'Loading...' : 'Login'}
</Reactive.button>
</form>
);
}
4. Context와 Provider 패턴
// contexts/AppStateContext.tsx
import { createContext, useContext } from 'react';
import { Observable } from '@legendapp/state';
interface AppState {
user: User | null;
settings: Settings;
// ...
}
const AppStateContext = createContext<Observable<AppState> | null>(null);
export function AppStateProvider({ children }) {
const state$ = useObservable<AppState>({
user: null,
settings: defaultSettings
});
// 영속성 설정
useEffect(() => {
persistObservable(state$.settings, {
local: 'app_settings'
});
}, []);
return (
<AppStateContext.Provider value={state$}>
{children}
</AppStateContext.Provider>
);
}
export function useAppState() {
const state$ = useContext(AppStateContext);
if (!state$) {
throw new Error('useAppState must be used within AppStateProvider');
}
return state$;
}
// 사용
function SomeComponent() {
const state$ = useAppState();
const user = use$(state$.user);
return <div>Welcome, {user?.name}!</div>;
}
5. 도메인 기반 아키텍처
// domains/shopping/state.ts
class ShoppingDomain {
private state$ = observable({
cart: {
items: [] as CartItem[],
coupon: null as string | null
},
checkout: {
step: 'cart' as CheckoutStep,
shippingAddress: null as Address | null,
paymentMethod: null as PaymentMethod | null
}
});
// Getters
get cart$() { return this.state$.cart; }
get checkout$() { return this.state$.checkout; }
// Computed
readonly total$ = observable(() => {
const items = this.cart$.items.get();
const subtotal = items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
const coupon = this.cart$.coupon.get();
const discount = coupon ? calculateDiscount(subtotal, coupon) : 0;
return {
subtotal,
discount,
total: subtotal - discount
};
});
// Actions
addToCart(product: Product, quantity = 1) {
const existingItem = this.cart$.items.find(
item => item.productId.peek() === product.id
);
if (existingItem) {
existingItem.quantity.set(prev => prev + quantity);
} else {
this.cart$.items.push({
productId: product.id,
name: product.name,
price: product.price,
quantity
});
}
}
removeFromCart(productId: string) {
this.cart$.items.set(prev =>
prev.filter(item => item.productId !== productId)
);
}
applyCoupon(code: string) {
// 쿠폰 검증 로직
this.cart$.coupon.set(code);
}
proceedToCheckout() {
if (this.cart$.items.get().length === 0) {
throw new Error('Cart is empty');
}
this.checkout$.step.set('shipping');
}
}
export const shoppingDomain = new ShoppingDomain();
Legend State는 "Write Less, Do More" 철학으로 복잡한 상태 관리를 단순하게 만들어줍니다.
3.3 FGR의 내부 동작 원리
// Observable의 핵심 구조
const observableValue = {
_value: "초기값",
_handlers: [], // 구독자들
get() {
// 현재 실행 중인 Effect를 구독자로 등록
if (currentTracker) {
this._handlers.push(currentTracker);
}
return this._value;
},
set(newValue) {
this._value = newValue;
// 구독자들에게 알림
this._handlers.forEach(handler => handler());
}
};
// use$ 훅의 간소화된 구현
function use$(callback) {
const [, forceUpdate] = useReducer(x => x + 1, 0);
useEffect(() => {
effect(() => {
callback(); // Observable 값 읽기 (자동 구독)
forceUpdate(); // 값 변경 시 리렌더링
});
}, []);
return callback();
}
🚀 Chapter 4: FGR 프레임워크 총정리
4.1 SolidJS: FGR의 정점
SolidJS는 FGR을 핵심으로 설계된 프레임워크입니다.
import { createSignal, createEffect, createMemo, Show, For } from 'solid-js';
function TodoApp() {
const [todos, setTodos] = createSignal([]);
const [filter, setFilter] = createSignal('all');
// 자동 메모이제이션
const filteredTodos = createMemo(() => {
const currentFilter = filter();
const currentTodos = todos();
switch(currentFilter) {
case 'active': return currentTodos.filter(t => !t.done);
case 'completed': return currentTodos.filter(t => t.done);
default: return currentTodos;
}
});
const stats = createMemo(() => ({
total: todos().length,
active: todos().filter(t => !t.done).length,
completed: todos().filter(t => t.done).length
}));
// JSX는 한 번만 실행됨
return (
<div>
<div>전체: {stats().total}</div>
<For each={filteredTodos()}>
{(todo) => <TodoItem todo={todo} />}
</For>
</div>
);
}
SolidJS의 Store: 중첩 반응성
import { createStore } from 'solid-js/store';
const [state, setState] = createStore({
user: {
profile: {
name: "김개발",
bio: "프론트엔드 개발자"
},
settings: {
theme: "dark",
notifications: true
}
}
});
// 세밀한 업데이트
setState("user", "profile", "name", "박개발");
// user.profile.name을 구독하는 곳만 업데이트!
// 배치 업데이트
batch(() => {
setState("user", "settings", "theme", "light");
setState("user", "settings", "notifications", false);
});
4.2 Vue 3: Composition API와 반응성
Vue 3는 Proxy 기반의 반응형 시스템을 제공합니다.
import { ref, reactive, computed, watchEffect } from 'vue';
// Vue의 반응형 원시 타입들
const count = ref(0); // Signal과 유사
const state = reactive({ // Store와 유사
user: { name: "김개발", age: 25 }
});
// Computed = Memo
const doubled = computed(() => count.value * 2);
// WatchEffect = Effect
watchEffect(() => {
console.log(`${state.user.name}: ${doubled.value}`);
});
// 자동 추적
count.value++; // watchEffect 재실행
state.user.name = "박개발"; // watchEffect 재실행
4.3 Svelte: 컴파일 타임 반응성
Svelte는 컴파일 시점에 반응형 코드를 생성합니다.
<script>
// $ 레이블로 반응형 선언
let count = 0;
$: doubled = count * 2; // Memo와 유사
$: { // Effect와 유사
console.log(`count: ${count}, doubled: ${doubled}`);
}
// 컴파일 후 실제 생성되는 코드 (간소화)
// $$invalidate(0, count = 5);
// if ($$dirty & /*count*/ 1) $$invalidate(1, doubled = count * 2);
</script>
<button on:click={() => count++}>
{count} × 2 = {doubled}
</button>
4.4 MobX: 데코레이터 기반 반응성
import { makeObservable, observable, computed, autorun, action } from 'mobx';
class TodoStore {
todos = [];
filter = "all";
constructor() {
makeObservable(this, {
todos: observable,
filter: observable,
filteredTodos: computed,
addTodo: action
});
}
get filteredTodos() {
switch(this.filter) {
case "active": return this.todos.filter(t => !t.done);
case "completed": return this.todos.filter(t => t.done);
default: return this.todos;
}
}
addTodo(text) {
this.todos.push({ text, done: false });
}
}
const store = new TodoStore();
// 자동 반응
autorun(() => {
console.log(`Todos: ${store.filteredTodos.length}`);
});
4.5 Preact Signals: 경량 FGR 솔루션
Preact는 2022년 Signals를 도입하여 선택적 FGR을 지원합니다.
import { signal, computed, effect } from '@preact/signals';
// 컴포넌트 외부에서도 정의 가능
const globalCount = signal(0);
const doubled = computed(() => globalCount.value * 2);
function Counter() {
// 컴포넌트는 한 번만 렌더링
console.log("Component rendered once");
return (
<div>
<span>{globalCount}</span> {/* 텍스트 노드만 업데이트 */}
<span>{doubled}</span>
<button onClick={() => globalCount.value++}>+</button>
</div>
);
}
// 컴포넌트 외부에서 Effect
effect(() => {
document.title = `Count: ${globalCount.value}`;
});
📊 Chapter 5: 실전 패턴과 최적화
5.1 동적 의존성 추적 패턴
const [showDetails, setShowDetails] = createSignal(false);
const [summary, setSummary] = createSignal("요약");
const [details, setDetails] = createSignal("상세 정보");
createEffect(() => {
console.log("Summary:", summary());
// 조건부 의존성
if (showDetails()) {
console.log("Details:", details());
}
});
// showDetails가 false일 때
setDetails("새 상세 정보"); // Effect 실행 안 됨!
setShowDetails(true); // 이제 details도 추적 시작
5.2 폼 검증 시스템 구현
function createFormValidator() {
const fields = {
email: createSignal(""),
password: createSignal(""),
confirmPassword: createSignal("")
};
const validators = {
email: createMemo(() => {
const value = fields.email[0]();
if (!value) return { valid: false, error: "필수 입력" };
if (!value.includes('@')) return { valid: false, error: "올바른 이메일 형식이 아닙니다" };
return { valid: true };
}),
password: createMemo(() => {
const value = fields.password[0]();
if (value.length < 8) return { valid: false, error: "8자 이상 입력" };
if (!/[A-Z]/.test(value)) return { valid: false, error: "대문자 포함 필수" };
if (!/[0-9]/.test(value)) return { valid: false, error: "숫자 포함 필수" };
return { valid: true };
}),
confirmPassword: createMemo(() => {
const password = fields.password[0]();
const confirm = fields.confirmPassword[0]();
if (password !== confirm) return { valid: false, error: "비밀번호가 일치하지 않습니다" };
return { valid: true };
})
};
const isFormValid = createMemo(() => {
return Object.values(validators).every(v => v().valid);
});
const errors = createMemo(() => {
return Object.entries(validators)
.filter(([_, validator]) => !validator().valid)
.map(([field, validator]) => ({
field,
error: validator().error
}));
});
return { fields, validators, isFormValid, errors };
}
5.3 실시간 데이터 대시보드
function createRealtimeDashboard() {
const [data, setData] = createSignal([]);
const [filters, setFilters] = createStore({
status: "all",
dateRange: { start: null, end: null },
searchTerm: ""
});
// 다단계 파생 상태
const filtered = createMemo(() => {
let result = data();
if (filters.status !== "all") {
result = result.filter(item => item.status === filters.status);
}
if (filters.searchTerm) {
result = result.filter(item =>
item.name.toLowerCase().includes(filters.searchTerm.toLowerCase())
);
}
if (filters.dateRange.start && filters.dateRange.end) {
result = result.filter(item =>
item.date >= filters.dateRange.start &&
item.date <= filters.dateRange.end
);
}
return result;
});
const sorted = createMemo(() => {
return [...filtered()].sort((a, b) => b.date - a.date);
});
const stats = createMemo(() => ({
total: filtered().length,
active: filtered().filter(i => i.status === "active").length,
pending: filtered().filter(i => i.status === "pending").length,
completed: filtered().filter(i => i.status === "completed").length,
avgValue: filtered().reduce((sum, i) => sum + i.value, 0) / filtered().length || 0
}));
const chartData = createMemo(() => {
const grouped = filtered().reduce((acc, item) => {
const date = item.date.toDateString();
acc[date] = (acc[date] || 0) + item.value;
return acc;
}, {});
return Object.entries(grouped).map(([date, value]) => ({ date, value }));
});
// WebSocket 연결 시뮬레이션
createEffect(() => {
const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = (event) => {
const newItem = JSON.parse(event.data);
setData(prev => [...prev, newItem]);
};
return () => ws.close();
});
return { sorted, stats, chartData, filters, setFilters };
}
🎯 Chapter 6: FGR vs Virtual DOM - 성능 비교
6.1 벤치마크 시나리오
// 1000개의 아이템을 가진 리스트에서 하나의 아이템 업데이트
// React (Virtual DOM)
function ReactList({ items }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name}: {item.value}
</li>
))}
</ul>
);
}
// 결과: 전체 리스트 Virtual DOM 재생성 → 비교 → 하나의 DOM 노드 업데이트
// SolidJS (FGR)
function SolidList(props) {
return (
<ul>
<For each={props.items}>
{(item) => (
<li>
{item.name}: {item.value}
</li>
)}
</For>
</ul>
);
}
// 결과: 변경된 아이템의 텍스트 노드만 직접 업데이트
6.2 메모리 사용량 비교
// Virtual DOM 방식
// - 전체 컴포넌트 트리의 Virtual DOM 객체 유지
// - 이전 Virtual DOM과 새 Virtual DOM 두 개 보관
// - Fiber 노드 추가 메모리
// FGR 방식
// - Signal-Effect 연결 그래프만 유지
// - Virtual DOM 없음
// - 컴포넌트 인스턴스 최소화
🚨 Chapter 7: 주의사항과 트레이드오프
7.1 FGR의 한계
// 1. 학습 곡선
// React 개발자가 익숙한 패턴
const [state, setState] = useState({ name: "김개발", age: 25 });
setState({ ...state, age: 26 });
// FGR에서 요구되는 패턴
const state = createStore({ name: "김개발", age: 25 });
setState("age", 26);
// 2. 디버깅의 복잡성
// 반응형 그래프가 복잡해지면 의존성 추적이 어려움
createEffect(() => {
// 이 Effect가 왜 실행되었는지 추적하기 어려울 수 있음
const result = complexCalculation();
sideEffect(result);
});
// 3. 생태계 호환성
// React 생태계의 라이브러리들과 호환성 문제
// - React DevTools 지원 제한
// - 써드파티 컴포넌트 라이브러리 통합 어려움
7.2 언제 FGR을 선택해야 하는가?
✅ FGR이 적합한 경우:
- 실시간 데이터가 많은 대시보드
- 복잡한 상태 의존성을 가진 애플리케이션
- 성능이 매우 중요한 모바일 웹
- 세밀한 반응성 제어가 필요한 경우
❌ FGR이 부적합한 경우:
- 간단한 CRUD 애플리케이션
- SEO가 중요한 콘텐츠 사이트
- React 생태계에 깊게 의존하는 프로젝트
- 팀의 학습 비용이 부담되는 경우
🎓 Chapter 8: 미래 전망
8.1 React의 대응: React Forget과 Signals 제안
// React Forget (자동 메모이제이션 컴파일러)
function Component({ data }) {
// 컴파일러가 자동으로 useMemo 적용
const expensive = data.map(item => heavyComputation(item));
return <List items={expensive} />;
}
// React Signals 제안 (TC39 Stage 1)
const counter = new Signal(0);
const doubled = new Computed(() => counter.value * 2);
effect(() => {
console.log(doubled.value);
});
8.2 웹 표준화 움직임
JavaScript 표준에 Signals를 추가하려는 TC39 제안이 진행 중입니다. 이것이 실현되면:
- 프레임워크 간 상호 운용성 향상
- 브라우저 수준의 최적화 가능
- 표준화된 반응형 프로그래밍 모델
📝 마무리: FGR로 시작하는 새로운 개발 경험
Fine-Grained Reactivity는 단순히 성능 최적화 기법이 아닙니다. 개발자 경험과 사용자 경험을 동시에 혁신하는 패러다임 전환입니다.
🎯 FGR이 가져다주는 변화
| 측면 | 기존 방식 | FGR 방식 | 개선 효과 |
|---|---|---|---|
| 성능 | 전체 컴포넌트 리렌더링 | 변경 부분만 업데이트 | 5-10배 빠른 업데이트 |
| 메모리 | Virtual DOM 이중 구조 | 직접 DOM 조작 | 50% 메모리 절약 |
| 개발 경험 | 수동 의존성 관리 | 자동 추적 | 버그 90% 감소 |
| 코드 복잡도 | useEffect, useMemo 남발 | 선언적 파생 상태 | 코드량 30% 감소 |
🚀 지금 시작하는 FGR 로드맵
1단계: 개념 학습 (1주)
// 간단한 카운터로 Signal 패턴 익히기
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
createEffect(() => {
console.log(`Count: ${count()}, Doubled: ${doubled()}`);
});
2단계: 부분 적용 (2-4주)
- React 프로젝트: Legend State로 전역 상태 관리
- 새 프로젝트: SolidJS로 간단한 페이지 구현
- Vue 사용자: Composition API의 reactive/computed 적극 활용
3단계: 프로덕션 준비 (2-3개월)
- 팀 교육 및 코딩 컨벤션 수립
- 기존 프로젝트 점진적 마이그레이션
- 성능 모니터링 및 최적화
🎓 마지막 조언
"완벽한 기술은 없지만, 더 나은 선택은 있습니다."
FGR은 다음과 같은 프로젝트에 특히 적합합니다:
- ✅ 실시간 데이터 처리가 중요한 대시보드
- ✅ 복잡한 상태 의존성을 가진 애플리케이션
- ✅ 모바일 성능이 중요한 PWA
- ✅ 새로 시작하는 프로젝트
반면 다음의 경우는 신중하게 고려하세요:
- ⚠️ 단순한 콘텐츠 중심 웹사이트
- ⚠️ React 생태계에 깊게 의존하는 레거시 프로젝트
- ⚠️ 팀의 학습 리소스가 제한적인 상황
🌟 FGR의 미래
TC39에서 JavaScript 표준으로 Signals 제안이 진행 중이며, React도 React Forget과 Signals 도입을 검토하고 있습니다. FGR은 선택이 아닌 필수가 되어가고 있습니다.
지금 시작하면, 당신은 웹 개발의 다음 10년을 준비하는 것입니다.
📚 참고 자료
공식 문서
심화 학습
- Building a Reactive Library from Scratch
- A Hands-on Introduction to Fine-Grained Reactivity
- TC39 Signals Proposal