
"어? 방금 저장했는데 왜 안 보이지?"
사용자가 데이터를 변경했는데, 특정 컴포넌트에서만 반영되고 다른 컴포넌트에서는 여전히 이전 값이 보이는 문제가 발생했어요. 같은 로컬스토리지 키를 바라보고 있는데 말이죠.
회사에서 로컬스토리지에 접근할 때 아래의 custom hook을 만들어서 값이 필요한 곳에서 사용중이였어요.
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (param: SetStateAction<T>) => void] {
const [state, setState] = useState<T>(() => {
if (typeof window === "undefined") {
return initialValue;
}
const localStorage = window.localStorage;
const localValue = localStorage.getItem(key);
return localValue ? JSON.parse(localValue) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState];
}
얼핏 보면 문제없어 보이는 이 hook은 사실 문제가 있어요. 일반적인 케이스에서는 잘 작동했지만, 특정 상황에서는 문제가 발생했어요.
두 개의 컴포넌트에서 같은 키를 바라보고 있을 때 하나의 컴포넌트에서 로컬스토리지 값을 변경해도 나머지 컴포넌트에서 업데이트되지 않는 문제가 있어요.
왜냐하면 각 컴포넌트에서 useLocalStorage hook을 호출하면 별도의 useState로 관리하고 있기 때문이에요.

만약, 사용자가 같은 URL을 가진 페이지를 2개의 탭(A,B)으로 띄웠다고 가정할게요. 이 때, 한 페이지(A)에서 로컬스토리지에 값을 변경해도 다른 페이지(B)는 동기화되지 않는 문제가 있었어요.
저희 회사는 성과관리 플랫폼을 만들고 있기 때문에 여러 개의 탭을 띄워서 작업하는 경우가 있어요.
따라서 이 문제도 해결하고 싶었어요.
위의 문제들은 useEffect에서 storage 이벤트를 등록하여 해결할 수 있지만 useSyncExternalStore를 사용하여 useEffect 없이 외부 상태를 구독할 수 있는 방식이 떠올라서 위 방식을 사용해보게 되었어요.
useSyncExternalStore에 대해 알아보면서 tearing 문제도 해결된다는 것을 알게되었어요.
하지만 useSyncExternalStore도 완벽하지는 않아요. useTransition과 함께 사용할 때 일부 제약이 있을 수 있어요. 이에 대해서는 이 글을 참고하시면 좋을 것 같아요!
useSyncExternalStore는 외부 저장소를 구독할 수 있다.
useSyncExternalStore에 대한 자세한 사용법은 공식 문서를 참고해주세요
간단하게 설명하면 아래와 같이 사용해요. 회사에서는 next를 쓰고 getServerSnapshot까지 사용하고 있지만 해당 포스팅에서는 리액트를 사용할 예정이기 때문에 getServerSnapshot를 생략할게요.
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
간단하게 몇가지만 설명할게요.
콜백 인자를 받아서 구독하는 함수예요.
구독 함수는 useEffect처럼 클린업 함수를 반환해야 해요.
subscribe가 호출되므로 subscribe를 외부에 두는 것을 권장해요.컴포넌트가 필요로하는 store 데이터의 스냅샷을 반환하는 함수에요.
만약 store가 변경되지 않았다면 getSnapshot을 반복해서 호출해도 항상 동일한 값을 반환해야 해요.
이 때 Object.is로 동일한 값인지 확인해요
만약 반환 값이 다르다면 컴포넌트는 리렌더링돼요.
위의 정보를 바탕으로 처음엔 아래와 같은 useLocalStorage hook을 만들었어요.
우선 subscribe 함수를 만들어요.
subscribe 함수는 storage 이벤트 리스너를 등록하고, 컴포넌트가 언마운트되면 클린업 함수로 등록한 이벤트 리스너를 제거하는 역할을 해요.
const subscribe = useCallback(
(callback: () => void) => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === storageKey) {
callback();
}
};
window.addEventListener("storage", handleStorageChange);
return () => window?.removeEventListener("storage", handleStorageChange);
},
[storageKey]
);
여기서 e.key === storageKey의 로직이 있는 이유는 localStorage의 모든 키 변경에 대해 storage 이벤트가 발생하기 때문이에요.
만약 해당 if문이 없다면 다른 로컬스토리지의 키가 변경될 때마다 불필요한 리렌더링이 발생할 수 있어요.
따라서 구독하고 있는 key가 변경될 때만 콜백을 실행할 수 있도록해요.
localStorage에서 값을 읽어요. 로컬스토리지는 문자열을 저장하기 때문에 JSON으로 파싱해서 반환해요.
해당 키에 대한 값이 없으면 initialValue를 반환해요.(초기값)
const getSnapshot = useCallback(() => {
try {
const storageValue = window.localStorage.getItem(storageKey);
return storageValue ? JSON.parse(storageValue) : initialValue;
} catch (error) {
return initialValue;
}
}, [storageKey, initialValue]);
getSnapshot으로 로컬스토리지에 값을 가져오니 이제 값을 업데이트하는 함수를 만들어요.
우선 로컬스토리지에 값을 JSON 문자열로 저장해요
그 이후 storage 이벤트를 직접 만들어 이벤트를 실행시켜요.
const setValue = useCallback(
(newValue: T) => {
try {
window.localStorage.setItem(storageKey, JSON.stringify(newValue));
window.dispatchEvent(
new StorageEvent("storage", {
key: storageKey,
newValue: JSON.stringify(newValue),
storageArea: window.localStorage,
})
);
} catch (error) {}
},
[storageKey]
);
위의 로직을 포함한 useLocalStorage전체 코드는 아래와 같아요.
export function useLocalStorage<T>(
key: keyof typeof LOCAL_STORAGE_KEY,
initialValue: T
): [T, (value: T) => void] {
const storageKey = LOCAL_STORAGE_KEY[key];
const subscribe = useCallback(
(callback: () => void) => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === storageKey) {
callback();
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
},
[storageKey]
);
const getSnapshot = useCallback(() => {
try {
const storageValue = window.localStorage.getItem(storageKey);
return storageValue ? JSON.parse(storageValue) : initialValue;
} catch (error) {
return initialValue;
}
}, [storageKey, initialValue]);
const storage = useSyncExternalStore(subscribe, getSnapshot);
const setValue = useCallback(
(newValue: T) => {
try {
window.localStorage.setItem(storageKey, JSON.stringify(newValue));
window.dispatchEvent(
new StorageEvent("storage", {
key: storageKey,
newValue: JSON.stringify(newValue),
storageArea: window.localStorage,
})
);
} catch (error) {}
},
[storageKey]
);
return [storage, setValue];
}
위와 같이 만들고 간단하게 테스트했을 때 큰 문제가 없었어요.
아래와 같이 두 개의 컴포넌트(A, B)에서 같은 키에 대해 구독을 하고 값을 변경했을 때 동기화가 되는 것을 확인했어요.

간단한 테스트를 마치고 develop 브랜치에 머지 후 dev 환경에 배포했어요.
"이제 사용만 하면 되겠다!"라고 생각한 것도 잠시, Sentry 알림이 울리기 시작했어요. 몇몇 페이지에서 무한 리렌더링이 발생하고 있었죠. 🚨

"분명 테스트에서는 문제없었는데...?"는 생각은 있었지만 공식문서를 다시 읽어봤어요.

그리고 중요한 부분을 놓친 것을 발견했어요. getSnapshot은 store가 변경되지 않았을 때 동일한 값을 반환해야 한다는 것이었어요.
이 때, 리액트는 이 값을 Object.is로 비교하는데, 만약 참조가 다르면 "값이 변경됐다"고 판단하고 리렌더링을 발생시켜요.
Object.is는 두 값이 같은지 비교해요. 원시 타입이라면 값 비교를, 참조 타입이라면 메모리 주소를 비교해요.
따라서 참조값이 다른 객체를 비교하면 값이 같더라도 항상 false를 반환해요.
const foo = { a: 1 };
const bar = { a: 1 };
Object.is(foo, bar); // false
getSnapshot 함수를 다시 볼게요.
const getSnapshot = useCallback(() => {
const storageValue = window.localStorage.getItem(storageKey);
return storageValue ? JSON.parse(storageValue) : initialValue;
}, [storageKey, initialValue]);
스토리지에서 값을 가져와 JSON 객체로 변환시켜요.
이때 JSON.parse는 매번 새로운 객체 참조를 반환해요.
그렇기 때문에 공식 문서에서 말한 "store가 변경되진 않았지만 getSnapshot을 호출 할 때 마다 다른 값을 반환해요.
따라서 리렌더링을 반복하게 되고 결론적으로 무한 리렌더링이 일어나요.
JSON.parse('{"count": 0}') === JSON.parse('{"count": 0}') // false
따라서 위의 문제를 해결하기 위해 ref에 값을 저장하기로 했어요.
우선 ref에 값을 저장해요.
const lastValueRef = useRef<T>(initialValue);
subscribe는 동일하고 getSnapshot 함수의 로직이 조금 달라져요.
const getSnapshot = useCallback(() => {
try {
const storageValue = window.localStorage.getItem(storageKey);
const parsed = storageValue ? JSON.parse(storageValue) : initialValue;
if (isEqual(parsed, lastValueRef.current)) {
return lastValueRef.current;
} else {
lastValueRef.current = parsed;
return parsed;
}
} catch (error) {
return lastValueRef.current;
}
}, [initialValue, storageKey]);
lodash의 isEqual을 사용하여 깊은 비교를 해요.
JSON.parse가 매번 새로운 객체를 생성하더라도, isEqual은 객체의 내용을 비교해요.
만약 내용이 같다면 이전에 저장해둔 lastValueRef.current를 반환하고, 내용이 다를 때만 새로운 값을 ref에 저장하고 반환해요.
이렇게 하면:
Object.is 비교에서도 같은 참조를 반환하므로 무한 리렌더링이 해결돼요!
위의 수정 사항을 반영한 전체코드는 아래와 같아요
export function useLocalStorage<T>(
key: keyof typeof LOCAL_STORAGE_KEY,
initialValue: T
): [T, (value: T) => void] {
const storageKey = LOCAL_STORAGE_KEY[key];
const lastValueRef = useRef<T>(initialValue);
const subscribe = useCallback(
(callback: () => void) => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === storageKey) {
callback();
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
},
[storageKey]
);
const getSnapshot = useCallback(() => {
try {
const storageValue = window.localStorage.getItem(storageKey);
const parsed = storageValue ? JSON.parse(storageValue) : initialValue;
if (isEqual(parsed, lastValueRef.current)) {
return lastValueRef.current;
} else {
lastValueRef.current = parsed;
return parsed;
}
} catch (error) {
return lastValueRef.current;
}
}, [initialValue, storageKey]);
const storage = useSyncExternalStore(subscribe, getSnapshot);
const setValue = useCallback(
(newValue: T) => {
try {
window.localStorage.setItem(storageKey, JSON.stringify(newValue));
window.dispatchEvent(
new StorageEvent("storage", {
key: storageKey,
newValue: JSON.stringify(newValue),
storageArea: window.localStorage,
})
);
} catch (error) {}
},
[storageKey]
);
return [storage, setValue];
}
위 코드에는 몇 가지 개선점이 아직 남아있어요!
위의 요소들은 전부 개선하고 현재 회사에서는 useSyncExternalStore를 활용한 로컬스토리지 훅을 사용하고 있어요!
위 글에 다른 문제가 있다면 댓글로 알려주세요!