피드 당겨서 새로고침 — 최신 갱신 수단 부재에서 커밋 0 제스처까지 (2026-08-25)
- 기간: 2026-08-25 (세션 2 — 구현
6402d603·랜딩 이 세션, 오너 요청 원문: "피드 탭에서 화면 아래로 잡아당기면 새로고침되게 해줘" — 이슈 #774) - 랜딩: PR #776(Phase 1,
bf45ebd3) · PR #778(Phase 2-1 스프링백) · PR #781(Phase 2-2 문구 통일, 오너 지시 CI 생략 admin 머지·사후 CI 통과) — 마이그레이션 0·서버 신설 0, Vercel 배포 확인(.fd-ptr를 lazy 청크feed-*.css에서, 문구 통일을FeedScreen-*.js에서 실측) - 설계서: 없음(단일 기능 트랙) — Phase 계획서·예상 효과·개선사항 표는 이슈 #774 본문
- 정본:
src/react/ui/mobile/lib/pullRefresh.ts(createPullRefreshGesture상태 기계 +usePullRefresh훅, 튜닝 상수PULL_REFRESH_TUNING한 곳) ·feed-props.mdonRefresh절 - 도구: 없음
- 게이트:
tests/react/feedPullRefresh.test.mjs8건(스크롤 게이트·슬롭·감쇠·임계·busy 재진입 금지·배선 단언) - 버그리포트: 없음(기능 신설)
- 계약:
feed-props.mdCallbacks에onRefresh() → Promise?등재(미공급이면 제스처 비활성)
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 구현·PR·CI·머지 | ✅ PR #776 (bf45ebd3) |
| Phase 2 | 실기기 제스처 확인 | ✅ 오너 확인 "잘 되고 … 완료됐음"(08-25) |
| Phase 2-1 | 오너 피드백 반영 — 놓는 즉시 스프링백(busy 유지 구간 제거) | ✅ PR #778 |
| Phase 2-2 | 오너 지시 — 문구 "당겨서 새로고침" 하나로 통일 | ✅ PR #781 |
| Phase 3 | 홈 탭·일지 탭 등 다른 탭 확산 여부 | ✅ 결정: 확산 안 함, 피드만 유지(08-25 오너) |
| Phase 4 | 작업 기록 | ✅ 이 문서 |
트랙 종결(2026-08-25) — 오너 확인 후 이슈 #774 닫음.
1. 배경
팔로잉 포스트(2026-08-23, 20260821480000) 도입 뒤에도 피드 탭의 갱신 경로는 셋뿐이었다 — 최초 1회 적재(loadedProfileFeedRef 게이트로 재진입 시 재적재 없음), 무한 스크롤(다음 페이지만), 세션 저장/삭제 후 무효화(내 글만). 즉 친구가 새로 올린 글을 사용자가 앱 안에서 받아볼 수단이 없었다. 에러 상태에서만 "다시 시도" 버튼이 노출된다. 오너가 표준 모바일 관용구인 당겨서 새로고침을 요청했다.
2. 문제 제기
최신 피드를 다시 받을 트리거가 UI에 없다
retryProfileFeed(= loadProfileFeed({force:true}), 첫 페이지 강제 재적재)는 이미 있었지만 에러 화면의 "다시 시도"에만 배선돼 있었다. 정상 상태에서는 호출 경로 자체가 없음.
제스처를 새로 달 때의 렌더 예산 제약
렌더 입자성 캠페인(2026-08-23) 이후 제스처 중 React 커밋은 0이 규범이다(useSheetDrag가 선례). useState로 당김 오프셋을 추적하면 touchmove마다 루트 커밋이 터진다 — 같은 화면에서 회귀.
3. 해결 방안
원칙 (오너 요청, 2026-08-25)
"피드 탭에서 화면 아래로 잡아당기면 새로고침" — 적용 범위는 요청 그대로 피드 탭만. 다른 탭 확산은 Phase 3 오너 결정으로 남김.
접근
| 대안 | 판정 |
|---|---|
| 신규 새로고침 RPC/로더 | 기각 — retryProfileFeed가 이미 정확히 "첫 페이지 강제 재적재"다. 신설은 중복 |
| React 상태로 당김 오프셋 추적 | 기각 — touchmove마다 커밋. 입자성 규범 위반 |
| 판정 로직을 훅 안에 인라인 | 기각 — 임계·감쇠·재진입 금지를 단위 테스트로 잠글 수 없다 |
| DOM 무의존 상태 기계 분리 + DOM 직접 조작 훅 | 채택 — createPullRefreshGesture(idle→tracking→pulling→busy)는 순수 로직이라 8건 테스트로 고정, 훅은 transform·data-ptr 속성만 만진다(커밋 0) |
인디케이터는 스피너 없이 텍스트 + opacity 펄스(오너 로딩 UX 원칙 = 스피너 폐기). 문구 전환은 CSS가 data-ptr(pull/arm/busy) 속성 셀렉터로 수행 — React 무관여.
4. 적용한 내용
Phase 1 — 구현·랜딩 (#776)
| 파일 | 변경 |
|---|---|
src/react/ui/mobile/lib/pullRefresh.ts | 신설 — 상태 기계 + usePullRefresh(onRefresh) 훅(contentRef 콜백 ref·indicatorRef 반환) |
src/react/ui/mobile/screens/FeedScreen.tsx | onRefresh prop, .fd-full에 contentRef + 첫 자식 .fd-ptr 밴드 |
src/react/ui/mobile/styles/screens/feed.css | .fd-ptr 규칙(상단 밖 52px 밴드, data-ptr별 문구, busy 펄스) |
src/react/mobileApp.tsx | onRefresh={retryProfileFeed} 한 줄 |
docs/contracts/feed-props.md | onRefresh 계약 등재 |
tests/react/feedPullRefresh.test.mjs | 신설 8건 |
동작: .screen.scrollTop > 0이면 즉시 브라우저 스크롤에 넘김 → 아래로 8px(slop) 넘게 끌면 클레임·preventDefault()({passive:false}) → 감쇠 0.5로 최대 110px 당김, 56px 이상에서 놓으면 발동 → 52px 고정 + retryProfileFeed() + 최소 450ms 유지 후 원위치. busy 중 재당김 무시. 튜닝 상수는 PULL_REFRESH_TUNING 한 곳.
Phase 2-1 — 오너 피드백 반영: 놓는 즉시 스프링백 (#778)
오너 피드백(08-25): "그냥 땡기고 나서 바로 위로 올라가게 해줘. 새로고침중 이런 텍스트 때문에 뭔가 애니메이션이 끊기는듯." → 임계 이상에서 놓아도 즉시 원위치로 변경: 52px 유지 오프셋·data-ptr="busy" 표시·450ms 최소 홀드 제거(settleOffsetPx·minBusyMs 상수 삭제, "새로고침 중" span·busy CSS 제거). 새로고침 Promise가 접힐 때까지 재발동을 막는 가드는 유지. 같은 턴 오너 결정으로 Phase 3(다른 탭 확산)은 확산 안 함으로 종결.
이어서 Phase 2-2(#781): 오너 지시 "텍스트만 당겨서 새로고침 으로 바꿔줘. ci하지말고 바로 반영" — 인디케이터 문구를 상태 무관 "당겨서 새로고침" 하나로 통일(arm 문구·.fd-ptr-msg 전환 CSS 제거), 지시대로 CI 대기 없이 admin 머지(로컬 게이트 전체 통과·사후 CI verify 통과). 오너 실기기 확인(08-25)으로 트랙 종결.
주요 결정과 그 근거
- 에러 표면 없음:
onRefresh실패는 인디케이터만 접힌다 — 오류 표출은 기존 컨테이너 경로(reportedUserError·status="error")가 담당. 당김이 별도 에러 UI를 만들면 관문 밖 표면이 늘어난다. - 최소 busy 450ms(#776): 번쩍임 방지 의도였으나 오너 체감은 "애니메이션이 끊긴다" — Phase 2-1에서 busy 표시 자체를 제거하며 함께 삭제.
작업 중 드러난 것
- 소스 앵커 게이트 오탐: 테스트에서 렌더 출력에
html.indexOf("8자+ 리터럴")을 쓰면sourceSliceAnchors게이트가 소스 앵커로 수집해 실패한다 → 렌더 출력 검사는 정규식 단언(assert.match)으로. e.touches[0]는 TS2532: 인덱스 접근은 지역 변수로 좁혀서 사용.- 로컬 ESLint 실행 불가(기존 환경 문제)라 lint 판정은 PR CI가 유일했다.
- Vercel 마커는 메인
app-*.css에 없다: 피드 화면 스타일은mobileRoot아래 lazy 청크feed-*.css로 스플릿된다 — 배포 확인 폴링이 메인 CSS만 봐서 10분간 오탐(lazy 청크 함정 재확인). 마커 검사는 화면 소유 청크를 찾아서 할 것.
5. 적용 결과
| 항목 | 결과 |
|---|---|
| 앱 내 최신 피드 갱신 수단 | 0개 → 1개(에러 전용 "다시 시도" 제외) |
| 제스처 중 React 커밋 | 0 유지(DOM 직접 조작 — 입자성 규범 준수) |
| 서버 변경 | 0(기존 retryProfileFeed 재사용) |
| 해제 애니메이션 | 52px 유지+"새로고침 중" 표시 → 놓는 즉시 스프링백(#778, 오너 피드백) |
| 전체 테스트 스위트 | 1,975 pass / 0 fail(신설 8건 포함, 워크트리 실측) · tsc·dead-CSS·소스 앵커 게이트 통과 |
| CI(lint 포함) | ✅ scope·verify pass(verify 3m46s) — 로컬 ESLint 불가라 lint는 CI가 판정 |
| Vercel 반영 | ✅ Production assets/feed-CgpQb4vB.css에 .fd-ptr 규칙 실측 — 마커는 레포에서 이 PR의 4파일에만 존재(고유성 증명) |
| 인디케이터 문구 | pull/arm/busy 3종 → "당겨서 새로고침" 하나(#781, 오너 지시) — Production FeedScreen-*.js 실측 |
| 실기기 제스처 체감 | ✅ 오너 실기기 확인(08-25, 스프링백 반영 후 "잘 되고 … 완료됐음") |
6. 이번 개선으로 향상된 것
친구 새 글을 화면에서 즉시 받는다
탭 재진입·앱 재시작 없이 표준 제스처 한 번으로 첫 페이지 재적재. 팔로잉 포스트 기능이 실사용 가능한 루프로 닫혔다.
구조적으로 남는 것
createPullRefreshGesture는 DOM·React 무의존이라 다른 탭 확산(Phase 3) 시 그대로 재사용된다.onRefresh는feed-props.md계약 항목이라 컨테이너 교체와 무관하게 유지된다.- 제스처 판정 회귀는 테스트 8건이 잠근다(임계·재진입·스크롤 게이트).
남은 것
없음 — 전 Phase 종결(오너 확인 08-25, 이슈 #774 닫음).