Post

금칙어 필터링 기능 개발

메이플 주문서 시뮬레이터 금칙어 필터링 기능이 추가되었습니다. 개발과정에서 어떤 고민을 하였고, 어떻게 개발하였는지 기술합니다.

금칙어 필터링 기능 개발

금칙어 필터링이 필요한 이유

개발관리 중인 사이트에는 댓글을 달거나, 사용자가 원하는 닉네임으로 새로운 기록을 등록할 수 있는 기능이 존재한다. 사용자끼리 소통하고 재미를 위해 경쟁 기능을 추가한 것이였지만 역시나 많은 사람들이 모이게 되니 부적절한 표현을 작성하는 사람들도 있었다.

농협 oooo-ooo-ooo 로 돈 보내주세요

010-xxxx-xxxx 전화주세요

위와 같은 내용을 비롯해서 욕설, 타 사이트 광고 등등 운영의도와는 다른 목적으로 사용되고 있었기 때문에 부적절한 단어나 문구가 들어간 컨텐츠를 차단할 필요성이 생겼다.

개발 목표

이러한 금칙어 시스템은 한곳에서만 사용되는 기능이 아니다.

  • 댓글 내용
  • 댓글 닉네임
  • 기록 등록자 닉네임

여러 곳에서 사용되고, 나중에 사용자가 참가할 수 있는 컨텐츠가 개발된다면 어디서든 필요해질 수 있는 시스템이다. 따라서 각각의 기능별로 시스템을 따로 구축하기 보다는 모든 기능에서 공통적으로 적용될 수 있는 금칙어 시스템을 만들어내는 것이 목표이다.

개발 환경

  • Spring Boot 3.2.2
  • JAVA 17
  • MySQL 8.3

금칙어 저장

금칙어 저장위치

금칙어를 걸러내기 위해서는 서버나 DB에 금칙어 목록이 저장되어 있어야한다. 어디에 금칙어를 저장하고 관리해야 할까?

  1. 자바 메모리에 저장
  2. 데이터베이스에 저장
  3. 두가지 방법을 혼용

금칙어 목록을 자바 메모리에 저장하게 되면 데이터베이스와의 네트워크 통신이 필요없기 때문에 속도가 빠르다는 장점이 있다. 하지만 금칙어 목록을 수정하고 싶으면 애플리케이션을 다시 올려야한다는 단점이 있다. 그리고 메모리에 저장하기 때문에 사전에 메모리의 공간을 얼마나 차지하게 되는지 조사가 필요하다. 메모리 공간을 많이 사용하게 된다면 성능에도 문제가 되기 때문이다.

데이터베이스에 저장하면 관리자가 금칙어를 관리하기 편해진다. 금칙어 CRUD API를 열어두고 DB에 있는 금칙어 목록을 참고해서 필터링 로직을 작성하면 되기 때문이다. 그리고 금칙어의 수정작업이 있을 때마다 서버를 열고닫을 필요도 없어진다. 하지만 금칙어 검사가 필요할 때마다 데이터베이스와 통신을 해야한다. 금칙어 로직은 댓글이 작성될 때, 기록이 등록될 때 등등 사용자가 참여하는 컨텐츠라면 언제나 호출되기 때문에 매번 데이터베이스와 통신이 필요하다는 문제점이 있다.

그래서 이 두 가지 방법을 혼용해서 금칙어 목록을 DB에 저장해두고 주기적으로 DB로부터 폴링(polling)해와 메모리에 저장하는 방식을 사용했다. 이렇게 하면 금칙어 설정이 바뀔 때마다 서버를 열고 닫지 않아도 되고 검사가 필요할 때마다 데이터베이스와의 통신도 발생하지 않는다.

메모리에 금칙어 목록을 저장해도 괜찮은가?

금칙어목록을 메모리에 저장하는 것이 성능에 영향을 주지 않는지 검증이 필요하다. 5글자로 이루어진 금칙어 5만개가 있다고 가정해보자. 한글은 한 글자당 2바이트로 처리되기 때문에 사용되는 메모리의 양은 아래와 같다.

\[2 \times 5 \times 50,000 = 500,000 \, \text{bytes} \approx 0.5 \, \text{MB}\]

그래서 금칙어 목록을 전부 메모리에 저장한다고 해도 애플리케이션의 성능에 영향을 미칠 만한 공간은 차지하지 않는다고 판단했다.

금칙어 필터링 알고리즘

메모리에 금칙어의 목록을 저장해두었으니 이제 사용자가 입력한 문장으로부터 어떻게 금칙어를 찾아낼 것인가에 대해 이야기 해보자.

String.contains

먼저 String클래스의 contains 메서드를 사용하는 간단한 방법이 있다. 금칙어를 메모리의 배열이나 List에 담아두고 반복문을 돌면서 문장에 금칙어가 포함되어 있는지 검사하는 방법이다.

하지만 이 방법의 시간복잡도는

  • n: 문장 길이
  • m: 금칙어의 갯수

라고 할 때 $O(mn)$ 이다. 구현이 간단하지만 시간 복잡도가 커 대량의 텍스트나 다수의 금칙어를 처리할 때 성능이 저하 될 수 있다.

아호코라식(Aho-Carasick) 알고리즘

아호코라식 알고리즘은 문자열 탐색 알고리즘의 일종으로, 하나의 문장에서 여러개의 문자열을 찾고자할 때 적합한 알고리즘이다. 트라이 자료구조를 사용해서 찾고자 하는 단어의 형태를 트리 형태로 만들어 두고 일치/불일치 여부를 판단하게 된다. 이 알고리즘의 시간복잡도는

  • n: 문장 길이
  • m: 트라이를 구성하는 노드의 갯수(=모든 금칙어의 길이의 합)
  • k: 매칭된 금칙어의 총 갯수

라고 할 때 $O(n+m+k)$ 이다. String.contains 메서드를 사용하는 방법보다는 확실히 빠른 시간복잡도를 가진다.

소요시간 실험

  1. Random Number API로 단어를 불러와 금칙어로 저장한다.
  2. 불러온 단어가 포함되어 있는 200자 분량의 예문을 작성한다. 200자로 정한 이유는 현재 서비스에서 가장 길게 작성할 수 있는 댓글의 최대 길이가 200자이기 때문이다.
  3. 각 알고리즘에서 시간이 얼마나 걸리는지 측정한다.

Random Number API와 RestTemplate를 사용하여 외부로부터 랜덤한 단어 목록을 불러온 후, 200자 길이의 예문에 이 단어 일부를 삽입하여 실험을 진행했다. 결과적으로, 금칙어가 약 5000개인 환경에서 완전탐색 방식과 아호-코라식 알고리즘의 소요시간은 5ms 이상 차이가 나지 않았다.

더 큰 차이를 관찰하기 위해서는 검사할 컨텐츠의 길이를 수천, 수만 자로 늘려야 한다. 그러나 현재 제공하는 서비스는 최대 200자 길이의 컨텐츠만 허용하기 때문에 이러한 테스트는 실질적으로 의미가 없다. 극한의 환경에서는 아호-코라식 알고리즘이 더 효율적일 것으로 예상되지만, 현재 환경에서는 두 가지 방법을 구분할 필요가 없어 보인다.

어떤 방법을 선택하였는가?

나는 필터링 알고리즘을 적용하는 인터페이스를 정의하고, 두 방법으로 금칙어를 찾아내는 구현체를 따로따로 만들었다. 그리고 등록된 금칙어의 수가 적은 지금은 String.contains 메서드를 활용하는 구현체를 사용하고 있다. String.contains 를 선택한 이유는 다음과 같다.

  • 현재 등록된 금칙어의 수가 적다.
  • 간단하게 구현할 수 있다.
  • 위의 실험 결과에도 따르듯, 현재 환경에서는 두 알고리즘의 차이가 무의미할 정도로 작다.

아호코라식 알고리즘은 금칙어의 수가 많을 때 완전탐색 방식에 비해 빠른 속도를 자랑한다. 지금은 등록해둔 금칙어의 수가 1,000개 이하이기 때문에 아호-코라식 알고리즘의 빠른 시간복잡도로 가질 수 있는 이점이 적다고 판단했다. 그리고 아호-코라식 알고리즘은 트라이 자료구조를 사용해서 금칙어의 목록을 저장한다. 따라서 List로 금칙어의 목록을 저장하는 것 보다 더 많은 메모리를 사용하게 된다.

현재 금칙어의 수가 제한적이므로 간단하고 직관적인 String.contains 메서드를 활용한 구현체를 사용할 계획이다. 하지만 시스템의 확장성을 고려하여, 향후 금칙어 목록이 대폭 증가하거나 성능 지표가 미리 설정한 임계값을 초과할 경우, 아호-코라식 알고리즘을 기반으로 한 고성능 구현체로 전환할 것이다. 이러한 단계적 접근 방식을 통해 현재의 개발 효율성과 미래의 성능 요구사항을 모두 충족시킬 수 있을 것으로 기대한다.

개발

전체적인 구조는 다음과 같다.

금칙어 필터링 비즈니스 로직.svg

각 인터페이스의 기능을 살펴보면

  • BanWordFilter: 전체 금칙어 필터링 프로세스를 관리하는 주요 인터페이스.
  • BanWordValidator: 실제 금칙어 검증 로직을 수행하는 인터페이스.
  • BanWordStorage: 금칙어 목록을 저장하고 관리하는 인터페이스
  • BanWordFetcher: 데이터베이스나 외부 소스로부터 금칙어 목록을 가져오는 인터페이스

필터링 요청, 검증 로직, 데이터 저장, 금칙어 페치 등의 기능을 분리하여 독립적으로 개선하고 교체할 수 있도록 구현하였습니다.

BanWordValidator

금칙어가 실제 문장에 존재하는지 검증하는 책임을 가지는 인터페이스 입니다. 구현체로는 String.contains를 사용하여 브루트포스로 금칙어 여부를 검색하는 BruteForceBanWordValidator와 아호-코라식 알고리즘을 사용하는 AhoCarasickBanWordValidator가 있습니다. 여기서는 현재 사용중인 BruteForceBanWordValidator에 대해서 살펴보겠습니다.

먼저 BanWordValidator는 검사할 문장을 파라미터로 받아 그 결과를 boolean으로 반환하는 메서드 하나만 정의되어 있습니다.

1
2
3
public interface BanWordValidator {
    public boolean containsBanWord(String sentence);
}

구현체 중 하나인 BruteForceBanWordValidatorBanWordStorage 인터페이스에 의존하여 금칙어 목록을 가져오며, String.contains 메서드를 사용하여 금칙어가 파라미터로 전달받은 문장 내에 포함되어 있는지 검사합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
@RequiredArgsConstructor
public class BruteForceBanWordValidator implements BanWordValidator {

    private final BanWordStorage storage;

    @Override
    public boolean containsBanWord(String sentence) {
        List<String> banWords = storage.getBanWords();

        Optional<String> findBanWord = banWords.stream()
            .filter(sentence::contains)
            .findAny();

        return findBanWord.isPresent();
    }
}

findBanWordOptional 타입으로 반환되는데, 이곳에 데이터가 존재한다는 것이 문장 내에 포함된 금칙어가 있다는 것을 의미합니다.

BanWordStorage

BanWordStorage는 금칙어 정보를 저장하고 관리하는 책임을 가지고 있는 인터페이스다. 이곳에는 금칙어를 등록하는 registerBanWords, 새롭게 단어를 추가하는 addBanWord, 그리고 저장되어 있는 금칙어를 반환하는 getBanWords 메서드가 정의되어 있ek.

1
2
3
4
5
6
public interface BanWordStorage {
    public void registerBanWords(List<String> words);
    public void addBanWord(String word);
    public void addBanWords(List<String> words);
    public List<String> getBanWords();
}

현재 구현체로는 금칙어 정보를 List로 관리하는 ListBanWordStorage를 사용하고 있다.. 그리고 BanWordFetcher 인터페이스에 의존하여 주기적으로 금칙어 목록을 갱신한다.

BanWordFetcher

데이터베이스나 외부 소스로부터 주기적으로 금칙어 목록을 갱신하는 책임을 가지고 있는 인터페이스다. 금칙어를 폴링해 List로 반환하는 fetchBanWords 메서드가 정의되어 있다.

1
2
3
public interface BanWordFetcher {
    public List<String> fetchBanWords();
}

구현체로는 ListBanWordFetcher를 사용합니다. 스프링 프레임워크의 @Scheduled 애노테이션을 활용하여 주기적으로 금칙어 목록을 갱신한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
@RequiredArgsConstructor
@Slf4j
public class ListBanWordFetcher implements BanWordFetcher {

    private final BanWordRepository repository;

    @Override
    @Scheduled(
        fixedDelayString = "${gongnomok.banword.polling.refreshMinutes}",
        timeUnit = TimeUnit.MINUTES)
    public List<String> fetchBanWords() {
        return list = repository.findAll()
            .stream()
            .map(BanWord::getWord)
            .toList();
    }

}

고민점

금칙어 갱신 주기

금칙어는 한번 정해지면 잘 바뀌지 않기 때문에 자주 갱신될 필요가 없다. 실시간성이 중요하지 않고, 부적절한 댓글이 초 단위로 발생하지 않기 때문에 현재는 10분을 주기로 금칙어 정보를 갱신하도록 설정하였다.

금칙어 폴링 취소

Untitled

금칙어 목록이 마지막으로 금칙어를 갱신한 이후로 수정된 적이 없음에도 또 다시 폴링을 해오는 것은 네트워크 자원의 낭비라고 판단했다. 금칙어 목록이 마지막으로 수정된 시간(lastModifiedTime)과 마지막으로 금칙어를 갱신한 시점(lastUpdatedTime)을 비교해서 폴링 요청을 취소할지 판단한다.

  • 마지막 수정 시점이 마지막 폴링 시점보다 늦다면 폴링이 필요하다.
  • 마지막 수정 이후에 폴링한 적이 있다면 폴링이 불필요하다.
  • lastUpdatedTime < lastModifiedTime 일 때 폴링한다.

금칙어 테이블 인덱스

테이블을 새롭게 생성하면 따라오는 고민 중 하는 인덱스 설정부분이다. 금칙어를 저장하는 ban_word 테이블에는 별도로 인덱스를 설정하지 않았다. 금칙어의 어떤 속성을 조건으로 검색하는 것이 아니라 서버에서 금칙어 리스트를 주기적으로 모두 폴링하는 용도로만 사용하고 있기 때문이다. 인덱스를 만들어서 활용할 수 있는 부분이 없고 불필요한 인덱스의 생성이 오히려 데이터베이스의 저장공간만 잡아먹을 것이라고 생각했다.

This post is licensed under CC BY 4.0 by the author.