제목, 태그, 카테고리로 검색

모든 글
약 7분 분량 프로젝트/위키엔진

콘텐츠 필터링 — Aho-Corasick 금칙어 탐지와 운영 안전장치

목차

이전 글

LTR 재랭킹 + 카테고리 자동 분류에서 XGBoost LambdaMART로 NDCG@10을 +4.8%p 개선하고, 카테고리 28개 자동 분류 + Lucene 네이티브 Facet + 태그 216만 건 인덱싱을 완료했습니다.

지표결과
NDCG@100.6910 → 0.7387 (+4.8%p, 5-Fold CV)
카테고리 분류28개 주제별, 정확도 ~83%
FacetSortedSetDocValuesFacetCounts 네이티브 전환
LTR 프로덕션CPU 포화로 비활성화 (LTR_ENABLED=false)

검색 기능(품질, 랭킹, 인프라)이 고도화되었지만, 커뮤니티 서비스 운영에 필수적인 콘텐츠 안전장치가 없습니다.


1. 정상 상태 — 현재 콘텐츠 관리 현황

현재 wikiEngine은 위키피디아 데이터 기반이므로, 콘텐츠 품질이 높고 유해 콘텐츠가 거의 없습니다. 하지만 사용자 게시글 작성이 가능해지면 다음 문제가 발생합니다:

유해 콘텐츠 유형:
1. 금칙어 포함 게시글 (욕설, 혐오 표현)
2. 스팸 게시글 (광고, 도배)
3. 저품질 콘텐츠 (의미 없는 내용, 낚시 제목)
4. 개인정보 노출 (전화번호, 주소 등)

현재 이에 대한 필터링/모니터링/신고 시스템이 전혀 없다.


2. 문제 상황 — 왜 지금 콘텐츠 필터링이 필요한가

구조적 문제

  1. 게시글 작성 API가 이미 열려 있다POST /api/v1.0/posts로 누구나(인증된 사용자) 게시글 작성 가능
  2. k6 부하 테스트에서 게시글을 대량 생성한다 — 제목/본문에 아무 문자열이나 들어감
  3. 자동완성이 검색 로그 기반이다 — 유해 검색어가 자동완성에 그대로 노출될 수 있음

curl -X POST /api/v1.0/posts -d '{"title":"금칙어 포함 제목","content":"..."}'를 보내면 아무 검증 없이 DB에 저장되고 Lucene 인덱스에 포함됩니다.

커뮤니티 서비스의 표준 안전장치

문제영향사례
금칙어 미차단커뮤니티 분위기 악화, 사용자 이탈대부분의 커뮤니티 서비스에서 기본 제공
스팸 미차단검색 결과 오염, 사용자 경험 저하Stack Overflow, Reddit 등 모두 스팸 필터 운영
신고 시스템 부재유해 콘텐츠 자정 기능 없음카카오, 네이버 모두 신고/블라인드 시스템 운영
자동완성 오염유해 검색어가 자동완성에 노출구글/네이버 자동완성 필터링

3. 문제 분석 — Aho-Corasick 선택 근거

금칙어 탐지 알고리즘 비교:

방식시간 복잡도구현판단
String.contains() 루프O(N×M) (N=텍스트, M=금칙어 수)단순금칙어 1,000개면 느려짐
Aho-CorasickO(N+Z) (Z=매칭 수, M에 무관)robert-bor/aho-corasick 라이브러리 사용선택
정규식 합성O(N)금칙어 변경 시 재컴파일대안

Aho-Corasick은 Trie 자료구조에 failure link를 추가하여, 텍스트를 한 번만 순회하면서 모든 패턴을 동시에 매칭합니다. 금칙어가 16,090개여도 텍스트 길이에만 비례하는 시간이 소요됩니다.


4. 구현

4-1. 금칙어 필터링

두 가지 시점에 적용:
1. 쓰기 시점: 게시글 작성/수정 시 금칙어 포함 여부 검사
2. 검색 시점: 자동완성 결과에서 금칙어 포함 제안 필터링
@Service
public class ContentFilterService {
/**
* Aho-Corasick automaton 2개를 Caffeine 캐시(TTL 10분)로 보관.
* - koreanTrie: 부분 일치 — "금칙" → "금칙어" 차단 (합성어 커버)
* - englishTrie: 단어 경계 매칭 — "ass" → "assassination" 허용 (Scunthorpe 방지)
*
* 라이브러리: org.ahocorasick:ahocorasick:0.6.3 (robert-bor)
*/
private record BannedAutomata(Trie koreanTrie, Trie englishTrie) {}
/**
* Aho-Corasick으로 텍스트 내 금칙어를 O(N+Z) 시간에 탐지한다.
* N = 텍스트 길이, Z = 매칭 수. 금칙어 수(M)에 무관하게 선형 시간.
*
* 현재 금칙어 한국어 3,094개 + 영어 12,996개 = 16,090개.
*/
private boolean isBanned(String text, BannedAutomata automata) {
String lower = text.toLowerCase();
if (!automata.koreanTrie().parseText(lower).isEmpty()) return true;
return !automata.englishTrie().parseText(lower).isEmpty();
}
public List<String> filterSuggestions(List<String> suggestions) {
BannedAutomata automata = getAutomata();
return suggestions.stream()
.filter(s -> !isBanned(s, automata))
.toList();
}
}

영어 Trie의 단어 경계 매칭 — Scunthorpe 문제: “ass”를 금칙어로 등록하면 “assassination”, “class”, “Scunthorpe” 같은 정상 단어까지 차단됩니다. 영어 금칙어는 단어 경계(\b)로 매칭하여 이 문제를 방지합니다. 한국어는 교착어 특성상 부분 일치가 더 적합하다 (“금칙” → “금칙어”, “금칙어목록” 등을 모두 잡아야 함).

금칙어 사전

CREATE TABLE banned_words (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
word VARCHAR(100) NOT NULL UNIQUE,
category ENUM('PROFANITY', 'HATE_SPEECH', 'SPAM', 'ADULT', 'PERSONAL_INFO') NOT NULL,
severity ENUM('LOW', 'MEDIUM', 'HIGH') NOT NULL DEFAULT 'MEDIUM',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_category (category)
);

4-2. 블라인드 게시글 — Lucene 인덱스 연동

// 블라인드된 게시글은 검색에서 제외
// 방법 1: Lucene 인덱스에서 삭제 (CDC 이벤트로 자동)
// 방법 2: 검색 시 필터 추가 (Occur.MUST_NOT)
// 방법 2 선택 — 복원 가능하도록
Query blindFilter = new TermQuery(new Term("status", "BLIND"));
builder.add(blindFilter, BooleanClause.Occur.MUST_NOT);
신고 처리 흐름:
1. 사용자가 게시글 신고 (POST /api/v1.0/posts/{id}/report)
2. 신고 누적 N건 이상 → 자동 블라인드 (검색 결과에서 제외)
3. 관리자 리뷰 → 승인(삭제) 또는 반려(복원)

재색인 불필요: 기존 인덱스에 blinded 필드 없음 → MUST_NOT 매칭 없음 → 전체 통과 (정상). 블라인드 처리 시 CDC 이벤트 → 해당 문서만 재인덱싱 → 검색 제외 반영.

4-3. 자동완성 안전장치

자동완성 구현에서 구현한 검색 로그 기반 자동완성에 금칙어 필터를 적용합니다.

현재 자동완성 흐름:
사용자 입력 → Redis flat KV 조회 → Top-10 반환
필터 추가:
사용자 입력 → Redis flat KV 조회 → 금칙어 필터링 → Top-10 반환

금칙어 필터는 Redis 조회 후 앱 레벨에서 수행합니다. Redis에 금칙어를 저장하지 않고, Caffeine 캐시에 올린 금칙어 Set으로 필터링합니다. 이유: Redis KV 재빌드 주기(1시간)와 금칙어 업데이트가 독립적이어야 하므로.

4-4. 자동완성 Lucene fallback 품질 개선

문제: Lucene fallback에서 PrefixQuery가 Nori-analyzed title 필드를 사용. 형태소 분석된 토큰으로 prefix 매칭하면 의도와 무관한 결과가 반환된다.

원인: 자동완성은 형태소 분석 없이 원본 prefix로 매칭해야 하는데, 검색용 Nori-analyzed 필드를 공유하고 있었음.

해결: title_raw StringField (untokenized, lowercased) 추가 + PrefixQuery 대상 변경. 한국어 “금칙” → “금칙어”, 영어 “prog” → “programming”.

아키텍처 정리:

  • Redis (메인): 검색 로그 기반 인기 검색어 제안 — CQRS 읽기 경로, O(1)
  • Lucene (fallback): Redis 미스 시 문서 제목 기반 보조 제안
    • 단일 단어 (“자바”) → title_raw PrefixQuery (untokenized)
    • 띄어쓰기 포함 (“자바 가비지”) → BM25 title 검색 (Nori 분석)
  • 자동완성 ≠ 형태소 분석 (현업 표준: 네이버/구글/ES 모두 별도 untokenized 필드 사용)

4-5. Negative Caching — 빈 결과 짧은 TTL

문제: 앱 기동 직후 인덱스 로딩 전에 검색 → 0건 캐시 → 5~10분간 0건 유지.

해결: TieredCacheService에서 빈 결과는 30초 TTL, 정상 결과는 기존 10분 유지.

30초 TTL 선정 근거: 앱 기동 시 Lucene SearcherManager 초기화(인덱스 로딩)가 수 초~수십 초 소요됩니다. 이보다 짧은 30초로 설정하면, 인덱스 로딩 완료 후 캐시가 만료되어 다음 요청에서 정상 결과를 자동 갱신합니다. RFC 2308(DNS Negative Caching)은 60~300초를 권장하지만, 이는 DNS 전파 지연이 전제된 수치이며 앱 레벨 캐시에서는 더 짧은 TTL이 적절하다. AWS CloudFront도 negative TTL 기본값이 5초로 짧게 설정되어 있습니다. 빈 결과를 아예 캐시하지 않으면 cache penetration(동일 쿼리가 매번 origin까지 관통)이 발생하므로, 짧은 TTL로 캐시하되 빠르게 만료시키는 것이 ByteByteGo의 Cache Miss Attack 패턴에서도 권장하는 방식이다.


5. 검증 — Before/After

금칙어 자동완성 필터링

“바보” 입력 시 — 자동완성 제안 없음:

금칙어 자동완성 차단 — "바보" 입력 시 제안 없음

“자바” 입력 시 — 정상 자동완성:

정상 자동완성 — "자바" 입력 시 제안 목록 표시

“바보”는 banned_words_ko.txt(3,094개)에 포함된 금칙어. Aho-Corasick Trie.parseText()로 O(N+Z) 탐지 → ContentFilterService.filterSuggestions()에서 제거.

블라인드 게시글 검색 제외

Before — id=619166 게시글이 검색 결과에 포함:

블라인드 Before — 619166 포함

After — id=619166 게시글이 검색 결과에서 제외됨:

블라인드 After — 619166 제외

POST /admin/lucene/reindex?ids=619166으로 Lucene 인덱스에 blinded=true 반영 후, Occur.MUST_NOT TermQuery("blinded","true")에 의해 검색 결과에서 자동 제외. 블라인드 해제 시 blinded=false로 재인덱싱하면 검색에 복원됩니다.


다음 글

AI 검색 요약 — RAG에서 Lucene BM25 검색 결과를 LLM 컨텍스트에 주입하여 AI 요약 답변을 생성하고, SSE 스트리밍, 출처 인용, 할루시네이션 방지, 비용 모니터링까지 구현합니다.


출처

Author
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.