청약 알림을 한 탭에서 켜놓고 다른 탭으로 돌아오면, “알림이 꺼져 있어요” UI 가 그대로 보이는 문제가 있었어요. localStorage 는 분명 켜져 있는데 화면이 stale 했던 거예요. 같은 키를 보고 있는 두 탭이 동기화되지 않으면 사용자는 “내가 분명 켰는데 왜 안 보이지?” 가 되거든요.
1. 진단하기
먼저 데이터 흐름을 살펴봤어요. 청약 알림 on/off 상태는 atomWithSafeLocalStorage 라는 jotai atom 변형으로 관리되고 있었어요. 이 atom 은 mount 시 localStorage 에서 값을 읽고, 변경 시 localStorage 에 쓰기를 하는 구조예요.
여기서 짚고 갈 두 가지가 있어요. 첫째, jotai atom 의 동기화 채널은 React 트리 안으로 한정돼요. 같은 React 앱 안에선 잘 동기화되지만, 같은 origin 의 다른 탭 에서 같은 키를 변경하더라도 우리 앱은 알 수가 없어요. 둘째, 모바일 웹뷰처럼 native bridge 를 통해 localStorage 가 변경되는 경우 — 예를 들어 다른 화면에서 native 가 직접 값을 set 하는 경우 — 도 같은 한계예요.
여기에 추가로 모바일 특유의 시나리오가 있어요. 사용자가 앱을 백그라운드로 보냈다가 한참 뒤에 다시 돌아오면, 그 사이에 localStorage 값이 바뀌어 있을 수 있어요. 백그라운드 → 포그라운드 전환은 React 의 재렌더 사이클로는 잡히지 않아서, 별도의 이벤트로 감지해야 해요.
이 두 케이스에 모두 대응해야 알림 UI 가 항상 진실을 보여줄 수 있겠다 싶었어요.
2. 재현하기
가장 단순한 시나리오로 재현했어요.
- 탭 A 에서 청약 알림을 켜요. localStorage 의 값이
true로 바뀌어요. - 탭 B 에서 같은 페이지를 열어둬요. 화면은 “알림이 꺼져 있어요” 그대로.
- 탭 A 에서 다시 알림을 끄거나, 다른 화면에서 native 가 값을 바꾸어도 — 탭 B 의 화면은 변하지 않아요.
또 다른 시나리오:
- 사용자가 알림을 끈 상태로 앱을 백그라운드로 보내요.
- 다른 화면(다른 탭이든 native 이든)에서 알림이 켜져요.
- 사용자가 다시 앱으로 복귀해요. 화면은 여전히 “알림이 꺼져 있어요”.
두 케이스 모두 외부에서 일어난 변화를 우리 앱이 감지할 수 있는 채널이 필요했어요.
3. 수정하기
SafeLocalStorage 클래스에 subscribe 메서드를 추가했어요. storage 이벤트(다른 탭에서의 변화) 와 visibilitychange 이벤트(백그라운드 복귀) 를 동시에 구독해서, 둘 중 하나라도 발화하면 콜백을 호출해요.
subscribe(
key: string,
callback: (value: string | null) => void,
): () => void {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return () => {}
}
const handleStorageEvent = (e: StorageEvent) => {
if (e.storageArea === localStorage && e.key === key) {
callback(e.newValue)
}
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
const value = this.getItem(key)
callback(value ?? null)
}
}
window.addEventListener('storage', handleStorageEvent)
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
window.removeEventListener('storage', handleStorageEvent)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}
여기서 한 가지 자주 빠지는 함정이 있어요. storage 이벤트는 다른 탭 에서 localStorage 가 변경됐을 때만 발화해요. 같은 탭 내의 localStorage.setItem 으로는 발화하지 않아요. 그래서 같은 탭 안에서의 동기화는 jotai atom 의 기존 메커니즘이 담당하고, 탭 간 동기화는 storage 이벤트가 보강해요. 두 채널의 역할이 명확하게 분리돼요.
visibilitychange 는 또 다른 종류의 보강이에요. 다른 탭이든 native 든, 우리가 화면에 없는 동안 일어난 모든 변화 를 한 번에 동기화하는 트리거가 돼요. 포그라운드로 돌아오는 순간 localStorage 를 다시 읽고 콜백을 부르니까요.
이 subscribe 가 atom 안쪽에서 사용되면서, 알림 토글이 어디서 일어나든 — 같은 탭이든 다른 탭이든 백그라운드 중이든 — UI 는 늘 진실을 보여주게 됐어요.
4. 재발 방지하기
localStorage 같은 외부 상태를 다룰 땐 항상 “변화가 일어날 수 있는 모든 채널” 을 먼저 그려보는 습관을 들이려고 해요.
첫째, 같은 탭 vs 다른 탭. 단일 탭만 가정하고 짠 코드는 사용자가 같은 사이트를 두 탭으로 열기 시작하는 순간 깨져요. storage 이벤트가 같은 탭에선 발화하지 않는다는 점을 명심해두면, “같은 탭 동기화는 별도 채널이 필요하다” 는 결론으로 자연스럽게 이어져요.
둘째, 포그라운드 vs 백그라운드. 모바일 웹/앱에서는 사용자가 화면 밖에 있는 시간이 흔해요. visibilitychange 의 'visible' 전환을 동기화 트리거로 쓰면, 그 사이에 무엇이 일어났든 한 번에 따라잡을 수 있어요. polling 보다 훨씬 효율적이에요.
셋째, 같은 패턴을 다른 외부 상태에도 적용해요. cookie, IndexedDB, 또는 native bridge 를 통해 들어오는 값들도 비슷한 외부 채널이에요. “이 값은 우리 앱이 모르는 사이에 바뀔 수 있는가?” 를 묻고, 그렇다면 어떤 이벤트로 감지할지를 같이 설계해요.
외부 상태와 React 상태를 동기화하는 일은 자칫 “괜찮아 보이지만 가끔 깨진다” 는 함정에 빠지기 쉬워요. 변화의 채널을 일찍 그려두면, 가끔 깨지는 버그가 처음부터 생기지 않아요.