Skip to content

그룹 레인 UGC 안전 편입 — 초대 차단·정지 게이트·금칙어·그룹 콘텐츠 신고/내리기 (2026-09-02)

  • 기간: 2026-09-02 (세션 1개, f80aea00-c2bb-418f-b504-3479f5a28a35). 출처: 출시 전 감사(2026-08-30, 이슈 #922) → 앱스토어 심사 재평가(#665 Phase 7) → 오너 결정 D5 "1. 넣는다"(그룹 탭 첫 제출 포함)
  • 랜딩: PR #1137(Phase 1~3, 95635ed7). 마이그레이션 1건 20260902235900(group_ugc_safety_v1) — Production 적용은 릴리스 레인(main → production PR, 앱 배포와 한 몸). Vercel 배포 동반(앱 화면·관리자 큐)
  • 설계서: 이슈 #922 본문(감사 확정 분석·Phase 초안) + 댓글(착수 보고·유저 예시 전→후·"예상 효과·개선사항" 절)
  • 정본: docs/contracts/group-props.md §3·§4 신고·차단 절 · 서버 group_require_member_v1(그룹 RPC 단일 관문)·group_content_visible_v1·group_content_hidden_v1·report_content_v1 · 클라 moderationStore.ts(대상 종류 6종)
  • 도구: 없음(schema.sql 재생성은 migrations:renumber 규칙과 같은 로컬 스크립트로 1회)
  • 게이트: pgTAP group_ugc_safety_v1.test.sql(32단언, migration-smoke) · 마이그레이션 postcheck(CHECK 정의·함수 본문 토큰·권한) · moderationStore.test.mjs 그룹 케이스 · designContract 마커 2종
  • 버그리포트: bug-report/bug-060-20260830.md
  • 계약: group-props.md §3(라운지 신고·차단·notices[].authorId/mine)·§4(onReportComment) · report_content_v1 p_target_type 6종(서버 CHECK와 1:1) · 라운지 공지 mine 추가 키

Phase 현황

Phase내용상태
Phase 1초대·차단 — 차단 시 대기 초대 양방향 삭제, 초대 루프 차단 쌍·정지 계정 스킵, 초대 목록 필터, 숨은 초대 수락 = 조용히 삭제✅ PR #1137
Phase 2정지·금칙어 — 그룹 RPC 관문 정지 42501, 정지 작성자 글 숨김, 그룹명·공지 금칙어 22023✅ PR #1137
Phase 3신고·내리기 — 그룹 콘텐츠 3종 신고/내리기 편입, 멤버만 접수, 라운지·보드 읽기 숨김, 그룹 화면 신고·차단 진입점, 관리자 큐 미리보기✅ PR #1137
Phase 4탈퇴·강퇴 RPC·UI⬜ 범위 밖(이슈 본문대로 별건)

1. 배경

UGC 안전 v1(2026-08-25)이 피드 레인(세션·댓글·사용자)에 App Store 1.2 요건 5종을 깔았다. 그 다음 날 그룹 v1(2026-08-26)이 라운지 채팅·화이트보드 댓글·공지·초대라는 새 UGC 표면을 열었지만, 안전 배터리는 "차단 필터를 요구할 함수 이름"을 열거하는 방식이라 그룹 함수 11종은 자동으로 편입되지 못했다. 출시 전 전면 감사(08-30)가 이 공백을 확정했고(적대적 검증이 반박에 실패), 앱스토어 심사 재평가(09-02)에서 "남은 반려 위험 1순위"로 재확인됐다. 오너가 첫 제출에 그룹 탭을 넣기로 결정(D5)해 Phase 1~3을 제출 전 한 묶음으로 처리했다.

2. 문제 제기

라운지 채팅을 신고할 수 없었다

content_reports.target_type CHECK와 report_content_v1 분기가 세션·댓글·사용자만 알았다. 유저 A가 라운지에서 금칙어에 안 걸리는 표현으로 B를 괴롭히면 B는 그 메시지를 신고할 방법이 없었다. 그룹 화면에는 신고·차단 버튼이 0개였다.

차단해도 초대가 남았다

block_user_v1은 팔로우만 지웠다. B가 A를 차단해도 A가 보낸 그룹 초대가 B의 그룹 탭에 A의 이름·아바타·그룹명(20자 임의 텍스트)과 함께 남았고, invite_to_group_v1은 차단 쌍을 보지 않아 재초대가 무한 반복 가능했다.

정지가 그룹에 닿지 않았다

group_require_member_v1은 멤버십만 확인했고 group_content_visible_v1은 차단만 봤다. 정지된 계정이 이전에 쓴 그룹 글은 정지 뒤에도 전 멤버에게 보였고, 정지 계정 자신도 그룹 RPC를 계속 쓸 수 있었다(클라이언트 정지 게이트는 fail-open·부팅 1회 조회).

그룹명·공지에 금칙어 검사가 없었다

#975가 채팅·보드 댓글에만 contains_banned_term_v1을 넣었다. 그룹명은 초대받은 사람 화면에 그대로 뜨는 텍스트 채널이라 차단·금칙어 두 방어를 동시에 우회했다.

3. 해결 방안

원칙 (오너 결정 D5, 2026-09-02)

  • "1. 넣는다" — 그룹 탭을 첫 제출에 포함 → #922 Phase 1~3을 제출 전에 선행(채택). 첫 제출에서 그룹 탭을 숨기는 안은 기각.
  • 피드와 다른 안전 기준을 두지 않는다(#975 D5 계승): 판정기·문구·SQLSTATE를 피드와 같게.

접근

대안판단
그룹 RPC를 기존 배터리에 편입 — 관문 1곳(group_require_member_v1)에 정지, 가시성 1곳에 정지 작성자, 신고 대상은 열(target_group_content_id) 하나로 확장, 읽기 RPC는 판정기 group_content_hidden_v1 호출채택 — 마이그레이션 1건, 관리자 큐·클라이언트 시트 재사용
그룹 전용 신고 테이블 신설기각 — 관리자 큐가 둘로 갈라진다
쓰기 RPC마다 정지 검사 삽입기각 — 재발행 6종에 읽기는 남는다. 관문 1곳이 전 RPC를 덮는다
Phase 4 탈퇴·강퇴 동시 처리보류 — 심사 직결 아님

4. 적용한 내용

Phase 1~3 — 마이그레이션 20260902235900_group_ugc_safety_v1 + 클라이언트 (#1137)

  • 서버: content_reports.target_group_content_id 신설(FK 없음 — 세 테이블에 걸친 id), content_reports CHECK 6종·content_takedowns CHECK 5종 · 재발행 13함수 + 신규 group_content_hidden_v1(내부, authenticated 실행 불가) · 라운지 공지 mine 추가 키 · 파일 끝 postcheck.
  • 클라: GroupScreen.tsx 타인 채팅·댓글·공지 행 "신고"(group.chatReport·group.noticeReport) → mobileApp.tsx가 피드와 같은 ⋯ 메뉴(신고하기·차단하기)·사유 시트·차단 확인을 그룹 판에 렌더(대상 종류로 판 분리, blockConfirm.sourceKind) · 신고·차단 직후 로컬 즉시 숨김(채팅·댓글·공지·초대) · groupStore.ts 공지 authorId·mine · 타입·레포지토리 검증·DesktopAdminReports.tsx 라벨 3종.
  • 테스트: pgTAP 32단언(초대 차단 3·목록 필터·수락 소비·금칙어 2·정지 읽기/쓰기/생성 42501·정지 작성자 숨김·신고 접수 3·D3 3·본인 22023·비멤버 P0002·관리자 미리보기·내리기 D4 2·CHECK·권한) · moderationStore 그룹 케이스.

주요 결정과 그 근거

  • 정지 게이트를 관문에 — 그룹 RPC 전체(읽기 포함)가 group_require_member_v1을 지나므로 1곳 수정이 11종을 덮는다. 정지 계정의 읽기까지 막히지만 부팅 정지 게이트가 앱을 덮으므로 실사용 차이는 없다.
  • 비멤버 신고 = P0002 'Content not found' — 그룹 콘텐츠는 멤버만 볼 수 있으므로 존재 여부를 드러내지 않는다(피드의 "차단 관계면 접수"와 다른 점: 그룹은 차단해도 멤버십이 남아 신고 가능).
  • 숨은 초대 수락 = 조용히 삭제(accepted false) — 차단 사실을 드러내지 않으면서 재조회 없이도 행이 정리된다.
  • target_group_content_id에 FK 없음 — 삭제된 원본은 관리자 큐에서 미리보기 null·"내려짐"으로 표시(댓글 삭제와 같은 취급). 내리기는 삭제된 id에도 기록된다(무해).

작업 중 드러난 것

  • tests/audit/pending-changes.json명부(manifest)에 등재된 테스트만 신고한다 — 미등재 파일(moderationStore.test.mjs)을 신고하면 check:test-manifest가 실패한다.
  • data-lg-action 마커는 designContract.ts 목록에 등록해야 designContract.test.mjs가 통과한다.
  • JSX 태그 속성 사이의 {/* */} 주석은 TS1005 — 주석은 속성 밖에.
  • migrations:renumber는 브랜치 번호가 꼬리보다 크면 유지한다("No renumber needed") — 임시 번호 235900이 그대로 확정됐다. 다음 랜딩은 이 번호 뒤로 붙는다.
  • schema.sql 로컬 재생성 규칙 = 마이그레이션 정렬·CRLF→LF·trim 후 "\n\n" join + 끝 "\n"(renumber 도구와 동일).

5. 적용 결과

항목결과
그룹 콘텐츠 신고 수단0 → 3종(채팅·보드 댓글·공지), 내리기 대상 2종 → 5종 (pgTAP 18~30)
차단 후 남는 대기 초대1 → 0 (pgTAP 3·8·9), 차단 쌍 새 초대 invited_count 1 → 0 (pgTAP 4·5)
정지 계정 그룹 RPC200 → 42501 'Account suspended', 정지 작성자 글 노출 있음 → 없음 (pgTAP 12·15~17)
그룹명·공지 금칙어게시 → 22023 거부 (pgTAP 6·7)
로컬 게이트npm run check 체인 + check:unused 통과, 단위 2,357건 실패 0
CIverify + migration-smoke(pgTAP) 통과 → 랜딩 잠금 하 머지
Production릴리스 레인 대기(main → production PR) — 미적용
미검증(정보)실기기 화면 배치(신고 버튼·시트 겹침) — 자동 증거 밖

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

그룹 탭이 App Store 1.2 요건을 피드와 같은 수준으로 충족한다

심사관이 라운지 채팅을 열면 타인 메시지에 신고·차단 진입점이 있고, 신고한 글은 즉시 사라지며, 관리자 큐에서 24시간 내 처리할 수 있다.

차단이 그룹까지 관통한다

차단하는 순간 초대·글·이름이 그룹 탭에서 사라지고, 상대는 차단 사실을 모른 채 초대가 조용히 무시된다.

구조적으로 남는 것

  • 그룹 RPC 단일 관문에 정지 검사 — 새 그룹 RPC는 관문만 지나면 자동 편입.
  • 그룹 콘텐츠 숨김 판정기 하나(피드 D3/D4와 같은 규칙) — 새 그룹 읽기 RPC는 판정기 호출 한 줄.
  • 신고 대상 종류가 열린 구조(target_group_content_id) — 다음 UGC 표면은 CHECK 한 줄과 분기 하나.
  • postcheck가 관문·가시성·초대·읽기 RPC의 조건을 본문 토큰으로 잠근다 — 다른 트랙의 재발행이 조건을 떨어뜨리면 적용 단계에서 멈춘다.

남은 것

  • #922 Phase 4 탈퇴·강퇴 RPC·UI(그룹 v1 기록의 "탈퇴·해산 UI 부재"와 같은 건).
  • 화이트보드 제목·종목 메모의 금칙어 검사(이번 범위 밖).
  • Production 적용 = 다음 릴리스 PR(main → production)에 포함 → 이슈 #922·#665 Phase 7은 [스테이징] → 릴리스 후 [반영완료].