001. Pull-to-Refresh 기능 추가 (홈 화면)
Status: 계획 수립 작성일: 2026-02-08 발견 계기: 모바일 앱 사용자가 홈 화면에서 아래로 당겨서 콘텐츠를 새로고침하는 네이티브 UX 패턴 필요 참조 구현:
apps/dha-sleep-app-web/components/screens/home/home-screen.tsx연관 Plan: 없음
배경
dha-sleep-app-web의 홈 화면은 HomeCard(동적 인사말 + 제안 액션)를 표시하지만, 사용자가 수동으로 콘텐츠를 새로고침할 방법이 없다. 현재는 앱 포그라운드 복귀 시 5분 이상 경과한 경우에만 자동 재페치되며, 사용자 주도적인 갱신은 불가능하다.
Pull-to-refresh는 모바일 앱의 핵심 UX 패턴으로, 사용자가 화면 상단에서 아래로 스와이프하여 최신 데이터를 요청할 수 있게 한다.
Goals
- 홈 화면에서 pull-to-refresh 제스처로 HomeCard + Prefetch 데이터를 갱신할 수 있다
- 새로고침 진행 중 시각적 피드백(스피너/인디케이터)을 제공한다
- Framer Motion(이미 의존성에 존재)만으로 구현하여 새 라이브러리 추가 없이 완성한다
Non-Goals
- 채팅 화면(
/chat)에 pull-to-refresh 추가 — 별도 플랜으로 분리 - 네이티브 Capacitor Pull-to-Refresh 플러그인 통합 — 웹 기반으로 구현
- 홈 화면 레이아웃 리디자인 — 기존 구조 유지
- HomeCard API 백엔드 변경 — 기존 GET/POST
/api/home-card활용
문제 분석
현상
- 사용자가 홈 화면에서 수동으로 데이터를 갱신할 수 없음
visibilitychange기반 자동 재페치는 사용자 의도와 무관- stale 카드가 표시될 때 갱신할 직관적 방법 부재
근본 원인
- Pull-to-refresh 컴포넌트 미구현
- 홈 화면의 메인 영역이 스크롤 컨테이너가 아닌
flex-col고정 레이아웃
기존 구현 현황
| 항목 | 상태 | 비고 |
|---|---|---|
| HomeCard 페치 로직 | ✅ 완료 | useHomeCardStore.fetchCard() |
| HomeCard 리프레시 로직 | ✅ 완료 | useHomeCardStore.refreshCard() |
| Prefetch 로직 | ✅ 완료 | usePrefetchStore.prefetchAll() |
| 포그라운드 복귀 재페치 | ✅ 완료 | visibilitychange 이벤트 |
| Pull-to-refresh UI | ❌ 미구현 | 이번 플랜 대상 |
| Touch gesture 처리 | ❌ 미구현 | Framer Motion drag 활용 예정 |
해결 방안
핵심 원칙
- 외부 라이브러리 없이 Framer Motion의
drag+onDragEnd로 제스처 감지 - 기존 refresh 로직 재활용:
fetchCard()(GET) 호출하여 최신 HomeCard 즉시 반영 - 접근성 준수:
aria-label,prefers-reduced-motion적용 - 터치 최적화: 44px+ 터치 타겟, smooth 애니메이션
데이터 플로우 (C4 Level 3)
사용자 Pull Down (touchstart → touchmove → touchend)
│
▼
PullToRefresh 컴포넌트 (터치 이벤트 기반)
├─ deltaY > threshold (80px)?
│ ├─ Yes → setIsRefreshing(true) + 스피너 표시
│ │ │
│ │ ▼
│ │ Promise.all([
│ │ fetchHomeCard(), ← HomeCard 재페치
│ │ prefetchAll(), ← Zygote + Profile 재페치
│ │ ])
│ │ │
│ │ ▼
│ │ setIsRefreshing(false) + 스피너 숨김
│ │
│ └─ No → 원위치 복귀 (스프링 애니메이션)
│
▼
HomeCard 업데이트 → HeroSection re-render
설계 결정
Q1. 구현 방식: Framer Motion drag vs 커스텀 Touch Events?
Context: 프로젝트에 Framer Motion v11.15.0이 이미 설치되어 있고 다양한 컴포넌트에서 사용 중. 그러나 pull-to-refresh는 스크롤 최상단에서만 동작해야 하며, 브라우저의 기본 overscroll 동작과 충돌할 수 있다.
Decision: 커스텀 Touch Events (touchstart, touchmove, touchend) 기반으로 구현하되, 애니메이션은 Framer Motion의 motion.div + animate 속성을 활용한다.
Consequences:
- 긍정: 스크롤 최상단 감지 로직을 세밀하게 제어 가능, overscroll bounce 방지
- 긍정: 브라우저 기본 pull-to-refresh(Chrome 등)와 충돌 방지를 위한
overscroll-behavior: none제어 가능 - 트레이드오프: Framer Motion
drag보다 약간 더 많은 코드량
Alternatives Considered:
- Framer Motion
drag="y"— overscroll과 충돌 이슈가 있으며 스크롤 최상단 감지가 어려움 - 외부 라이브러리 (
react-simple-pull-to-refresh) — 새 의존성 추가 불필요, Framer Motion으로 충분
Q2. 컴포넌트 구조: 독립 컴포넌트 vs HomeScreen 내 인라인?
Context: Pull-to-refresh는 다른 화면에서도 재사용 가능한 범용 패턴이다.
Decision: PullToRefresh 독립 래퍼 컴포넌트로 분리하여 재사용성 확보
Consequences:
- 긍정: 채팅 화면 등 다른 화면에서도 재사용 가능
- 긍정: 홈 화면 코드 복잡도 최소화 (HomeScreen은 래퍼만 추가)
- 트레이드오프: 파일 1개 추가
Q3. Refresh 대상: HomeCard만 vs 전체 데이터?
Context: 홈 화면 마운트 시 HomeCard, Zygote Session, User Profile을 병렬로 페치한다.
Decision: HomeCard(fetchCard) + Prefetch(prefetchAll)를 모두 갱신한다. 단, 외부 건강 데이터 동기화(syncExternalHealthData)는 제외.
Consequences:
- 긍정: 사용자가 최신 인사말 + 최신 Zygote 세션을 한번에 갱신
- 트레이드오프: 네트워크 요청 2-3개 동시 발생 (수용 가능한 수준)
Alternatives Considered:
- HomeCard만 갱신 — Prefetch 데이터가 stale한 채로 유지될 수 있음
- 전체 데이터(건강 동기화 포함) — 건강 데이터 동기화는 무거워서 사용자 체감 지연 발생
Rabbit Holes
- Chrome overscroll 동작과의 충돌 —
overscroll-behavior-y: containCSS로 해결 가능, 깊이 파고들 필요 없음 - Capacitor 네이티브 pull-to-refresh 통합 — 웹 뷰 기반 앱이므로 웹으로 구현하는 것이 일관성 있음
- 스크롤 가능한 콘텐츠 내 pull-to-refresh — 홈 화면은 스크롤 영역이 아닌 고정 레이아웃이므로 최상단 감지가 단순
구현 계획
| 문서 | 내용 | 변경 파일 | 우선순위 |
|---|---|---|---|
| 01-pull-to-refresh-component | Phase 1: PullToRefresh 컴포넌트 + 홈 화면 통합 | 1 NEW + 2 EDIT | P0 |
| 99-verification | 빌드/테스트/기능 검증 | - | P0 |
| 총계 | 1 NEW + 2 EDIT |
영향 범위
변경 대상 파일
apps/dha-sleep-app-web/
├── components/
│ └── ui/
│ └── pull-to-refresh.tsx [NEW] PullToRefresh 래퍼 컴포넌트
├── components/
│ └── screens/
│ └── home/
│ └── home-screen.tsx [EDIT] PullToRefresh 래퍼 적용
└── app/
└── globals.css [EDIT] overscroll-behavior CSS 추가
변경하지 않는 것
| 파일/모듈 | 이유 |
|---|---|
stores/home-card-store.ts | 기존 fetchCard() 재활용, 변경 불필요 |
stores/prefetch-store.ts | 기존 prefetchAll() 재활용, 변경 불필요 |
components/screens/home/hero-section.tsx | 데이터만 변경되므로 컴포넌트 수정 불필요 |
components/screens/home/home-top-bar.tsx | 변경 불필요 |
백엔드 API (/api/home-card) | 기존 GET/POST 엔드포인트 활용 |
capacitor.config.ts | 웹 기반 구현이므로 변경 불필요 |
컴플라이언스 체크
| 항목 | 해당 여부 | 대응 |
|---|---|---|
| PII 저장/처리 | No | 프론트엔드 UI 변경만 |
| 감사 로그 | No | 해당 없음 |
| 삭제 cascade | No | 해당 없음 |
| 데이터 보존 정책 | No | 해당 없음 |
| 데이터 레지던시 (europe-west3) | No | 프론트엔드만 변경 |
예상 효과
| 시나리오 | Before | After |
|---|---|---|
| 사용자가 최신 HomeCard를 보고 싶을 때 | 앱 종료 → 재시작 또는 5분 대기 | 아래로 당겨서 즉시 갱신 |
| Stale 카드가 표시될 때 | 자동 백그라운드 갱신 대기 (3-12초) | 즉시 수동 갱신 가능 |
| Zygote 세션이 만료됐을 때 | 포그라운드 복귀 시 자동 갱신 | Pull-to-refresh로 즉시 재생성 |