오늘날 웹 애플리케이션은 효과적으로 데이터를 관리하고, 반응형 사용자 경험을 제공하기 위해 점점 더 복잡해지고 있다. 이에 따라 애플리케이션의 성능과 유지보수성을 높이기 위해 체계적인 상태 관리가 필수적이게 되었다. 특히 React와 같은 프레임워크를 사용할 때 컴포넌트 간의 데이터를 효율적으로 관리하고 일관된 상태를 유지하려면 상태 관리 라이브러리를 활용할 필요가 있다.
💡Jotai란?
Jotai는 일본어로 원자(atom)를 의미하는 이름에서 유래했으며, React 애플리케이션에서 가장 작은 단위의 상태를 관리할 수 있도록 설계된 상태 관리 라이브러리이다. Jotai는 Recoil과 비슷한 Atom 기반의 상태 관리 라이브러리이며, 가볍고 쉽게 전역 상태를 관리할 수 있다.
원자 (atom)
원자는 상태의 가장 작은 단위로, atom 함수를 통해 생성할 수 있다. 예를 들어, 카운터의 숫자를 나타내는 상태를 관리하기 위해 counterAtom이라는 원자를 만들 수 있다. Jotai에서는 컴포넌트에서 useAtom 훅을 통해 원자를 직접 읽고 쓸 수 있다.
Jotai의 장점
1. 직관적이고 사용이 간편하다
Jotai는 학습 곡선이 낮은 상태 관리 라이브러리로, 간단한 API를 통해 상태를 정의하고 사용할 수 있다.
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>현재 카운트: {count}</p>
<button onClick={() => setCount((prev) => prev + 1)}>증가</button>
<button onClick={() => setCount((prev) => prev - 1)}>감소</button>
</div>
);
}
위 코드처럼 상태(atom)를 정의하고, useAtom 훅을 사용하여 상태를 읽고 수정할 수 있다.
Redux처럼 복잡한 설정 과정이 없고, Context API처럼 복잡한 구조를 만들지 않아도 되기 때문에 간편하다.
2. 리렌더링 관리를 효율적으로 할 수 있다
Jotai는 상태 변경 시 필요한 컴포넌트만 리렌더링한다. 이로 인해 대규모 애플리케이션에서도 성능 문제를 최소화 할 수 있다.
import { atom, useAtom } from 'jotai';
const userNameAtom = atom('Isaac');
const userAgeAtom = atom(25);
function UserName() {
const [name] = useAtom(userNameAtom);
return <p>이름: {name}</p>;
}
function UserAge() {
const [age] = useAtom(userAgeAtom);
return <p>나이: {age}</p>;
}
function App() {
const [, setName] = useAtom(userNameAtom);
return (
<div>
<UserName />
<UserAge />
<button onClick={() => setName('Sopia')}>이름 변경</button>
</div>
);
}
이 코드는 userNameAtom만 변경되었을 때 UserName 컴포넌트만 리렌더링되며, UserAge 컴포넌트는 영향을 받지 않는다.
3. 번들의 크기가 가볍다
Jotai는 가벼운 상태 관리 라이브러리로, 애플리케이션의 전체 번들 크기를 줄일 수 있다. 이는 특히 초기 로딩 시간이 중요한 웹 애플리케이션에서 유용하다.
예로 들어 Redux는 약 6KB인 반면, Jotai는 약 1KB이다. 따라서 작은 규모의 프로젝트나 퍼포먼스 최적화가 중요한 프로젝트에서 Jotai를 사용하면 빠르고 가벼운 애플리케이션을 구축할 수 있다.
4. 유연성과 확장성이 높다
Jotai는 React의 훅과 자연스럽게 통합되어 있으며, 비동기 데이터를 다루거나 상태를 결합하는 작업에서도 확장성이 좋다.
import { atom, useAtom } from 'jotai';
import { atomWithQuery } from 'jotai/query';
const userDataAtom = atomWithQuery(() => ({
queryKey: ['user'],
queryFn: async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
return response.json();
},
}));
function UserProfile() {
const [user] = useAtom(userDataAtom);
return (
<div>
<h2>사용자 프로필</h2>
<p>이름: {user.name}</p>
<p>이메일: {user.email}</p>
</div>
);
}
Jotai의 비동기 확장 기능(atomWithQuery)을 활용하면 데이터를 손쉽게 가져올 수 있다. 또한, API 호출, 캐싱, 상태 관리 모두를 단순화할 수 있어 생산성을 높일 수 있다.
5. 타입 안전성을 제공한다
Jotai는 TypeScript와 호환되기 때문에 타입 정의가 쉬우며, 이로 인해 유지보수성이 향상되고 코드를 일관되게 유지할 수 있다.
import { atom, useAtom } from 'jotai';
type User = {
id: number;
name: string;
email: string;
};
const userAtom = atom<User>({ id: 1, name: '승원', email: 'test@example.com' });
function UserProfile() {
const [user] = useAtom(userAtom);
return (
<div>
<p>ID: {user.id}</p>
<p>이름: {user.name}</p>
<p>이메일: {user.email}</p>
</div>
);
}
상태 관리 라이브러리별 차이점
라이브러리 | 상태 관리 방식 | 리렌더링 최적화 | 중앙 스토어 여부 | 학습 곡선 | 번들 크기 |
Jotai | 개별 Atom 분산 관리 | Atom 의존 컴포넌트만 리렌더링 | 없음 | 낮음 | 작음 |
React Context | Context API | 모든 하위 컴포넌트 리렌더링 | 없음 | 낮음 | 보통 |
Recoil | 개별 Atom 분산 관리 | Atom 의존 컴포넌트만 리렌더링 | 없음 | 중간 | 중간 |
Redux | 중앙 집중형 스토어 | 미들웨어로 최적화 가능 | 있음 | 높음 | 큼 |
Zustand | 소형 스토어 분산 관리 | 필요한 컴포넌트만 리렌더링 | 없음 | 낮음 | 작음 |
대표적인 상태 관리 라이브러리에는 Redux, Recoil, Jotai, Zustand 등이 있다. 이중 Redux는 대규모 애플리케이션에 적합하지만, 복잡하기 때문에 간단한 프로젝트에서는 오히려 부담이 될 수 있다. 반면에 Jotai와 같은 상태 관리 라이브러리는 상대적으로 가벼운 구조와 직관적인 API를 제공하며, 효율적인 상태 관리와 더불어 개발자 경험을 개선하는 데 중점을 두고 있다.
Jotai는 단순한 API, 리렌더링 최적화, 비동기 상태 관리, 작은 번들 크기를 가지고 있다는 특징이 있다. 특히 성능 최적화가 중요한 프로젝트에 적합하며, 다른 라이브러리와 비교했을 때 학습 곡선이 낮아 빠른 적용이 가능하다.
Jotai와 React Context의 차이점
React Context는 상태를 공유하기 위해 Context API를 사용하는 반면, Jotai는 Atoms를 통해 더 세밀하게 상태를 관리한다. React Context는 상태 변경 시 Context를 사용하는 모든 하위 컴포넌트가 리렌더링되지만, Jotai는 Atoms에 의존하는 컴포넌트만 리렌더링되어 성능 최적화가 뛰어나다.
import { atom, useAtom } from 'jotai';
import React, { createContext, useContext, useState } from 'react';
// Jotai: Atom 관리
const countAtom = atom(0);
function JotaiCounter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>Jotai 카운트: {count}</p>
<button onClick={() => setCount((prev) => prev + 1)}>증가</button>
</div>
);
}
// React Context: 상태 관리
const CountContext = createContext();
function ContextCounter() {
const { count, setCount } = useContext(CountContext);
return (
<div>
<p>Context 카운트: {count}</p>
<button onClick={() => setCount((prev) => prev + 1)}>증가</button>
</div>
);
}
function App() {
const [count, setCount] = useState(0);
return (
<div>
<CountContext.Provider value={{ count, setCount }}>
<ContextCounter />
</CountContext.Provider>
<JotaiCounter />
</div>
);
}
Jotai의 경우 상태 변경이 발생해도 필요한 컴포넌트만 리렌더링되며, React Context는 전체 하위 컴포넌트가 리렌더링된다.
Jotai와 Recoil의 차이점
Recoil은 Jotai와 마찬가지로 Atoms를 사용해 상태를 관리하며, React와 통합할 수 있다. 그러나 Recoil은 비교적 번들 크기가 크고 API가 복잡하다. 반면, Jotai는 더 간단한 API와 작은 번들 크기를 통해 학습 곡선을 낮추고, 경량화된 상태 관리가 필요한 프로젝트에 적합하다는 특징이 있다.
// Jotai Atom
const jotaiCountAtom = atom(0);
// Recoil Atom
const recoilCountAtom = atom({
key: 'recoilCount',
default: 0,
});
Jotai의 Atom 정의 방식은 매우 직관적이며, Recoil은 key를 반드시 설정해야 한다.
Jotai와 Redux의 차이점
Redux는 중앙 집중형 스토어로 상태를 관리하며, 상태 변경 시 액션과 리듀서가 필요하다. 반면, Jotai는 분산된 Atom을 통해 상태를 정의하고 바로 읽고 쓰는 방식을 사용한다.
// Redux 액션 및 리듀서
const increment = () => ({ type: 'INCREMENT' });
const counterReducer = (state = 0, action) => {
switch (action.type) {
case 'INCREMENT':
return state + 1;
default:
return state;
}
};
// Jotai
const counterAtom = atom(0);
const [count, setCount] = useAtom(counterAtom);
setCount((prev) => prev + 1);
Redux는 대규모 애플리케이션에서 유용하지만, 설정과 코드가 복잡하다. 반면에 Jotai는 코드 몇 줄로 상태 관리를 구현할 수 있다.
Jotai와 Zustand의 차이점
Zustand는 간단한 상태 관리에 적합하며, 작은 스토어로 상태를 관리한다. 그러나 Jotai는 Suspense와 비동기 상태 관리를 더 잘 지원하며, 고급 상태 관리가 필요한 경우 유리하다.
// Jotai의 비동기 상태 관리
const asyncAtom = atom(async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
return response.json();
});
// Zustand의 상태 관리
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
Jotai는 비동기 데이터와 통합하여 React Suspense와 같은 기능을 쉽게 사용할 수 있다.
React Suspense란?
React Suspense는 React에서 비동기 작업을 쉽게 처리하고, 로딩 상태를 간편하게 관리할 수 있도록 돕는 기능이다. 비동기적으로 데이터를 가져오거나 컴포넌트를 로드할 때, 로딩 상태를 UI에 매끄럽게 표시할 수 있는 도구이다. Suspense는 주로 코드 분할과 데이터 가져오기와 관련된 기능에 사용된다.
https://github.com/pmndrs/jotai
Jotai에 대한 자세한 내용은 GitHub와 공식 문서를 참고한다.
💡Jotai 설치하기
다음의 명령어를 터미널에 입력하여 Jotai를 설치할 수 있다.
# npm
npm install jotai
# yarn
yarn add jotai
# pnpm
pnpm add jotai
💡Jotai의 사용
Jotai는 상태의 최소 단위인 Atom을 생성하고, 이를 통해 상태를 관리한다. Atoms는 기본적으로 전역 상태처럼 작동하며, 어떤 컴포넌트에서나 접근할 수 있다.
Jotai를 사용하는 간단한 카운터 애플리케이션을 만드는 과정을 살펴보며 사용 방법을 익혀보도록 하자.
Atom 정의
Jotai에서 상태를 정의하려면 atom 함수를 사용한다. Atom은 초기값을 설정하여 상태를 생성할 수 있으며, 이 상태는 다른 컴포넌트에서 참조하고 변경할 수 있다.
// store.js
import { atom } from 'jotai';
// 상태를 나타내는 atom 정의
export const countAtom = atom(0); // 초기값은 0
Atom 사용하기
Jotai에서 상태를 읽고 수정하려면 useAtom 훅을 사용한다. useAtom은 useState 훅과 비슷하게 동작하며, 상태 값과 이를 업데이트할 수 있는 함수를 반환한다.
// Counter.js
import React from 'react';
import { useAtom } from 'jotai'; // useAtom 훅을 import
import { countAtom } from './store'; // 정의한 Atom을 import
function Counter() {
// Atom을 사용하여 상태를 읽고 업데이트
const [count, setCount] = useAtom(countAtom);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount((c) => c + 1)}>Increase</button>
<button onClick={() => setCount((c) => c - 1)}>Decrease</button>
</div>
);
}
export default Counter;
위 코드에서 useAtom(countAtom)은 countAtom 상태를 읽고, setCount를 통해 값을 변경할 수 있게 해준다. setCount는 count 값을 변경하는 함수이다. setCount에 전달된 함수는 이전 값을 매개변수로 받아 새로운 값을 반환하는 방식으로 동작한다. 예를 들어, 버튼 클릭 시 Increase는 count 값을 1 증가시키고, Decrease는 1 감소시킨다.
전역 상태 관리하기
Jotai는 전역 상태 관리에도 적합하다. 여러 컴포넌트에서 동일한 상태를 공유할 수 있으며, 컴포넌트가 상태를 변경할 때 다른 컴포넌트에서 자동으로 업데이트된다. 이를 통해 복잡한 상태 관리 로직을 쉽게 해결할 수 있다.
전역 상태 관리를 위해서는 상태를 별도의 파일로 분리하여 관리하는 것이 좋다. store.js 파일을 만들어 상태를 정의하고, 필요한 컴포넌트에서 이 상태를 사용하면 된다.
// store.js
import { atom } from 'jotai';
// Atom 정의
export const countAtom = atom(0);
export const userAtom = atom({ name: "Isaac", age: 25 });
위 코드에서 countAtom은 카운트 상태를, userAtom은 사용자 정보를 관리하는 상태를 정의한다. 이렇게 정의된 Atom들은 다른 컴포넌트에서 쉽게 참조하고 사용할 수 있다.
파생된 상태
Jotai는 기존 Atom을 기반으로 새로운 상태를 파생시킬 수 있는 기능을 제공한다. 이를 통해 다른 상태의 변화를 기반으로 계산된 값을 쉽게 만들 수 있다. 파생된 상태는 get을 사용하여 다른 Atom의 값을 읽어와 새 상태를 생성한다.
// store.js
import { atom } from 'jotai';
import { countAtom } from './store';
// 파생된 상태: countAtom의 값을 두 배로 만듦
export const doubleCountAtom = atom((get) => get(countAtom) * 2);
위 코드에서 doubleCountAtom은 countAtom의 값을 읽어 그 값을 두 배로 만든 새로운 상태이다. get을 사용하여 다른 Atom을 읽고, 계산된 값을 반환한다.
파생된 상태를 사용하는 컴포넌트
// DoubleCounter.js
import React from 'react';
import { useAtom } from 'jotai';
import { countAtom } from './store';
import { doubleCountAtom } from './store';
function DoubleCounter() {
const [count] = useAtom(countAtom); // 원본 카운트 값
const [doubleCount] = useAtom(doubleCountAtom); // 파생된 카운트 값
return (
<div>
<h1>Count: {count}</h1>
<h2>Double Count: {doubleCount}</h2>
</div>
);
}
export default DoubleCounter;
위 코드에서 DoubleCounter 컴포넌트는 countAtom과 doubleCountAtom을 모두 사용한다. doubleCountAtom은 countAtom의 값을 두 배로 만든 상태이므로, count와 doubleCount가 함께 표시된다.
비동기 상태 관리
Jotai는 비동기 상태 관리도 간편하게 처리할 수 있다. 비동기 처리는 atom의 콜백 함수로 정의할 수 있으며, 이 함수는 프로미스를 반환하면 자동으로 상태를 업데이트한다.
// store.js
import { atom } from 'jotai';
// 비동기 상태 관리: API에서 데이터를 가져오는 atom
export const fetchDataAtom = atom(async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
const data = await response.json();
return data;
});
이와 같이 fetchDataAtom은 API로부터 데이터를 가져와 상태로 저장한다. async 함수를 사용하여 비동기 작업을 처리하고, atom에서 해당 값을 반환하였다.
비동기 상태를 사용하는 컴포넌트
// DataFetcher.js
import React from 'react';
import { useAtom } from 'jotai';
import { fetchDataAtom } from './store';
function DataFetcher() {
const [data] = useAtom(fetchDataAtom);
return (
<div>
<h2>Fetched Data:</h2>
{data ? (
<pre>{JSON.stringify(data, null, 2)}</pre>
) : (
<p>Loading...</p>
)}
</div>
);
}
export default DataFetcher;
비동기 상태는 컴포넌트에서 useAtom을 통해 사용된다. 데이터가 로딩 중일 때는 Loading...을 표시하고, 데이터가 로딩되면 JSON.stringify를 사용해 데이터를 화면에 표시한다.
💡Jotai의 활용
다크 모드 구현 예제
Jotai를 사용하여 다크 모드 토글 기능을 간단하게 구현할 수 있다.
사용자 인터페이스(UI)에서 다크 모드를 활성화하거나 비활성화할 수 있도록 atom을 사용하여 상태를 관리하려고 한다.
1. Atom 정의하기
다크 모드 상태를 관리할 atom을 정의한다. 다크 모드가 활성화되면 true 값을 가지고, 그렇지 않으면 false 값을 가진다.
기본값은 false로 설정하여 앱이 처음 로드될 때는 라이트 모드로 시작되도록 한다.
// store.js
import { atom } from 'jotai';
// 다크 모드 상태를 나타내는 atom 정의
export const isDarkModeAtom = atom(false);
2. 다크 모드 토글 컴포넌트
이제 useAtom 훅을 사용하여 다크 모드를 토글 하는 버튼을 만들어 보도록 하자.
버튼을 클릭하면 isDarkModeAtom의 값이 바뀌며, 그에 따라 UI의 테마가 전환된다.
// DarkModeToggle.js
import React from 'react';
import { useAtom } from 'jotai';
import { isDarkModeAtom } from './store';
function DarkModeToggle() {
// isDarkModeAtom을 사용하여 다크 모드 상태 읽기 및 업데이트
const [isDarkMode, setIsDarkMode] = useAtom(isDarkModeAtom);
const toggleDarkMode = () => {
setIsDarkMode((prev) => !prev); // 상태를 반전시켜 다크 모드를 토글
};
return (
<button onClick={toggleDarkMode}>
{isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
</button>
);
}
export default DarkModeToggle;
위 코드에서 toggleDarkMode 함수는 isDarkMode 상태를 반전시킨다.
다크 모드가 활성화되면 버튼 텍스트는 "Switch to Light Mode"로 변경되고, 비활성화되면 "Switch to Dark Mode"로 변경된다.
3. 테마 스타일 적용하기
다크 모드와 라이트 모드를 전환하기 위해, isDarkMode 상태에 따라 전체 페이지의 배경색, 글자 색 등을 변경한다. 이를 위해 상태에 따라 동적으로 클래스를 추가하거나 인라인 스타일을 적용할 수 있다.
// App.js
import React from 'react';
import { useAtom } from 'jotai';
import { isDarkModeAtom } from './store';
import DarkModeToggle from './DarkModeToggle';
function App() {
// isDarkModeAtom을 사용하여 다크 모드 상태 읽기
const [isDarkMode] = useAtom(isDarkModeAtom);
// 다크 모드와 라이트 모드에 따른 스타일을 동적으로 설정
const appStyles = {
backgroundColor: isDarkMode ? '#333' : '#FFF',
color: isDarkMode ? '#FFF' : '#333',
transition: 'background-color 0.3s ease, color 0.3s ease',
height: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
};
return (
<div style={appStyles}>
<h1>Welcome to the Jotai Dark Mode Example</h1>
<DarkModeToggle />
</div>
);
}
export default App;
isDarkModeAtom을 사용하여 상태를 읽고, 그 값에 따라 스타일을 동적으로 설정한다. isDarkMode 값이 true이면 어두운 배경색(#333)과 흰색 글자색(#FFF)을 적용하고, false이면 라이트 모드 스타일로 설정한다. transition을 사용하여 색상 변경 시 부드러운 애니메이션 효과도 추가하였다.
4. 추가 개선점
사용자가 페이지를 새로고침하더라도 다크 모드 상태를 유지할 수 있도록 localStorage에 상태를 저장하고, 페이지가 로드될 때 localStorage에서 값을 불러오는 기능을 추가할 수 있다.
// store.js (localStorage와 함께 사용)
import { atom } from 'jotai';
// 초기 상태가 localStorage에 저장된 값에 따라 결정되도록 설정
const savedDarkMode = localStorage.getItem('darkMode') === 'true';
export const isDarkModeAtom = atom(savedDarkMode);
isDarkModeAtom.onMount = (setAtom) => {
// 상태가 변경될 때마다 localStorage에 저장
setAtom((prev) => {
localStorage.setItem('darkMode', prev);
return prev;
});
};
이외에 더 복잡한 테마 관리가 필요한 경우, Jotai와 함께 styled-components 또는 tailwindcss 같은 CSS-in-JS 솔루션을 사용해 스타일을 더 효율적으로 관리할 수 있다.
참고 자료
Basics of Jotai Easy State Management in Next.js with Jotai, Rasit Colakel, 2024.03.26.
Jotai: The Ultimate React State Management, Puru Vijay, 2022.10.21.
State Management on React - Jotai, Kevin Toshihiro Uehara, 2023.06.01.