useJsonLocalStorage: JSON 형식으로 LocalStorage를 다루는 React Hook 개발기
JSON 형식으로 LocalStorage를 다루는 React Hook 개발기
TL;DR
useJsonLocalStorage는 JSON 형식으로 LocalStorage를 다루기 위한 React Hook입니다.
- https://www.npmjs.com/package/use-json-localstorage
- https://github.com/dohyeon-kr/use-json-localstorage
LocalStorage를 단순한 문자열 저장소가 아니라,
타입이 유지되는 외부 상태 저장소처럼 다루는 것을 목표로 만들었습니다.
무엇을 만들었는가?
import { useLocalStorageValue, getRuntimeLocalStorage } from 'use-json-localstorage';
function Counter() {
const count = useLocalStorageValue('count', 0);
const runtimeStorage = getRuntimeLocalStorage();
const onClick = () => {
runtimeStorage.set('count', count + 1);
};
return (
<div>
<div>{count}</div>
<button onClick={onClick}>Increment</button>
</div>
);
}이 라이브러리는 다음을 제공합니다.
- LocalStorage를 JSON 기반으로 직렬화/역직렬화
useSyncExternalStore를 이용한 React state와의 동기화- 런타임에서 LocalStorage를 직접 제어할 수 있는 싱글톤 API
왜 만들었는가?
현재 개발 중인 사이드 프로젝트의 초기 MVP에서는
백엔드를 빠르게 키우기보다, 가능한 한 클라이언트 중심의 구조로 가고 싶었습니다.
그래서 간단한 데이터 저장소로 LocalStorage를 선택했지만, 곧 몇 가지 불편함이 드러났습니다.
- 문자열만 저장 가능하다
- 타입이 보존되지 않는다
- React state와 자연스럽게 동기화되지 않는다
- client-only API라 접근 패턴이 지저분해진다
이 문제들을 해결하기 위해 다음을 목표로 삼았습니다.
- LocalStorage 접근 과정을 추상화하고
- 타입 추론이 가능한 API를 제공하며
- 런타임에서의 변경을 React state와 동기화하는 것
어떻게 해결했는가?
1. 단순한 LocalStorage 접근 훅
초기에는 client 환경에서 LocalStorage 접근을 보조하는 간단한 훅으로 시작했습니다.
const useLocalStorage = (callback) => {
useEffect(() => {
if (!globalThis.localStorage) return;
callback(globalThis.localStorage);
}, [callback]);
};하지만 곧 타입 추론에 대한 니즈가 생겼고,
이를 위해 LocalStorage API를 그대로 노출하기보다 감싼 형태의 API가 필요하다고 판단했습니다.
2. JSON 기반 API 래핑
const useLocalStorage = (callback) => {
useEffect(() => {
if (!globalThis.localStorage) return;
callback({
get: (key) => JSON.parse(globalThis.localStorage.getItem(key)),
set: (key, value) =>
globalThis.localStorage.setItem(key, JSON.stringify(value)),
});
}, [callback]);
};이 방식으로 클라이언트 접근 추상화와 타입 추론이라는 목표는 달성할 수 있었습니다.
하지만 곧 한계가 드러났습니다.
Set,Map,Date,RegExp같은 타입은 JSON으로 표현할 수 없다
이를 해결하기 위해 superjson을 도입했습니다.
import SuperJSON from "superjson";
const useLocalStorage = (callback) => {
useEffect(() => {
if(!globalThis.localStorage) return;
callback({
get: (key) => {
return SuperJSON.parse(globalThis.localStorage.getItem(key));
},
set: (key, value) => {
globalThis.localStorage.setItem(key, SuperJSON.stringify(value));
},
});
}, [callback]);
}3. Hook이 아닌, 싱글톤 Runtime Store
구조를 정리한 뒤 ChatGPT에게 리뷰를 요청했고,
“이 로직은 Hook일 필요가 없다”는 피드백을 받았습니다.
LocalStorage의 subset API를 반환하는 싱글톤 함수로 만드는 편이 더 명확했고,
이 판단은 결과적으로 구조를 단순하게 만들어주었습니다.
export const getRuntimeLocalStorage = () => {
if (!(globalThis as ExtendedGlobalThis).runtimeLocalStorage) {
(globalThis as ExtendedGlobalThis).runtimeLocalStorage = (() => {
return {
set: (key: string, value: unknown) => {
globalThis.localStorage.setItem(key, SuperJSON.stringify(value));
},
get: <T>(key: string) => {
const value = globalThis.localStorage.getItem(key);
return value ? SuperJSON.parse<T>(value) : null;
},
remove: (key: string) => {
globalThis.localStorage.removeItem(key);
},
};
})();
}
return (globalThis as ExtendedGlobalThis).runtimeLocalStorage;
};4. React state와의 동기화 문제
이제 남은 문제는 LocalStorage 변경을 React state와 어떻게 동기화할 것인가였습니다.
처음에는 EventEmitter + useEffect 조합으로 해결하려 했습니다. 하지만 다시 리뷰를 받는 과정에서 두 가지 문제가 드러났습니다.
EventEmitter는 Node 종속이다
외부 저장소를 React와 동기화하는 방식으로는 더 이상 권장되지 않는다
React 팀은 이러한 문제를 해결하기 위해 useSyncExternalStore 를 공식 API로 제공하고 있습니다.
이에 따라 구현을 전면 수정했습니다.
5. 최종 구조
'use client';
import SuperJSON from "superjson";
interface SerializeStorage {
set: <T>(key: string, value: T) => void;
get: <T>(key: string) => T | null;
remove: (key: string) => void;
on: (type: string, callback: (event: LocalStorageEvent) => void) => () => void;
}
type ExtendedGlobalThis = typeof globalThis & {
runtimeLocalStorage: SerializeStorage;
};
function deserialize<T>(serializedJavascript: string): T {
return SuperJSON.parse(serializedJavascript);
}
type Listener<T> = (event: T) => void;
type LocalStorageEvent = {
key: string;
value?: unknown;
};
function createEmitter<T>() {
const listeners = new Map<string, Set<Listener<T>>>();
return {
emit(type: string, event: T) {
listeners.get(type)?.forEach((listener) => listener(event));
},
on(type: string, listener: Listener<T>) {
if (!listeners.has(type)) {
listeners.set(type, new Set());
}
listeners.get(type)!.add(listener);
return () => listeners.get(type)!.delete(listener);
}
};
}
export const getRuntimeLocalStorage = () => {
if (!(globalThis as ExtendedGlobalThis).runtimeLocalStorage) {
(globalThis as ExtendedGlobalThis).runtimeLocalStorage = (() => {
const emitter = createEmitter<LocalStorageEvent>();
return {
set: (key: string, value: unknown) => {
const serializedValue = SuperJSON.stringify(value);
globalThis.localStorage.setItem(key, serializedValue);
emitter.emit(`set`, { key, value });
return;
},
get: <T>(key: string) => {
const value = globalThis.localStorage.getItem(key);
return value ? deserialize<T>(value) : null;
},
// remove API 니즈가 있을 것을 깨달아 추가하였습니다.
remove: (key: string) => {
globalThis.localStorage.removeItem(key);
emitter.emit(`remove`, { key });
return;
},
on: (type: string, callback: Listener<LocalStorageEvent>) => {
return emitter.on(type, callback)
},
};
})();
};
return (globalThis as ExtendedGlobalThis).runtimeLocalStorage;
}'use client';
import { useRef, useSyncExternalStore } from "react";
import { getRuntimeLocalStorage } from "./getRuntimeLocalStorage";
export const useLocalStorageValue = <T>(key: string, defaultValue?: T) => {
const store = useRef(getRuntimeLocalStorage());
return useSyncExternalStore<T | undefined>(
(onStoreChange) => {
const setUnsubscribe = store.current.on('set', (event) => {
if (event.key === key) {
onStoreChange();
}
});
const removeUnsubscribe = store.current.on('remove', (event) => {
if (event.key === key) {
onStoreChange();
}
});
return () => {
setUnsubscribe();
removeUnsubscribe();
};
},
() => {
const value = store.current.get<T>(key);
return value ?? defaultValue;
},
() => {
// 서버에서는 default값 보다 '없다'라는 정보를 표현하고 싶어서 undefined를 반환하도록 했습니다.
return undefined;
}
);
};이로써 동시성 환경에서도 안전하게 동작하는 구조를 완성할 수 있었습니다.
마무리
이러한 과정을 거쳐 라이브러리를 npm에 배포했고, GitHub Actions를 통한 배포 자동화까지 마무리했습니다.
작은 라이브러리이지만, LocalStorage를 조금 더 React스럽게 다루고 싶은 분들에게 의미 있는 선택지가 되기를 바랍니다.
앞으로도 이러한 도구들을 계속 만들어보고 싶습니다. 읽어주셔서 감사합니다.
READ NEXT
같은 주제의 글 더 보기
제 얼굴을 본뜬 SVG 프레젠터를 만들고, 프로그래머블 API와 TTS를 연동해 릴스 자동화 시스템에 넣었습니다. 팔과 손을 움직이던 첫 프로토타입부터 얼굴의 인상, 안경과 옷깃, 음성에 맞춘 동작을 다듬기까지 AI와 함께 겪은 시행착오를 정리했습니다.
Read EntrySpring·Kotlin 백엔드 구조를 다시 설계하며 UseCase, Domain, Repository의 책임과 상태 저장, 트랜잭션 경계를 정리한 기록입니다.
Read Entry
댓글 0
로그인 없이 익명으로 남길 수 있습니다.