Blog

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 프레임워크 총정리

📊 Chapter 5: 실전 패턴과 최적화

🎯 Chapter 6: FGR vs Virtual DOM - 성능 비교

🚨 Chapter 7: 주의사항과 트레이드오프

🎓 Chapter 8: 미래 전망

📝 마무리와 정리


🎯 들어가며: 웹 개발의 패러다임 전환

당신이 스프레드시트에서 =A1+B1이라는 공식을 작성했다고 상상해보세요. A1이나 B1의 값을 변경하면, 결과가 즉시 자동으로 업데이트됩니다. 어떤 버튼을 클릭하거나 페이지를 새로고침할 필요가 없죠.

하지만 React로 이와 같은 동작을 구현하려면 어떨까요?

tsx
// 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)

  1. 상태 변경 → 2. 전체 컴포넌트 함수 재실행 → 3. 새로운 Virtual DOM 생성 → 4. 이전 Virtual DOM과 비교(diff) → 5. 변경된 부분만 실제 DOM 업데이트

✅ Fine-Grained Reactivity 방식

  1. 상태 변경 → 2. 변경된 상태를 구독하는 특정 DOM 노드만 직접 업데이트

이제 실제 예제로 확인해보겠습니다:

typescript
// 스프레드시트의 동작 방식
// 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 방식)

tsx
function ReactComponent() {
  const [count, setCount] = useState(0);
  
  // 렌더링 시점에 값을 "당겨옴"
  const doubled = count * 2; // 매번 계산
  
  return <div>{doubled}</div>;
}

Push 모델 (FGR 방식)

tsx
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를 가진 반응형 컨테이너입니다.

typescript
// 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을 자동으로 추적하고 변경 시 재실행됩니다.

typescript
// 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는 계산 비용이 높은 파생 값을 캐싱합니다.

typescript
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 완전한 반응형 라이브러리 구축

이제 실제로 작동하는 반응형 시스템을 처음부터 구현해봅시다.

typescript
// 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 업데이트: 성능 최적화

javascript
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: 선택적 의존성 제외

javascript
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를 사용한 전역 상태 관리의 문제점을 살펴봅시다:

jsx
// 문제가 되는 코드
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 시스템
jsx
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$ 훅
jsx
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 (파생 상태)
jsx
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)
jsx
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"
jsx
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 완벽 지원
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. 고급 기능들
jsx
// 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 컴포넌트 최적화 패턴
jsx
// 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 앱
jsx
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의 차별점

  1. 제로 보일러플레이트: 액션, 리듀서, 셀렉터 불필요
  2. 직관적 API: get/set만으로 모든 작업 가능
  3. 최고의 성능: 벤치마크 1위
  4. 완벽한 TypeScript: 100% 타입 안전
  5. 통합 영속성: LocalStorage, IndexedDB, MMKV 지원
  6. 원격 동기화: Supabase, Firebase, Keel 등 지원
  7. React Native 최적화: Expo 공식 추천

⚡ Legend State 성능 최적화 가이드

Legend State는 기본적으로 매우 최적화되어 있지만, 대규모 애플리케이션에서 더 나은 성능을 위한 고급 기법들이 있습니다.

1. Batch 처리로 리렌더링 최소화
javascript
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. 프록시 생성 최적화
javascript
// ❌ 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. 배열 렌더링 최적화
javascript
// ❌ 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 기반 안정적인 참조
javascript
// ✅ 객체 배열에는 항상 고유 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() 활용
javascript
// 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)
typescript
// 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)
typescript
// 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. 컴포넌트 레벨 상태 패턴
typescript
// 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 패턴
typescript
// 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. 도메인 기반 아키텍처
typescript
// 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의 내부 동작 원리

javascript
// 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을 핵심으로 설계된 프레임워크입니다.

jsx
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: 중첩 반응성

javascript
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 기반의 반응형 시스템을 제공합니다.

javascript
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는 컴파일 시점에 반응형 코드를 생성합니다.

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: 데코레이터 기반 반응성

javascript
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을 지원합니다.

jsx
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 동적 의존성 추적 패턴

javascript
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 폼 검증 시스템 구현

javascript
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 실시간 데이터 대시보드

javascript
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 벤치마크 시나리오

javascript
// 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 메모리 사용량 비교

javascript
// Virtual DOM 방식
// - 전체 컴포넌트 트리의 Virtual DOM 객체 유지
// - 이전 Virtual DOM과 새 Virtual DOM 두 개 보관
// - Fiber 노드 추가 메모리

// FGR 방식  
// - Signal-Effect 연결 그래프만 유지
// - Virtual DOM 없음
// - 컴포넌트 인스턴스 최소화

🚨 Chapter 7: 주의사항과 트레이드오프

7.1 FGR의 한계

javascript
// 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 제안

javascript
// 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주)

typescript
// 간단한 카운터로 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년을 준비하는 것입니다.


📚 참고 자료

공식 문서

심화 학습

커뮤니티