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) · E2ECASE-018(local-auth-admin-full-stack) - 버그리포트: 없음(신기능 트랙)
- 계약: feed-props·profile-screen-props·desktop-screens-props 각 1절,
docs/data/rpc-catalog.md8행
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 결정 확정(D1~D8 권장안)·계획 게시 | ✅ 이슈 코멘트 |
| Phase 1 | DB 기반 일괄(테이블 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 6 | CASE-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 |
| E2E | CASE-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 라벨 등 — 상위 트랙에서 계속