React 상태관리 - Jotai

Take!·2025년 8월 6일

JavaScript

목록 보기
9/12

시작하며...

지난 Zustand편에 이어 Jotai를 알아보았다. Jotai의 핵심 개념과 철학, 그리고 실제 활용법까지 확인해보자.

Jotai란?

  • 일단 의미는 이제는 많은 사람들이 알고 있는데, 단순히 일본어로 "상태"라는 뜻이다. 기존의 top-down 방식 상태와는 완전히 다른 bottom-up atomic approach를 제안하는 React 상태 관리 라이브러리이다.

  • 핵심 철학 : 상태를 원자 단위로 생각할 것!

<script>
const bigStore = {
	user: { name: 'wtlee', email: 'wtlee@email.com' },
    counter: { count: 0 },
    todos: []
}

//	Jotai의 방식 : 작은 원자들의 조합 -> 상태 구성
const userNameAtom = atom('wtlee');
const userEmailAtom = atom('wtlee@email.com');
const countAtom = atom(0);
const todosAtom = atom([]);
</script>
  • Atom: 상태의 최소 단위
    • Atom은 Jotai의 핵심 개념으로, 상태의 가장 작은 단위를 나타낸다. 마치 화학에서 원자가 물질의 기본 구성 요소인 것처럼, Jotai에서는 atom이 상태의 기본 구성 요소인 것이다!
  • 기본 Atom 생성
<script>
import { atom } from 'jotai'

const countAtom = atom(0);
const nameAtom = atom('wtlee');
const isLoadingAtom = atom(false);

//	객체 atom
const userAtom = atom({ name: 'wtlee', age: 35 });

//	배열 atom
const todosAtom = atom([
	{ id: 1, text: '밥먹기', completed: false },
    { id: 2, text: '놀기', completed: true },
]);
</script>
  • Atom의 세 가지 타입
<script>
//	1. Read-Write Atom (기본)
const countAtom = atom(0);

//	2. Read-Only Atom (Derived Atom)
const doubleCountAtom = atom((get) => get(countAtom) * 2);

//	3. Write-Only Atom (Action Atom)
const incrementAtom = atom(
	null,
    (get, set) => {
    	set(countAtom, get(countAtom) + 1)
    }
);

//	4. Read-Write Atom (Custom Logic)
const upperCaseNameAtom = atom(
	(get) => get(nameAtom).toUpperCase(),	//	read
    (get, set, newValue) => set(nameAtom, newValue.toLowerCase());	//	write
</script>

구독/알림 패턴의 진화된 구현

  • Jotai는 기존의 구독/알림 패턴을 atom 레벨에서 구현하되, 의존성 추적을 자동화함.

전형적인 구독/알림 vs Jotai

<script>
//	전통적인 방식
class Store {
	constructor() {
    	this.state = { count: 0, name: 'wtlee' };
        this.subscribers = new Set();
    }
    
    subscribe(callback) {
    	this.subscribers.add(callback);
        return () => this.subscribers.delete(callback);		//	closure
    }
    
    setState(newState) {
    	this.state = { ...this.state, ...newState };
        this.subscribers.forEach(callback => callback(this.state));
    }
}

---

//	Jotai 방식 -> 자동 의존성 추적
const countAtom = atom(0);
const nameAtom = atom('wtlee');

//	이 atom은 countAtom에만 의존하므로, count가 변경될 때만 업데이트
const doubleCountAtom = atom((get) => get(countAtom) * 2);

//	이 atom은 countAtom과 nameAtom 모두에 의존
const greetingAtom = atom((get) => `Hello ${get(nameAtom)}, count is ${get(countAtom)}`
</script>

의존성 그래프

<script>

//	의존성 그래프 예시
const firstNameAtom = atom('wootaik');
const lastNameAtom = atom('Lee');
const ageAtom = atom(35);

//	파생 atoms - 자동으로 의존성 추적
const fullNameAtom = atom((get) => `${get(firstNameAtom)} ${get(lastNameAtom)}`)

const greetingAtom = atom((get) => 
  `Hello, I'm ${get(fullNameAtom)} and I'm ${get(ageAtom)} years old`
);

// firstNameAtom 변경 시:
// firstNameAtom → fullNameAtom → greetingAtom 순서로 업데이트
// ageAtom은 영향받지 않음
</script>

Jotai 내부 구현 원리

<script>
class JotaiStore {
	constructor() {
    	this.atomStateMap = new WeakMap()				//	atom -> value
        this.atomDependentsMap = new WeakMap()			//	atom -> Set<dependent atoms>
        this.atomDependenciesMap = new WeakMap()		//	atom -> Set<dependency atoms>
        this.atomListenersMap = new WeakMap()			//	atom -> Set<Listeners>
        this.currentReadingAtom = null					//	현재 읽고 있는 atom 추적
    }
    
    readAtom(atom) {
    	//	의존성 추적을 위해 현재 읽고 있는 atom 기록
        const prevReadingAtom = this.currentReadingAtom;		//	의존성 추적을 위해 현재 읽고 있는 atom 기록
        this.currentReadingAtom = atom
        
        let value;
        
        if (atom.read) {
        	//	derived atom (파생된 atom)인 경우
        	value = atom.read(this.readAtom.bind(this));
        } else {
        	//	primitive atom인 경우
            if (!this.atomStateMap.has(atom)) {
            	this.atomStateMap.set(atom, atom.init)
            }
            value = this.atomStateMap.get(atom)
        }
        
        //	의존성 등록
        if (prevReadingAtom && prevReadingAtom !== atom) {
        	this.addDependency(prevReadingAtom, atom);
        }
        
        this.currentReadingAtom = prevReadingAtom;
        return value;
    }
    
    writeAtom(atom, value) {
    	if (atom.write) {
        	atom.write(this.readAtom.bind(this), this.writeAtom.bind(this), value)
        } else {
        	this.atomStateMap.set(atom, value);
        }
        
        //	의존하는 atoms들에게 알림
        this.invalidateAtom(atom);
    }
    
    addDependency(dependent, dependency) {
    	if (!this.atomDependentsMap.has(dependency)) {
        	this.atomDependentsMap.set(dependency, new Set())
        }
        
        this.atomDependentsMap.get(dependency).add(dependent);
        
        if (!this.atomDependenciseMap.has(dependent)) {
        	this.atomDependenciesMap.set(dependent, new Set());
        }
        this.atomDependenciesMap.get(dependent).add(dependency);
    }
    
    invalidateAtom(atom) {
    // 이 atom에 의존하는 모든 atoms를 무효화
    const dependents = this.atomDependentsMap.get(atom)
    if (dependents) {
      dependents.forEach(dependent => {
        this.invalidateAtom(dependent)
      })
    }
    
    // 리스너들에게 알림
    const listeners = this.atomListenersMap.get(atom)
    if (listeners) {
      listeners.forEach(listener => listener())
    }
  }
}
</script>

실제 사용 패턴

  • 기본 사용법
<script>
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'

// atoms 정의
const countAtom = atom(0)
const nameAtom = atom('wootaik')

function Counter() {
  const [count, setCount] = useAtom(countAtom)
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>
        Increment
      </button>
    </div>
  )
}

function NameDisplay() {
  const name = useAtomValue(nameAtom) // 읽기 전용
  return <h1>Hello, {name}!</h1>
}

function NameInput() {
  const setName = useSetAtom(nameAtom) // 쓰기 전용
  return (
    <input 
      onChange={(e) => setName(e.target.value)}
      placeholder="Enter name"
    />
  )
}
</script>
  • 복합 상태 관리
<script>
// 기본 atoms
const todosAtom = atom([])
const filterAtom = atom('all') // 'all' | 'completed' | 'active'

// 파생 atoms
const filteredTodosAtom = atom((get) => {
  const todos = get(todosAtom)
  const filter = get(filterAtom)
  
  switch (filter) {
    case 'completed':
      return todos.filter(todo => todo.completed)
    case 'active':
      return todos.filter(todo => !todo.completed)
    default:
      return todos
  }
})

const todoStatsAtom = atom((get) => {
  const todos = get(todosAtom)
  return {
    total: todos.length,
    completed: todos.filter(todo => todo.completed).length,
    active: todos.filter(todo => !todo.completed).length
  }
})

// 액션 atoms
const addTodoAtom = atom(
  null,
  (get, set, text) => {
    const todos = get(todosAtom)
    set(todosAtom, [
      ...todos,
      { id: Date.now(), text, completed: false }
    ])
  }
)

const toggleTodoAtom = atom(
  null,
  (get, set, id) => {
    const todos = get(todosAtom)
    set(todosAtom, 
      todos.map(todo => 
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    )
  }
)

// 컴포넌트에서 사용
function TodoApp() {
  return (
    <div>
      <AddTodo />
      <FilterButtons />
      <TodoList />
      <TodoStats />
    </div>
  )
}

function AddTodo() {
  const [, addTodo] = useAtom(addTodoAtom)
  const [text, setText] = useState('')
  
  const handleSubmit = (e) => {
    e.preventDefault()
    if (text.trim()) {
      addTodo(text.trim())
      setText('')
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Add todo..."
      />
      <button type="submit">Add</button>
    </form>
  )
}

function TodoList() {
  const filteredTodos = useAtomValue(filteredTodosAtom)
  
  return (
    <ul>
      {filteredTodos.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  )
}

function TodoItem({ todo }) {
  const [, toggleTodo] = useAtom(toggleTodoAtom)
  
  return (
    <li>
      <input 
        type="checkbox"
        checked={todo.completed}
        onChange={() => toggleTodo(todo.id)}
      />
      <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
        {todo.text}
      </span>
    </li>
  )
}

function TodoStats() {
  const stats = useAtomValue(todoStatsAtom)
  
  return (
    <div>
      Total: {stats.total} | 
      Completed: {stats.completed} | 
      Active: {stats.active}
    </div>
  )
}
</script>

고급 기능들

1. Atom Family - 동적 Atom 생성

<script>
import { atomFamily } from 'jotai/utils'

// ID별로 동적으로 atom 생성
const todoAtomFamily = atomFamily((id) => 
  atom({ id, text: '', completed: false })
)

// 사용자별 설정 atoms
const userSettingsFamily = atomFamily((userId) =>
  atom({ theme: 'light', language: 'en', notifications: true })
)

function TodoItem({ id }) {
  const [todo, setTodo] = useAtom(todoAtomFamily(id))
  
  return (
    <div>
      <input 
        value={todo.text}
        onChange={(e) => setTodo(prev => ({ ...prev, text: e.target.value }))}
      />
      <input 
        type="checkbox"
        checked={todo.completed}
        onChange={(e) => setTodo(prev => ({ ...prev, completed: e.target.checked }))}
      />
    </div>
  )
}
</script>

2. 비동기 Atom

<script>
// 비동기 데이터 fetching
const userAtom = atom(async (get) => {
  const userId = get(userIdAtom)
  const response = await fetch(`/api/users/${userId}`)
  return response.json()
})

// Suspense와 함께 사용
function UserProfile() {
  const user = useAtomValue(userAtom)
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

function App() {
  return (
    <ErrorBoundary fallback={<div>Something went wrong</div>}>
      <Suspense fallback={<div>Loading...</div>}>
        <UserProfile />
      </Suspense>
    </ErrorBoundary>
  )
}
</script>

3. Atom with Storage

<script>
import { atomWithStorage } from 'jotai/utils'

// localStorage와 동기화
const themeAtom = atomWithStorage('theme', 'light')
const userPreferencesAtom = atomWithStorage('userPrefs', {
  language: 'en',
  timezone: 'UTC'
})

function ThemeToggle() {
  const [theme, setTheme] = useAtom(themeAtom)
  
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current theme: {theme}
    </button>
  )
}
</script>

4. Atom Effects(사이드 이펙트)

<script>
// localStorage 동기화 effect
const countWithEffectAtom = atom(
  0,
  (get, set, newValue) => {
    set(countAtom, newValue)
    localStorage.setItem('count', String(newValue))
  }
)

// API 호출 effect
const saveUserAtom = atom(
  null,
  async (get, set, userData) => {
    set(loadingAtom, true)
    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        body: JSON.stringify(userData)
      })
      const user = await response.json()
      set(userAtom, user)
      set(successMessageAtom, 'User saved successfully!')
    } catch (error) {
      set(errorAtom, error.message)
    } finally {
      set(loadingAtom, false)
    }
  }
)
</script>

성능 최적화

  • Jotai는 자동으로 최적화를 제공하지만, 추가적인 최적화 기법들이 존재한다.

1. 선택적 구독

<script>
// 필요한 데이터만 정확히 구독
function UserName() {
  const name = useAtomValue(userNameAtom) // name만 변경될 때 리렌더링
  return <span>{name}</span>
}

function UserEmail() {
  const email = useAtomValue(userEmailAtom) // email만 변경될 때 리렌더링
  return <span>{email}</span>
}
</script>

2. 메모이제이션된 파생 상태

<script>
// 복잡한 계산은 자동으로 메모이제이션됨
const expensiveComputationAtom = atom((get) => {
  const data = get(dataAtom)
  
  // 무거운 계산 - data가 변경될 때만 재계산
  return data.reduce((acc, item) => {
    // 복잡한 연산...
    return acc + item.value * item.weight
  }, 0)
})
</script>

3. 조건부 atom 활성화

<script>
import { atomWithReset, RESET } from 'jotai/utils'

const conditionalAtom = atom((get) => {
  const isActive = get(isActiveAtom)
  
  if (!isActive) {
    return null // 비활성 상태에서는 계산하지 않음
  }
  
  return get(expensiveAtom)
})
</script>

🌟 Jotai의 핵심 장점

1. 자동 최적화

의존성 추적이 자동화되어 불필요한 리렌더링 없음
복잡한 selector 최적화가 불필요

2. 조합성 (Composability)

작은 atoms를 조합하여 복잡한 상태 구성
재사용 가능한 상태 로직

3. 직관적인 API

React의 useState와 유사한 사용법
학습 곡선이 낮음

4. TypeScript 친화적

뛰어난 타입 추론
타입 안정성 보장

5. 비동기 친화적

Promise와 Suspense의 자연스러운 통합
로딩/에러 상태 자동 관리

6. 테스트 용이성

개별 atom 단위 테스트
간단한 모킹

🎯 언제 Jotai를 사용할까?

적합한 경우

  • 복잡한 상태 의존성이 많은 애플리케이션
  • 성능 최적화가 중요한 대규모 애플리케이션
  • 컴포넌트 중심 아키텍처
  • 비동기 데이터를 많이 다루는 경우
  • 실시간 업데이트가 필요한 애플리케이션

주의할 점

  • 팀이 atomic thinking에 익숙하지 않은 경우
  • 매우 간단한 상태만 필요한 경우
  • 기존 Redux ecosystem에 강하게 의존하는 경우
profile
확장성 있는 설계와 유지보수가 용이한 클린 코드 지향하는 개발자입니다.

0개의 댓글