필터를 바꿨는데 다시 들어오면 이전 설정이 나왔다
LearningX 대시보드는 선택한 학기와 학생·교수자 화면의 필터를 계정 설정으로 저장한다. 화면에서 바꾼 값이 다음 접속에도 남아야 했지만, 일부 브라우저에서는 이전 설정이 다시 나타났다. 저장 요청을 페이지 이탈 시점에 보내던 것이 문제였다.
문제를 보고받은 뒤에는 종료 이벤트를 바꾸는 대응부터 했다. 부모 페이지의 beforeunload와 popstate 대신 자식 문서의 visibilitychange를 구독하고, 문서가 hidden이 되면 설정을 보냈다. 개발 환경에서는 동작했지만 통합 서버의 일부 환경에서 다시 문제가 생겼다.
페이지가 숨겨졌다는 신호를 받는 것과 비동기 요청이 끝날 시간을 확보하는 것은 달랐다. 특히 Safari 웹·모바일에서 이벤트와 요청 완료를 일관되게 기대하기 어려웠다는 당시 기록이 있다. 마지막 순간을 더 정확히 감지해도 마지막 순간에 시작한 통신은 남았다.
종료 신호를 찾는 대신 살아 있는 동안 보냈다
sendBeacon도 검토했다. 그러나 당시 설정 API는 인증 토큰을 헤더로 전달했고, 이 방식에서는 필요한 헤더를 직접 지정할 수 없었다. 이 제약을 피하려고 인증 경로까지 바꾸기보다 저장을 시작하는 시점을 옮겼다.
재발을 확인한 뒤 이탈 이벤트의 저장 핸들러를 제거했다. Redux에 있는 설정이 바뀌면 useUserPreference가 변화를 관찰하고 저장 thunk를 실행한다. 사용자가 페이지를 떠나기 전에 일반적인 인증 요청으로 서버에 전달할 기회를 만든 것이다.
- 필터 변경화면과 Redux 설정을 먼저 갱신한다.
- 연속 변경 모으기예약된 타이머를 거쳐 이전 값과 비교한다.
- 설정 POST저장 thunk가 현재 상태에서 학기와 역할별 필터를 읽어 전송한다.
저장 대상은 selectedTerms, learnerFilter, instructorFilter다. 당시 훅은 선택 학기가 비어 있지 않은지, 이전 값과 다른지, 저장 중인지 확인한 뒤 호출했다. 학생·교수자 필터를 별도 상태로 유지하면서 같은 계정 설정 요청에 담는 구조였다.
이 선택은 저장 요청을 늘리는 비용을 갖는다. 페이지를 한 번 떠날 때만 보내던 요청을 변경 도중에도 보내기 때문이다. 그래서 빠른 연속 변경을 모으는 지연을 함께 뒀다. 화면에 바로 반영한 값과 서버에 저장된 값 사이에는 짧은 간격이 생긴다.
useThrottle이라는 이름만으로 주기 저장을 기대할 수 없다
당시 코드는 useThrottle을 거친 값을 다시 useDebounce에 전달했다. 이름만 보면 연속 입력 중에도 일정 주기로 값을 내보내고 마지막 호출을 모으는 조합처럼 보인다. 그러나 실제 타이머 구현은 달랐다. 아래는 그 시점 useThrottle의 핵심 부분이다.
useEffect(() => {
const handler = setTimeout(() => {
setThrottledValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]); 값이 바뀌면 이전 타이머를 취소하고 새 타이머를 시작한다. 연속 변경이 계속되는 동안 일정 주기로 저장 기회를 주는 동작은 없다. 이름은 throttle이지만 이 구현은 변경이 멈추기를 기다리는 debounce 동작이다.
| 단계 | 기본 지연 | 실제 동작 |
|---|---|---|
| useThrottle | 1,000ms | 입력이 바뀌면 예약을 취소하고 다시 기다림 |
| useDebounce | 500ms | 앞 단계가 내보낸 값에 대해 다시 지연 |
다른 상태 변화가 끼어들지 않는다면 두 기본 지연만 합쳐도 약 1.5초다. 당시 회고의 “약 1초 지연”을 실제 저장 완료시간의 측정값으로 가져오지는 않았다. 타이머 예약 이후 서버 왕복도 필요하고, 추가 변경이 있으면 예약이 다시 밀릴 수 있다.
요청을 앞당겼지만 마지막 변경의 보장은 남았다
설정을 변경한 직후 페이지를 닫으면 지연된 호출이 실행되기 전에 화면이 사라질 수 있다. 이 커밋에는 종료 전에 예약을 즉시 실행하는 flush, 실패한 설정의 자동 재시도, 로컬에 남겨 다음 접속에 다시 보내는 저장소가 없다. 이전보다 통신할 시간을 확보한 선택이지 모든 이탈에서 무손실 저장을 보장한 구현은 아니다.
저장 thunk는 실행 시 getState()에서 payload를 읽고, 설정 API의 응답 상태를 확인한다. API 실패를 상태에 남기는 경로도 있다. 하지만 훅이 저장 thunk를 호출했다는 사실과 사용자가 변경한 값이 영속 저장됐다는 사실을 같은 것으로 취급할 수는 없다. 저장 중 추가 변경, 요청 실패 뒤 재전송, 기기 간 동시 편집의 계약은 별도 검증이 필요하다.
이 사례에서 바꾼 것은 브라우저 종료에 의존하던 저장 기회다. 그다음에 다룰 문제는 지연을 줄일지, 마지막 값을 별도로 남길지, 실패를 어떻게 사용자에게 알릴지다.
설정 상태 테스트와 브라우저 종료 테스트를 구분했다
당시 사용자 필터 설정 저장 오류에 관한 내부 회고는 저장 시점을 바꾼 대응으로 문제를 해결했다고 기록하면서 Safari 추가 검증을 남겼다. 브라우저별 성공률이나 네트워크 조건별 저장 완료시간을 측정한 자료는 확인되지 않았다.
이후 추가한 Jest 테스트는 학기와 역할별 필터 reducer, 필터 초기화, 설정 조회 성공·실패, 저장 API 실패의 상태를 검사한다. API는 모킹한다. 저장 방식을 바꾸면서 함께 추가한 시험은 아니며, 타이머를 거쳐 실제 브라우저 종료 직전 요청이 완료되는지 확인하는 시험도 아니다.
페이지가 끝나는 순간에 중요한 통신을 몰아두면 감지 이벤트를 바꿔도 같은 제약을 다시 만날 수 있다. 대시보드에서는 설정 변경이 일어나는 동안 저장을 시작하도록 옮겼고, 그 대가인 요청 수와 지연, 마지막 변경의 보장을 따로 보게 됐다.