localStorage를 React 상태처럼 쓰면 동기화가 빠지는 이유

작은 웹사이트에 한국어와 영어 전환 버튼을 붙이려다가 묘한 장면을 만났습니다.
버튼을 누른 탭은 영어가 됐는데, 옆 탭은 여전히 한국어였습니다. 새로고침하면 둘이 다시 같은 언어가 됩니다. 저장은 됐고 화면도 바뀌었는데, 둘 사이의 소식만 늦었습니다.
처음에는 localStorage에 값을 썼으니 React도 알아서 알겠지 싶습니다. 하지만 localStorage는 React 상태가 아닙니다. 값은 보관해도 컴포넌트에 “다시 읽어!”라고 말해 주지 않습니다. (창고 직원에게 물건을 맡겼는데 방송까지 해주길 기대한 셈입니다.)
이 문제의 방향은 분명합니다. 브라우저 저장소를 단순한 변수로 감싸지 말고, 읽기·구독·서버 스냅샷을 가진 외부 저장소로 다뤄야 합니다.
저장과 반응은 다른 책임입니다
언어 선택 기능에는 사실 네 가지 책임이 있습니다.
- 현재 언어를 읽습니다.
- 언어를 바꾸면 같은 탭의 화면을 다시 그립니다.
- 다른 탭에서 바꾼 값도 받아들입니다.
- 서버 렌더링 중에는 브라우저 API를 호출하지 않습니다.
localStorage.setItem()은 값 쓰기와 영속화만 해결합니다. 같은 문서에서 값을 쓴 직후 React 구독자에게 변화를 알리는 일은 별도로 필요합니다. 다른 문서에서 바뀐 값은 브라우저의 storage 이벤트를 통해 들어옵니다.
그래서 저장소의 형태는 다음처럼 생각하는 편이 좋습니다.
const locale = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
여기서 중요한 것은 훅 이름보다 계약입니다. getSnapshot은 지금 값을 읽고, subscribe는 다음 변경을 알리며, getServerSnapshot은 window가 없는 환경에서 안전한 값을 줍니다.
같은 탭과 다른 탭은 소식 경로가 다릅니다
이 부분에서 한 번 더 미끄러지기 쉽습니다.
브라우저의 storage 이벤트만 구독하면 다른 탭의 변경은 받을 수 있지만, 값을 쓴 바로 그 탭의 갱신은 별도 경로가 필요합니다. 반대로 React 상태만 바꾸면 현재 화면은 즉시 갱신되지만 다른 탭은 모릅니다.
따라서 쓰기 함수가 해야 할 일은 두 가지입니다.
function setLocale(next: Locale) {
localStorage.setItem("locale", next);
window.dispatchEvent(new Event("localechange"));
}
구독자는 커스텀 이벤트와 storage 이벤트를 모두 듣습니다. 하나는 같은 탭용, 다른 하나는 탭 사이 동기화용입니다.
이 구조가 약간 번거로워 보인다면 정상입니다. 외부 시스템과 상태를 맞추는 일은 원래 useState 한 줄보다 책임이 많습니다. 그 차이를 코드에 숨기지 않는 편이 오히려 나중에 덜 놀랍니다.
저장된 문자열을 바로 믿지 마세요
localStorage에는 과거 버전의 값, 사용자가 개발자 도구에서 바꾼 값, 오타가 들어갈 수 있습니다. TypeScript가 getItem()의 결과를 Locale로 만들어 주지는 않습니다.
그래서 읽기 경계에서 값을 정규화합니다.
function resolveLocale(value: string | null): Locale {
return value === "ko" || value === "en" ? value : "en";
}
지원하지 않는 값의 기본 언어를 무엇으로 할지는 제품 선택입니다. 중요한 것은 그 선택이 한 함수에 있고, 테스트할 수 있으며, 호출자마다 다른 추측을 하지 않는다는 점입니다.
번역 사전의 키도 같은 원리로 묶을 수 있습니다. 기준 사전에서 Dictionary 타입을 만들고 다른 언어가 그 키를 모두 채우도록 하면, 새 문구를 추가하고 한쪽 번역을 빼먹는 실수를 컴파일 단계에서 발견할 수 있습니다.
이 정도면 언제 충분할까요?
두 언어를 지원하는 작은 사이트에서 필요한 것이 저장 유지, 타입 안전한 사전, 같은 탭과 다른 탭의 동기화 정도라면 이 패턴은 꽤 정직합니다.
반대로 복수형, 성별 규칙, ICU MessageFormat, 번역 플랫폼 연동, 지연 로딩이 필요하다면 검증된 i18n 라이브러리를 쓰는 편이 낫습니다. 직접 만든 저장소가 갑자기 국제화 프레임워크 흉내를 내기 시작하면, 작은 칼로 참치를 해체하는 상황이 됩니다. (칼이 나쁜 건 아닌데 오늘 메뉴가 커졌습니다.)
제가 이 경계를 적용한 코드는 공개 저장소 localizations에서 볼 수 있습니다. 작은 사이트의 언어 상태가 필요하다면 읽기·쓰기 코드보다 먼저 누가 변경을 알리고, 서버에서는 무엇을 읽을지부터 적어 보세요. 그 세 문장이 정해지면 구현은 놀랄 만큼 얌전해집니다.
다음 글도 받아보세요.
글을 끝까지 읽으셨다면, 다음 글은 받은편지함이나 RSS 리더에서 만나보세요.