OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 87 / 90
stack-analysis
CHAPTER 87 / 90
읽기 약 2
FUNCTION

신고/차단/모더레이션


핵심 개념

report·block·shadow ban·자동 필터·human review — 안전한 커뮤니티.

본문

데이터 모델

SQL📋 코드 (38줄)
CREATE TABLE reports (
  id UUID PRIMARY KEY,
  reporter_id UUID NOT NULL,
  target_type VARCHAR NOT NULL,  -- 'post', 'comment', 'user', 'message'
  target_id UUID NOT NULL,
  reason VARCHAR NOT NULL,        -- 'spam', 'harassment', 'illegal', 'nudity', ...
  description TEXT,
  status VARCHAR DEFAULT 'pending',  -- pending, reviewed, action_taken, dismissed
  reviewed_by UUID,
  reviewed_at TIMESTAMP,
  action TEXT,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX ON reports(target_type, target_id);
CREATE INDEX ON reports(status, created_at);


CREATE TABLE blocks (
  blocker_id UUID NOT NULL,
  blocked_id UUID NOT NULL,
  created_at TIMESTAMP DEFAULT NOW(),
  PRIMARY KEY (blocker_id, blocked_id)
);

CREATE INDEX ON blocks(blocked_id);


-- 사용자 제재
CREATE TABLE user_sanctions (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL,
  type VARCHAR NOT NULL,    -- 'warning', 'mute', 'shadowban', 'suspend', 'ban'
  reason VARCHAR,
  expires_at TIMESTAMP,
  applied_by UUID NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

신고 처리

TYPESCRIPT📋 코드 (52줄)
async function reportContent(reporterId: string, input: ReportInput) {
  // 본인 신고 차단
  if (input.targetType === 'user' && input.targetId === reporterId) {
    throw new BadRequestError('Cannot report yourself');
  }

  // 중복 신고 검증
  const existing = await db.report.findFirst({
    where: {
      reporterId,
      targetType: input.targetType,
      targetId: input.targetId,
      createdAt: { gt: new Date(Date.now() - 86400 * 1000) },
    },
  });
  if (existing) return existing;

  const report = await db.report.create({
    data: { reporterId, ...input },
  });

  // 자동 처리 — 같은 콘텐츠에 N건 이상 신고 시 자동 숨김
  const reportCount = await db.report.count({
    where: {
      targetType: input.targetType,
      targetId: input.targetId,
      status: 'pending',
    },
  });

  if (reportCount >= 5) {
    await autoHideContent(input.targetType, input.targetId);
    await alertModerators(input);
  }

  return report;
}


async function autoHideContent(type: string, id: string) {
  if (type === 'post') {
    await db.post.update({
      where: { id },
      data: { status: 'hidden' },
    });
  } else if (type === 'comment') {
    await db.comment.update({
      where: { id },
      data: { status: 'hidden' },
    });
  }
}

차단 (사용자 간)

TYPESCRIPT📋 코드 (36줄)
async function blockUser(blockerId: string, blockedId: string) {
  if (blockerId === blockedId) throw new BadRequestError('Cannot block yourself');

  return db.$transaction(async (tx) => {
    await tx.block.upsert({
      where: { blockerId_blockedId: { blockerId, blockedId } },
      create: { blockerId, blockedId },
      update: {},
    });

    // 양쪽 팔로우 해제
    await tx.follow.deleteMany({
      where: {
        OR: [
          { followerId: blockerId, followedId: blockedId },
          { followerId: blockedId, followedId: blockerId },
        ],
      },
    });
  });
}


// 차단된 사용자 콘텐츠 필터
async function getFeed(userId: string) {
  const blockedIds = await redis.smembers(`blocked_by:${userId}`);
  const blockedByIds = await redis.smembers(`blocked:${userId}`);
  const excludeIds = [...new Set([...blockedIds, ...blockedByIds])];

  return db.post.findMany({
    where: {
      userId: { notIn: excludeIds },
      // ...
    },
  });
}

Shadow Ban (몰래 차단)

TYPESCRIPT📋 코드 (25줄)
// 사용자는 자신이 ban 된지 모름 + 다른 사람에겐 콘텐츠 안 보임
async function shadowBan(userId: string, reason: string, byAdminId: string) {
  await db.userSanction.create({
    data: { userId, type: 'shadowban', reason, appliedBy: byAdminId },
  });
}


// 피드·검색 등 모든 곳에서 필터
async function getFeed(viewerId: string) {
  const shadowBanned = await db.userSanction.findMany({
    where: { type: 'shadowban', expiresAt: { gt: new Date() } },
    select: { userId: true },
  });
  const banIds = shadowBanned.map(s => s.userId);

  return db.post.findMany({
    where: {
      OR: [
        { userId: { notIn: banIds } },
        { userId: viewerId },  // 본인 게시물은 본인에겐 보임
      ],
    },
  });
}

자동 필터 (룰 + AI)

TYPESCRIPT📋 코드 (48줄)
// 1. 룰 기반
const FORBIDDEN_PATTERNS = [
  /\b(?:fuck|shit|bitch)\b/i,  // 영어 욕설
  /씨발|좆까|개새끼/,             // 한국어 욕설
  /\d{3}-?\d{4}-?\d{4}/,       // 전화번호 (개인정보)
  /https?:\/\/(?:bit\.ly|tinyurl)/,  // 단축 URL (스팸)
];

function isForbidden(content: string): boolean {
  return FORBIDDEN_PATTERNS.some(p => p.test(content));
}


// 2. AI 모더레이션
async function moderateWithAI(content: string) {
  const res = await fetch('https://api.openai.com/v1/moderations', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${process.env.OPENAI_KEY}` },
    body: JSON.stringify({ input: content }),
  });
  const data = await res.json();

  if (data.results[0].flagged) {
    return {
      blocked: true,
      reasons: Object.entries(data.results[0].categories)
        .filter(([, v]) => v)
        .map(([k]) => k),
    };
  }

  return { blocked: false };
}


// 3. 통합 검증
async function validateContent(content: string) {
  if (isForbidden(content)) {
    return { ok: false, reason: 'profanity' };
  }

  const ai = await moderateWithAI(content);
  if (ai.blocked) {
    return { ok: false, reason: 'ai_flagged', categories: ai.reasons };
  }

  return { ok: true };
}

모더레이터 큐

TSX📋 코드 (29줄)
// 관리자 페이지 — 신고 처리
function ModerationQueue() {
  const { data: reports } = useQuery({
    queryKey: ['mod', 'queue'],
    queryFn: () => fetch('/api/admin/reports?status=pending').then(r => r.json()),
  });

  return (
    <div>
      <h1>모더레이션 큐 ({reports?.length})</h1>
      <ul>
        {reports?.map(r => (
          <li key={r.id} className="border p-4">
            <h3>{r.reason}</h3>
            <p>{r.description}</p>
            <ContentPreview type={r.targetType} id={r.targetId} />

            <div className="flex gap-2 mt-4">
              <button onClick={() => takeAction(r.id, 'remove')}>삭제</button>
              <button onClick={() => takeAction(r.id, 'warn_user')}>경고</button>
              <button onClick={() => takeAction(r.id, 'shadowban')}>섀도우 밴</button>
              <button onClick={() => takeAction(r.id, 'dismiss')}>기각</button>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

다음 챕터

CH.88 "알림 시스템: 실시간 + 배치".


AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude

무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6

내 프로젝트의 모더레이션 부분을 분석해서
실전 분석 + 개선 우선순위를 알려줘.
ChatGPT

무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro

모더레이션 관련 실제 서비스 5개를
비교 분석해서 패턴 추출를 알려줘.
Gemini

무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro

내 코드베이스에서 모더레이션
최적화 가능 위치를 보고해줘.
Grok

무료: Grok 4.1 / SuperGrok $30/mo

2026년 한국 풀스택 시장의
모더레이션 트렌드를 솔직히 알려줘.

⭐ 이것만 기억하세요
신고/차단/모더레이션 이 3가지만 확실히 잡으세요
1.N건 이상 신고 시 자동 숨김 + 모더레이터 알림
2.Shadow ban = 사용자 모르게 콘텐츠 차단 — 트롤 효과적
3.룰 기반 + AI 모더레이션 = 자동 필터 + 인간 검토 큐


공유하기
진행도 87 / 90