Skip to content

UGC 안전 v1 — 신고·차단 그린필드에서 App Store 1.2 요건 5/5까지 (2026-08-25)

  • 기간: 2026-08-24 ~ 2026-08-25 (설계 세션 1 + 구현 세션 1, 오너 지시: "이슈 #670 진행 — 승인 없이 완주, 땜질 말고 본질적으로 더 좋은 구조로")
  • 랜딩: PR #725(Phase 1, DB 기반 20260821740000) · #735(Phase 2, 클라 배선) · #737(Phase 3, 앱 UI) · #738(Phase 4, 약관 v4 + 20260821760000) · #740(Phase 5, 관리자 큐) · #741(Phase 6, CASE-018·이 기록) — 마이그레이션 740000·760000 Production 적용, Vercel 배포 확인
  • 설계서: 이슈 #670 본문(2026-08-24 전수 조사 — 예상 효과·개선사항 절 포함) + 착수 계획서 코멘트(2026-08-25, 구조 변경 1건)
  • 정본: profile_feed_can_view_v1 = 팔로우+차단+정지 단일 게이트(supabase/migrations/20260821740000_ugc_safety_v1.sql) · moderationStore.ts(시트 상태·즉시 숨김·REPORT_REASONS) · SUPPORT_COPY.contactEmail(barbelicCopy.ts) · 가시성 계약은 740000 머리말 · docs/contracts/feed-props.md "2026-08-25 신고·차단" 절 · profile-screen-props.md "안전·약관·문의" 절
  • 도구: 없음(레포 안 완결)
  • 게이트: pgTAP ugc_safety_v1.test.sql(47) · barbelic_legal_v4.test.sql(v4 세계) · tests/react/moderationStore.test.mjs(13) · E2E CASE-018(local-auth-admin-full-stack)
  • 버그리포트: 없음(신기능 트랙)
  • 계약: feed-props·profile-screen-props·desktop-screens-props 각 1절, docs/data/rpc-catalog.md 8행

Phase 현황

Phase내용상태
Phase 0결정 확정(D1~D8 권장안)·계획 게시✅ 이슈 코멘트
Phase 1DB 기반 일괄(테이블 5·게이트·우회 재발행·RPC 9·금칙어)✅ PR #725 (20260821740000 Production)
Phase 2클라이언트 배선(타입 3맵·4단·moderationStore)✅ PR #735
Phase 3앱 UI(⋯ 메뉴·신고 시트·차단 확인·안전 카드·정지 게이트)✅ PR #737
Phase 4약관 v4 쌍 + 서버 요구 전환✅ PR #738 (20260821760000 Production)
Phase 5관리자 신고 큐 탭✅ PR #740
Phase 6CASE-018·rpc-catalog·이 기록✅ PR #741

1. 배경

앱스토어 심사 점검(이슈 #665, 08-24)이 반려 확실 2건을 찾았고 그중 하나가 이것이다: 피드가 좋아요·댓글·팔로우·핸들 검색·프로필 사진을 갖춘 명백한 UGC/소셜 앱인데 신고·차단이 코드·DB 전부 그린필드(content_reports/user_blocks/moderation grep 0건)였다. App Store Guideline 1.2가 요구하는 5가지 — ① 게시 전 필터링 ② 신고 ③ 차단 ④ 공개 연락처 ⑤ 무관용 약관 — 의 충족이 0/5. 설계 세션(08-24)이 전수 조사로 이슈 #670에 설계를 남겼고, 오너가 08-25 "승인 대기 없이 완주, 본질적으로 더 좋은 구조"로 착수를 지시했다.

2. 문제 제기

신고·차단·조치 수단이 전무해 심사 요건 0/5였다

content_reports·user_blocks·block_user·report_user·moderation·abuse_report.sql·.ts·.mjs·.md 전체에서 일치 0건(08-24 실측). 약관 v3 제7·8·10조는 권리침해만 다뤘다.

소셜 가시성이 게이트 하나와 우회 5개로 갈라져 있었다

profile_feed_can_view_v1이 소셜 읽기의 단일 관문이지만, 피드 엔진·친구 검색·팔로잉 목록·팔로우·댓글 목록 5개는 user_follows/session_comments를 직접 읽는다 — 차단을 게이트에만 넣으면 이 5개가 구멍이 된다.

3. 해결 방안

원칙 (오너 결정, 2026-08-25)

오너 지시("권장안대로 완주")에 따라 이슈 #670 5절의 권장안을 D1~D8 전부 채택:

  • D1 차단 = 양방향 불가시 + 양방향 팔로우 자동 해제(해제해도 미복구)
  • D2 신고 대상 = 세션 포스트/댓글/사용자 3종
  • D3 신고 즉시 신고자 화면에서 숨김(content_reports 재사용)
  • D4 내리기 = content_takedowns로 가리기(원본 불변·복구 가능)
  • D5 축출 = user_suspensions + 부팅 게이트
  • D6 게시 전 필터링 = 서버 금칙어 사전 + 댓글·핸들 검사
  • D7 차단 목록 = ProfileScreen 시트(상태는 스토어 소유)
  • D8 연락처 = 이메일 텍스트+복사, 주소는 약관에 이미 공개된 daddywhale91@gmail.com

접근

대안판정
DB를 Phase 1/5/6으로 3분할(이슈 본문 원안) — 우회 RPC를 최대 3회 재발행기각
콘텐츠 안전 DB 전체(테이블 5·게이트·우회 재발행·공개/관리자 RPC·금칙어)를 마이그레이션 1본에 응집 — 각 RPC를 완전한 필터로 정확히 1회 재발행채택
content_reports.target_key를 생성 컬럼으로(이슈 원안)기각 — 댓글 삭제(on delete set null)가 재계산돼 유니크 충돌로 댓글 삭제가 깨질 수 있음 → RPC 삽입 시 계산으로 변경
내리기 = 전 표면 삭제기각 — 소셜 표면 제거로 한정: 피드·댓글·반응·비소유자 상세에서 사라지되 소유자의 사적 훈련 기록(일지·본인 상세·통계)은 원본 그대로
반응 뮤테이션 4종 개별 재발행기각 — 전부 session_social_target_owner_v1을 경유하므로 헬퍼 1곳에 행위자 정지·내려짐 검사를 넣어 일괄 커버

4. 적용한 내용

Phase 1 — DB 기반 일괄 (#725, 20260821740000)

테이블 5종(user_blocks·content_reports·content_takedowns·user_suspensions· moderation_banned_terms 18행 시드) + RLS(쓰기는 definer RPC 전용) + 공개 RPC 4종 + get_my_moderation_state_v1(부팅 게이트, fail-open) + 관리자 RPC 3종. profile_feed_can_view_v1에 차단 양방향·대상 정지 조건(본인 단락 유지 — 본인 콘텐츠 항상 가시), 우회 5 RPC + 반응 헬퍼 2종 + 댓글 작성·핸들 저장(금칙어 22023) + 세션 상세(비소유자 내려짐 P0002)를 활성 정의 전문으로 재발행. pgTAP 47단언.

Phase 2 — 클라이언트 배선 (#735)

타입 3맵 등재, repository→socialDomain→barbelicApi 4단, moderationStore(프레임워크 무의존)/moderationController, 임퍼서네이션 가드 BLOCKED_RPC_EXACT 3건, feedSocialStore 오류 문구 분기(금칙어/정지). 행동 계약 13건.

Phase 3 — 앱 UI (#737)

FeedModeration 4컴포넌트(⋯ 메뉴/사유 시트/차단 확인/정지 게이트), FdPost ⋯ 버튼(타인 포스트만)·댓글 신고, ProfileScreen 안전 카드 3행+차단 목록 시트(무상태 유지), mobileApp 로컬 숨김 병합(피드·홈·댓글), 마커 hook 6·action 11.

Phase 4 — 약관 v4 (#738, 20260821760000)

terms-v4 제8조 「커뮤니티 콘텐츠와 무관용 원칙」(금지 행위·필터링·신고/차단·24h 조치·정지) 신설 + privacy-v4 커뮤니티·신고 수집 항목 — 불변성 게이트의 쌍 강제대로 동시 발행. legalDocuments v4 등재+CURRENT 전환(동의 기록은 서버 요구 버전을 따르므로 동일 배포 안전), Vercel 새 번들 확인 서버 요구 버전 전환 적용(구 번들 throw 함정 회피).

Phase 5 — 관리자 신고 큐 (#740)

ADMIN_VIEW_TABS 1행으로 라우팅·사이드바·테스트 단언 파생, UiDesktopAdminReportsBody (상태 필터+기각/내리기/정지/해제, 조치 메모 prompt 취소=중단), 관리자 RPC 3종 4단 배선.

Phase 6 — 검증 (#741)

CASE-018 신고·차단 전체 왕복 E2E(모바일 실UI + 관리자 RPC + service 리드백 + 리로드 후 서버 필터 증명) — 첫 CI 주행 [17/17] 그린(run 32760158641), case.json에 passed 회부. rpc-catalog 8행, 이 기록. 머지 후 Production 쓰기 스모크(아래 5절).

주요 결정과 그 근거

  • 게이트 응집: 차단·정지를 profile_feed_can_view_v1 한 곳에 넣어 게이트 경유 표면 (상세·main 카드·세트 지표·좋아요·댓글·친구 리포트·달력·계획 반영)이 자동 상속 — 앞으로의 소셜 RPC도 게이트만 쓰면 차단을 공짜로 얻는다.
  • comment_count 뷰어 정합: 카드 카운트와 댓글 시트 목록이 같은 필터를 타도록 session_social_counts_v1을 뷰어 가시 기준으로 재발행(like_count는 전역 유지 — 목록 표면이 없어 불일치가 보이지 않음).
  • 즉시 숨김 이중 구조: D3는 스토어 로컬 숨김(재조회 전) + 서버 필터(재조회 후)의 이중 구조 — CASE-018이 리로드로 서버 쪽을 별도 증명.

작업 중 드러난 것

  • 번호 선점 2회(트랙 통산 6번째): 730000을 #661(target_muscles)이, 750000을 #734(planned_sets)가 CI 도는 사이 선점·Production 적용 → 740000·760000 재번호. 커밋 직전 실측만으로 부족하고 머지 직전 재실측이 필요하다.
  • CASE-017도 #663이 선점 → E2E는 CASE-018로 배정.
  • 피드 엔진 활성 정의는 530000이 아니라 610000(08-24 감사 이후 웜업 폴백 재발행) — 재발행 전 "이후 마이그레이션에서 재발행됐는지" grep이 필수였다.
  • 엔진은 authenticated 실행 불가: pgTAP에서 get_profile_feed_v2_engine 직접 호출이 42501 — 표면(get_profile_feed)으로 단언해야 한다(내부 표면 위생).
  • 동의 v4 전환이 v3 동의를 제출하던 pgTAP 3본을 파손(apply_onboarding_consents는 현재 요구 버전만 수용) → 같은 PR에서 v4로 갱신. 다음 버전 전환 때도 같은 그물에 걸린다.
  • 차단·계정 삭제 트랙(#663)이 ProfileScreen·mobileApp을 동시에 만져 리베이스 충돌 2곳 수동 해소(안전 카드 → 로그아웃 → 계정 삭제 순서).
  • GitHub Actions 전면 즉시 실패(08-24 18:14 UTC~): 전 잡이 스텝 0개로 3~4초 만에 실패, GitHub 상태 정상·재시도 6회 동일 — private 레포 사용량 한도 소진 유력(오너 빌링 확인 필요). #741은 동일 트리가 11분 전 전 레인 그린(CASE-018 포함)임을 근거로, 브랜치 보호 부재 확인 후 빨간 체크 상태로 머지했다.

5. 적용 결과

항목결과
App Store 1.2 요건 충족0/5 → 5/5 (①금칙어 서버 검사 ②신고 3경로 ③차단 1탭 ④문의 행+정지 화면 병기 ⑤약관 v4 무관용)
차단 수단없음 → 포스트 ⋯ 1탭, 게이트 경유 8+ 표면 + 우회 5 RPC 동시 불가시
신고 경로0개 → 3개(포스트·댓글·사용자), 접수 즉시 신고자 화면 숨김
조치 수단없음 → 관리자 신고 큐(대기/조치/기각 필터·내리기·정지·해제)
약관 무관용 조항0 → v4 제8조(24h 조치 명문화), Production 요구 버전 v4 전환
마이그레이션20260821740000·20260821760000 Production 적용, 장부·객체 9종·게이트 반영 실측
pgTAP+47(ugc_safety) · barbelic_legal v4 세계 · 클라 행동 계약 +13
E2ECASE-018 전체 왕복 — 첫 CI 주행 [17/17] 그린, passed 회부(run 32760158641)
Production 쓰기 스모크롤백 dry-run(DO 블록 raise)으로 차단 왕복 실측 — 게이트 t→f·팔로우 양방향 0·해제 t, 롤백 후 잔존 0·팔로우 원상
미검증오너 실기기 확인(신고·차단 UI 체감, 약관 v4 표시) — 확인 대기. 피드 엔진 4필터 추가 후 로딩 시간 실측 미실시(실측 페이지 수십 KB대 유지 예상)

6. 이번 개선으로 향상된 것

소셜 가시성이 "게이트 하나"로 응집됐다

차단·정지가 profile_feed_can_view_v1에 살므로 신규 소셜 RPC는 게이트만 쓰면 자동 상속. 우회 5개도 이번에 완전한 필터로 1회 재발행돼, 다음 차수는 "게이트를 쓰는가"만 물으면 된다.

조치가 되돌릴 수 있는 구조가 됐다

내리기는 content_takedowns(원본 불변·감사 가능), 정지는 lifted_at 스탬프 해제 — 오판정 복구가 데이터 복원 없이 가능하다.

구조적으로 남는 것

  • 신고 상태 기계(pending → actioned/dismissed) + resolved_by/resolution_note 감사 기록
  • 금칙어 사전은 운영에서 행 추가로 확장(클라 미노출), 분리 문자 우회 수집
  • 뮤테이션 RPC 신설 시 BLOCKED_RPC_EXACT 등재 관행(접두사 미포착분)
  • CASE-018 = 신고·차단·조치 왕복의 회귀 방어선

남은 것

  • 오너 실기기 확인(신고·차단·차단 목록·약관 v4·문의 복사) — 확인 대기
  • GitHub Actions 사용량/빌링 확인(08-24 18:14 UTC부터 전 잡 즉시 실패) — 오너만 가능
  • 임퍼서네이션 가드의 기존 미포착 뮤테이션(like/comment/follow/handle/adopt 계열) — 이번 범위 밖 관찰, 별도 건
  • 앱스토어 심사 트랙(#665)의 나머지: 데모 계정·App Privacy 라벨 등 — 상위 트랙에서 계속