로컬 스토리지로 리덕스(Redux)를 대체하면 벌어지는 일
여기 아주 좋은 해외 블로그 포스트가 하나 있는데요, 이 글의 핵심만 전체적으로 살펴볼까 힙니다.
"Redux나 Zustand, Context API는 언제 쓰는 거고 왜 필요한 건가요? 그냥 로컬 스토리지를 쓰면 안 되나요?" 라는 질문에서 시작하는 글이거든요.
표면적으로 보면 이 둘은 목적이 전혀 다르다고 답할 수 있겠죠.
하지만 정말 그럴까요?
둘 다 데이터를 저장하는 건 마찬가지인데, 도대체 뭐가 그렇게 다른 걸까요?
모든 상태 관리 라이브러리를 걷어내고 브라우저 네이티브 API만으로 깔끔하게 개발할 수 있다면 정말 좋지 않을까요?
오늘 그 가능성을 한번 제대로 파헤쳐 보겠습니다.
우리는 왜 Context, Redux, Zustand를 쓸까요
먼저 이 상태 관리 도구들이 왜 필요한지부터 짚고 넘어가야 하는데요.
리액트의 모든 것은 '상태(state)'를 중심으로 돌아가죠.useState
같은 훅으로 데이터를 상태에 담고, 그 데이터를 화면에 렌더링하며, 사용자 인터랙션에 따라 상태를 업데이트해서 화면을 다시 그립니다.
드롭다운 메뉴의 isOpen
상태처럼, 특정 컴포넌트 안에서만 쓰이는 '로컬 상태'는 useState
만으로도 충분한데요.
const Dropdown = ({ children, trigger }) => {
// 이 isOpen 상태는 Dropdown 컴포넌트만 알고 있습니다.
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(!isOpen)}>{trigger}</button>
{isOpen && children}
</>
);
};
문제는 여러 컴포넌트가 '공유'해야 하는 상태가 생길 때 발생합니다.
예를 들어 앱 전체의 다크 모드 테마를 관리하는 상태가 있다고 해보죠.
토글 버튼은 저쪽 구석에 있는데, 이 isDarkMode
라는 값은 화면의 절반이 넘는 컴포넌트들에게 전달되어야 합니다.
하지만 리액트의 데이터 흐름은 엄격하게 계층적이어서, 컴포넌트는 자신의 부모/자식과만 소통할 수 있거든요.
형제 컴포넌트와는 직접 데이터를 주고받을 수 없죠.
이 문제를 해결하기 위해 '상태 끌어올리기(lifting state up)'라는 기법을 사용하는데요.
상태를 필요로 하는 컴포넌트들의 가장 가까운 공통 부모로 상태를 옮기는 겁니다.
const App = () => {
const [isDarkMode, setIsDarkMode] = useState(false);
return (
<>
<ToggleTheme
isDarkMode={isDarkMode}
onClick={() => setIsDarkMode(!isDarkMode)}
/>
<SomeBeautifulContentComponent isDarkMode={isDarkMode} />
</>
);
};
하지만 이 패턴은 곧바로 'prop drilling'이라는 새로운 문제를 낳는데요.
상태를 실제로 사용하지 않는 수많은 중간 컴포넌트들이 오직 자식에게 값을 전달하기 위해 props를 계속해서 넘겨줘야 하는 거죠.
코드는 금세 지저분해지고 관리하기 어려워집니다.
바로 이 prop drilling의 지옥에서 우리를 구원해주는 것이 Context, Redux, Zustand 같은 상태 관리 도구들입니다.
상태와 관련된 모든 것을 별도의 공간에 격리해두고, 필요한 컴포넌트에서 직접 값을 가져다 쓸 수 있게 해주는 거죠.
로컬 스토리지는 무엇을 위한 도구일까요
지금까지 우리가 다룬 상태는 모두 브라우저의 자바스크립트 런타임, 즉 '메모리' 안에서만 존재했는데요.
사용자가 브라우저 탭을 닫거나 새로고침하면 모든 데이터는 사라져 버리죠.
만약 이 데이터를 더 오래 '지속(persist)'시키고 싶다면, 우리는 데이터베이스나 파일, 혹은 '로컬 스토리지' 같은 외부 저장소가 필요합니다.
로컬 스토리지는 데이터를 페이지의 짧은 생명주기보다 훨씬 더 영구적으로 저장하는 간단한 방법인데요.
사용자가 탭을 닫았다가 다시 방문해도 데이터는 그대로 남아있죠.
보통 테마 설정처럼, 사용자가 매번 다시 설정하기를 원치 않는 환경설정 값들을 저장하는 데 많이 사용됩니다.
API도 setItem
, getItem
, removeItem
등으로 아주 간단하죠.
일반적으로 로컬 스토리지와 상태 관리를 함께 사용할 때는, 앱이 처음 로드될 때 로컬 스토리지에서 값을 읽어와 상태 관리 도구(Context, Redux 등)의 초기값으로 설정해주는 방식을 사용하는데요.
const ThemeProvider = ({ children }) => {
// 로컬 스토리지에서 초기값을 가져와서
const theme = localStorage.getItem("theme") || 'light';
// Context의 value로 제공합니다.
return (
<ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
);
};
자, 여기서 바로 그 질문이 다시 등장합니다.
어차피 로컬 스토리지에 값이 있는데, 왜 굳이 Context 같은 중간 단계를 거쳐야 할까요?
그냥 컴포넌트에서 직접 로컬 스토리지를 읽으면 안 되는 걸까요?
// 왜 이렇게는 안 쓰는 걸까요?
const useTheme = () => {
return localStorage.getItem("theme");
};
const Button = () => {
const theme = useTheme(); // 여기서 직접 로컬 스토리지를 읽습니다.
// ...
}
Context도, Redux도 없이 코드는 훨씬 단순해졌는데요.
도대체 무엇이 우리를 막고 있는 걸까요?
로컬 스토리지를 상태 관리자로 쓸 수 없는 이유
결론부터 말하자면, 여러 가지 심각한 문제들이 우리를 막고 있습니다.
1. 리액트는 로컬 스토리지의 변화를 감지하지 못합니다
가장 치명적인 문제인데요.
테마를 토글하는 버튼을 만든다고 상상해보죠.
// 이건 제대로 동작하지 않습니다!
const ToggleThemeButton = () => {
const theme = localStorage.getItem("theme");
return (
<button
onClick={() => {
// 로컬 스토리지 값만 바꿀 뿐,
// 리액트는 이 변화를 전혀 알지 못합니다.
localStorage.setItem("theme", theme === "dark" ? "light" : "dark");
}}
>
다크 모드 ON/OFF
</button>
);
};
이 버튼을 클릭하면 로컬 스토리지의 값은 분명히 바뀝니다.
그래서 페이지를 새로고침하면 바뀐 테마가 적용되죠.
하지만 버튼을 클릭하는 그 순간에는 화면에 아무런 변화도 일어나지 않습니다.
왜냐하면 리액트의 UI를 업데이트하려면 반드시 '리렌더링'을 유발해야 하고, 리렌더링을 유발하는 유일한 방법은 '상태를 변경'하는 것이기 때문이죠.localStorage.setItem()
은 리액트의 상태와 아무런 관련이 없기 때문에, 리액트는 아무것도 모르고 가만히 있는 겁니다.
결국 우리는 이 외부 시스템(로컬 스토리지)의 변화를 리액트의 생명주기와 연결해 줄 무언가가 필요한데요.
그게 바로 useState
이고, 여러 컴포넌트가 공유해야 하니 결국 다시 Context나 Redux로 돌아오게 되는 거죠.
2. 기묘한 'storage' 이벤트의 함정
"그렇다면 로컬 스토리지의 변경을 감지하는 이벤트를 리스닝하면 되지 않을까?" 라고 생각할 수 있는데요.
실제로 storage
라는 이벤트가 존재합니다.useEffect
안에서 이 이벤트를 리스닝해서, 변경이 감지되면 리액트 상태를 업데이트해주면 완벽할 것 같죠.
useEffect(() => {
window.addEventListener("storage", (event) => {
if (event.key === "theme") {
setTheme(event.newValue);
}
});
// ...
}, []);
하지만 이 코드는 동작하지 않습니다.
이걸 디버깅하다 보면 머리가 터질지도 모르는데요.
MDN 문서 첫 문단에 그 이유가 숨어있습니다.
"이 이벤트는 값을 변경한 그 창에서는 발생하지 않습니다."
기가 막히죠?
이 이벤트는 다른 탭과의 데이터 동기화를 위해 만들어진 것이지, 현재 탭의 변화를 감지하기 위한 것이 아니었던 겁니다.
3. 서버 사이드 렌더링(SSR)과의 충돌
로컬 스토리지는 브라우저 API이기 때문에, 서버 환경에는 존재하지 않는데요.
만약 당신의 앱이 Next.js처럼 SSR이나 서버 컴포넌트를 사용한다면, 서버에서 localStorage
에 접근하려는 순간 "localStorage is not defined" 에러를 만나게 될 겁니다.
이를 해결하려면 복잡한 분기 처리가 필요해지죠.
4. 그 외의 기술적 한계와 위험성
- 문자열만 저장 가능: 로컬 스토리지는 오직 문자열만 저장할 수 있습니다.
객체나 배열을 저장하려면JSON.stringify
와JSON.parse
를 계속 사용해야 하는데, 이 과정에서 타입 안전성을 잃고 파싱 에러가 발생할 위험이 커지죠. - 글로벌 네임스페이스: 로컬 스토리지는 도메인 전체에서 공유되는 단 하나의 전역 공간입니다.
이름 충돌이 일어나기 아주 쉬운 환경이죠. - 에러 처리: 사용자의 브라우저 보안 설정이나 5MB 용량 제한 초과 등으로 인해 로컬 스토리지는 언제든 에러를 던질 수 있습니다.
이에 대한 방어 코드를 철저히 작성하지 않으면 앱 전체가 멈춰버릴 수 있죠.
결론 로컬 스토리지는 만능이 아니다
결론적으로, 로컬 스토리지를 리액트의 주 상태 관리자로 사용하려는 시도는 가능은 하지만, 결국 더 복잡하고, 더 취약하며, 어차피 내부적으로는 Context나 Redux 같은 상태 관리자를 사용해야만 하는 해결책으로 귀결됩니다.
그럴 바에는 그냥 처음부터 제대로 된 상태 관리 도구를 쓰는 게 훨씬 낫죠.
그렇다고 로컬 스토리지가 쓸모없다는 건 아닌데요.
사용자가 실수로 페이지를 닫았을 때 복구해주기 위한 '폼 데이터 백업', 간단한 '테마 설정' 저장, 그리고 여러 탭 간의 실시간 통신 같은 멋진 기능을 구현하는 데에는 아주 훌륭한 도구입니다.
단지 '공유 상태'를 대체하기에는 적합하지 않을 뿐이죠.
그 역할은 우리가 잘 아는 Context, Redux, Zustand에게 맡겨두는 것이 현명한 선택일 겁니다.