[REFACTOR] 타이머 상태 관리 동기화 구조 정리 - #280
Conversation
- 타이머 mutation별로 흩어져 있던 쿼리 무효화 호출을 invalidateTimerProgress(일시정지/재개/연장)와 invalidateTimerFinish(완료/중지) 두 헬퍼로 통일했습니다 - 기존 invalidateTimerState를 invalidateTimerProgress로 이름을 맞춰 다른 화면(home/today)에도 동일하게 적용했습니다
- sessionStorage를 컴포넌트 로컬 상태로 직접 읽던 방식을 zustand 스토어로 옮겼습니다 - TimerPanel과 FocusSession처럼 이 훅을 각자 호출하는 화면들이 초과시간 상태를 서로 동기화해서 볼 수 있게 했습니다
- TimerPanel과 useFocusSession에 거의 동일하게 중복돼 있던 progress/overtime/분 단위 변환 계산을 useTimerProgress 훅으로 추출했습니다
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Timo Performance ReportBundle Size — timo-web
Lighthouse — timo-web
Image Optimization — timo-web
측정 커밋: |
|
@coderabbitai review |
- invalidateTimerProgress(일시정지/재개/연장)에서 빠졌던 invalidateStatistics 호출을 복원했습니다 - use-focus-session.ts가 같은 무효화 로직을 따로 손으로 하고 있어서 invalidateTimerProgress를 쓰도록 함께 정리했습니다
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: Comment |
kimminna
left a comment
There was a problem hiding this comment.
타이머 상태는 역시 넘 어렵네요...
다만 구조 측면에서 더 리뷰를 달아보자면, 함수 내부에서 리액트 훅을 직접 호출하는지의 여부에 따라 hooks/ 폴더 내부에 구조화하는 기준을 조금 더 세우면 좋을 것 같아요!
천천히 리팩해봅쉬다~~
- 앞 커밋에서 새 파일 추가와 호출부 수정이 스테이징 누락으로 빠졌던 부분을 마저 커밋했습니다
- JSON.parse(raw)를 곧바로 as OvertimeBase로 단언하던 걸 unknown으로 받아 isOvertimeBase 타입가드로 좁히도록 바꿨습니다 - sessionStorage에서 읽은 외부 입력은 검증 전에 타입을 확정하면 안 된다는 리뷰 피드백을 반영했습니다
- completeTimer/stopTimer의 무효화 목록을 손으로 나열하던 걸 공유 헬퍼 invalidateTimerFinish(todoId)로 교체했습니다 - invalidateTodoDetail(todoId)는 date 파라미터가 없지만 React Query v5의 invalidateQueries는 기본이 prefix 매칭이라 date-scoped 캐시까지 함께 무효화됩니다 - 더는 쓰이지 않는 invalidateActiveTimer/invalidateHomeView 구조분해도 정리했습니다
- invalidateTimerProgress/invalidateTimerFinish에 여러 줄 JSDoc과 invalidateTimerFinish의 todoId에 @PARAM을 추가했습니다 - 동작 변화는 없고 문서만 보강했습니다
- invalidateTimerProgress에 includeFocus, todoId 옵션을 추가했습니다 - 홈/투데이 훅에서 mutation.onSuccess로 흩어져 있던 무효화 로직을 헬퍼 호출로 통일했습니다 - 타이머 종료 시 invalidateTimerFinish를 사용하도록 변경했습니다
…to refactor/web/279-unify-timer-query-invalidation
ehye1
left a comment
There was a problem hiding this comment.
타이머 관련 로직들 깔끔하게 리팩토링해주셨네요!!! 다이어그램과 함께 자세하게 설명해주셔서 전체 흐름 이해하는 데 도움이 많이 됐습니다. 고생 많으셨습니다🥰
| changeTodoStatus( | ||
| { todoId, data: { isCompleted: true, date: dateKey } }, | ||
| { | ||
| onSuccess: () => { | ||
| invalidateHomeAndFocus(); | ||
| invalidateTodoDetail(dateKey, todoId); | ||
| invalidateStatistics(); | ||
| }, | ||
| }, | ||
| ); |
There was a problem hiding this comment.
혹시 기존 onSuccess의 invalidate는 invalidateTimerFinish에서 동일한 쿼리들을 무효화하고 있어서 중복이라고 판단해 삭제하신 걸까요?
삭제하게 되면 현재 구조에서 PATCH 요청보다 invalidateTimerFinish가 먼저 실행되기 때문에 완료 상태가 서버에 반영되기 전에 GET 응답이 돌아올 수 있을 것 같아요. 이후에 PATCH가 정상적으로 완료되더라도 성공 이후에 다시 무효화하는 로직이 없기 때문에, 화면에는 서버와 다르게 미완료로 남아보일 가능성이 있다고 생각했어요. 지금은 서버 응답이 빨라서 눈으로 보기에 문제가 없어 보여서 이 정도 케이스는 고려하지 않아도 되는지 궁금합니다!
- changeTodoStatus의 무효화 로직을 mutation 정의 시점 onSuccess로 옮겨 어떤 호출부에서도 PATCH 완료 후 항상 재검증하도록 했습니다 - 타이머 종료 시 invalidateTimerFinish만 실행되고 투두 완료 PATCH 성공 이후 재검증이 없어 완료 상태가 화면에 반영되지 않을 수 있던 레이스 컨디션을 수정했습니다 - invalidateTodoDetail이 date를 옵션으로 받도록 확장하고 화면별로 중복 구현돼 있던 로컬 무효화 래퍼를 제거해 focus/TimerPanel과 구조를 통일했습니다 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWUhaUE5dxUTjmjKe4c3LV
ISSUE 🔗
close #279
What is this PR? 🔍
타이머 상태 관리에서 발생하던 동기화 부담을 줄이기 위해, 쿼리 무효화 로직을 통일하고 overtime 상태를 zustand로 옮기고,
TimerPanel/useFocusSession에 중복돼 있던 진행률 계산을 공통 훅으로 추출했습니다.배경
TimerPanel,useFocusSession, home/today 훅 등 여러 화면에 각각 손으로 흩어져 있었습니다.activeTimer전체를 zustand로 미러링하는 방안도 검토했습니다. 하지만 이 경우 React Query 캐시와 zustand 스토어라는 진실 소스가 2개가 되어 이 둘을 맞추는 sync bridge 코드가 항상 필요해지고, 버그가 나면 캐시와 스토어가 서로 어긋나는 새로운 동기화 문제가 생깁니다. 게다가 이 방식은 실제로 겪은 문제(여러 화면에 걸친 무효화 목록 관리, 화면 간 파생 상태 중복)를 직접 해결해주지 않아서 채택하지 않았습니다. 대신 서버 상태가 아닌 것(overtime 기준값)만 zustand로 승격하고, 나머지는 React Query 구조 안에서 정리하는 쪽을 택했습니다.쿼리 무효화 로직
invalidateTimerProgress/invalidateTimerFinish두 헬퍼로 통일했습니다.TimerPanel의 mutation마다 무효화할 쿼리키 조합을 다르게 손으로 나열하고 있었고, 기존에 있던 통합 헬퍼(invalidateTimerState)는TimerPanel에서 쓰이지 않은 채 home/today 화면에서만 쓰이고 있어 이름과 실제 용도가 어긋나 있었습니다.invalidateTimerProgress(activeTimer+home+timeBoxes+statistics)로, 완료/중지처럼 종료되는 액션은invalidateTimerFinish(위 4개+today+focusTodo+선택적 todoDetail)로 나눴습니다.invalidateTimerState호출부도 동일한 조합만 하고 있어서, 동작 변화 없이invalidateTimerProgress로 이름만 맞췄습니다.useFocusSession도 startTimer/changeStatus/extendTimer에서 같은 조합을 직접 나열하고 있던 걸invalidateTimerProgress호출로 통일했습니다.invalidateTimerProgress에서 뺐었는데, 리뷰 과정에서 이 가정이 틀릴 수 있다는 피드백을 받아 statistics 무효화를 다시 포함했습니다.useStartTimer/useChangeStatus의 mutation 설정에 달아둔 전역onSuccess(invalidateTimerProgress()+invalidateFocusTodo())와, pause/resume/start 호출부의onSuccess(invalidateTodoDetail만 호출)가 각각 따로 존재해 같은 액션에 대해 무효화 호출이 두 군데로 나뉘어 있었습니다. 이를invalidateTimerProgress가{ includeFocus?, todoId? }옵션을 받도록 확장해, 호출부 하나에서invalidateTimerProgress({ includeFocus: true, todoId })한 번으로 합쳤습니다. 종료(stop) 액션도invalidateTimerProgress()호출과, 이어지는changeTodoStatus의 중첩onSuccess(home/today별로 조금씩 다른 무효화 조합을 다시 나열하던 부분)를invalidateTimerFinish(todoId)한 번으로 합쳤습니다.Overtime 상태 관리
overtimeBaseSeconds)을 sessionStorage 기반 컴포넌트 로컬 상태에서 zustand 스토어로 옮겼습니다.TimerPanel과useFocusSession이 각자useTimerOvertime을 호출하면 서로 다른 React 상태 인스턴스를 가지게 되어, 한쪽에서markOvertimeStart를 호출해도 다른 쪽은 자기timerId가 바뀌어useEffect가 재실행되기 전까지 반영되지 않는 화면 간 비동기화가 있었습니다.stores/timer/useTimerOvertimeStore.ts에{ timerId, baseSeconds }단일 상태를 두고, sessionStorage 읽기/쓰기는utils/timer/overtime-storage.ts로 분리했습니다(useAuthStore가token-manager.ts를 쓰는 기존 패턴과 동일).use-timer-overtime.ts는 이 스토어를 감싸는 얇은 훅으로 남겨 외부 API(useTimerOvertime(timer) => { overtimeBaseSeconds, markOvertimeStart })는 그대로 유지해 호출부 변경을 최소화했습니다.activeTimer자체(서버 상태)는 여전히 React Query가 유일한 소스이고, zustand는 서버에 없는 순수 클라이언트 상태(overtime 기준값)만 담당합니다.타이머 진행률 계산
TimerPanel과useFocusSession에 거의 동일하게 중복돼 있던 progress/overtime/분 단위 변환 계산을useTimerProgress훅으로 추출했습니다.todo.durationSeconds)만 다를 뿐 나머지 계산 로직이 완전히 동일하게 복붙돼 있어서, 한쪽만 고치고 다른 쪽을 놓칠 위험이 있었습니다.hooks/timer/use-timer-progress.ts가{ timer, overtimeBaseSeconds, fallbackPlannedSeconds? }를 받아{ plannedSeconds, remainingSeconds, progress, isOvertime, overtimeProgress, plannedMinutes, basePlannedMinutes, actualMinutes }를 반환합니다.TimerPanel은fallbackPlannedSeconds를 생략(0)하고,useFocusSession은todo?.durationSeconds ?? 0을 넘겨 두 화면의 유일한 차이를 파라미터로 흡수했습니다.To Reviewers
overtime 스토어를
{ timerId, baseSeconds }단일 값으로 뒀는데, 이건 활성 타이머가 한 번에 하나만 존재한다는 서버 정책(동시 시작 시 409)을 전제로 한 설계입니다. 이 전제가 맞는지 한번 봐주세요.pause/resume 시 서버 응답을 기다렸다가 무효화하는 방식은 이번 PR에서 그대로 뒀습니다(낙관적 업데이트는 의도적으로 범위에서 제외했고, 후속 작업으로 남겨뒀습니다).
UI 변경은 없고 내부 상태 관리 구조만 정리한 PR이라, 실제 로그인 후 타이머 pause/resume/extend/complete/stop 클릭 테스트는 프로덕션 API 인증이 필요해 제가 직접 하지 못했습니다 — 리뷰 시 한 번 확인 부탁드립니다.
Screenshot 📷
Test Checklist ✔
pnpm check-types통과pnpm lint통과/home,/today,/focus라우트가 500 없이 컴파일/응답되는지 확인 (curl)