TypeScript와 Web Storage
이 문서에서는 TypeScript와 Web Storage를 설명합니다.
실용적인 예제를 포함해 TypeScript와 Web Storage를 설명합니다.
YouTube Video
TypeScript와 Web Storage
브라우저의 Web Storage는 문자열용 키/값 저장소입니다. 이는 동기식 API를 사용하는 가벼운 기능이지만, 문자열만 저장할 수 있고 저장 용량 한도 초과와 같은 예외를 직접 처리해야 함에 유의하세요. TypeScript와 결합하면 타입 안전성, 안전한 직렬화/역직렬화, 키의 중앙 관리, 만료 및 버저닝을 추가하여 프로덕션에 적합한 설계를 구현할 수 있습니다.
localStorage와 sessionStorage
브라우저를 닫은 후에도 남는 영구 저장소가 localStorage이고, 탭/창 단위로 탭을 닫으면 지워지는 세션 저장소가 sessionStorage입니다. 둘 다 값을 키-값 쌍(문자열)으로 저장합니다.
1// Simple usage example: store and retrieve a string
2// This example uses localStorage to persist a username.
3localStorage.setItem('username', 'alice');
4console.log('saved username:', localStorage.getItem('username')); // "alice"
5
6// session storage example
7sessionStorage.setItem('session-id', 'xyz-123');
8console.log('session id:', sessionStorage.getItem('session-id')); // "xyz-123"
- 이 코드는 문자열을 저장하고 가져오는 예제입니다.
Web Storage는 문자열만 저장할 수 있으므로, 객체는 저장 전에 JSON으로 변환해야 합니다.
JSON 파싱을 사용하는 예제
Web Storage에 객체를 저장하고 복원하려면 JSON.stringify / JSON.parse를 사용하세요.
1// Simple write & read with JSON
2// Store a small object as JSON and read it back.
3const user = { id: 1, name: "Alice" };
4localStorage.setItem("app:user", JSON.stringify(user));
5
6const raw = localStorage.getItem("app:user");
7const parsed = raw ? (JSON.parse(raw) as { id: number; name: string }) : null;
8
9console.log("user:", parsed); // user: { id: 1, name: "Alice" }
- 이 코드는 최소한의 동작 예제입니다. 실제 애플리케이션에서는 파싱 실패나 저장소 할당량 부족과 같은 예외도 고려해야 합니다.
JSON 파싱에 대한 예외 처리 예제
여기서는 읽기/쓰기를 안전하게 하고 JSON.parse 실패 및 setItem 예외를 처리하기 위한 래퍼를 제공합니다.
1// Safe JSON helpers
2// These helpers handle parse errors and memory/storage issues separately.
3export function safeParseJson<T>(raw: string | null): T | null {
4 if (raw == null) return null;
5
6 try {
7 return JSON.parse(raw) as T;
8 } catch (error: any) {
9 if (error instanceof SyntaxError) {
10 console.error("JSON parse error:", error.message);
11 return null;
12 }
13
14 console.error("Unexpected JSON error:", error);
15 return null;
16 }
17}
18
19// Safe setter for JSON values
20export function setJson(storage: Storage, key: string, value: unknown): void {
21 try {
22 const json = JSON.stringify(value);
23 storage.setItem(key, json);
24 } catch (error: any) {
25 if (error?.name === "QuotaExceededError") {
26 console.error("Storage quota exceeded while saving JSON:", error.message);
27 } else if (error instanceof TypeError) {
28 console.error("JSON serialization failed:", error.message);
29 } else {
30 console.error("Unexpected error while setting JSON:", error);
31 }
32 }
33}
34
35// Safe getter for JSON values
36export function getJson<T>(storage: Storage, key: string, fallback?: T): T | null {
37 const parsed = safeParseJson<T>(storage.getItem(key));
38 return parsed ?? (fallback ?? null);
39}- 이 유틸리티는 앱 전반에서 재사용할 수 있습니다.
try/catch를 추가하고 싶을 때는 한 겹 더 래핑하세요.
TTL(만료)을 포함한 저장 예제
Web Storage 자체에는 TTL이 없으므로, 값에 expiresAt을 추가해 관리하세요.
1// TTL wrapper
2// Store { value, expiresAt } and automatically expire items on read.
3type WithTTL<T> = { value: T; expiresAt: number | null };
4
5export function setWithTTL<T>(storage: Storage, key: string, value: T, ttlMs?: number) {
6 const payload: WithTTL<T> = { value, expiresAt: ttlMs ? Date.now() + ttlMs : null };
7 setJson(storage, key, payload);
8}
9
10export function getWithTTL<T>(storage: Storage, key: string): T | null {
11 const payload = getJson<WithTTL<T>>(storage, key);
12 if (!payload) return null;
13 if (payload.expiresAt && Date.now() > payload.expiresAt) {
14 storage.removeItem(key);
15 return null;
16 }
17 return payload.value;
18}- TTL은 캐시와 자동 저장(초안)에 효과적이며 불일치를 줄일 수 있습니다.
키 중앙 관리와 네임스페이스(충돌 방지) 예제
키를 prefix + version + name 형태로 표준화하면 충돌을 줄이고 마이그레이션을 단순화할 수 있습니다.
1// Key factory with namespacing and versioning
2// Create namespaced keys like "myapp:v1:theme" to avoid collisions.
3const APP = "myapp";
4const V = "v1";
5
6const ns = (k: string) => `${APP}:${V}:${k}`;
7
8const Keys = {
9 theme: ns("theme"),
10 user: ns("user"),
11 cart: ns("cart"),
12 draft: ns("draft"),
13};- 키에 네임스페이스를 포함하면 나중에 버전을 전환하거나 정리를 수행하기가 더 쉬워집니다.
할당량 초과(QuotaExceededError) 및 폴백 전략 예제
setItem 실행 시 QuotaExceededError가 발생할 수 있음을 고려하고, 저장 실패 시를 대비한 폴백 전략을 설계하세요. 예를 들어 저장 용량을 초과한 경우, 오래된 데이터를 삭제하거나 sessionStorage 또는 인메모리 캐시로 폴백하여 앱의 전반적인 안정성을 유지할 수 있습니다.
1// Quota-safe set with fallback to in-memory storage
2// Return true if stored, false otherwise.
3export function trySetJson(storage: Storage, key: string, value: unknown, fallback?: Map<string, string>): boolean {
4 try {
5 storage.setItem(key, JSON.stringify(value));
6 return true;
7 } catch (err) {
8 console.warn("Failed to set item:", key, err);
9 if (fallback) {
10 try {
11 fallback.set(key, JSON.stringify(value));
12 return true;
13 } catch {
14 return false;
15 }
16 }
17 return false;
18 }
19}
20
21// Example fallback usage
22const inMemoryFallback = new Map<string, string>();
23const ok = trySetJson(localStorage, Keys.cart, { items: [] }, inMemoryFallback);
24if (!ok) console.log("Saved to fallback map instead");- 폴백 대상에 따라 데이터의 지속성이 보장되지 않을 수 있습니다. 따라서 사용 사례에 맞는 적절한 저장소 대상을 선택하세요. 예를 들어 프라이빗 브라우징 모드이거나 저장소 제한이 있는 환경에서는 일시적으로 메모리나
sessionStorage를 사용해 기능을 유지할 수 있습니다.
탭 간 동기화(storage 이벤트)와 동일 탭 알림 예제
window.addEventListener('storage', …)를 사용하면 다른 탭에서 발생한 저장소 변경을 감지할 수 있습니다. 그러나 이 이벤트는 같은 탭 내에서는 발생하지 않습니다. 따라서 같은 탭 내 변경 알림을 위해서는 CustomEvent를 사용해 자체 이벤트를 발행하세요.
1// Cross-tab and same-tab notification helpers
2// storage event fires on other tabs; use CustomEvent for same-tab listeners.
3const SAME_TAB_EVENT = "storage:changed";
4
5function notifyChanged(key: string) {
6 window.dispatchEvent(new CustomEvent(SAME_TAB_EVENT, { detail: { key } }));
7}
8
9function setJsonWithNotify(storage: Storage, key: string, value: unknown) {
10 setJson(storage, key, value);
11 notifyChanged(key);
12}
13
14// Listeners
15window.addEventListener("storage", (e) => {
16 if (e.key === Keys.theme) {
17 const theme = safeParseJson<string>(e.newValue);
18 console.log("Theme changed in another tab:", theme);
19 }
20});
21
22window.addEventListener(SAME_TAB_EVENT, (e: Event) => {
23 const detail = (e as CustomEvent).detail as { key: string };
24 console.log("Changed in this tab:", detail.key);
25});- 이 코드를 사용하면 다른 탭과 현재 탭 모두에서 저장소 변경을 동기화할 수 있습니다.
타입 안전한 레지스트리(키별 엄격한 타입 지정)
저장 및 조회 시 실수를 방지하기 위해 TypeScript에서 키와 타입의 매핑을 정의하세요.
1// Typed registry that enforces types per key
2// Registry maps keys to their allowed types.
3type Registry = {
4 [k in typeof Keys.theme]: "light" | "dark";
5} & {
6 [k in typeof Keys.user]: { id: number; name: string };
7};
8
9type KeyOf<R> = Extract<keyof R, string>;
10
11export const TypedStore = {
12 get<K extends KeyOf<Registry>>(key: K, storage: Storage = localStorage): Registry[K] | null {
13 return getJson<Registry[K]>(storage, key);
14 },
15 set<K extends KeyOf<Registry>>(key: K, value: Registry[K], storage: Storage = localStorage): void {
16 setJson(storage, key, value);
17 },
18 remove<K extends KeyOf<Registry>>(key: K, storage: Storage = localStorage): void {
19 storage.removeItem(key);
20 },
21};- {^ i18n_speak 型をキーに関連付けることで、ランタイムでの誤使用をコンパイル時に検出でき、安全性を高めることができます。 ^}
복합 타입을 위한 직렬화/리바이버(Date/Map 등)
Date, Map 등의 객체를 올바르게 복원하려면 JSON.stringify의 replacer와 reviver를 활용하세요.
1// Serializing Dates with replacer and reviver
2// Custom replacer marks Date objects for correct revival.
3function replacer(_k: string, v: unknown) {
4 if (v instanceof Date) return { __type: "Date", value: v.toISOString() };
5 return v;
6}
7
8function reviver(_k: string, v: any) {
9 if (v && v.__type === "Date") return new Date(v.value);
10 return v;
11}
12
13function setJsonWithDates(storage: Storage, key: string, value: unknown) {
14 storage.setItem(key, JSON.stringify(value, replacer));
15}
16
17function getJsonWithDates<T>(storage: Storage, key: string): T | null {
18 const raw = storage.getItem(key);
19 if (!raw) return null;
20 try { return JSON.parse(raw, reviver) as T; } catch { return null; }
21}- 이 방법을 사용하면
Date객체가Date로 올바르게 복원됩니다. 마찬가지로Map과Set도 표시하여 복원할 수 있습니다.
버저닝과 마이그레이션 전략 예제
향후 저장 형식이 바뀔 가능성이 있다면, 페이로드에 버전을 포함하고 마이그레이션을 준비하세요.
1// Versioned payload pattern for migrations
2// Keep { v, data } and migrate on read if necessary.
3type VersionedPayload<T> = { v: number; data: T };
4
5function migrateUserV1toV2(u1: { id: number; name: string }) {
6 return { id: u1.id, profile: { displayName: u1.name } };
7}
8
9function readUserAnyVersion(): { id: number; profile: { displayName: string } } | null {
10 const raw = localStorage.getItem(Keys.user);
11 if (!raw) return null;
12 try {
13 const obj = JSON.parse(raw) as VersionedPayload<any>;
14 if (obj.v === 2) {
15 return obj.data;
16 } else if (obj.v === 1) {
17 const migrated = migrateUserV1toV2(obj.data);
18 localStorage.setItem(Keys.user, JSON.stringify({ v: 2, data: migrated }));
19 return migrated;
20 }
21 return null;
22 } catch (err) {
23 console.error("migration parse error", err);
24 return null;
25 }
26}- 작은 마이그레이션을 차곡차곡 적용하면 하위 호환성을 유지할 수 있습니다.
SSR(서버 사이드 렌더링)에서의 처리 예제
window가 없는 환경에서는 localStorage에 직접 참조하면 에러가 발생하므로, 환경 가드를 사용하세요.
1// Guard for SSR
2// Return a Storage-compatible object or null when not in browser.
3export const isBrowser = (): boolean => typeof window !== "undefined" && typeof window.localStorage !== "undefined";
4
5export const safeLocalStorage = (): Storage | null => (isBrowser() ? window.localStorage : null);
6
7// Usage
8const ls = safeLocalStorage();
9if (ls) setJson(ls, Keys.theme, "dark");- SSR을 지원해야 하는 코드에서는 항상
typeof window를 확인하세요.
실무 팁
- 디바운스/스로틀로 쓰기 빈도를 제한하세요(UI 동작으로 인한 버스트 완화).
- 키는 네임스페이스 + 버전으로 관리하세요(예:
app:v1:...). - 민감한 정보(액세스 토큰 등)는 원칙적으로 저장하지 마세요. 부득이한 경우, 짧은 수명과 서버 검증 또는 WebCrypto 사용을 고려하세요.
- 용량은 브라우저마다 다르며(수 MB 수준), 큰 데이터는 IndexedDB에 저장하세요.
- 동일 탭 알림에는
CustomEvent, 탭 간 알림에는storage를 사용하세요. - SSR에서는 항상
typeof window를 확인하세요.
통합된 '타입 안전 스토어' 클래스
지금까지 다룬 네임스페이스, 타입 안전성, 만료(TTL), 예외 처리 등의 요소를 통합한 제네릭 클래스의 구현 예를 살펴보겠습니다. 실제 제품에서는 테스트, 로깅, LRU 기반의 오래된 데이터 삭제, 암호화 등도 고려하세요.
1// Comprehensive TypedStorage store integrating many patterns shown above.
2// - type-safe registry per key
3// - prefix (namespace + version)
4// - trySet with fallback
5// - same-tab notify
6// - TTL optional getter/setter
7type Jsonifiable = string | number | boolean | null | Jsonifiable[] | { [k: string]: Jsonifiable };
8
9interface StoreOptions {
10 storage?: Storage | null; // default: auto-detected localStorage or null
11 prefix?: string; // e.g., "myapp:v1"
12 sameTabEvent?: string | null;
13 fallback?: Map<string, string>; // in-memory fallback
14}- 이 코드는 타입 안전하고 기능이 풍부한 키-값 저장소를 구축하기 위한 설정과 타입 정의를 보여줍니다.
1export class TypedStorage<Reg extends Record<string, Jsonifiable | object>> {
2 private storage: Storage | null;
3 private prefix: string;
4 private sameTabEvent: string | null;
5 private fallback?: Map<string, string>;
6
7 constructor(private registry: Reg, opts: StoreOptions = {}) {
8 this.storage = opts.storage ?? (typeof window !== "undefined" ? window.localStorage : null);
9 this.prefix = (opts.prefix ?? "app:v1") + ":";
10 this.sameTabEvent = opts.sameTabEvent ?? "storage:changed";
11 this.fallback = opts.fallback;
12 }
13
14 private k(key: keyof Reg & string) { return this.prefix + key; }- 이 코드는 타입 안전한 키-값 저장소를 제공하는
TypedStorage클래스의 핵심입니다. 이는registry를 기반으로 허용된 키와 그 타입을 관리하고, 접두사가 붙은 저장 키를 생성합니다. 또한localStorage와 인메모리 폴백을 사용하며, 같은 탭 내 변경 알림을 위한 이벤트 이름을 설정할 수 있게 합니다.
1 // Basic get with optional TTL-aware retrieval
2 get<K extends keyof Reg & string>(key: K): Reg[K] | null {
3 const fullKey = this.k(key);
4 try {
5 const raw = this.storage ? this.storage.getItem(fullKey) : this.fallback?.get(fullKey) ?? null;
6 if (!raw) return null;
7 // Check if value is TTL-wrapped
8 const maybe = safeParseJson<{ value: Reg[K]; expiresAt?: number }>(raw);
9 if (maybe && typeof maybe.expiresAt === "number") {
10 if (maybe.expiresAt && Date.now() > maybe.expiresAt) {
11 this.remove(key);
12 return null;
13 }
14 return maybe.value;
15 }
16 return safeParseJson<Reg[K]>(raw);
17 } catch (err) {
18 console.error("TypedStorage.get error", err);
19 return null;
20 }
21 }get메서드는 지정된 키의 값을 타입 안전한 방식으로 가져오며, 선택적으로 TTL(만료)이 있는 값을 처리할 수 있습니다.
1 // Basic set; returns success boolean
2 set<K extends keyof Reg & string>(key: K, value: Reg[K]): boolean {
3 const fullKey = this.k(key);
4 const payload = JSON.stringify(value);
5 try {
6 if (this.storage) this.storage.setItem(fullKey, payload);
7 else this.fallback?.set(fullKey, payload);
8 if (this.sameTabEvent) window.dispatchEvent(new CustomEvent(this.sameTabEvent, { detail: { key: fullKey } }));
9 return true;
10 } catch (err) {
11 console.warn("TypedStorage.set primary failed, trying fallback", err);
12 try {
13 if (this.fallback) {
14 this.fallback.set(fullKey, payload);
15 return true;
16 }
17 return false;
18 } catch (e) {
19 console.error("TypedStorage.set fallback failed", e);
20 return false;
21 }
22 }
23 }set메서드는 지정된 키에 값을 저장하고, 성공 여부를 나타내는 불리언을 반환합니다.
1 // Set with TTL convenience
2 setWithTTL<K extends keyof Reg & string>(key: K, value: Reg[K], ttlMs?: number): boolean {
3 const payload = { value, expiresAt: ttlMs ? Date.now() + ttlMs : null };
4 return this.set(key, payload as unknown as Reg[K]);
5 }setWithTTL메서드는 TTL(만료)과 함께 값을 저장합니다.
1 remove<K extends keyof Reg & string>(key: K) {
2 const fullKey = this.k(key);
3 try {
4 if (this.storage) this.storage.removeItem(fullKey);
5 this.fallback?.delete(fullKey);
6 } catch (err) { console.warn("TypedStorage.remove error", err); }
7 }remove메서드는 지정된 키에 대한 값을 저장소와 폴백 모두에서 삭제합니다.
1 clear() {
2 try {
3 if (this.storage) this.storage.clear();
4 this.fallback?.clear();
5 } catch (err) { console.warn("TypedStorage.clear error", err); }
6 }
7}clear메서드는 저장소와 폴백에 저장된 모든 데이터를 삭제합니다.
1// Usage example
2type MyReg = {
3 theme: "light" | "dark";
4 user: { id: number; name: string };
5 draft: string;
6};
7
8const memFallback = new Map<string, string>();
9const store = new TypedStorage<MyReg>({} as MyReg, {
10 prefix: "myapp:v1",
11 sameTabEvent: "storage:changed",
12 fallback: memFallback
13});
14store.set("theme", "dark");
15console.log(store.get("theme")); // "dark"
16store.setWithTTL("draft", "in-progress...", 1000 * 60 * 60); // keep 1 hour
-
이 코드는
TypedStorage를 사용하는 예제로, 타입 안전한 키-값 저장소에서 "theme"와 "draft" 같은 값을 저장하고 조회하며, TTL과 폴백도 지원합니다. 저장소 작업을 안전하게 수행하기 위해 같은 탭 알림과 인메모리 폴백을 구성합니다. -
TypedStorage클래스는 실용적인 시작점입니다. 필요에 따라 LRU 전략, 암호화, 압축, IndexedDB로의 폴백을 구현하세요.
요약
TypeScript와 함께 Web Storage를 사용할 때는 견고한 설계를 위해 네 가지—타입 안전성, 예외 대응력, 보안, 동기화(다중 탭)—를 항상 염두에 두세요. 지금까지 살펴본 래퍼와 유틸리티는 그 예입니다. 필요하면 IndexedDB 등 다른 브라우저 스토리지로 마이그레이션할 수도 있습니다.
위의 기사를 보면서 Visual Studio Code를 사용해 우리 유튜브 채널에서 함께 따라할 수 있습니다. 유튜브 채널도 확인해 주세요.