React에서 API 요청을 배치로 처리하고 외부 store와 동기화하는 라이브러리입니다. useSyncExternalStore를 사용하여 React 18+와 완벽하게 호환됩니다.
- 🚀 배치 처리: 여러 개별 요청을 하나의 배치 요청으로 합쳐서 처리
- 🔄 Store 통합: Zustand, Redux 등 다양한 상태 관리 라이브러리와 호환
- ⚡ 성능 최적화: 중복 요청 제거 및 지능적인 캐싱
- 🎯 TypeScript 지원: 완전한 타입 안정성
- 🪝 React Hooks: 간편한 React 통합
- 🛠️ 커스터마이징: 버퍼 시간, 배치 크기 등 설정 가능
- 💾 상태 영속성: localStorage/sessionStorage를 통한 상태 저장
npm install react-batcher
# 또는
yarn add react-batcher
# 또는
pnpm add react-batcherimport { BatchableItem } from 'react-batcher';
// 모든 아이템은 반드시 id 필드를 가져야 합니다
interface User extends BatchableItem {
id: string;
name: string;
email: string;
avatar?: string;
}import { create } from 'zustand';
import { BatchManager, createSimpleZustandAdapter } from 'react-batcher';
// Zustand store 생성
const useUserStore = create<Record<string, User>>(() => ({}));
// Store 어댑터 생성
const userStoreAdapter = createSimpleZustandAdapter({
getState: useUserStore.getState,
setState: useUserStore.setState,
subscribe: useUserStore.subscribe,
});// BatchFetcher 타입: (ids: string[]) => Promise<T[]>
async function fetchBatchUsers(userIds: string[]): Promise<User[]> {
const response = await fetch('/api/batch-users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userIds }),
});
const data = await response.json();
return data.users;
}export const userBatchManager = new BatchManager<User>({
store: userStoreAdapter,
fetcher: fetchBatchUsers,
bufferConfig: {
delayMs: 100, // 100ms 대기 후 배치 처리
maxBatchSize: 50, // 최대 50개씩 배치 처리
},
debug: true,
onError: (error, failedIds) => {
console.error('Failed to fetch users:', error, failedIds);
},
});import { useBatchedItem } from "react-batcher";
function UserProfile({ userId }: { userId: string }) {
const user = useBatchedItem(userBatchManager, userId);
if (!user) {
return <div>Loading user...</div>;
}
return (
<div>
<img src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}// 메인 클래스
export { BatchManager } from './BatchManager';
// 타입 정의
export type {
BatchableItem,
Store,
BatchFetcher,
BufferConfig,
BatchManagerConfig,
BatchManagerState,
} from './types';
// React 훅
export {
useBatchedItem,
useBatchedItems,
useAllBatchedItems,
usePreloadItems,
useFlushBatch,
useBatchManagerState,
useBatchedState,
useBatchStats,
} from './hooks';
// Store 어댑터
export {
createZustandAdapter,
createSimpleZustandAdapter,
} from './adapters/zustand';
export { createReduxAdapter } from './adapters/redux';
export { createMemoryStore } from './adapters/memory';
// 유틸리티
export { createBuffer, type BufferInstance } from './buffer';
export {
createPersistence,
loadState,
saveState,
clearPersistedState,
type PersistenceConfig,
} from './persistence';배치 처리할 데이터의 기본 인터페이스입니다. 모든 아이템은 반드시 고유한 id를 가져야 합니다.
interface BatchableItem {
id: string;
}
// 사용 예시
interface User extends BatchableItem {
id: string;
name: string;
email: string;
}
interface Product extends BatchableItem {
id: string;
title: string;
price: number;
}외부 상태 관리 라이브러리와의 통합을 위한 추상화 인터페이스입니다.
interface Store<T extends BatchableItem> {
// 현재 store의 상태를 가져오는 함수
getState: () => Record<string, T>;
// store 상태 변경을 구독하는 함수
subscribe: (listener: () => void) => () => void;
// store 상태를 업데이트하는 함수
setState: (newState: Record<string, T>) => void;
}API에서 배치로 데이터를 가져오는 함수 타입입니다.
type BatchFetcher<T extends BatchableItem> = (ids: string[]) => Promise<T[]>;
// 사용 예시
const fetchUsers: BatchFetcher<User> = async ids => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify({ ids }),
});
return response.json();
};버퍼 설정 옵션입니다.
interface BufferConfig {
// 배치 처리를 위한 대기 시간 (밀리초)
// @default 100
delayMs?: number;
// 한 번에 처리할 최대 아이템 수
// @default undefined (제한 없음)
maxBatchSize?: number;
}
// 사용 예시
const config: BufferConfig = {
delayMs: 50, // 50ms 대기
maxBatchSize: 100, // 최대 100개씩 처리
};재시도 설정 옵션입니다.
interface RetryConfig {
// 최대 재시도 횟수 @default 3
maxRetries?: number;
// 재시도 지연 시간 (밀리초) @default 1000
retryDelay?: number;
// 백오프 전략 @default 'exponential'
// - linear: 고정 지연
// - exponential: 지수적 증가
backoff?: 'linear' | 'exponential';
// 재시도 가능한 에러인지 판단하는 함수
shouldRetry?: (error: Error, attempt: number) => boolean;
}BatchManager 생성 시 사용하는 설정 옵션입니다.
interface BatchManagerConfig<T extends BatchableItem> {
// [필수] 외부 store 인스턴스
store: Store<T>;
// [필수] 배치로 데이터를 가져오는 함수
fetcher: BatchFetcher<T>;
// [선택] 버퍼 설정
bufferConfig?: BufferConfig;
// [선택] 에러 발생 시 호출되는 콜백
onError?: (error: Error, failedIds: string[]) => void;
// [선택] 디버그 모드 활성화
debug?: boolean;
// [선택] 초기 상태
initialState?: Partial<BatchManagerState>;
// [선택] 재시도 설정
retryConfig?: RetryConfig;
// [선택] 상태 저장 키 (localStorage/sessionStorage에 저장)
persistenceKey?: string;
}BatchManager의 현재 상태를 나타내는 인터페이스입니다.
interface BatchManagerState {
// 현재 상태
// - idle: 대기 중
// - pending: 버퍼에 아이템이 쌓이는 중
// - processing: API 요청 실행 중
status: 'idle' | 'pending' | 'processing';
// 배치 실행 횟수
executionCount: number;
// 총 처리된 아이템 수
totalItemsProcessed: number;
// 버퍼가 비어있는지 여부
isEmpty: boolean;
// 대기 중인 아이템이 있는지 여부
isPending: boolean;
// 현재 버퍼 크기
bufferSize: number;
// 대기 중인 요청 개수
pendingRequestCount: number;
// 마지막 배치 실행 시각 (timestamp)
lastExecutionTime: number | null;
// 마지막 에러
lastError: Error | null;
}BatchManager는 API 요청을 배치로 모아서 효율적으로 처리하고, 외부 store와 동기화하는 핵심 클래스입니다.
const batchManager = new BatchManager<User>({
store: userStoreAdapter,
fetcher: fetchBatchUsers,
bufferConfig: {
delayMs: 100,
maxBatchSize: 50,
},
debug: true,
onError: (error, failedIds) => {
console.error('Fetch failed:', error, failedIds);
},
persistenceKey: 'user-batch-state', // localStorage에 상태 저장
});아이템을 요청합니다. store에 있으면 즉시 반환하고, 없으면 배치 요청에 추가 후 null을 반환합니다.
const user = batchManager.requestItem('user-123');
if (user) {
console.log('User found:', user.name);
} else {
console.log('User is being fetched...');
}store에서 아이템을 가져옵니다. 요청은 하지 않습니다.
// 이미 로드된 아이템만 가져오기 (API 호출 없음)
const user = batchManager.getItem('user-123');여러 아이템을 미리 로드합니다. 화면에 표시하기 전에 데이터를 미리 가져올 때 유용합니다.
// 페이지 진입 시 필요한 사용자들 미리 로드
batchManager.preloadItems(['user-1', 'user-2', 'user-3']);대기 중인 모든 배치 요청을 즉시 처리합니다. 타임아웃을 기다리지 않습니다.
// 여러 아이템 요청 후 즉시 처리
batchManager.requestItem('user-1');
batchManager.requestItem('user-2');
batchManager.requestItem('user-3');
await batchManager.flush(); // 100ms 기다리지 않고 즉시 API 호출
// 페이지 떠나기 전에 대기 중인 요청 모두 처리
window.addEventListener('beforeunload', () => {
batchManager.flush();
});store의 모든 아이템을 가져옵니다.
const allUsers = batchManager.getAllItems();
Object.values(allUsers).forEach(user => {
console.log(user.name);
});특정 아이템을 store에서 제거합니다.
// 사용자 삭제 후 store에서도 제거
await deleteUser('user-123');
batchManager.removeItem('user-123');store의 모든 아이템을 제거합니다.
// 로그아웃 시 모든 캐시 클리어
function handleLogout() {
batchManager.clearAll();
}현재 대기 중인 요청 개수를 반환합니다. (디버깅용)
console.log(`Pending requests: ${batchManager.getPendingCount()}`);현재 버퍼에 대기 중인 아이템 수를 반환합니다.
console.log(`Buffer size: ${batchManager.getBufferSize()}`);버퍼가 비어있는지 확인합니다.
if (batchManager.isBufferEmpty()) {
console.log('No pending items');
}BatchManager의 현재 상태를 반환합니다.
const state = batchManager.getState();
console.log(`Status: ${state.status}`);
console.log(`Execution count: ${state.executionCount}`);
console.log(`Total processed: ${state.totalItemsProcessed}`);통계 정보를 반환합니다.
const stats = batchManager.getStats();
console.log(`Execution count: ${stats.executionCount}`);
console.log(`Total items processed: ${stats.totalItemsProcessed}`);
console.log(`Average items per batch: ${stats.averageItemsPerBatch}`);
console.log(`Last execution: ${new Date(stats.lastExecutionTime)}`);
console.log(`Current buffer size: ${stats.currentBufferSize}`);
console.log(`Pending requests: ${stats.pendingRequestCount}`);React의 useSyncExternalStore와 함께 사용하기 위한 메서드들입니다. 일반적으로 직접 사용하지 않고 제공되는 hooks를 사용합니다.
// 내부적으로 hooks에서 사용됨
const snapshot = useSyncExternalStore(
batchManager.subscribe,
batchManager.getSnapshot
);BatchManager 상태 변경을 구독하기 위한 메서드들입니다.
// 상태 변경 구독
const unsubscribe = batchManager.subscribeState(() => {
console.log('State changed:', batchManager.getStateSnapshot());
});
// 구독 해제
unsubscribe();단일 아이템을 가져오는 훅입니다. 아이템이 없으면 자동으로 배치 요청에 추가됩니다.
function useBatchedItem<T extends BatchableItem>(
manager: BatchManager<T>,
id: string
): T | null;
// 사용 예시
function UserCard({ userId }: { userId: string }) {
const user = useBatchedItem(userBatchManager, userId);
if (!user) {
return <Skeleton />;
}
return (
<div className="user-card">
<img src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
</div>
);
}여러 아이템을 한 번에 가져오는 훅입니다.
function useBatchedItems<T extends BatchableItem>(
manager: BatchManager<T>,
ids: string[]
): (T | null)[];
// 사용 예시
function UserList({ userIds }: { userIds: string[] }) {
const users = useBatchedItems(userBatchManager, userIds);
return (
<div className="user-list">
{users.map((user, index) => (
<div key={userIds[index]}>
{user ? user.name : 'Loading...'}
</div>
))}
</div>
);
}store의 모든 아이템을 가져오는 훅입니다.
function useAllBatchedItems<T extends BatchableItem>(
manager: BatchManager<T>
): Record<string, T>;
// 사용 예시
function AllUsers() {
const allUsers = useAllBatchedItems(userBatchManager);
return (
<ul>
{Object.values(allUsers).map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}아이템들을 미리 로드하는 훅입니다. ids가 변경될 때마다 자동으로 preload됩니다.
function usePreloadItems<T extends BatchableItem>(
manager: BatchManager<T>,
ids: string[]
): void;
// 사용 예시
function UserDashboard({ friendIds }: { friendIds: string[] }) {
// 친구 목록을 미리 로드
usePreloadItems(userBatchManager, friendIds);
return (
<div>
<h1>Dashboard</h1>
{/* 이후 UserCard에서 useBatchedItem 사용 시 이미 로드됨 */}
{friendIds.map(id => (
<UserCard key={id} userId={id} />
))}
</div>
);
}배치를 수동으로 flush하는 함수를 반환하는 훅입니다.
function useFlushBatch<T extends BatchableItem>(
manager: BatchManager<T>
): () => Promise<void>;
// 사용 예시
function SubmitForm() {
const flushBatch = useFlushBatch(userBatchManager);
const handleSubmit = async () => {
// 폼 제출 전에 대기 중인 모든 요청 처리
await flushBatch();
submitForm();
};
return <button onClick={handleSubmit}>Submit</button>;
}BatchManager의 전체 상태를 구독하는 훅입니다.
function useBatchManagerState<T extends BatchableItem>(
manager: BatchManager<T>
): BatchManagerState;
// 사용 예시
function BatchStatus() {
const state = useBatchManagerState(userBatchManager);
return (
<div className="batch-status">
<p>Status: {state.status}</p>
<p>Pending: {state.isPending ? 'Yes' : 'No'}</p>
<p>Buffer Size: {state.bufferSize}</p>
<p>Executions: {state.executionCount}</p>
<p>Total Processed: {state.totalItemsProcessed}</p>
{state.lastError && (
<p className="error">Error: {state.lastError.message}</p>
)}
</div>
);
}BatchManager의 특정 상태만 선택적으로 구독하는 훅입니다. 불필요한 리렌더링을 방지합니다.
function useBatchedState<T extends BatchableItem, R>(
manager: BatchManager<T>,
selector: (state: BatchManagerState) => R
): R;
// 사용 예시
function LoadingIndicator() {
// status만 구독하여 다른 상태 변경 시 리렌더링 방지
const status = useBatchedState(
userBatchManager,
state => state.status
);
if (status === 'processing') {
return <Spinner />;
}
return null;
}
function PendingBadge() {
// isPending만 구독
const isPending = useBatchedState(
userBatchManager,
state => state.isPending
);
return isPending ? <Badge>Loading...</Badge> : null;
}
// 여러 필드를 하나의 객체로 선택
function BatchInfo() {
const info = useBatchedState(userBatchManager, state => ({
count: state.executionCount,
total: state.totalItemsProcessed
}));
return <p>Batches: {info.count}, Items: {info.total}</p>;
}BatchManager의 통계 정보를 구독하는 훅입니다.
function useBatchStats<T extends BatchableItem>(manager: BatchManager<T>): {
executionCount: number;
totalItemsProcessed: number;
averageItemsPerBatch: number;
lastExecutionTime: number | null;
};
// 사용 예시
function StatsDisplay() {
const stats = useBatchStats(userBatchManager);
return (
<div className="stats">
<p>Total batches: {stats.executionCount}</p>
<p>Total items: {stats.totalItemsProcessed}</p>
<p>Avg per batch: {stats.averageItemsPerBatch.toFixed(1)}</p>
{stats.lastExecutionTime && (
<p>Last run: {new Date(stats.lastExecutionTime).toLocaleString()}</p>
)}
</div>
);
}외부 라이브러리 없이 사용할 수 있는 간단한 인메모리 store입니다.
function createMemoryStore<T extends BatchableItem>(): Store<T>;
// 사용 예시
import { createMemoryStore, BatchManager } from 'react-batcher';
const store = createMemoryStore<User>();
const batchManager = new BatchManager<User>({
store,
fetcher: fetchBatchUsers,
});단순한 Zustand store를 위한 어댑터입니다. Store가 Record<string, T> 형태일 때 사용합니다.
function createSimpleZustandAdapter<T extends BatchableItem>(zustandStore: {
getState: () => Record<string, T>;
setState: (state: Record<string, T>) => void;
subscribe: (listener: () => void) => () => void;
}): Store<T>;
// 사용 예시
import { create } from 'zustand';
import { createSimpleZustandAdapter } from 'react-batcher';
// 단순 형태의 Zustand store
const useUserStore = create<Record<string, User>>(() => ({}));
const userStoreAdapter = createSimpleZustandAdapter({
getState: useUserStore.getState,
setState: useUserStore.setState,
subscribe: useUserStore.subscribe,
});items 필드로 래핑된 Zustand store를 위한 어댑터입니다.
function createZustandAdapter<T extends BatchableItem>(zustandStore: {
getState: () => { items: Record<string, T> };
setState: (partial: { items: Record<string, T> }) => void;
subscribe: (listener: () => void) => () => void;
}): Store<T>;
// 사용 예시
interface UserStore {
items: Record<string, User>;
// 다른 상태들...
filter: string;
}
const useUserStore = create<UserStore>(() => ({
items: {},
filter: '',
}));
const userStoreAdapter = createZustandAdapter({
getState: useUserStore.getState,
setState: useUserStore.setState,
subscribe: useUserStore.subscribe,
});Redux store를 위한 어댑터입니다.
function createReduxAdapter<T extends BatchableItem>(
reduxStore: {
getState: () => any;
dispatch: (action: any) => void;
subscribe: (listener: () => void) => () => void;
},
selector: (state: any) => Record<string, T>,
updateAction: (items: Record<string, T>) => any
): Store<T>;
// 사용 예시
import { createReduxAdapter } from 'react-batcher';
import { store } from './redux/store';
const userStoreAdapter = createReduxAdapter(
store,
// selector: Redux state에서 users 추출
state => state.users.items,
// action creator: 업데이트 액션 생성
users => ({ type: 'users/setAll', payload: users })
);
// Redux Toolkit 사용 시
import { setUsers } from './redux/usersSlice';
const userStoreAdapter = createReduxAdapter(
store,
state => state.users,
users => setUsers(users)
);여러 호출을 배치로 모아서 처리하는 버퍼 함수를 생성합니다. BatchManager 내부에서 사용되지만, 직접 사용할 수도 있습니다.
interface BufferInstance<T> {
add: (item: T) => void; // 버퍼에 아이템 추가
flush: () => Promise<void>; // 즉시 처리
size: () => number; // 버퍼 크기
isEmpty: () => boolean; // 비어있는지 확인
}
function createBuffer<T, R = void>(options: {
ms: number; // 대기 시간
subscribedFn: (items: T[]) => Promise<R>; // 배치 처리 함수
maxBatchSize?: number; // 최대 배치 크기
}): BufferInstance<T>;
// 사용 예시: 로그 배치 전송
const logBuffer = createBuffer<LogEntry>({
ms: 5000, // 5초마다
maxBatchSize: 100, // 또는 100개 모이면
subscribedFn: async logs => {
await fetch('/api/logs', {
method: 'POST',
body: JSON.stringify(logs),
});
},
});
// 로그 추가
logBuffer.add({ level: 'info', message: 'User clicked button' });
logBuffer.add({ level: 'error', message: 'API failed' });
// 페이지 종료 시 즉시 전송
window.addEventListener('beforeunload', () => logBuffer.flush());상태를 localStorage/sessionStorage에 저장하고 불러오는 유틸리티입니다.
interface PersistenceConfig {
key: string; // Storage key
storage?: 'localStorage' | 'sessionStorage'; // @default 'localStorage'
fields?: Array<keyof BatchManagerState>; // 저장할 필드들
serialize?: (state: Partial<BatchManagerState>) => string;
deserialize?: (data: string) => Partial<BatchManagerState>;
}
// 상태 불러오기
function loadState(
config: PersistenceConfig
): Partial<BatchManagerState> | null;
// 상태 저장
function saveState(state: BatchManagerState, config: PersistenceConfig): void;
// 저장된 상태 삭제
function clearPersistedState(config: PersistenceConfig): void;
// 헬퍼 함수들 생성
function createPersistence(config: PersistenceConfig): {
load: () => Partial<BatchManagerState> | null;
save: (state: BatchManagerState) => void;
clear: () => void;
};// 사용 예시: 직접 persistence 사용
import {
createPersistence,
loadState,
saveState,
clearPersistedState,
} from 'react-batcher';
// 방법 1: createPersistence 사용
const persistence = createPersistence({
key: 'my-batch-state',
storage: 'localStorage',
fields: ['executionCount', 'totalItemsProcessed'],
});
const savedState = persistence.load();
// ... 작업 후
persistence.save(currentState);
persistence.clear();
// 방법 2: 개별 함수 사용
const config = { key: 'my-batch-state' };
const state = loadState(config);
saveState(newState, config);
clearPersistedState(config);
// BatchManager에서 자동으로 사용
const batchManager = new BatchManager<User>({
store,
fetcher,
persistenceKey: 'user-batch-state', // 자동으로 상태 저장/복원
});// users
const userBatchManager = new BatchManager<User>({
store: createMemoryStore<User>(),
fetcher: fetchBatchUsers,
});
// products
const productBatchManager = new BatchManager<Product>({
store: createMemoryStore<Product>(),
fetcher: fetchBatchProducts,
});
// 컴포넌트에서
function ProductWithSeller({ productId }: { productId: string }) {
const product = useBatchedItem(productBatchManager, productId);
const seller = useBatchedItem(userBatchManager, product?.sellerId ?? '');
if (!product) return <Loading />;
return (
<div>
<h2>{product.title}</h2>
<p>Sold by: {seller?.name ?? 'Loading...'}</p>
</div>
);
}const batchManager = new BatchManager<User>({
store,
fetcher,
onError: (error, failedIds) => {
// 에러 로깅
console.error('Batch fetch failed:', error);
// 사용자에게 알림
toast.error(`Failed to load ${failedIds.length} users`);
// 에러 추적 서비스에 전송
Sentry.captureException(error, {
extra: { failedIds },
});
},
});
// 컴포넌트에서 에러 상태 표시
function UserWithError({ userId }: { userId: string }) {
const user = useBatchedItem(batchManager, userId);
const { lastError } = useBatchManagerState(batchManager);
if (lastError) {
return <ErrorMessage error={lastError} />;
}
if (!user) {
return <Skeleton />;
}
return <UserCard user={user} />;
}function ConditionalUser({ userId, shouldLoad }: {
userId: string;
shouldLoad: boolean;
}) {
// shouldLoad가 false면 빈 ID로 요청하지 않음
const user = useBatchedItem(
userBatchManager,
shouldLoad ? userId : ''
);
if (!shouldLoad) {
return <p>Click to load user</p>;
}
return user ? <UserCard user={user} /> : <Loading />;
}// 특정 사용자 정보 갱신
async function refreshUser(userId: string) {
// 기존 캐시 제거
userBatchManager.removeItem(userId);
// 다시 요청
userBatchManager.requestItem(userId);
await userBatchManager.flush();
}
// 전체 캐시 클리어
function clearAllCache() {
userBatchManager.clearAll();
}기존 코드에서 이 라이브러리로 마이그레이션하는 방법:
const user = userStoreManager.requestUser(userId);
userStoreManager.preloadUsers(userIds);
const pendingCount = userStoreManager.getPendingCount();const user = useBatchedItem(userBatchManager, userId);
userBatchManager.preloadItems(userIds);
const pendingCount = userBatchManager.getPendingCount();MIT
이슈나 PR을 환영합니다!