Claude API모더레이션SupabaseNext.jsLLM

신고를 AI가 먼저 심사하는 커뮤니티 모더레이션 파이프라인

2026년 7월 13일4 분 읽기

익명 커뮤니티를 운영하면 피할 수 없는 일이 있습니다. 신고 처리. 유저가 신고 버튼을 누르면 그 내용은 어딘가에 쌓이고, 결국 운영자(=나)가 하나씩 보고 "삭제할까, 무시할까"를 결정해야 합니다.

제 사이드 프로젝트에는 이미 댓글 작성 시점의 AI 필터가 있었습니다. 그런데 신고는 달랐어요. 신고 테이블에 쌓이기만 하고, 제가 관리자 페이지에 로그인해서 처리할 때까지 문제 댓글이 그대로 노출됩니다. 잠든 사이에 악성 댓글이 신고 10건을 받아도 아침까지 방치되는 구조죠. 1인 운영 서비스에서 이건 치명적입니다.

그래서 신고가 들어오는 순간 AI가 먼저 심사하는 파이프라인을 만들었습니다.

설계 — 3갈래 분류와 "겁쟁이 AI" 원칙

핵심 아이디어는 신고를 AI가 삼심제의 1심처럼 처리하는 겁니다:

신고 접수 → AI 재심사 (신고 사유 + 댓글 원문 + 방 맥락)
  ├─ VIOLATION (명백한 위반) → 댓글 자동 숨김 + 관련 신고 일괄 종결
  ├─ CLEAN (명백한 정상)     → 신고 자동 기각 (허위신고 방어)
  └─ UNCERTAIN (애매)        → 관리자 큐에 "검토 필요"로 대기

설계에서 가장 중요했던 결정은 AI를 겁쟁이로 만드는 것이었습니다. VIOLATION은 자동 삭제로, CLEAN은 신고 무시로 이어지는 실행 권한이 있는 판정입니다. 오판의 비용이 크죠. 그래서 시스템 프롬프트에 이렇게 못박았습니다:

"확신이 서지 않으면 반드시 UNCERTAIN을 반환해라. VIOLATION은 자동 삭제, CLEAN은 신고 자동 기각으로 이어지므로 애매하면 사람이 봐야 한다."

또 하나의 원칙: 방 폐쇄는 자동 실행하지 않는다. 댓글 숨김은 복구 가능한 soft delete지만, 방 폐쇄는 그 안의 대화 전체에 영향을 주는 파괴적 조치라 AI가 VIOLATION으로 판정해도 관리자 검토로만 보냅니다. 자동화의 범위는 "되돌릴 수 있는 것"까지만.

Step 1 — 작성 필터와 뭐가 다른가: 맥락

작성 시점 필터는 댓글 텍스트만 봅니다. 신고 시점 심사는 재료가 더 많습니다:

const review = await reviewReportedContent({
  content,                    // 신고된 댓글 원문
  reportReason,               // 신고자가 적은 사유
  topicTitle,                 // 어느 방(누구/무엇에 대한 이야기)인지
});

이 차이가 실제로 판정 품질을 바꿉니다. 뒤의 테스트 사례에서 보여드릴게요.

구현은 Claude API의 structured output(json_schema)으로 판정을 강제했습니다:

output_config: {
  format: {
    type: "json_schema",
    schema: {
      type: "object",
      properties: {
        verdict: { type: "string", enum: ["VIOLATION", "UNCERTAIN", "CLEAN"] },
        reason: { type: "string" },   // 관리자가 볼 한 문장 요약
      },
      required: ["verdict", "reason"],
    },
  },
},

reason을 필수로 받는 게 포인트입니다. 판정만 남기면 나중에 "AI가 왜 이걸 숨겼지?"를 알 수 없는데, 근거 한 문장이 감사 로그가 됩니다.

Step 2 — 히스토리: 삭제하지 말고 상태를 바꿔라

기존 구현은 신고를 처리하면 행을 삭제했습니다. 자동화를 붙이면서 이게 문제가 됐어요. AI가 알아서 처리하는데 기록이 사라지면, 운영자는 AI가 뭘 했는지 알 길이 없습니다.

그래서 삭제 대신 상태 머신으로 바꿨습니다:

create type report_status as enum (
  'PENDING',        -- 접수됨 (AI 심사 전 또는 심사 실패)
  'AUTO_HIDDEN',    -- AI가 위반 판정 → 대상 자동 숨김
  'NEEDS_REVIEW',   -- AI가 애매 판정 → 관리자 검토 필요
  'AUTO_DISMISSED', -- AI가 정상 판정 → 자동 기각
  'RESOLVED'        -- 관리자가 직접 처리 완료
);

alter table reports add column status report_status not null default 'PENDING';
alter table reports add column ai_verdict text;   -- 판정
alter table reports add column ai_reason text;    -- 판단 근거 (감사 로그)
alter table reports add column resolution text;   -- 최종 조치 설명
alter table reports add column resolved_at timestamptz;

관리자 페이지는 "검토 대기"(PENDING + NEEDS_REVIEW)와 "처리 이력"(나머지) 2단으로 나눴고, 각 신고에 AI 판정·근거·처리 결과가 표시됩니다. AI가 새벽에 뭘 숨기고 뭘 기각했는지 아침에 커피 마시며 훑어볼 수 있는 구조죠.

오탐 대비도 필수입니다. 자동 숨김은 soft delete라서 관리자 페이지에 "댓글 복구 (오탐)" 버튼을 뒀고, 복구하면 해당 이력이 "관리자가 복구 (자동 숨김 오탐)"으로 남습니다.

Step 3 — AI와 무관한 안전장치: 신고 누적 임계값

AI가 UNCERTAIN으로 판단했더라도, 여러 사람이 신고하는 데는 이유가 있을 가능성이 높습니다. 그래서 AI 판정과 독립적인 규칙을 하나 더 뒀습니다:

// 같은 댓글에 미처리 신고가 3건 이상 쌓이면 일단 숨긴다
if ((count ?? 0) >= AUTO_HIDE_REPORT_THRESHOLD) {
  await hideCommentAndResolve(admin, targetId, `신고 ${count}건 누적으로 자동 숨김`);
}

"일단 가리고, 억울하면 관리자가 복구"가 익명 커뮤니티에서는 그 반대보다 안전합니다. 노출된 악성 댓글의 피해는 즉각적이고, 잘못 가려진 정상 댓글의 피해는 복구 가능하니까요.

실패 모드도 명시적으로 설계했습니다. Claude API 호출이 실패하면? 신고는 PENDING으로 남아서 관리자 큐에 뜹니다. AI가 죽어도 신고가 증발하지 않고, 기존의 수동 프로세스로 자연스럽게 강등됩니다.

Step 4 — E2E 테스트: AI가 나보다 신중했던 순간

구현 후 실제 신고를 넣어 세 경로를 다 검증했는데, 여기서 흥미로운 일이 있었습니다.

테스트 1 — 정상 댓글 + 감정적 신고. "감자튀김은 역시 에어프라이어가 최고죠"라는 댓글을 "그냥 마음에 안 들어요"로 신고했습니다:

{
  "status": "AUTO_DISMISSED",
  "ai_verdict": "CLEAN",
  "ai_reason": "에어프라이어 관련 일상적인 의견으로 위반 사항 없으며 신고 사유도 단순 불만 수준임."
}

허위·감정 신고가 관리자 큐에 도달하기 전에 걸러집니다.

테스트 2 — 협박성 문장. "찾아가서 죽여버린다"류의 문장을 넣고 신고했더니, 저는 당연히 VIOLATION을 예상했는데:

{
  "status": "NEEDS_REVIEW",
  "ai_verdict": "UNCERTAIN",
  "ai_reason": "살해·주소추적 언급이 있으나 에어프라이어 주제 맥락상 과장된 농담일 가능성이 있어 사람 검토가 필요함."
}

방 맥락(에어프라이어)을 보고 "과장된 농담일 수 있다"며 사람에게 넘긴 겁니다. 처음엔 오판인가 싶었는데 생각할수록 이게 맞는 동작이었어요. 커뮤니티에서 "아 이거 진짜 죽여버리고 싶네 ㅋㅋ"류의 과장은 흔하고, 진짜 협박과의 구분은 맥락이 필요합니다. 확신이 없으면 UNCERTAIN — 프롬프트에 심어둔 원칙이 정확히 작동한 순간이었습니다.

같은 댓글에 "협박 맞아요"라는 구체적 사유의 신고가 더 쌓이자 그때는 VIOLATION으로 판정해 자동 숨김됐고, 걸려 있던 신고들도 일괄 종결됐습니다.

테스트 3에서 엣지 케이스도 발견했습니다. 이미 숨겨진 댓글에 새 신고가 들어오면 심사할 대상이 없어 PENDING으로 영원히 남더군요. "대상이 이미 숨김 처리됨"으로 즉시 종결하는 분기를 추가했습니다. E2E 테스트 없이는 못 찾았을 버그입니다.

트러블슈팅 요약

상황설계 결정
AI 오판으로 정상 댓글 삭제soft delete + 관리자 "복구 (오탐)" 버튼 + 이력 기록
AI가 애매한 걸 과감하게 판정프롬프트에 "확신 없으면 UNCERTAIN" + 자동 조치의 비가역성 명시
Claude API 장애PENDING 유지 → 수동 프로세스로 자연 강등
이미 숨겨진 대상에 신고즉시 종결 처리 (PENDING 영구 잔류 방지)
방 신고의 VIOLATION자동 폐쇄하지 않고 관리자 검토 (파괴적 조치는 사람이)

정리

  1. 신고 심사는 작성 필터보다 유리하다 — 신고 사유와 방 맥락까지 재료로 쓸 수 있다
  2. AI에게 실행 권한을 줄 때는 겁쟁이로 만들어라 — 애매하면 사람에게, 파괴적 조치는 자동화 제외
  3. 자동화 이력은 삭제가 아니라 상태 전환으로 — AI가 한 일을 사람이 감사할 수 있어야 한다
  4. AI와 독립적인 안전장치를 겹쳐라 — 신고 누적 임계값, API 장애 시 수동 강등
  5. 오탐 복구 경로를 먼저 만들어라 — 복구 버튼이 있어야 자동 숨김을 마음 편히 켤 수 있다

만들고 나니 1인 운영의 밤이 편해졌습니다. 명백한 악성은 신고 즉시 사라지고, 허위 신고는 알아서 기각되고, 저는 AI가 "애매해요"라고 표시한 것만 보면 됩니다. 그리고 무엇보다 — AI가 "방 맥락상 농담일 수 있다"며 판단을 유보했을 때, 자동화 시스템을 신뢰할 수 있겠다는 확신이 들었습니다. 잘 만든 자동화는 과감해서가 아니라 멈출 줄 알아서 믿을 수 있는 거더라고요.

PM

backtodev

40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.