커뮤니티 자동 모더레이션 파이프라인 — 규칙·모델·사람의 역할 분담

AI 기술

콘텐츠 모더레이션분류 모델임계값 튜닝휴먼 인 더 루프커뮤니티 운영

이 글은 누구를 위한 것인가

  • 사용자 게시물이 늘어나면서 수동 검토가 한계에 온 팀
  • 금칙어 필터를 쓰는데 오탐이 많아 사용자 불만이 쌓이는 경우
  • 모더레이션에 LLM을 넣으려는데 비용과 정확도를 가늠 중인 개발자

들어가며

커뮤니티가 커지면 모더레이션은 반드시 자동화 대상이 된다. 그런데 자동화의 첫 시도는 대체로 이렇게 실패한다.

  금칙어 목록을 만든다
    ↓
  "시발"을 막으니 "시발점", "시발역"이 걸린다
    ↓
  예외를 추가한다
    ↓
  사용자가 "ㅅㅂ", "시1발", "시발"로 우회한다
    ↓
  목록이 3,000개가 되고 오탐도 그만큼 늘어난다

문자열 매칭은 맥락을 모른다는 게 근본 한계이고, 예외를 아무리 추가해도 이 한계는 사라지지 않는다.

이 글은 규칙·모델·사람을 어떻게 나눠 배치하는지에 관한 것이다.


1. 3단계 구조 — 각 층이 다른 일을 한다

  [1층] 규칙 필터        빠르고 확실한 것만
         ↓ 통과
  [2층] 분류 모델        맥락 판단
         ↓ 애매한 것
  [3층] 사람 검토 큐     최종 판단 + 학습 데이터 생성

각 층의 역할을 혼동하면 전체가 망가진다.

[1층 — 규칙 필터]

  담당:  이론의 여지가 없는 것
         · 전화번호·계좌번호 패턴 (개인정보)
         · 알려진 스팸 URL 도메인
         · 동일 문구 대량 반복

  금지:  욕설 판정, 비방 판정
         → 맥락 없이는 판단 불가. 2층으로 넘긴다

1층에서 욕설을 판정하려는 시도가 실패의 출발점이다. 규칙 필터는 "형식이 정해진 것"에만 쓴다. 계좌번호는 정규식으로 잡히지만 비방은 아니다.

// 1층 — 형식 기반만
const HARD_RULES = [
  { code: 'PHONE',   re: /01[016-9][-.\s]?\d{3,4}[-.\s]?\d{4}/ },
  { code: 'ACCOUNT', re: /\b\d{2,6}[-]\d{2,6}[-]\d{2,7}\b/ },
  { code: 'REPEAT',  test: (s: string) => hasRepeatedBlock(s, 5) },
];

개인정보 패턴은 차단이 아니라 마스킹 제안이 낫다. 본인 연락처를 자발적으로 남기는 경우도 있으므로, 막기보다 "연락처가 포함돼 있습니다. 가릴까요?"로 되묻는 편이 사용자 경험이 좋다.


2. 2층 — 무엇으로 분류할 것인가

선택지가 셋이다.

자체 분류 모델상용 분류 APILLM 판정
지연수 ms수십~수백 ms수백 ms~수 초
비용학습·운영 비용호출당 과금토큰당 과금 (가장 비쌈)
도메인 적응높음낮음프롬프트로 조정 가능
초기 구축라벨 데이터 필요즉시즉시

시작은 상용 API나 LLM, 규모가 커지면 자체 모델이 일반적인 경로다. Perspective API 같은 서비스는 toxicity 계열 점수를 바로 주므로 초기 기준선을 잡기 좋다.

전량을 LLM에 태우지 않는 것이 중요하다. 비용이 선형으로 늘어난다.

  1층 통과분 전부 → LLM       ✗ 하루 10만 건이면 감당 안 됨

  1층 통과분 → 경량 분류기 → 애매한 것만 LLM   ✓

경량 분류기가 확실한 정상(점수 매우 낮음)과 확실한 위반(매우 높음)을 걸러내면, LLM 호출은 전체의 5~15%로 줄어든다.

LLM으로 판정할 때는 판정 결과를 스키마로 강제해야 파싱이 안정적이다.

{
  "type": "json_schema",
  "schema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "verdict": { "type": "string", "enum": ["ok", "review", "block"] },
      "categories": {
        "type": "array",
        "items": { "type": "string", "enum": ["harassment", "sexual", "spam", "privacy", "misinfo"] }
      },
      "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
      "reason": { "type": "string", "maxLength": 200 }
    },
    "required": ["verdict", "categories", "confidence", "reason"]
  }
}

구조화 출력의 사용법은 구조화 출력 문서를 참고한다. reason을 받아두는 것이 3층 검토자에게 큰 도움이 되고, 나중에 모델 판정의 품질을 감사할 때도 쓰인다.


3. 임계값 — 오탐과 미탐의 비용은 비대칭이다

임계값을 감으로 정하면 반드시 한쪽으로 치우친다.

[비용 비교]

  오탐 (정상을 차단)
    → 사용자가 부당하게 막힘. 이탈. 항의.
    → 특히 첫 글이 막히면 그 사용자는 돌아오지 않는다

  미탐 (위반을 통과)
    → 다른 사용자가 유해 콘텐츠에 노출
    → 신고로 사후 처리 가능

  → 대부분의 커뮤니티에서 오탐 비용이 더 크다
     (단, 미성년자 대상·안전 관련 카테고리는 반대)

카테고리별로 임계값을 다르게 잡아야 한다. 스팸은 오탐해도 회복이 쉽지만, 괴롭힘 판정을 놓치면 피해가 즉시 발생한다.

from sklearn.metrics import precision_recall_curve
import numpy as np

# labeled: 사람이 판정한 (score, is_violation) 300~500건
p, r, th = precision_recall_curve(truth, scores)

# 자동 차단선: 정밀도 98% 이상인 가장 낮은 임계값
for pi, ri, ti in zip(p, r, th):
    if pi >= 0.98:
        print(f"차단 임계값 {ti:.3f} (정밀도 {pi:.3f}, 재현율 {ri:.3f})")
        break

# 검토 회부선: 재현율 95%를 확보하는 임계값
for pi, ri, ti in zip(p, r, th):
    if ri <= 0.95:
        print(f"검토 임계값 {ti:.3f}")
        break

지표 정의와 곡선 해석은 scikit-learn 모델 평가 문서를 기준으로 삼으면 팀 안에서 용어가 흔들리지 않는다.

[3구간 정책]

  score >= 0.92    자동 차단      정밀도 98% 지점
  0.40 ~ 0.92      검토 큐로
  score <  0.40    통과

  ★ 검토 큐 규모를 먼저 계산할 것

4. 검토 큐 — 용량을 먼저 계산한다

이 단계를 건너뛴 파이프라인은 예외 없이 무너진다.

[계산]

  일 게시물 10,000건
  1층 통과      9,400건
  중간 구간 12%  → 1,128건/일

  검토자 1명이 시간당 60건 처리
  1,128 / 60 = 약 19시간/일  ← 2~3명 필요

  인력이 1명뿐이라면?
    → 임계값을 조정해 중간 구간을 480건으로 좁혀야 한다

인력이 처리 못 할 큐를 만드는 것은 큐를 안 만든 것보다 나쁘다. 적체된 큐는 시간이 지나면 아무도 안 보고, 그 사이 통과된 콘텐츠는 사실상 무검열이다.

[큐 우선순위]

  1. 신고 누적 건수      여러 명이 신고 = 실제 문제일 확률 높음
  2. 모델 점수 높은 순
  3. 노출량 (조회수)      많이 읽힌 것부터
  4. 작성자 위반 이력

  + 대기 시간 상한: 4시간 넘은 건은 강제로 상단

마지막 줄이 필요한 이유는, 우선순위만으로 정렬하면 낮은 점수의 건이 영원히 처리되지 않기 때문이다.

큐가 감당 안 될 때의 폴백도 정해둬야 한다.

  큐 길이 > 임계        → 검토 대기 중인 글을 "노출 축소"로 임시 처리
                          (차단은 아님. 목록 하단·추천 제외)
  큐 길이 > 위험 임계    → 임계값 자동 상향 (검토 회부량 감소)
                          + 운영팀 알림

5. 오탐 회복 — 이의 제기 경로가 필수다

자동 차단은 반드시 틀린다. 틀렸을 때 되돌리는 경로가 없으면 오탐 비용이 무한대가 된다.

[차단 통지 — 나쁜 예]

  "커뮤니티 규정에 위반되어 등록할 수 없습니다."
  → 무엇이 문제인지 모름. 고칠 수 없음. 그냥 떠남

[차단 통지 — 좋은 예]

  등록되지 않았습니다
  사유: 개인정보(연락처)가 포함된 것으로 판단됐습니다

  [수정하기]   [이의 제기]

  * 잘못된 판단이라면 이의 제기해 주세요. 24시간 내 검토합니다.

사유를 카테고리 수준으로라도 알려주는 것이 핵심이다. 정확한 위치까지 알려주면 회피 학습에 쓰일 수 있으므로, 카테고리까지가 적정선이다.

이의 제기는 최우선으로 검토 큐에 넣는다. 그리고 이의 제기 결과는 그대로 학습 데이터가 된다.

CREATE TABLE moderation_decisions (
  id            BIGSERIAL PRIMARY KEY,
  content_id    BIGINT NOT NULL,
  stage         TEXT NOT NULL,        -- 'rule' | 'model' | 'human'
  verdict       TEXT NOT NULL,        -- 'ok' | 'review' | 'block'
  score         REAL,
  categories    TEXT[],
  model_version TEXT,
  decided_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  -- 이의 제기로 뒤집힌 경우
  overturned_at TIMESTAMPTZ,
  overturned_by TEXT
);

CREATE INDEX ON moderation_decisions (model_version, verdict)
  WHERE overturned_at IS NOT NULL;

overturned_at이 붙은 행이 가장 값진 데이터다. 모델이 틀린 사례가 정확히 기록되므로, 다음 학습이나 임계값 조정의 직접적 근거가 된다.


6. 회피 시도 — 정규화를 어디까지 할 것인가

사용자는 필터를 우회한다. 이건 막을 수 없고, 비용을 올리는 것이 목표다.

[흔한 우회]

  자모 분리       ㅅㅂ, ㅄ
  숫자 치환       시1발, 55발
  공백·특수문자   시 발, 시*발
  제로폭 문자     시발  (보이지 않는 U+FEFF 삽입)
  유사 글자       ㅅ|발

전처리 정규화로 상당수를 흡수할 수 있다. 유니코드 정규화 형식(NFC/NFKC 등)의 차이는 UAX #15에 정의돼 있다.

import re, unicodedata

ZERO_WIDTH = dict.fromkeys(map(ord, "​‌‍⁠"), None)

def normalize(text: str) -> str:
    t = unicodedata.normalize("NFKC", text)   # 전각→반각, 호환 문자 통합 (UAX #15)
    t = t.translate(ZERO_WIDTH)               # 제로폭 제거
    t = re.sub(r"[\s\-_.*·]+", "", t)         # 구분자 제거
    t = t.lower()
    return t

정규화를 지나치게 하면 오탐이 폭증한다. 공백을 전부 제거하면 정상 문장에서 우연한 조합이 만들어진다("시각 발표" → "시각발표"에는 문제 없지만, 더 공격적인 정규화에서는 사고가 난다).

[원칙]

  · 정규화한 텍스트는 판정에만 쓰고, 저장·표시는 원문으로
  · 정규화 강도를 올릴 때마다 오탐률을 재측정
  · 회피 대응은 규칙(1층)이 아니라 모델(2층) 강화로 가는 게 장기적으로 유리

마지막이 중요하다. 회피 패턴을 규칙으로 쫓아가면 영원히 끝나지 않는다. 회피된 표현이 포함된 실제 위반 사례를 학습 데이터에 넣는 편이 근본적이다.


7. 도메인 특수성 — 일반 모델이 놓치는 것

범용 유해성 분류기는 도메인 은어를 모른다. 특정 커뮤니티에서만 통하는 표현은 정상으로 분류되거나, 반대로 정상 표현이 위반으로 잡힌다.

[전형적 오탐 유형]

  · 업계 은어를 유해 표현으로 오인
  · 의료·법률 상담 글의 신체·범죄 관련 용어
  · 자조적 표현("나 진짜 죽겠다")을 자해 신호로 오인
  · 지역·직군 명칭을 비하로 오인

특정 직군·업종 커뮤니티라면 이 문제가 더 두드러진다. 예를 들어 토닥의 Q&A 게시판처럼 야간 근무자들이 근로 계약·미지급 임금·건강 문제를 상담하는 공간에서는, 일반 분류기가 위험 신호로 볼 만한 표현이 정상적인 상담 내용인 경우가 많다. 도메인을 모르는 모델을 그대로 붙이면 정작 도움이 필요한 글이 막힌다.

[대응]

  1. 도메인 용어 사전을 만들어 화이트리스트로 (1층에서 면제)
  2. 오탐 사례를 카테고리별로 태깅해 축적
  3. 200~500건이 모이면 임계값을 카테고리별로 재조정
  4. 그래도 안 되면 도메인 데이터로 파인튜닝 또는 프롬프트에 예시 주입

4번으로 바로 가지 않는 것이 좋다. 1~3번으로 해결되는 경우가 대부분이고, 파인튜닝은 유지 비용이 계속 든다.


8. 관측 지표

[일간]

  1층 차단률 / 2층 회부율 / 3층 처리율
  검토 큐 길이·최대 대기 시간
  자동 차단 중 이의 제기 비율        ← 오탐률의 대리 지표
  이의 제기 중 번복 비율             ← 실제 오탐률
  카테고리별 판정 분포

[알림]

  번복 비율 > 15%        임계값이 너무 낮음
  큐 대기 > 6시간        인력 또는 임계값 조정 필요
  특정 카테고리 급증      모델 이상 또는 실제 사건 발생

"이의 제기 중 번복 비율"이 가장 직접적인 품질 지표다. 이 값이 오르면 모델이나 임계값이 나빠진 것이고, 반대로 0에 가까우면 임계값이 지나치게 보수적이어서 미탐이 많을 가능성을 봐야 한다.


9. 정리

  1. 3층 구조 — 규칙은 형식만, 모델은 맥락, 사람은 최종 판단
  2. 1층에서 욕설·비방을 판정하려 하지 않는다
  3. 전량 LLM 금지 — 경량 분류기로 5~15%만 넘긴다
  4. 임계값은 라벨 300~500건으로, 카테고리별로 다르게
  5. 검토 큐 용량을 먼저 계산 — 감당 못 할 큐는 만들지 않는다
  6. 이의 제기 경로 필수, 결과가 곧 학습 데이터
  7. 정규화는 판정에만, 강도를 올릴 때마다 오탐률 재측정
  8. 도메인 은어는 화이트리스트로 먼저 대응
  9. 번복 비율을 핵심 지표로 본다

가장 자주 생략되는 건 5번이다. 모델과 임계값은 공들여 만들면서 큐 용량 계산은 안 하는데, 그 결과 적체된 큐가 파이프라인 전체를 무의미하게 만든다.