LLM 욕설 필터가 욕을 그대로 통과시킨 이유 — 버그가 아니라 프롬프트였다
욕설이 그대로 올라갔다
내가 만든 익명 커뮤니티 서비스 "노가리"를 직접 써보다가 테스트 삼아 댓글 하나를 남겼다.
씨발...
등록 버튼을 눌렀다. 그런데 그대로 올라갔다. 한 번 더 확인해보려고 "병신들", "좆같아"도 남겨봤다. 전부 통과했다.
당황해서 코드를 열어봤다. 분명 LLM 기반 모더레이션 필터를 붙여놨는데, 왜 이런 노골적인 욕설이 그냥 통과됐을까. 로그를 확인하고 프롬프트를 다시 읽어보니 답이 나왔다. 버그가 아니었다. 시스템이 정확히 내가 지시한 대로 동작한 것뿐이었다.
원인 — 내가 그렇게 짜놨다
기존 모더레이션 함수의 프롬프트는 이랬다.
system:
"입력된 댓글에 심각한 패드립, 살해 협박, 명백한 허위 사실 유포 성격의 " +
"문장이 포함되어 있는지 확인해라. 일반적인 비판, 풍자, 해학 수준의 뒷담화는 " +
"통과시켜라. 익명 커뮤니티 특유의 거친 표현 자체는 차단 사유가 아니다."
이 프롬프트를 처음 짤 때는 나름의 논리가 있었다. "노가리(뒷담화) 까는 익명 커뮤니티인데 표현이 너무 순화되면 재미없지 않을까? 심한 것만 막고 나머지는 자유롭게 두자." 그런데 이 프롬프트를 실제로 받은 Claude 입장에서 생각해보면, "씨발"이라는 한 단어는 패드립도, 살해 협박도, 허위사실 유포도 아니다. 그러니 모델이 규칙을 위반한 게 아니라, 내가 준 규칙 자체가 욕설을 걸러내지 않도록 설계돼 있었던 것이다.
LLM 필터를 다루면서 가장 먼저 깨달아야 하는 게 이거다. "이상하게 동작하네"라고 느껴질 때, 먼저 의심해야 할 건 모델이 아니라 내가 쓴 프롬프트다. 특히 금지 목록을 나열하는 방식(블랙리스트형 프롬프트)은 내가 나열하지 않은 항목은 전부 통과 대상이 된다는 걸 잊기 쉽다.
정책부터 다시 정하기
코드를 고치기 전에 원칙부터 명확히 했다.
뒷담화(노가리)는 까도 된다. 하지만 욕은 하지 않는다.
이 한 줄이 이후 모든 구현의 기준이 됐다. "이 정책은 별로다", "관리가 소홀한 거 아니냐"처럼 욕설 없이 날 선 비판을 하는 건 커뮤니티의 정체성이니 살려야 한다. 하지만 욕설 단어 자체는 내용과 무관하게 차단한다. 이 둘을 분리하는 게 핵심이었다.
Step 1 — 정규식 1차 필터로 확실한 것부터 즉시 차단
기존에도 개인정보(주민번호, 전화번호, 이메일) 패턴을 정규식으로 즉시 차단하는 1차 필터가 있었다. LLM 호출 없이 즉시 처리되니 빠르고 비용도 안 든다. 여기에 욕설 패턴을 추가했다.
// 흔한 욕설/비속어(초성·로마자 우회 포함). 정규식으로 가장 명백한 것만
// 즉시 차단하고, 애매하거나 변형이 심한 표현은 뒤의 LLM 단계가 판단한다.
const PROFANITY_PATTERNS: RegExp[] = [
/씨\s*발/i,
/시\s*발/i,
/병\s*신/i,
/좆\s*같/i,
/좆\s*또/i,
/개\s*새\s*끼/i,
/지\s*랄/i,
/니\s*기\s*미/i,
/ㅅㅂ/i,
/ㅂㅅ/i,
/sib+al/i,
/sip+al/i,
/byeong\s*sin/i,
/ssibal/i,
];
몇 가지 설계 포인트가 있다.
\s*로 글자 사이 공백/구분자를 허용했다. "씨 발", "씨.발"처럼 공백이나 문자를 끼워 넣는 흔한 필터 우회 수법을 잡기 위해서다.- 초성(ㅅㅂ, ㅂㅅ)과 로마자 표기(sibal, sippal)까지 포함했다. 실제로 테스트해보니 "Sippal"이라고 영문으로 쓴 것도 기존 필터를 그냥 통과했다.
- 이 정규식은 가장 명백한 것만 잡는다. 변형이 너무 심하거나 애매한 표현은 여기서 못 잡아도 괜찮다 — 그건 다음 단계인 LLM이 맥락으로 판단한다.
Step 2 — LLM 2차 심사 프롬프트를 명확하게 재정의
정규식을 통과한 나머지는 LLM에게 넘기는데, 여기서 프롬프트를 완전히 다시 썼다.
system:
"너는 익명 커뮤니티 '노가리'의 댓글 검열 담당자다. 이 커뮤니티의 핵심 " +
"원칙은 '뒷담화(노가리)는 까도 되지만 욕은 하지 않는다'이다.\n" +
"차단 대상: 욕설·비속어(변형·초성·로마자 표기 포함), 패드립, 살해·폭력 " +
"협박, 명백한 허위 사실 유포, 심각한 인신공격.\n" +
"허용 대상: 대상에 대한 비판, 풍자, 해학, 불만 표현 — 단 욕설 없이 " +
"표현된 경우에 한한다. '별로다', '실망스럽다', '이해가 안 간다' 같은 " +
"표현은 통과. 욕설 단어가 하나라도 섞여 있으면 나머지 내용이 정상적인 " +
"비판이어도 차단해라."
이전 버전과 비교하면 차이가 뚜렷하다.
| 이전 | 이후 | |
|---|---|---|
| 원칙 | "심각한 것만 막는다" (블랙리스트형) | "욕설은 무조건, 비판은 욕설 없이" (경계선이 명확) |
| 욕설 취급 | 차단 사유 아님 | 명시적 차단 대상 1순위 |
| 애매한 케이스 처리 | 언급 없음 | "욕설 단어가 하나라도 섞이면 전체 차단"으로 우선순위 명시 |
| 예시 | 없음 | 허용되는 표현의 구체적 예시 제공 |
특히 마지막 줄, **"욕설 단어가 하나라도 섞여 있으면 나머지 내용이 정상적인 비판이어도 차단해라"**가 중요했다. LLM은 문장 전체의 톤을 보고 "이 정도면 그냥 강한 비판이지"라고 관대하게 판단하는 경향이 있는데, 그 여지를 프롬프트에서 미리 차단한 것이다.
이미 신고된 콘텐츠를 재심사하는 reviewReportedContent 함수의 프롬프트도 같은 원칙으로 맞췄다. 작성 시점 필터와 신고 재심사 필터가 서로 다른 기준을 쓰면, "작성할 땐 통과했는데 신고하니 다른 이유로 막힌다"는 혼란스러운 사용자 경험이 생기기 때문이다.
Step 3 — 실제로 검증하기
프롬프트를 바꾸고 "될 것 같다"에서 멈추면 안 된다. 배포 전에 브라우저로 실제 API를 두드려서 확인했다.
const tests = ["씨발...", "좆같아.", "병신들", "Sippal", "니기미 좆또"];
for (const content of tests) {
const res = await fetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topicId, content }),
});
console.log(content, "→", res.status);
}
결과:
씨발... → 422 (차단됨)
좆같아. → 422 (차단됨)
병신들 → 422 (차단됨)
Sippal → 422 (차단됨)
니기미 좆또 → 422 (차단됨)
그리고 반대 케이스도 확인했다. 욕설 없는 강한 비판은 여전히 통과해야 한다.
"이 정책은 진짜 별로다. 피해자 지원이 너무 느림" → 201 (등록됨)
"전세사기범들 진짜 벌 좀 세게 받았으면" → 201 (등록됨)
의도한 대로 정확히 갈렸다. 욕설은 100% 차단, 뒷담화의 자유는 그대로 유지.
Step 4 — 이미 올라간 욕설은 소급 처리
정책을 바꿔도 이미 등록된 댓글은 그대로 남아있다. 테스트하며 남겼던 욕설 댓글 5개를 DB에서 찾아 소프트 삭제(soft delete)했다.
await supabase
.from("comments")
.update({ deleted_at: new Date().toISOString() })
.in("id", profanityCommentIds);
물리적으로 지우지 않고 deleted_at만 채우는 이유는, 나중에 "이 정책이 언제부터 어떤 콘텐츠에 적용됐는지" 감사 추적이 필요할 수 있어서다. 필터를 손보는 작업일수록 기록을 남겨두는 게 나중에 도움이 된다.
정리
- LLM이 이상하게 동작하면 먼저 프롬프트를 의심하라. 대부분의 경우 모델은 지시받은 대로 정확히 동작한다. "필터링이 안 된다"는 버그가 아니라 "내가 필터링 기준을 명확히 정의하지 않았다"는 설계 문제인 경우가 많다.
- 콘텐츠 정책은 프롬프트를 쓰기 전에 한 문장으로 정리하라. "뒷담화는 까도 욕은 안 된다"처럼 명확한 원칙이 있으면 프롬프트도, 나중의 판단도 일관될 수 있다.
- 계층적 방어가 비용과 정확도 모두에 유리하다. 확실한 패턴(정규식)은 LLM 호출 없이 즉시 처리하고, 애매한 판단이 필요한 것만 LLM에 넘긴다. 명백한 욕설 5건을 전부 정규식 단계에서 잡아냈다는 건, API 호출 비용도 아끼고 응답 속도도 빨라졌다는 뜻이다.
- 경계 케이스의 우선순위를 프롬프트에 명시하라. "비판 내용에 욕설이 섞이면 어느 쪽을 우선할지"처럼 애매해질 수 있는 지점은 반드시 명시적으로 정해줘야 LLM이 관대하게 판단하는 걸 막을 수 있다.
- 배포 전 실제 API로 양쪽 다 확인하라. 차단돼야 할 것이 차단되는지뿐 아니라, 통과돼야 할 것이 여전히 통과하는지도 함께 확인해야 진짜 검증이다.
익명 커뮤니티를 운영하다 보면 "표현의 자유"와 "최소한의 품위" 사이에서 계속 선을 그어야 한다. 그 선이 모호하면 LLM도 모호하게 판단한다. 결국 좋은 모더레이션 시스템은 좋은 모델이 아니라 명확한 정책 문장 하나에서 시작한다는 걸 이번에 직접 겪으며 배웠다.
backtodev
40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.