← 모든 프로젝트

TOSS · OPEN SOURCE

React 훅의 이벤트·생명주기 오류 수정

다른 탭에서 localStorage.clear()를 실행해도 화면에 이전 값이 남고, React의 ref가 새로 붙을 때는 이전 렌더의 콜백이 실행됐습니다. 두 오류를 실제 조건으로 재현해 수정하고, 다시 발생하지 않도록 테스트를 남겼습니다.

ReactBrowser QAOpen Source
담당 범위
버그 재현 · 동작 수정 · 회귀 테스트 · 리뷰 반영
기간·맥락
2026.09 / #482·#483 병합
현재 단계
공개 PR 병합 / 패키지 릴리즈 포함 시점 별도
수정한 두 동작

전체 삭제 이벤트의 key: null도 다시 읽기 · 의존성 변경 시 cleanup(A) → setup(B) 보장

01

배경: 정상적인 업데이트 사이에 숨어 있는 경계 조건

react-simplikit의 useStorageState는 브라우저 저장소의 값을 React 상태처럼 읽고, useRefEffect는 DOM 요소가 ref에 연결되거나 바뀔 때 setup과 cleanup을 실행합니다. 평소의 값 변경과 요소 부착은 동작했지만, 저장소 전체 삭제와 의존성 변경처럼 이벤트의 모양이나 실행 순서가 달라지는 경우에는 결과가 어긋났습니다.

서로 다른 두 버그였습니다. 하나는 어떤 저장소 이벤트를 다시 읽기의 신호로 볼지, 다른 하나는 ref가 붙는 순간 어떤 렌더의 콜백을 읽을지가 핵심이었습니다. 공개 API를 바꾸지 않고 각각의 실패 조건을 재현한 뒤 수정했습니다.

02

다른 탭에서 저장소를 비우면 왜 값이 남았나

탭 A에서 name을 읽는 화면을 열고, 같은 출처의 탭 B에서 localStorage.clear()를 실행했습니다. 저장소에서는 name이 사라졌지만 탭 A의 훅은 기존 값인 Alice를 계속 보여줬습니다. 기본값 Guest를 지정한 화면이라면 그 값으로 돌아가야 했습니다.

PR에서 재현한 입력과 기대값
const [name] = useStorageState<string>('name', { defaultValue: 'Guest' });

탭 A: name === 'Alice'
탭 B: localStorage.clear()
탭 A: 실제 'Alice' / 기대 'Guest'
  1. 탭 B전체 저장소 삭제
  2. storage 이벤트key가 null로 전달
  3. 기존 구독 조건'name'과 다르다고 무시
  4. 탭 Asnapshot을 다시 읽지 않아 Alice 유지
저장소는 비워졌지만 화면 값은 그대로 남은 이유
03

이벤트 값을 대입하지 않고 다시 읽은 이유

기존 구독 조건은 이벤트의 key가 관찰 중인 'name'과 같을 때만 다시 읽었습니다. 그런데 clear()의 이벤트는 개별 key 대신 null을 보냅니다. 수정한 코드는 이때도 구독자에게 변경을 알립니다. 실제 화면 값은 이벤트의 newValue가 아니라 기존 getSnapshot()이 훅에 지정된 저장소를 다시 읽어 결정합니다.

구독 조건의 실제 변경 — #482
// 이전: 전체 삭제 이벤트(key === null)는 통과하지 못함
if (event.key === key) onStoreChange();

// 수정: 전체 삭제이거나 관찰 중인 key가 바뀌면 다시 읽음
if (event.key == null || event.key === key) onStoreChange();
바뀐 조건이 다른 저장소 값까지 지우지 않는지 확인했습니다
발생한 이벤트구독 처리화면 값
localStorage 전체 삭제key: null → 다시 읽기저장된 값이 없으므로 기본값 또는 undefined
sessionStorage 전체 삭제key: null → 다시 읽기localStorage의 값은 그대로 유지
관찰 중인 key 삭제일치하는 key → 다시 읽기기본값으로 변경
무관한 key 삭제불일치 → 건너뜀관찰 중인 값 유지

특히 다른 저장소의 clear()도 key: null일 수 있어, 이벤트만 보고 무조건 기본값을 대입하면 안 됩니다. 훅이 실제로 읽는 저장소의 snapshot을 확인하는 기존 경로를 재사용했습니다.

04

의존성은 B인데 setup은 A로 실행된 순간

useRefEffect에 전달한 의존성이 A에서 B로 바뀌면 기존 DOM 연결을 cleanup(A)로 정리하고, 새 콜백의 setup(B)를 실행해야 합니다. 실제 DOM을 렌더링하는 테스트에서는 cleanup(A) → setup(A)가 기록됐습니다. ref가 새로 연결되는 시점에는 usePreservedCallback이 보관한 콜백이 아직 effect에서 갱신되기 전이었기 때문입니다.

  1. B로 다시 렌더새 callback 생성
  2. 기존 ref 해제cleanup(A)
  3. 새 ref 부착effect 갱신 전이라 setup(A)
  4. effect 실행그제야 콜백이 B로 갱신
수정 전: 새 ref를 붙이는 순간에는 이전 렌더의 콜백이 남아 있었습니다

이 문제는 useIntersectionObserver에서도 드러났습니다. root, rootMargin, threshold가 바뀌면 새 observer는 만들어지지만, 이미 화면에 붙은 요소가 이전 observer에 다시 연결될 수 있었습니다. observer 생성 여부만 검사해서는 이 오류를 잡을 수 없었습니다.

05

ref 부착 시점과 미커밋 렌더를 함께 처리

의존성이 달라질 때마다 useMemo로 콜백 보관 객체를 새로 만들었습니다. 새 ref 함수는 새 객체를 참조하므로, DOM에 부착되는 순간 B의 콜백을 읽을 수 있습니다. 기존 연결의 cleanup은 그대로 실행됩니다. 변경 후에는 A에서 B로 갱신할 때 cleanup(A) → setup(B)가 됩니다.

콜백을 무조건 최신 값으로 덮어쓰지 않은 이유
상황처리지키려는 동작
의존성 변경새 콜백 보관 객체 생성ref가 붙을 때 새 setup 실행
의존성 동일effect에서 기존 객체의 콜백 갱신같은 요소는 재연결하지 않고, 이후 요소 교체에는 최신 콜백 사용
렌더가 커밋되지 않음렌더 중 기존 객체를 수정하지 않음화면에 남은 ref가 미커밋 콜백을 읽지 않음

공통 usePreservedCallback을 수정하지 않고 useRefEffect 안에서 처리했습니다. 기존 공개 시그니처를 유지하면서 ref가 연결되는 시점에 필요한 콜백만 바꾸는 범위였습니다.

06

실패하는 테스트부터 리뷰 반영까지

두 수정 모두 테스트가 기존 코드에서 실패하는 것을 먼저 확인했습니다. useStorageState는 전체 삭제 시 기본값 유무를 나눠 검증했고, useRefEffect는 StrictMode 유무와 observer의 세 옵션 변경에서 실패를 재현했습니다. 수정 후에는 기존 동작을 깨지 않았는지도 확인했습니다.

공개 PR에 남긴 당시 검증
대상확인한 조건PR에 남은 결과
#482 저장소전체 삭제·기본값 유무·다른 저장소 삭제·무관한 key전체 삭제 테스트 2개 수정 전 실패, 수정 후 관련 테스트 53개 통과
#482 실제 탭같은 출처의 두 페이지에서 localStorage.clear()Chromium에서 key: null 이벤트와 값 갱신 확인
#483 ref와 observerStrictMode 유무·root·rootMargin·threshold 변경수정 전 5가지 실패 조건 재현
#483 회귀cleanup·노드 교체·미커밋 렌더·새 observer의 observe/unobserve관련 테스트를 일반 모드와 React Compiler 모드에서 확인
#483 실제 브라우저React 18.3.1·19.2.4와 네이티브 IntersectionObserverChromium에서 수정 전후 동작 비교

메인테이너는 새 테스트 파일을 기존 명세 파일에 합치고, 같은 실패를 반복해서 확인하는 사례는 덜어내자고 요청했습니다. Suspense 테스트도 “커밋되지 않은 렌더의 콜백을 무시한다”는 보호 조건이 이름에서 보이도록 고쳤습니다. 테스트 개수보다 어떤 실행 순서를 지키는지가 분명해야 한다는 리뷰였습니다.

07

병합 결과와 확인 범위

useStorageState의 전체 삭제 처리 #482는 2026년 9월 8일, useRefEffect의 콜백 갱신 #483은 9월 9일 병합됐습니다. 첫 수정은 전체 삭제 후 기본값 복귀, 두 번째 수정은 의존성 변경 시 새 콜백으로 DOM을 다시 연결하는 동작을 테스트에 남겼습니다.

여기서 확인한 결과는 PR 병합과 당시의 테스트·브라우저 검증까지입니다. 이 두 수정으로 모든 소비 서비스의 오류가 얼마나 줄었는지, 패키지의 어느 버전부터 사용됐는지는 별도로 확인하지 않았습니다.

08

두 수정에서 공통으로 본 것

첫 버그에서는 이벤트가 왔는지보다 key: null도 읽기 신호로 취급하는지가 중요했습니다. 두 번째 버그에서는 콜백이 최신인지보다 ref가 붙는 그 시점에 어떤 콜백을 읽는지가 중요했습니다. 정상 경로에서는 보이지 않던 입력과 순서를 테스트 이름·기대값에 그대로 남겼습니다.

↗

수정 PR

다음 프로젝트기획부터 첫 배포까지, 입맛 테스트 MVP ↗