상태 관리 npm 패키지 만들어보기
문제 발견
React나 NextJS로 프로젝트를 구현하다 보면 localStorage
등의 브라우저 스토리지에 데이터를 저장하고 관리해야 하는 일이 자주 발생합니다. 이러한 브라우저 저장소의 상태 관리 로직을 직접 구현할 수도 있지만, 몇 가지 중요한 고려사항들이 존재합니다.
1. 키 관리의 어려움
브라우저 스토리지(localStorage
, sessionStorage
등)에 데이터를 저장할 때는 반드시 고유한 키가 필요합니다. 이 키를 통해 데이터를 식별하고 접근할 수 있죠. 하지만 실제 개발 과정에서 이러한 키를 체계적으로 관리하는 것은 생각보다 까다로운 작업입니다.
- 즉석에서 임의로 키를 생성하다 보니 일관성 있는 키 네이밍이 어려움
- 여러 개발자가 협업할 때 키 충돌 가능성
- 키의 중복 사용이나 미사용 키의 방치
- 네임스페이스 관리의 부재
이는 React Query의 캐시 키 관리에서도 비슷하게 발생하는 문제입니다. 물론 keyFactory
와 같은 유틸리티를 직접 구현하여 해결할 수 있지만, 이러한 기능이 라이브러리 차원에서 제공된다면 개발자의 부담을 크게 줄일 수 있을 것입니다.
2. 버전 관리의 필요성
브라우저 스토리지에 저장된 데이터는 애플리케이션이 업데이트되더라도 그대로 남아있습니다. 이는 다음과 같은 문제를 야기할 수 있습니다
- 애플리케이션 업데이트로 데이터 구조가 변경되었을 때 기존 저장 데이터와의 호환성 문제
- 잘못된 데이터 구조로 인한 런타임 에러
- 데이터 마이그레이션의 어려움
- 캐시 무효화 시점 관리의 복잡성
3. 데이터 무결성과 보안
브라우저 스토리지는 기본적으로 평문으로 데이터를 저장하며, 누구나 접근할 수 있습니다. 이로 인해 다음과 같은 위험이 존재합니다
- 민감한 데이터의 노출 위험
- 저장된 데이터의 변조 가능성
- 데이터 손상 시 탐지 메커니즘 부재
- XSS 공격을 통한 데이터 탈취 위험
4. 성능과 용량 관리
브라우저 스토리지는 제한된 용량을 가지고 있으며, 잦은 접근은 성능 저하를 일으킬 수 있습니다
- 스토리지 용량 한계 관리의 어려움
- 불필요한 데이터로 인한 공간 낭비
- 빈번한 직렬화/역직렬화로 인한 성능 저하
- 대용량 데이터 처리 시의 병목 현상
5. 동기화와 일관성
여러 탭이나 창에서 동일한 애플리케이션을 실행할 때 발생하는 문제들도 존재합니다.
- 탭 간 데이터 동기화 메커니즘 부재
- 동시성 제어의 어려움
- 오프라인 상태에서의 데이터 처리
- 서버 상태와의 일관성 유지 문제
이러한 다양한 문제점들을 해결하기 위해, 체계적이고 안전한 상태 관리 라이브러리의 필요성을 느꼈습니다. useSyncExternalStore
를 기반으로 한 새로운 상태 관리 라이브러리를 만들어보면서, 위의 문제점들을 어떻게 해결할 수 있는지 살펴보도록 하겠습니다.
라이브러리 구조
저는 이러한 여러가지 불편함을 개선하기 위해 Client Cache Query라는 npm 패키지를 구현하였습니다.
전체 아키텍처
데이터 흐름
문제 해결을 위한 접근
키 관리
1. 네임스페이스를 통한 키 구조화
constructor(options: StoreOptions<T>) {
this.options = {
...options,
namespace: options.namespace, // 네임스페이스 옵션 지원
};
}
키 충돌을 방지하고 관련 데이터를 그룹화하기 위해 네임스페이스 개념을 도입했습니다.
// 사용 예시
const userStore = new ExternalStore({
key: 'profile',
namespace: 'user',
// ... 다른 옵션들
});
const settingsStore = new ExternalStore({
key: 'preferences',
namespace: 'settings',
// ... 다른 옵션들
});
이렇게 하면 실제 스토리지에는 user:profile
, settings:preferences
와 같은 형태로 저장되어 키 충돌을 방지할 수 있습니다.
2. 버전 기반 키 관리
private readonly VERSION = "1.0.0";
private async getAsyncSnapshot(): Promise<T> {
// ...
if (parsed.version !== this.VERSION) {
console.warn(
`Version mismatch: stored=${parsed.version}, current=${this.VERSION}`
);
}
// ...
}
각 키에 버전 정보를 포함시켜, 데이터 구조가 변경되었을 때 이를 감지하고 적절히 처리할 수 있도록 했습니다. 이는 키 관리에 있어 중요한 부분인데, 버전이 변경되면 기존 키의 데이터를 마이그레이션하거나 무효화할 수 있기 때문입니다.
3. 메타데이터를 포함한 키-값 저장
const data: StoredData<T> = {
value,
expiry: this.options.ttl ? Date.now() + this.options.ttl : null,
version: this.VERSION,
timestamp: Date.now(),
hash: this.calculateHash(value),
};
단순히 키-값 쌍만 저장하는 것이 아니라, 각 키에 대한 풍부한 메타데이터를 함께 저장합니다. 즉,
- 키의 생성 시점
- 데이터의 만료 시간
- 무결성 검증을 위한 해시
- 버전 정보 등을 함께 관리할 수 있습니다.
4. 자동 키 정리 메커니즘
private cleanupExpiredItems(): void {
try {
const now = Date.now();
const keysToRemove: string[] = [];
Object.keys(this.options.storage).forEach((key) => {
try {
const item = this.options.storage.getItem(key);
if (item) {
const data = this.options.deserialize(item);
if (data.expiry && now > data.expiry) {
keysToRemove.push(key);
}
}
} catch (error: unknown) {
this.options.onError(
new Error(
`Failed to process item ${key}: ${this.getErrorMessage(error)}`
)
);
}
});
keysToRemove.forEach((key) => {
this.options.storage.removeItem(key);
});
} catch (error: unknown) {
this.options.onError(
new Error(`Cleanup process failed: ${this.getErrorMessage(error)}`)
);
}
}
만료된 키를 자동으로 정리하는 메커니즘을 구현했습니다. 이는 불필요한 키가 스토리지에 계속 남아있는 것을 방지하고, 스토리지 공간을 효율적으로 관리할 수 있게 해줍니다.
5. 키 액세스 추상화
// API 사용 예시
const [count, setCount] = useClientCache("counter", 0, {
ttl: 5000,
namespace: "app"
});
최종 사용자는 복잡한 키 관리 로직을 신경 쓸 필요 없이, 간단한 문자열 키만으로도 안전하게 데이터를 저장하고 접근할 수 있습니다. 라이브러리 내부에서 모든 키 관리 복잡성을 추상화하여 처리합니다.
이러한 접근 방식을 통해 키 관리와 관련된 대부분의 문제점들을 해결할 수 있었습니다
- 키 충돌 방지
- 체계적인 키 구조화
- 자동화된 키 수명 주기 관리
- 버전 관리를 통한 데이터 일관성 유지
- 사용하기 쉬운 API 제공
데이터 무결성과 보안
브라우저 스토리지의 문제점 중 하나는 사용자 누구든 접근 가능하고 데이터가 평문으로 저장된다는 점입니다. 이 문제를 해결하기 위해 다음과 같은 보안 메커니즘을 구현했습니다.
1. AES-GCM 암호화 구현
AES-GCM(Advanced Encryption Standard in Galois/Counter Mode)은 현대 암호화 표준으로, 다음과 같은 특징을 가집니다
- 기밀성: AES 블록 암호화를 통한 데이터 암호화
- 인증: GCM 모드를 통한 데이터 무결성 검증
- 고성능: 하드웨어 가속을 통한 빠른 연산 지원
다른 암호화 방식(예: AES-CBC)과 비교했을 때 AES-GCM을 선택한 이유는
- 암호화와 인증을 동시에 제공 (AEAD: Authenticated Encryption with Associated Data)
- 병렬 처리가 가능해 성능이 우수
- Web Crypto API에서 기본 지원
private async encryptData(data: string): Promise<string> {
if (!this.options.encryptionKey) return data;
try {
const encoder = new TextEncoder();
const keyData = encoder.encode(this.options.encryptionKey);
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "AES-GCM" },
false,
["encrypt"]
);
// 초기화 벡터(IV) 생성
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedData = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
encoder.encode(data)
);
return JSON.stringify({
iv: Array.from(iv),
data: Array.from(new Uint8Array(encryptedData)),
});
} catch (error: unknown) {
this.options.onError(
new Error(`Encryption failed: ${this.getErrorMessage(error)}`)
);
return data;
}
}
여기서 사용되는 초기화 벡터(IV)는 암호화할 때마다 무작위로 생성되는 값으로, 재생 공격(Replay Attack)을 방지합니다
- 재생 공격이란?: 공격자가 이전에 암호화된 데이터를 캡처하여 나중에 재전송하는 공격 방식
- IV의 역할: 같은 평문이라도 매번 다른 암호문을 생성하여 재생 공격을 무력화
- 무작위성 보장:
crypto.getRandomValues()
를 사용하여 암호학적으로 안전한 난수 생성
2. 데이터 무결성 검증
private calculateHash(data: any): string {
try {
return btoa(
String.fromCharCode.apply(
null,
new Uint8Array(new TextEncoder().encode(JSON.stringify(data))).reduce(
(arr, byte) => [...arr, byte],
[] as number[]
)
)
);
} catch (error: unknown) {
this.options.onError(
new Error(`Hash calculation failed: ${this.getErrorMessage(error)}`)
);
return "";
}
}
이 해시 계산 로직을 단계별로 살펴보면
JSON.stringify(data)
: 데이터를 JSON 문자열로 변환TextEncoder().encode()
: 문자열을 UTF-8 바이트 배열로 인코딩new Uint8Array()
: 바이트 배열을 TypedArray로 변환reduce()
: 바이트 배열을 일반 배열로 변환String.fromCharCode.apply()
: 바이트 값들을 문자로 변환btoa()
: 최종 문자열을 Base64로 인코딩
이러한 과정을 거치는 이유
- 모든 데이터 타입을 일관된 방식으로 해시화
- 바이너리 데이터 처리 가능
- Base64 인코딩으로 안전한 문자열 표현
3. XSS 공격 방지
XSS(Cross-Site Scripting) 공격은 웹 애플리케이션에 악성 스크립트를 삽입하는 공격 방식입니다.
interface StoreOptions<T> {
serialize?: (value: StoredData<T>) => string;
deserialize?: (value: string) => StoredData<T>;
}
커스텀 직렬화/역직렬화 함수를 통해
- 데이터 이스케이핑: 특수 문자(
<
,>
,"
,'
등) 처리 - 입력 검증: 허용된 데이터 형식만 저장
- 출력 인코딩: 저장된 데이터 사용 시 적절한 인코딩 적용
const [userData, setUserData] = useClientCache("userInfo", initialData, {
serialize: (value) => {
// 특수 문자 이스케이핑
const escaped = JSON.stringify(value).replace(/[<>'"]/g, (char) => {
const entities: Record<string, string> = {
'<': '<',
'>': '>',
"'": ''',
'"': '"'
};
return entities[char];
});
return escaped;
},
deserialize: (value) => {
// 데이터 타입 검증 후 파싱
const parsed = JSON.parse(value);
if (!isValidUserData(parsed)) {
throw new Error('Invalid data format');
}
return parsed;
}
});
이러한 보안 메커니즘들을 통해 브라우저 스토리지의 데이터를 안전하게 보호하고 관리할 수 있습니다.
동기화와 일관성
브라우저의 여러 탭이나 창에서 동일한 애플리케이션이 실행될 때는 데이터 동기화와 일관성 유지가 중요한 과제가 됩니다. 이를 해결하기 위해 다음과 같은 메커니즘을 구현했습니다.
1. Storage 이벤트 기반 동기화
private storageEventHandler: ((event: StorageEvent) => void) | null = null;
constructor(options: StoreOptions<T>) {
// ...
if (typeof window !== "undefined") {
this.storageEventHandler = this.handleStorageEvent.bind(this);
window.addEventListener("storage", this.storageEventHandler);
}
}
private handleStorageEvent(event: StorageEvent): void {
try {
if (
event.storageArea === this.options.storage &&
event.key === this.options.key
) {
this.memoryCache = null; // 캐시 무효화
this.notifySubscribers(); // 구독자들에게 변경 알림
}
} catch (error: unknown) {
this.options.onError(
new Error(
`Storage event handling failed: ${this.getErrorMessage(error)}`
)
);
}
}
Storage 이벤트를 활용하여
- 다른 탭에서 발생한 변경사항을 실시간으로 감지
- 메모리 캐시를 자동으로 무효화
- 모든 구독자에게 변경사항을 전파
2. 메모리 캐시와 영구 저장소 동기화
private async getAsyncSnapshot(): Promise<T> {
try {
// 1. 먼저 메모리 캐시 확인
if (this.memoryCache) {
const { value, expiry } = this.memoryCache;
if (!expiry || Date.now() < expiry) {
return value;
}
}
// 2. 영구 저장소에서 데이터 로드
const storedValue = this.options.storage.getItem(this.options.key);
if (storedValue !== null) {
const decryptedValue = await this.decryptData(storedValue);
const decompressedValue = await this.decompressData(decryptedValue);
const parsed = this.options.deserialize(
decompressedValue
) as StoredData<T>;
// 3. 데이터 유효성 검증
if (this.validateStoredData(parsed)) {
this.memoryCache = parsed;
return parsed.value;
}
}
return this.options.initialValue;
} catch (error: unknown) {
this.options.onError(
new Error(
`Failed to get async snapshot: ${this.getErrorMessage(error)}`
)
);
return this.options.initialValue;
}
}
계층적 저장소 구조를 통해
- 메모리 캐시로 빠른 접근 제공
- 영구 저장소와의 자동 동기화
- 데이터 일관성 보장
3. Stale-While-Revalidate 패턴
if (parsed.expiry && Date.now() < parsed.expiry) {
return parsed.value;
} else if (
this.options.staleWhileRevalidate &&
Date.now() < parsed.expiry + this.options.staleWhileRevalidate
) {
// 만료된 데이터지만 허용된 시간 내에는 사용
return parsed.value;
}
오래된 데이터 처리 전략
- TTL(Time To Live) 기반의 데이터 신선도 관리
- 설정된 시간 동안 오래된 데이터 허용
- 백그라운드에서 데이터 갱신 처리
4. 동시성 제어
private async setState(value: T): Promise<void> {
try {
// 1. 락 획득 시도
if (!this.acquireLock()) {
throw new Error("Failed to acquire lock");
}
// 2. 데이터 저장 및 메타데이터 업데이트
const data: StoredData<T> = {
value,
expiry: this.options.ttl ? Date.now() + this.options.ttl : null,
version: this.VERSION,
timestamp: Date.now(),
hash: this.calculateHash(value),
};
// 3. 데이터 저장
await this.persistData(data);
// 4. 구독자 알림
this.notifySubscribers();
} finally {
// 5. 락 해제
this.releaseLock();
}
}
private acquireLock(): boolean {
const lockKey = `${this.options.key}_lock`;
if (this.options.storage.getItem(lockKey)) {
return false;
}
this.options.storage.setItem(lockKey, Date.now().toString());
return true;
}
private releaseLock(): void {
const lockKey = `${this.options.key}_lock`;
this.options.storage.removeItem(lockKey);
}
간단한 락 메커니즘을 통해
- 동시 쓰기 작업 방지
- 데이터 일관성 보장
- 경쟁 상태(Race Condition) 해결
5. 오프라인 지원
interface StoreOptions<T> {
// ...
offlineStorage?: {
enabled: boolean;
maxItems?: number;
syncStrategy?: 'immediate' | 'background' | 'manual';
};
}
// 오프라인 큐 관리
private async handleOfflineOperation(operation: Operation): Promise<void> {
if (!navigator.onLine) {
this.offlineQueue.push(operation);
return;
}
// 온라인 상태가 되면 큐의 작업 처리
while (this.offlineQueue.length > 0) {
const nextOperation = this.offlineQueue.shift();
if (nextOperation) {
await this.processOperation(nextOperation);
}
}
}
오프라인 상태 처리
- 오프라인 작업 큐잉
- 온라인 복귀 시 자동 동기화
- 충돌 해결 전략 제공
이러한 동기화 메커니즘들을 통해
- 여러 탭/창 간의 실시간 데이터 동기화
- 성능과 일관성의 균형
- 안정적인 오프라인 지원
- 동시성 문제 해결
을 달성할 수 있었습니다. 예를들어 아래와 같이 간단하게 이 기능들을 활용할 수 있습니다
const [sharedData, setSharedData] = useClientCache("sharedKey", initialData, {
staleWhileRevalidate: 5000, // 5초
offlineStorage: {
enabled: true,
syncStrategy: 'background'
}
});
useSyncExternalStore 활용
React 18에서 도입된 useSyncExternalStore
는 외부 상태를 React와 동기화하는데 최적화된 훅입니다. 이 훅을 활용하여 브라우저 스토리지와 React 상태를 안전하고 효율적으로 연동했습니다.
useClientCache 훅 구현
export function useClientCache<T>(
key: string,
initialValue: T,
options: UseClientCacheOptions<T> = {}
): [T, (value: T | ((prev: T) => T)) => Promise<void>] {
// 기본 옵션 설정
const {
storage = typeof window !== "undefined" ? window.localStorage : undefined,
serialize = JSON.stringify,
deserialize = JSON.parse,
ttl,
namespace = "client-cache",
onError = console.error,
validateOnLoad = true,
compression = false,
encryptionKey,
maxSize = 5 * 1024 * 1024,
staleWhileRevalidate = 0,
} = options;
const namespacedKey = `${namespace}:${encodeURIComponent(key)}`;
const storeRef = useRef<ExternalStore<T> | null>(null);
여기서 주목할 점은
- 타입 안정성을 위한 제네릭 사용
- 유연한 옵션 구성
- 네임스페이스가 적용된 키 생성
useRef
를 통한 스토어 인스턴스 관리
스토어 초기화
if (!storeRef.current) {
if (!storage) {
console.warn(
"Storage is not available. Falling back to in-memory storage."
);
return [initialValue, async () => {}];
}
storeRef.current = new ExternalStore<T>({
storage,
key: namespacedKey,
serialize,
deserialize,
initialValue,
ttl,
onError,
validateOnLoad,
compression,
encryptionKey,
maxSize,
staleWhileRevalidate,
});
}
const store = storeRef.current;
스토어 초기화 과정에서
- 스토리지 가용성 체크
- SSR 대응을 위한 폴백 처리
- 설정된 옵션으로 ExternalStore 인스턴스 생성
useSyncExternalStore 연동
const state = useSyncExternalStore(
store.subscribe.bind(store),
store.getSnapshot.bind(store),
store.getServerSnapshot.bind(store)
);
useSyncExternalStore
는 세 가지 핵심 함수를 받습니다
subscribe
: 상태 변경을 구독하는 함수getSnapshot
: 현재 상태를 가져오는 함수getServerSnapshot
: SSR을 위한 초기 상태 제공 함수
상태 업데이트 함수
const setState = async (action: T | ((prev: T) => T)): Promise<void> => {
try {
const newValue =
typeof action === "function"
? (action as (prev: T) => T)(store.getSnapshot())
: action;
await store.setState(newValue);
} catch (error) {
onError(new Error(`Failed to set state: ${error}`));
}
};
상태 업데이트 함수는
- 값 또는 업데이터 함수를 지원
- 비동기 작업 처리
- 에러 핸들링 포함
리소스 정리
useEffect(() => {
return () => {
store.cleanup();
};
}, []);
return [state, setState];
컴포넌트 언마운트 시
- 구독 해제
- 메모리 캐시 정리
- 이벤트 리스너 제거
사용 예시
function UserProfile() {
const [profile, setProfile] = useClientCache(
"userProfile",
{ name: "", email: "" },
{
namespace: "user",
ttl: 3600000, // 1시간
validateOnLoad: true,
compression: true
}
);
const updateProfile = async (newData: typeof profile) => {
await setProfile(newData);
// 프로필 업데이트 완료
};
return (
<div>
<h1>{profile.name}</h1>
<p>{profile.email}</p>
<button onClick={() => updateProfile({ ...profile, name: "New Name" })}>
Update Name
</button>
</div>
);
}
useSyncExternalStore
를 활용함으로써
- 동시성 모드 완벽 지원
- 테어링(tearing) 현상 방지
- 상태 업데이트의 일관성 보장
- SSR 호환성 확보
를 달성할 수 있었습니다.
실제 사용 에시
실시간 장바구니 (탭 간 동기화)
장바구니 (0개 상품)
영구 테마 설정
폼 자동저장 (1주일 유지)
결론
이번 프로젝트를 통해 브라우저 스토리지 기반의 상태 관리에서 발생하는 다양한 문제점들을 해결하는 라이브러리를 구현해보았습니다. 최신 React API인 useSyncExternalStore
를 활용하여 안전하고 효율적인 상태 관리를 구현할 수 있었으며, 특히 다음과 같은 성과를 얻을 수 있었습니다.
주요 성과
-
체계적인 키 관리
- 네임스페이스를 통한 키 충돌 방지
- 메타데이터 기반의 키 수명주기 관리
- 자동 정리 메커니즘으로 스토리지 최적화
-
강력한 보안과 무결성
- AES-GCM 암호화로 데이터 보호
- 해시 기반 무결성 검증
- XSS 공격 방어 메커니즘
-
성능 최적화
- 메모리 캐시 계층 구현
- 불필요한 직렬화/역직렬화 최소화
- 데이터 압축 지원
-
안정적인 동기화
- 탭 간 실시간 상태 동기화
- 오프라인 작업 지원
- 동시성 제어
한계점 및 개선 사항
현재 구현에서 아직 해결하지 못한 몇 가지 과제들이 있습니다
-
복잡한 데이터 구조 지원
- 순환 참조가 있는 객체 처리
- 깊은 중첩 객체의 효율적인 업데이트
- 대용량 데이터셋 처리 최적화
-
더 정교한 버전 관리
- 자동 마이그레이션 지원
- 스키마 변경 감지 및 처리
- 롤백 메커니즘 구현
-
성능 모니터링
- 메모리 사용량 추적
- 성능 메트릭 수집
- 병목 지점 분석
향후 계획
앞으로 다음과 같은 기능들을 추가할 예정입니다
- 개발자 도구 지원
interface DevToolsOptions {
enabled: boolean;
logger?: (event: DevToolsEvent) => void;
maxHistory?: number;
persistDevTools?: boolean;
}
// 사용 예시
const [state, setState] = useClientCache("key", initialValue, {
devTools: {
enabled: process.env.NODE_ENV === 'development',
maxHistory: 50
}
});
- 플러그인 시스템
interface Plugin {
name: string;
onBeforeSet?: (value: any) => any;
onAfterSet?: (value: any) => void;
onError?: (error: Error) => void;
}
// 사용 예시
const loggingPlugin: Plugin = {
name: 'logging',
onAfterSet: (value) => {
console.log('State updated:', value);
}
};
- 미들웨어 지원
type Middleware<T> = (
store: ExternalStore<T>
) => (next: (action: T) => Promise<void>) => (action: T) => Promise<void>;
// 사용 예시
const loggingMiddleware: Middleware<any> = (store) => (next) => async (action) => {
console.log('Before:', store.getSnapshot());
await next(action);
console.log('After:', store.getSnapshot());
};
마치며
이 프로젝트를 통해 브라우저 스토리지 기반의 상태 관리가 단순한 키-값 저장 이상의 복잡한 요구사항들을 가지고 있다는 것을 배웠습니다. 특히 보안, 성능, 동기화 등 다양한 측면을 종합적으로 고려해야 한다는 점이 인상적이었습니다. 앞으로도 이 라이브러리를 개선하고 발전시켜 나가면서, 웹 애플리케이션의 상태 관리를 더욱 안전하고 효율적으로 만들어나가고자 합니다.
프로젝트 소스 코드는 GitHub 저장소에서 확인하실 수 있으며, npm을 통해 설치하여 사용하실 수 있습니다.