피드 좋아요·댓글 + 친구 세션 "자세히" — 팔로워 읽기 게이트와 반응 저장소 (2026-08-23 ~ 08-24)
- 기간: 2026-08-23 ~ 08-24 (세션 1개 — 원인 조사·서버·클라이언트·UI·Production 적용·버그리포트, 08-24 댓글 입력창 후속)
- 랜딩: PR #607(Phase 1~3,
b3c3e85c) · #615(Phase 4 BUG-007 리포트,e7498434) · #623(Phase 5 댓글 시트 키보드,111fa4d5). 마이그레이션20260821530000_feed_reactions_v1.sqlProduction 적용(장부 꼬리 530000,check:remote-schemamissing 0), 웹은 Vercel 자동 배포 - 정본: 반응 서버 표면
supabase/migrations/20260821530000_feed_reactions_v1.sql, 클라이언트 상태src/react/controllers/feedSocialStore.ts(+feedSocialController.ts), 화면src/react/ui/mobile/lib/FeedPost.tsx(FdPost푸터·FdCommentsSheet), 읽기 전용 상세src/react/controllers/sessionDetailStore.ts, 키보드 인셋src/react/ui/mobile/lib/keyboardInset.ts - 게이트: pgTAP
supabase/tests/database/feed_reactions_v1.test.sql(37),tests/react/feedSocialStore.test.mjs(9),tests/react/feedReactions.test.mjs(4) - 버그리포트:
bug-report/bug-007-20260823.md - 계약:
docs/contracts/feed-props.md"2026-08-23 좋아요·댓글 + 친구 세션 자세히" 절 +posts[].session에source:"sessions"명시,docs/data/rpc-catalog.mdRPC 4종 +get_session_detail게이트 행, PRD §5.7 - 이전 트랙: 친구 검색·팔로우 v1 → 아이디·친구 사진 → 팔로잉 포스트 → 친구 리포트 → 본 문서
1. 배경
오너가 2026-08-23 보고했다 — "피드에 지금 자세히를 누르면 상세 세션으로 안 들어가지는데 확인해주고, 상대 피드에 좋아요 및 댓글 달 수 있는 기능 구현해줘." 팔로잉 포스트(08-23, 20260821480000)로 피드 탭에 친구 카드가 처음 섞인 직후다. 카드는 리뷰·자유 기록·main 세트·KPI까지 보여 주는데, 카드의 "자세히 →"는 열리지 않았고 카드에 반응할 길(좋아요·댓글)은 없었다.
로컬 샌드박스(계정 둘, A가 B를 팔로우)에서 바로 재현됐다. 친구 카드의 "자세히 →"를 누르면 아무 일도 일어나지 않는다 — 토스트조차 없고, 네트워크에는 get_session_detail 호출이 없으며 record_client_error_event만 한 건 찍힌다. 콘솔에는 Uncaught (in promise) Error: Session source must be either sessions or planned_sessions.
2. 문제 제기
"자세히"는 두 층에서 막혀 있었다
클라이언트. 피드 카드의 session lite는 { id, status, date, title }로 만들어지는데(buildMobileFeedPosts · 데스크톱 lgDesktopProfileFeed), 상세를 여는 openSessionDetail은 pendingWorkoutSessions.find(id) || plans.find(id) || sessionLite로 원본을 고른 뒤 lgSessionSource(= sessionSourceOf, source가 sessions/planned_sessions가 아니면 throw)로 분기한다. 피드 lite가 3순위로 쓰이는 순간 throw — 호출부가 async 콜백이라 아무도 잡지 않고 Uncaught promise로 끝났다. 달력 월 plans에 같은 id가 있는 본인 세션만 그쪽 객체(source 있음)가 대신 쓰여 살아남았고, 그래서 "가끔 된다"처럼 보였다. 친구 세션과 보고 있는 달 밖의 내 세션은 전부 불가였다. 계약문의 lite에도 source가 없었다 — 컨테이너 요구와 계약이 어긋난 채 달력 우연 일치에 기대고 있던 결함.
서버. 클라이언트를 고쳐도 친구 카드는 get_session_detail의 session.user_id = auth.uid()에서 P0002 Session not found. 그 게이트만 열면 이번엔 세트 지표 함수 strength_set_metrics_map_json의 join 조건 session.user_id = auth.uid()가 친구 세트를 비워 클라이언트 어댑터가 exercise_sets.0.stats_load_kg is required로 거부한다. 팔로잉 포스트 트랙은 표시 함수(get_session_presentation_mains_json)만 팔로워 게이트로 바꿨고 상세 체인(상세 + 세트 지표)은 손대지 않았었다.
반응 저장소가 없었다
좋아요·댓글은 테이블도 RPC도 없었다. 피드 카드에 카운트를 실을 키도, 시트를 열 화면도 없었다. 서버 게이트는 팔로우 모델과 같아야 한다 — 피드가 보여 주는 세션(본인 또는 팔로우한 사람의 완료 세션)에만 반응할 수 있고, 쓰기는 RPC로만, 직접 읽기는 본인 행만.
3. 해결 방안
원칙 (제품 결정 — 권고안 채택, 오너 확인 대상으로 PR 본문에 명시)
| # | 결정 | 채택 |
|---|---|---|
| D1 | 친구 세션 상세의 읽기 게이트 | 피드와 같은 profile_feed_can_view_v1(소유자 또는 완료 세션의 팔로워). 상세·세트 지표 두 함수를 활성 정의 그대로 재발행, 쓰기 RPC는 소유자 고정 |
| D2 | 친구 세션 상세의 표면 | 읽기 전용 드로어 — 수정·삭제·갱신·"이 세션으로 시작" 없음, 리뷰 아바타는 소유자 이니셜. 페이로드는 본인 상세와 같음(체중 등 프라이버시 절단은 계획 반영 v1과 같이 범위 밖) |
| D3 | 반응 대상·권한 | 본인 또는 팔로우한 사람의 완료 세션. 좋아요 멱등 토글, 댓글 1~500자, 삭제는 작성자 또는 세션 소유자, 수정 없음 |
| D4 | 카운트 전달 | get_profile_feed 카드에 추가 전용 social{like_count, comment_count, liked_by_me}(contract_version·커서·바이트 상한 불변 — 구 번들은 키를 무시) |
| D5 | 데스크톱 | 범위 밖 — 홈 피드 DkH3SessionPost는 카운트·버튼 미배선, 친구 카드 "자세히"는 상세를 읽되 읽기 전용 표면 닫기는 모바일만 |
접근
- "자세히"는 lite 생성 경계(매퍼)에서
source를 확정한다. 컨테이너를 관대하게 만드는 안(없으면sessions로 추정)은 계획 lite에 같은 결함이 생기면 오판하므로 기각. - 서버 재발행은 schema.sql의 마지막(활성) 정의를 앵커 치환으로 생성한다(전사 오류 0).
pg_get_functiondef패치는 SCR-3 게이트가 막으므로 postcheck는pg_proc.prosrc로 게이트 문자열 존재를 확인한다. - 클라이언트는 팔로우 트랙과 같은 틀 — 프레임워크 무의존 스토어(낙관 반영·늦은 응답 폐기·오너 리셋) + React 어댑터 →
appController렌더 컨텍스트 →mobileApp→ 화면 prop. 카운트 씨앗은 카드가 들고 오고, 사용자가 반응한 세션만 스토어 정본이 덮는다(withFeedSocialOverrides). - 화면은 콜백 유무로만 표면을 닫는다(읽기 전용 = 콜백 미전달). 계약문에 prop 7개와 마커 4개(
feed.like·feed.comments·feedCommentsSheet·feedComment)를 등록.
4. 적용한 내용
Phase 1 — "자세히" 수리 (#607)
mobileFeedViewMappers.ts·barbelicViewMappers.ts(lgDesktopProfileFeed): session litesource: "sessions"고정, 친구 카드는author동반(읽기 전용 드로어의 소유자 헤더).- 마이그레이션
20260821530000:get_session_detail(where 한 곳)·strength_set_metrics_map_json(join 한 곳) 재발행 —profile_feed_can_view_v1(actor, owner), 상세는 완료 세션만. buildCompletedSession.ownerId(상세session.user_id) →lgSessionToLite.ownerId→sessionDetailStore.openCompletedWorkoutDetail이ownerId ≠ 나면selectedSession.readOnly+owner부착 →mobileApp이 수정·삭제·갱신·시작 콜백 미전달 +selectedSessionProfile = { ...owner, self: false }→SessionSheetBody리뷰 아바타 소유자 이니셜.- 회귀 테스트
tests/react/feedReactions.test.mjs: lite가sessionSourceOf를 통과, 친구 상세 readOnly·owner 부착, 본인 세션 무표식.
Phase 2 — 좋아요·댓글 서버 (#607, 같은 마이그레이션)
session_likes(session_id, user_id)·session_comments(id, session_id, user_id, body 1~500자/≤2000 B)— RLS 본인 행 읽기, 쓰기는 RPC 전용.- 내부 헬퍼
session_social_counts_v1·session_social_target_owner_v1(완료 세션 P0002 / 비팔로워 42501) ·session_comment_author_json_v1(피드 author 카드 모양 → 같은 사진 서명 경로 재사용). - 공개 RPC
set_session_like_v1(p_session_id, p_liked)·add_session_comment_v1·delete_session_comment_v1·list_session_comments_v1(오래된 순 ≤200). get_profile_feed_v2_engine재발행(480000 정의 + lateralsocial두 곳).- pgTAP
feed_reactions_v1.test.sql37 assert — 팔로워 상세 읽기(user_id·stats_load_kg)·계획 세션 P0002, 좋아요 멱등/취소/계획 거부, 댓글 검증·트림·author·정렬·truncated, 본인 세션 자기 반응, 카드social, 직접 insert 42501, 비팔로워 5경로 거부, 직접 select 본인 행만, 소유자 삭제·없는 댓글 P0002·작성자 삭제.
Phase 3 — 클라이언트·UI (#607)
- 데이터층:
barbelicRepository4 호출자 ·socialDomain(댓글 author 사진 =withFriendPhotos) ·barbelicApi· 어댑터social검증(있을 때만) ·buildProfileFeedItems/buildMobileFeedPosts투영 ·types/screenRpc.tsSessionPresentationSocialPayload. - 컨테이너:
feedSocialStore.ts(좋아요 낙관 반영 → 서버 확정/실패 되돌림·중복 탭 무시, 댓글 시트 열기/등록/삭제·카운트 동기화·늦은 응답 폐기· 오너 리셋, 테스트 9) +feedSocialController.ts→appController8키(feedSocialBySession · feedLikePendingIds · feedComments · toggleFeedLike · openFeedComments · closeFeedComments · submitFeedComment · deleteFeedComment) →mobileApp. - 화면:
FdPost푸터 하트(눌렀으면 채움)·말풍선 + 카운트(0이면 숫자 생략),FdCommentsSheet(목록 오래된 순 + 하단 입력 행 +canDelete행 삭제),UiFeedScreen/UiHomeScreen7 prop, CSS는primitives.css(홈·피드 공용 —feed.css는 피드 탭만 로드),designContract마커 4.
Phase 4 — 배포·기록 (#607 머지 → Production → #615)
- PR #607 CI 초록 → main 이동(#603~#609)으로 CONFLICTING → 리베이스(충돌 4파일:
schema.sql재생성,mobileApp/prd/feed-props양쪽 유지)- 마이그레이션 번호 재번호 520000 → 530000(#606 친구 리포트가 520000 선점) → 재검증 → squash 머지
b3c3e85c.
- 마이그레이션 번호 재번호 520000 → 530000(#606 친구 리포트가 520000 선점) → 재검증 → squash 머지
- 워크트리에서
origin/maindetach →supabase db push --linked530000(마이그레이션 내부 postcheck 통과) →check:remote-schemamissing 0 → Production 번들 확인(barbelicRepository-DUu72gjM.jsRPC 4종 + manifest 530000,FeedPost-B8z66dhl.js댓글 시트). - Production 실측(Management API, 박주희 관점): 성근 최신 완료 세션
get_session_detail정상(세트 16·지표 포함), 피드 첫 5장 중 성근 카드 4장에social. - BUG-007 리포트 + 계약
posts[].session에source:"sessions"명시 → docs-only PR #615 즉시 머지.
Phase 5 — 댓글 입력창이 안 보인다 (#623, 08-24)
- 오너 보고 "댓글은 입력하는 창이 안보이네". Chromium·WebKit(Safari 엔진) 헤드리스 iPhone 뷰포트에서 정적 레이아웃은 정상(입력 행 바닥 = 뷰포트 바닥), Production CSS(
mobileRoot-*.css)에 규칙 존재 → 원인 추정 = 앱에 소프트 키보드 처리 0건. iOS Safari·Android Chrome은 키보드가 떠도 레이아웃 뷰포트를 줄이지 않아.app바닥에 붙은 바텀시트 입력 행이 키보드 뒤로 들어간다. ui/mobile/lib/keyboardInset.tsuseKeyboardInset():innerHeight − (visualViewport.height + offsetTop)= 키보드가 레이아웃 바닥을 덮는 높이 (40px 미만은 주소창 변동 잡음으로 무시, 네이티브가 웹뷰를 줄이는 환경은 0).FdCommentsSheet는 그만큼 스크림padding-bottom으로 시트를 들어 올리고 시트max-height: 100%(스크림 content box = 키보드 뺀 높이)·min-height: 0로 남은 공간에 맞춘다.- 검증: Playwright
addInitScript로 가짜visualViewport(키보드 300px) 주입 → 입력 행 바닥 664 → 364(키보드 위), 해제 시 664 복귀.npm run check1810 PASS. 머지111fa4d5, ProductionFeedPost-KcgkAqJx.js확인. - 오너 실기기: 여전히 안 됨(08-24) — 별도 트랙으로 분리(아래 "남은 것").
작업 중 드러난 것
- 공유 체크아웃 3차 사고. 공유 작업 폴더에서
git checkout -b로 시작했더니 작업 도중 다른 세션이 브랜치를 바꿨다가main으로 되돌려 놓아, 내 커밋이 로컬 main에 찍히고 push는 빈 브랜치를 올렸다(gh pr create: "No commits between main and …").git branch -f→reset --hard origin/main→ force push로 복구하고 이후는 scratchpad worktree. 사이드 브랜치는 처음부터 worktree, push 뒤git log origin/main..branch확인. - 번호 선점 재발. PR 대기 중 #606이 520000을 랜딩·Production 적용 → 530000 재번호 + 참조 13파일 갱신. 커밋 직전과 PR 직전 두 번
git ls-tree origin/main supabase/migrations | tail. - 세트 지표도 게이트다.
get_session_detail만 열면strength_set_metrics_map_json이 친구 세트를 비워 어댑터가 거부한다 — 읽기 게이트를 넓힐 때는 표시 함수·상세·세트 지표를 한 묶음으로 본다. - 재발행
max-height이중 차감. 스크림padding-bottom: kb위에서 시트max-height: calc(100% - kb)는100%가 이미 content box(키보드 뺀 높이)라 두 번 빼 시트가 64px로 줄었다 —100%면 된다. - pgTAP: 같은 트랜잭션 안의 두 댓글은
created_at(now()) 동일 → 정렬 단언 전에reset role+update로 1분 빼고set local role authenticated.sessions.note는 not null(계획 픽스처''). - 로컬 스모크 계정 시드:
e2e/support/harness.mjs(provisionThrowawayUser+requiredOnboardingConsentAcceptances) +R.completeOnboarding(…, primaryTrainingStyle:"powerlifter")R.createCompletedSession(requestHash = sha256 hex); 핸들은 런마다 유니크(23505),db reset은 시드 계정을 지운다. 스크립트는 레포tmp/(gitignored)에서node --import tsx로.
- Production 번들 검증은
app-*.js만 보면 오탐 — 레포지토리는 별도 lazy 청크(barbelicRepository-*.js), 피드 포스트는FeedPost-*.js. - Browser 패널이 숨겨져 스크린샷이 안 될 때는 Playwright 헤드리스(
npx playwright install webkit으로 Safari 엔진까지)로 찍는다.
5. 적용 결과
| 항목 | 결과 |
|---|---|
| 친구 카드 "자세히 →" | 조용한 크래시(Uncaught promise, 토스트 없음) → 세션 상세 드로어(읽기 전용). 로컬 스모크 + Production 실측(박주희→성근 세션 상세 정상) |
| 보고 있는 달 밖의 내 카드 "자세히 →" | 같은 크래시 → 정상(lite source 고정) |
| 본인 카드 "자세히 →" | 변화 없음 — 수정·삭제 유지(스모크 확인) |
| 좋아요 | 없음 → 하트 탭 즉시 반영(낙관) + 서버 확정, 멱등, 팔로우한 사람·본인 완료 세션만 |
| 댓글 | 없음 → 시트(목록·등록·삭제), 1~500자, 작성자·세션 소유자 삭제. 로컬 스모크: 등록 → 행·카운트 1·입력 비움, 작성자 관점 소유자 삭제 → 0 |
| 피드 카드 카운트 | 없음 → social{like_count, comment_count, liked_by_me} 추가 전용(구 번들 무영향) |
| 서버 게이트 | 상세·세트 지표 소유자 전용 → profile_feed_can_view_v1 한 헬퍼로 피드 표시 함수(480000)와 통일 |
| 검증 | 샌드박스 db reset 53 마이그레이션 전체 재생 + pgTAP 51파일·798 PASS, npm run check 1792 PASS(#607)·1810 PASS(#623), Production check:remote-schema missing 0 |
| 댓글 입력창(실기기) | 키보드 인셋(#623) 배포 후에도 오너 실기기 재현 지속 — 미해결, 별도 트랙 |
6. 이번 개선으로 향상된 것
피드가 보여 주는 것은 열어 볼 수 있다
팔로우한 사람의 완료 세션은 카드(표시 함수)만이 아니라 상세(세트·지표·리뷰)까지 한 게이트로 읽힌다. 읽기 표면을 넓힐 때 고칠 함수가 어디까지인지(표시·상세·세트 지표) 이번 사고가 문서와 pgTAP으로 남았다.
"자세히"가 조용히 죽지 않는다
lite 생성 경계에서 source가 확정되고, 계약문과 회귀 테스트(sessionSourceOf 통과)가 같은 결함의 재유입을 막는다. 달력 plans 우연 일치에 기대던 구조가 없어졌다.
반응 저장소가 팔로우 모델과 같은 규칙으로 생겼다
RPC 전용 쓰기·본인 행 읽기·완료 세션·팔로워 게이트 — 친구 검색·팔로우·팔로잉 포스트·친구 리포트와 같은 틀이다. 댓글 author는 피드 author 카드 모양이라 사진 서명 경로를 새로 만들지 않았다.
클라이언트 반응 상태가 React 밖에서 검증된다
좋아요 낙관 반영·되돌림·중복 탭, 댓글 시트의 늦은 응답 폐기·오너 리셋이 스토어 행동 테스트 9건으로 잠겼다. 카드 씨앗과 스토어 정본의 병합(withFeedSocialOverrides)은 순수 함수다.
남은 것
- 댓글 입력창이 실기기에서 여전히 안 보임(오너 08-24) — 키보드 인셋(#623) 배포 뒤에도 재현. 키보드를 띄우기 전부터 없는지, 기기·브라우저 (iPhone Safari / 갤럭시 / 앱 빌드)가 무엇인지 캡처와 함께 확인해야 한다. 별도 트랙으로 분리(오너 결정).
- iOS/Android 앱 번들 —
capacitor.config.json에server.url이 없어 앱은 빌드 시점 웹 번들을 내장한다. #607·#623은 다음 앱 빌드에 실린다(#574 대기열). - 데스크톱 홈 피드(
DkH3SessionPost) 좋아요·댓글 미배선, 데스크톱 친구 카드 "자세히"의 읽기 전용 표면 닫기(수정·삭제는 서버가 42501/P0002로 거부). - 친구 세션 상세 프라이버시(체중 등 필드 절단) — 오너 결정 대기, 지금은 본인 상세와 같은 페이로드.