stack-analysis
CHAPTER 87 / 90
읽기 약 2분
FUNCTION
신고/차단/모더레이션
핵심 개념
report·block·shadow ban·자동 필터·human review — 안전한 커뮤니티.
본문
데이터 모델
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()
);신고 처리
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' },
});
}
}차단 (사용자 간)
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 (몰래 차단)
// 사용자는 자신이 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)
// 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 };
}모더레이터 큐
// 관리자 페이지 — 신고 처리
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