그룹 레인 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_v1p_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_reportsCHECK 6종·content_takedownsCHECK 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) |
| 정지 계정 그룹 RPC | 200 → 42501 'Account suspended', 정지 작성자 글 노출 있음 → 없음 (pgTAP 12·15~17) |
| 그룹명·공지 금칙어 | 게시 → 22023 거부 (pgTAP 6·7) |
| 로컬 게이트 | npm run check 체인 + check:unused 통과, 단위 2,357건 실패 0 |
| CI | verify + 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은
[스테이징]→ 릴리스 후[반영완료].