웹 프론트엔드 개발에서 가장 손쉽게 사용할 수 있는 클라이언트 저장소인 localStorage
. 설정이 필요 없고, 사용법도 간단해 많은 개발자들이 애용합니다. 하지만 이 간단함 이면에는 반드시 이해하고 넘어가야 할 구조적 한계가 존재합니다. 특히 로컬스토리지는 동기(synchronous) API라는 점에서 의도치 않은 버그나 퍼포먼스 문제를 유발할 수 있습니다.
로컬스토리지는 왜 동기 방식일까?
로컬스토리지는 브라우저 스펙상 동기적으로 설계된 API입니다. 즉, 다음과 같은 특성을 가집니다.
- JavaScript의 메인 실행 스레드에서 즉시 동작합니다.
- 읽기와 쓰기 작업은 대기 없이 즉시 반환되어야 합니다.
- 따라서 하나의 작업이라도 지연되면, 앱 전체의 반응성이 떨어질 수 있습니다.
이러한 구조는 단순한 앱에서는 문제가 되지 않지만, 실제 서비스에서는 여러 가지 문제로 이어질 수 있습니다.
동기 API로 인해 발생하는 주요 문제 유형
유형 1. UI 프리즈 / 메인 스레드 블로킹
현상:localStorage.getItem()
을 대량 반복 호출하면, 특히 모바일 환경에서 UI가 버벅이거나 잠시 멈추는 현상이 발생할 수 있습니다.
for (let i = 0; i < 1000; i++) {
const data = localStorage.getItem('someKey-' + i);
}
해결책:
- 초기에 데이터를 한 번에 읽어 메모리 캐싱해 두기
- 반복 접근을 막고
Map
이나useRef
등 인메모리 구조로 대체
const cache = new Map();
for (let i = 0; i < 1000; i++) {
cache.set(`someKey-${i}`, localStorage.getItem(`someKey-${i}`));
}
유형 2. 탭 간 동기화 문제
현상:
다른 탭에서 setItem()
으로 값을 변경한 뒤, 현재 탭에서 아직 그 변경을 인지하지 못해 이전 값을 읽는 현상 발생
원인:
- 로컬스토리지는 모든 탭에 공유되지만
- 변경 알림은 storage 이벤트로 비동기 전달됨
- 즉, 실시간성이 떨어짐
해결책:
window.addEventListener('storage')
로 변경 감지 처리
window.addEventListener('storage', (event) => {
if (event.key === 'token') {
console.log('다른 탭에서 토큰 변경:', event.newValue);
}
});
- 또는 Zustand 등 상태관리 라이브러리 + storage sync 플러그인 사용
유형 3. 저장 용량 초과
현상:QuotaExceededError
발생. 특히 모바일 브라우저는 저장 용량이 5MB 이하로 제한될 수 있음.
try {
localStorage.setItem('bigData', JSON.stringify(data));
} catch (e) {
if (e.name === 'QuotaExceededError') {
// 대체 로직 실행
}
}
해결책:
- 데이터를 압축 형식(JSON) 으로 저장
- 대용량 구조는 IndexedDB로 분리
- 저장 전에 사이즈를 미리 계산하거나 예외 처리 로직 작성
유형 4. 페이지 초기화 Race Condition
현상:
컴포넌트가 마운트되기 전에 localStorage에 접근 시 에러 발생. 특히 SSR 환경(Next.js)에서 빈번
const token = localStorage.getItem('token'); // SSR 시 에러
해결책:
typeof window !== 'undefined'
로 브라우저 환경인지 먼저 확인
const token = typeof window !== 'undefined'
? localStorage.getItem('token')
: null;
- 커스텀 훅으로 안전하게 래핑
function useSafeLocalStorage(key: string): string | null {
if (typeof window === 'undefined') return null;
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
유형 5. 타이밍 불일치 문제
현상:
localStorage 값이 필요한 로직보다 늦게 실행되어 초기 상태가 잘못 반영됨
해결책:
- 로컬스토리지 접근을
useEffect
로 분리하고 상태로 관리
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
const stored = localStorage.getItem('token');
setToken(stored);
}, []);
- Zustand 등에서는 비동기 hydration 기능 사용
문제 해결 전략 정리
문제 유형 | 해결 전략 |
UI 프리즈 | 캐싱, 메모리 구조 사용 |
탭 간 충돌 | storage 이벤트 활용 |
용량 초과 | 압축 저장, IndexedDB 분리 |
초기화 Race | 브라우저 체크 후 접근 |
타이밍 불일치 | useEffect에서 상태 관리 |
근본적인 대안: IndexedDB
localStorage의 구조적 한계를 극복하고 싶다면, 비동기 스토리지인 IndexedDB가 훨씬 강력한 대안이 됩니다.
- 비동기 I/O
- 대용량 구조화 데이터 저장 가능
- 트랜잭션 지원
라이브러리 예시: idb, Dexie.js
import { openDB } from 'idb';
const db = await openDB('my-db', 1, {
upgrade(db) {
db.createObjectStore('tokens');
},
});
await db.put('tokens', 'abc123', 'token');
const token = await db.get('tokens', 'token');
결론: 로컬스토리지는 신중하게, 단순하게
장점 | 단점 |
빠르고 사용법이 간단 | 동기 API로 인한 블로킹 위험 |
탭 간 공유 가능 | 실시간 동기화 어려움, 충돌 발생 |
영구 저장 | 저장 용량 제한, 만료 없음 |
로컬스토리지는 여전히 간단한 토큰, 설정값, 플래그 저장처럼 작은 용도에는 유용합니다. 하지만 빈번한 접근, 동시성, 대용량 저장이 필요한 경우에는 반드시 IndexedDB 또는 상태관리 + hydration 구조로 전환하는 것이 더 안전합니다.