Skip to content

[REFACTOR] 타이머 상태 관리 동기화 구조 정리 - #280

Open
jjangminii wants to merge 9 commits into
developfrom
refactor/web/279-unify-timer-query-invalidation
Open

[REFACTOR] 타이머 상태 관리 동기화 구조 정리#280
jjangminii wants to merge 9 commits into
developfrom
refactor/web/279-unify-timer-query-invalidation

Conversation

@jjangminii

@jjangminii jjangminii commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

ISSUE 🔗

close #279



What is this PR? 🔍

타이머 상태 관리에서 발생하던 동기화 부담을 줄이기 위해, 쿼리 무효화 로직을 통일하고 overtime 상태를 zustand로 옮기고, TimerPanel/useFocusSession에 중복돼 있던 진행률 계산을 공통 훅으로 추출했습니다.

배경

  • 기존 구조: 타이머는 React Query만으로 관리되고 있었고, mutation 성공 시 무효화할 쿼리키와 초과시간/진행률 파생 계산이 TimerPanel, useFocusSession, home/today 훅 등 여러 화면에 각각 손으로 흩어져 있었습니다.
  • 발생 문제: 화면이 늘어날 때마다 무효화 목록을 빠짐없이 맞춰야 했고, overtime 상태는 컴포넌트별 로컬 상태라 화면 간 동기화가 안 됐으며, 진행률 계산 로직은 두 곳에 사실상 동일하게 중복돼 있었습니다.
  • 해결 방향: 무효화 조합을 의미 단위(progress/finish) 헬퍼로 통일하고, 여러 화면이 공유해야 하는 overtime 상태만 zustand로 승격하고, 순수 계산 로직은 공통 훅으로 추출했습니다. React Query는 여전히 서버 상태의 유일한 소스로 유지했습니다.
  • 검토한 대안: activeTimer 전체를 zustand로 미러링하는 방안도 검토했습니다. 하지만 이 경우 React Query 캐시와 zustand 스토어라는 진실 소스가 2개가 되어 이 둘을 맞추는 sync bridge 코드가 항상 필요해지고, 버그가 나면 캐시와 스토어가 서로 어긋나는 새로운 동기화 문제가 생깁니다. 게다가 이 방식은 실제로 겪은 문제(여러 화면에 걸친 무효화 목록 관리, 화면 간 파생 상태 중복)를 직접 해결해주지 않아서 채택하지 않았습니다. 대신 서버 상태가 아닌 것(overtime 기준값)만 zustand로 승격하고, 나머지는 React Query 구조 안에서 정리하는 쪽을 택했습니다.

쿼리 무효화 로직

  • 변경 요약: 타이머 mutation 성공 시 호출하던 개별 invalidate 나열을 invalidateTimerProgress/invalidateTimerFinish 두 헬퍼로 통일했습니다.
  • 이유: TimerPanel의 mutation마다 무효화할 쿼리키 조합을 다르게 손으로 나열하고 있었고, 기존에 있던 통합 헬퍼(invalidateTimerState)는 TimerPanel에서 쓰이지 않은 채 home/today 화면에서만 쓰이고 있어 이름과 실제 용도가 어긋나 있었습니다.
  • 구현 방식: 일시정지/재개/연장처럼 타이머가 계속 진행 중인 액션은 invalidateTimerProgress(activeTimer+home+timeBoxes+statistics)로, 완료/중지처럼 종료되는 액션은 invalidateTimerFinish(위 4개+today+focusTodo+선택적 todoDetail)로 나눴습니다.
  • 경계 · 제약: home/today 화면에서 쓰던 invalidateTimerState 호출부도 동일한 조합만 하고 있어서, 동작 변화 없이 invalidateTimerProgress로 이름만 맞췄습니다. useFocusSession도 startTimer/changeStatus/extendTimer에서 같은 조합을 직접 나열하고 있던 걸 invalidateTimerProgress 호출로 통일했습니다.
  • 리뷰 중 수정: 처음에는 statistics가 완료된 세션 기준 데이터라 진행 중 액션에서는 무효화할 필요가 없다고 판단해 invalidateTimerProgress에서 뺐었는데, 리뷰 과정에서 이 가정이 틀릴 수 있다는 피드백을 받아 statistics 무효화를 다시 포함했습니다.

Overtime 상태 관리

  • 변경 요약: 초과시간 시작 기준값(overtimeBaseSeconds)을 sessionStorage 기반 컴포넌트 로컬 상태에서 zustand 스토어로 옮겼습니다.
  • 이유: TimerPaneluseFocusSession이 각자 useTimerOvertime을 호출하면 서로 다른 React 상태 인스턴스를 가지게 되어, 한쪽에서 markOvertimeStart를 호출해도 다른 쪽은 자기 timerId가 바뀌어 useEffect가 재실행되기 전까지 반영되지 않는 화면 간 비동기화가 있었습니다.
  • 구현 방식: stores/timer/useTimerOvertimeStore.ts{ timerId, baseSeconds } 단일 상태를 두고, sessionStorage 읽기/쓰기는 utils/timer/overtime-storage.ts로 분리했습니다(useAuthStoretoken-manager.ts를 쓰는 기존 패턴과 동일). use-timer-overtime.ts는 이 스토어를 감싸는 얇은 훅으로 남겨 외부 API(useTimerOvertime(timer) => { overtimeBaseSeconds, markOvertimeStart })는 그대로 유지해 호출부 변경을 최소화했습니다.
  • 경계 · 제약: activeTimer 자체(서버 상태)는 여전히 React Query가 유일한 소스이고, zustand는 서버에 없는 순수 클라이언트 상태(overtime 기준값)만 담당합니다.

타이머 진행률 계산

  • 변경 요약: TimerPaneluseFocusSession에 거의 동일하게 중복돼 있던 progress/overtime/분 단위 변환 계산을 useTimerProgress 훅으로 추출했습니다.
  • 이유: 활성 타이머가 없을 때의 fallback 값(0 vs todo.durationSeconds)만 다를 뿐 나머지 계산 로직이 완전히 동일하게 복붙돼 있어서, 한쪽만 고치고 다른 쪽을 놓칠 위험이 있었습니다.
  • 구현 방식: hooks/timer/use-timer-progress.ts{ timer, overtimeBaseSeconds, fallbackPlannedSeconds? }를 받아 { plannedSeconds, remainingSeconds, progress, isOvertime, overtimeProgress, plannedMinutes, basePlannedMinutes, actualMinutes }를 반환합니다. TimerPanelfallbackPlannedSeconds를 생략(0)하고, useFocusSessiontodo?.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 통과
  • dev 서버에서 /home, /today, /focus 라우트가 500 없이 컴파일/응답되는지 확인 (curl)
  • 실제 로그인 상태에서 pause/resume/extend/complete/stop 클릭 테스트 — 미실행: 프로덕션 API 인증 필요, 리뷰어 확인 요청

- 타이머 mutation별로 흩어져 있던 쿼리 무효화 호출을 invalidateTimerProgress(일시정지/재개/연장)와 invalidateTimerFinish(완료/중지) 두 헬퍼로 통일했습니다
- 기존 invalidateTimerState를 invalidateTimerProgress로 이름을 맞춰 다른 화면(home/today)에도 동일하게 적용했습니다
- sessionStorage를 컴포넌트 로컬 상태로 직접 읽던 방식을 zustand 스토어로 옮겼습니다
- TimerPanel과 FocusSession처럼 이 훅을 각자 호출하는 화면들이 초과시간 상태를 서로 동기화해서 볼 수 있게 했습니다
- TimerPanel과 useFocusSession에 거의 동일하게 중복돼 있던 progress/overtime/분 단위 변환 계산을 useTimerProgress 훅으로 추출했습니다
@vercel

vercel Bot commented Aug 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
timo Error Error Aug 25, 2026 7:14am

@github-actions
github-actions Bot requested review from ehye1 and kimminna August 23, 2026 08:14
@github-actions github-actions Bot added ⏰ Timo-web Timo 웹 서비스 ♻ Refactor 기능 개선 및 리팩토링 작업 ♠️ 정민 정민양 labels Aug 23, 2026
@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown

Timo Performance Report

Bundle Size — timo-web
라우트 크기 First Load JS
/[locale]/home 275.01 kB 🔴 480.83 kB
/[locale]/today 259.19 kB 🔴 465.01 kB
/[locale]/focus 221.83 kB 🔴 427.64 kB
/[locale]/settings 228.51 kB 🔴 434.33 kB
/[locale]/statistics 210.21 kB 🔴 416.02 kB
/[locale]/[...rest] 0 B 🟡 205.82 kB
/[locale]/login 281.84 kB 🔴 487.66 kB
/[locale]/oauth/calendar/callback 120.93 kB 🟡 326.75 kB
/[locale]/oauth/callback 120.60 kB 🟡 326.41 kB
/[locale]/onboarding 294.32 kB 🔴 500.14 kB
/[locale] 119.91 kB 🟡 325.73 kB
/[locale]/policy 120.88 kB 🟡 326.70 kB
/robots.txt/route 0 B 🟡 205.82 kB
/sitemap.xml/route 0 B 🟡 205.82 kB

공유 번들: 205.82 kB
🟢 < 200kB  |  🟡 < 350kB  |  🔴 ≥ 350kB (First Load JS · gzip)

Lighthouse — timo-web
URL Perf A11y LCP CLS TBT
/en/home 🔴 62 🟢 96 🔴 16.3s 🟢 0.000 🟡 490ms
/en/today 🔴 60 🟢 96 🔴 16.3s 🟢 0.000 🟡 550ms
/en/focus 🔴 63 🟢 96 🔴 16.1s 🟢 0.000 🟡 433ms
/en/statistics 🔴 60 🟢 96 🔴 16.1s 🟢 0.000 🟡 545ms

Perf ≥ 70 / A11y ≥ 85 목표
LCP 🟢 < 2.5s 🟡 < 4s 🔴 ≥ 4s  |  CLS 🟢 < 0.1 🟡 < 0.25 🔴 ≥ 0.25  |  TBT 🟢 < 200ms 🟡 < 600ms 🔴 ≥ 600ms

Image Optimization — timo-web
파일 크기 포맷 상태
favicon.png 27.84 kB PNG ⚠️ 🟢
images/google-calendar.png 36.20 kB PNG ⚠️ 🟢
images/google-logo.png 26.79 kB PNG ⚠️ 🟢
og.png 437.44 kB PNG ⚠️ 🟡

총 4개 · 528.28 kB  |  🟢 < 200KB  |  🟡 < 500KB  |  🔴 ≥ 500KB
⚠️ 4개 파일 WebP/AVIF 변환 권장

측정 커밋: 82606a6

@jjangminii

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

- invalidateTimerProgress(일시정지/재개/연장)에서 빠졌던 invalidateStatistics 호출을 복원했습니다
- use-focus-session.ts가 같은 무효화 로직을 따로 손으로 하고 있어서 invalidateTimerProgress를 쓰도록 함께 정리했습니다
@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e50fbbf9-d99e-4859-9294-be1d6e242ce9


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kimminna kimminna left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

타이머 상태는 역시 넘 어렵네요...
다만 구조 측면에서 더 리뷰를 달아보자면, 함수 내부에서 리액트 훅을 직접 호출하는지의 여부에 따라 hooks/ 폴더 내부에 구조화하는 기준을 조금 더 세우면 좋을 것 같아요!

천천히 리팩해봅쉬다~~

Comment thread apps/timo-web/hooks/timer/use-timer-progress.ts Outdated
Comment thread apps/timo-web/hooks/timer/use-timer-query-invalidation.ts
Comment thread apps/timo-web/utils/timer/overtime-storage.ts Outdated
…279)

- React 훅을 쓰지 않는 순수 계산 로직이라 hooks/의 use 접두사가 맞지 않다는 리뷰 피드백을 반영했습니다
- utils/timer/get-timer-progress.ts로 옮기고 getTimerProgress로 개명했습니다
- utils 컨벤션에 맞춰 JSDoc(설명/@param/@returns)을 추가했습니다
- 앞 커밋에서 새 파일 추가와 호출부 수정이 스테이징 누락으로 빠졌던 부분을 마저 커밋했습니다
- 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을 추가했습니다
- 동작 변화는 없고 문서만 보강했습니다

@kimminna kimminna left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️ 타이머의 신. 권위자

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⏰ Timo-web Timo 웹 서비스 ♠️ 정민 정민양 ♻ Refactor 기능 개선 및 리팩토링 작업

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[REFACTOR] 타이머 쿼리 무효화 로직 통일

2 participants