Skip to content

피드 좋아요·댓글 + 친구 세션 "자세히" — 팔로워 읽기 게이트와 반응 저장소 (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.sql Production 적용(장부 꼬리 530000, check:remote-schema missing 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[].sessionsource:"sessions" 명시, docs/data/rpc-catalog.md RPC 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), 상세를 여는 openSessionDetailpendingWorkoutSessions.find(id) || plans.find(id) || sessionLite로 원본을 고른 뒤 lgSessionSource(= sessionSourceOf, sourcesessions/planned_sessions가 아니면 throw)로 분기한다. 피드 lite가 3순위로 쓰이는 순간 throw — 호출부가 async 콜백이라 아무도 잡지 않고 Uncaught promise로 끝났다. 달력 월 plans에 같은 id가 있는 본인 세션만 그쪽 객체(source 있음)가 대신 쓰여 살아남았고, 그래서 "가끔 된다"처럼 보였다. 친구 세션과 보고 있는 달 밖의 내 세션은 전부 불가였다. 계약문의 lite에도 source가 없었다 — 컨테이너 요구와 계약이 어긋난 채 달력 우연 일치에 기대고 있던 결함.

서버. 클라이언트를 고쳐도 친구 카드는 get_session_detailsession.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 lite source: "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.ownerIdsessionDetailStore.openCompletedWorkoutDetailownerId ≠ 나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 정의 + lateral social 두 곳).
  • pgTAP feed_reactions_v1.test.sql 37 assert — 팔로워 상세 읽기(user_id·stats_load_kg)·계획 세션 P0002, 좋아요 멱등/취소/계획 거부, 댓글 검증·트림·author·정렬·truncated, 본인 세션 자기 반응, 카드 social, 직접 insert 42501, 비팔로워 5경로 거부, 직접 select 본인 행만, 소유자 삭제·없는 댓글 P0002·작성자 삭제.

Phase 3 — 클라이언트·UI (#607)

  • 데이터층: barbelicRepository 4 호출자 · socialDomain(댓글 author 사진 = withFriendPhotos) · barbelicApi · 어댑터 social 검증(있을 때만) · buildProfileFeedItems/buildMobileFeedPosts 투영 · types/screenRpc.ts SessionPresentationSocialPayload.
  • 컨테이너: feedSocialStore.ts(좋아요 낙관 반영 → 서버 확정/실패 되돌림·중복 탭 무시, 댓글 시트 열기/등록/삭제·카운트 동기화·늦은 응답 폐기· 오너 리셋, 테스트 9) + feedSocialController.tsappController 8키(feedSocialBySession · feedLikePendingIds · feedComments · toggleFeedLike · openFeedComments · closeFeedComments · submitFeedComment · deleteFeedComment) → mobileApp.
  • 화면: FdPost 푸터 하트(눌렀으면 채움)·말풍선 + 카운트(0이면 숫자 생략), FdCommentsSheet(목록 오래된 순 + 하단 입력 행 + canDelete 행 삭제), UiFeedScreen/UiHomeScreen 7 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.
  • 워크트리에서 origin/main detach → supabase db push --linked 530000(마이그레이션 내부 postcheck 통과) → check:remote-schema missing 0 → Production 번들 확인(barbelicRepository-DUu72gjM.js RPC 4종 + manifest 530000, FeedPost-B8z66dhl.js 댓글 시트).
  • Production 실측(Management API, 박주희 관점): 성근 최신 완료 세션 get_session_detail 정상(세트 16·지표 포함), 피드 첫 5장 중 성근 카드 4장에 social.
  • BUG-007 리포트 + 계약 posts[].sessionsource:"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.ts useKeyboardInset(): 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 check 1810 PASS. 머지 111fa4d5, Production FeedPost-KcgkAqJx.js 확인.
  • 오너 실기기: 여전히 안 됨(08-24) — 별도 트랙으로 분리(아래 "남은 것").

작업 중 드러난 것

  • 공유 체크아웃 3차 사고. 공유 작업 폴더에서 git checkout -b로 시작했더니 작업 도중 다른 세션이 브랜치를 바꿨다가 main으로 되돌려 놓아, 내 커밋이 로컬 main에 찍히고 push는 빈 브랜치를 올렸다(gh pr create: "No commits between main and …"). git branch -freset --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.jsonserver.url이 없어 앱은 빌드 시점 웹 번들을 내장한다. #607·#623은 다음 앱 빌드에 실린다(#574 대기열).
  • 데스크톱 홈 피드(DkH3SessionPost) 좋아요·댓글 미배선, 데스크톱 친구 카드 "자세히"의 읽기 전용 표면 닫기(수정·삭제는 서버가 42501/P0002로 거부).
  • 친구 세션 상세 프라이버시(체중 등 필드 절단) — 오너 결정 대기, 지금은 본인 상세와 같은 페이로드.