TIL

24/01/22 TIL __ 채팅기능에 금칙어 기능 추가 ( aho-corasick 라이브러리)

GABOJOK 2024. 1. 22. 23:54

 

 

이번에는 채팅에 금칙어 기능을 추가한 내용을 적어보려고 한다. 

 

🪴  구현 목표 기능

 

금칙어 기능을 구현하기 위해 처음에는 

하나의 변수에 특정 문자들을 배열에 담아서 
금칙어에 해당하는 경우 하트 이모티콘으로 치환해서 전송하려고 했다.

현재  zep에서 학습중인데 ** 이런식으로 치환되기 때문에 가장 먼저 이런 방법이 떠올랐다. 

예를 들면 야 이 💜💜💜야 

 

그러나 이렇게 하면,

스트리머는 유저가 욕을 보내는걸 알게 되어 그닥 좋을 것 같지는 않았다. 

방법을 바꿔봤다. 

유저가 금칙어를 작성해서 전송하려 하면,

방금 전송한 그 유저에게만 alert로 금칙어라 전송되지 않는다고 알려주기로 했다. 

 

 

 

🎄  구현 방법


이를 위해 금칙어 리스트를 찾아보았는데,  정말 많은 단어가 있었다.
많은 경우 1000개의 단어가 있기도 해서 파일로 따로 관리한다는 글도 보았다. 

문득 이 방법 말고 다른 방법이 있는지, 다른사람들은 금칙어 기능을 어떤 방법으로 구현을 할지 궁금해 졌다. 
내가 생각하고 있던 방법이 아닌 금칙어 기능에 대한 부분을 찾아보자 다양한 방법이 나왔다. 

가장 단순한 금칙어 문자들을 직접 관리하는 방법부터,
레벤슈타인 거리 알고리즘을 활용해서 금칙어 기능을 구현하는 방법,
정규표현식을 사용하는 방법
자연어 처리를 이용하는방법(라이브러리,  api 사용) 등등 꽤 다양하게 존재했다. 

정규표현식은 많은 금칙어를 필터링 하게 되면 성능이 저하될 수 있어 다른 방법을 찾아보았다.

레벤슈타인 거리 알고리즘은 처음 본 내용인데, 어떤건지만 대략 살펴봤다.
결론부터 말하자면 적합하지 않아서 패스했지만,
간략하게 말하면, 두 문자열 간의 차이를 측정하여, 기본 금칙어 에서 변형을 감지하기에 좋아보였다. 
예를들면 "fufu" 라는 단어가 금칙어라면, 'pufu' 라는 변형된 금칙어를 유저가 입력했을때, 
금칙어 목록에 있는 단어들과 현재 유저가 입력한 단어를 비교해서 , 레벤슈타인 거리를 계산한다.
그러나, 이는 복잡하고 경이로운 우리의 언어 한글에는 적합하지 않았다. 



좀더 효율적인 방법을 찾던 중 자연어 처리를 이용해서 금칙어 처리하는 기술이 눈에 들어왔다.
node, express, nest를 해오던 내가 갑자기 자연어를? 싶을수 있지만, 
지난번에 nest에서 지원하는 자연어 관련 라이브러리가 있다는 사실을 알게 되어,
다시 찾아보았다. 

https://cloud.google.com/vertex-ai/docs/training/overview?hl=ko

한국어를 지원하는 구글에서 제공하는  ai 기반 데이터 처리 도구가 있고,

사용하기 위해 뒤적뒤적 찾아보았지만,,, 아직은 어떻게 사용해야 할지, 무슨 말인지

또한 어떤 메커니즘으로 작동하고, 어떻게 학습시켜야 하는지.

이런 것들이 자연어에 대해 전혀 모르는 내가 당장 배워 써먹기에는 한계가 있을 것 같아 사용하지 않기로 했다. 

 

 

 

 

다시 금칙어 처리를 위한 알고리즘을 보던 중 , 

많은 단어들과 동시에 비교할 수 있는 aho-corasick 알고리즘을 알게 되었다.

한번에 여러개의 단어를 찾고 빠른 검색속도를 가지며, 복잡한 단어도 잘 찾는다.  

그렇지만 정확히 일치하는 패턴을 찾는데 특화되어 있다는 장점이자 단점인 특성을 가진다.

예를 들어 'cat'이라는 단어를 찾을 때, 'cat'만 찾지 'kat'이나 'cats'는 찾지 못한다고 한다.  


정확히 일치하는 패턴이 아닌 경우까지 찾으려면 정규표현식이나, fuzzy matching알고리즘 이라는걸 사용할 수 있다고 하지만,
이경우 에도 문제는 있다.  올바른 단어 즉 금칙어가 아님에도 금칙어라고 인식 할 수 있다는 것이다. 

그럴바에는 오히려 정확히 일치하는 패턴을 찾는 aho-corasick이나 kmp를 사용하는걸 고려하는게 맞을 것 같아 

일치하는 패턴을 가리는 알고리즘으로 생각의 범위를 좁혔다. 

kmp의 경우 단일 패턴 검색에 최적화 되어있는 구조라고 한다. 
즉 한번에 하나의 금칙어만 검색할 수 있고, 여러 금칙어를 검색하려면 각각에 대해 별도의 검색을 해야만 한다. 


aho-corasick은 많은 패턴 검색을 할수 있고 속도가 빠르지만, 
금칙어 목록을 트라이 구조로 메모리에 저장해야 하기 때문에 메모리 사용량이 늘어나고, 초기 설정시간이 있을 수 있다. (트라이 구조 생성 , 실패링크 설정할때 시간 소요.(금칙어 목록이 매우 클때))

 

금칙어 리스트는 계속해서 늘어날 예정으로 생각되었고, 많은 자료를 빠르게 검색하는 것 또한 중요하기 때문에 aho-corasick 알고리즘을 선택했다. 

 

aho-corasick 알고리즘 탕탕 당첨

 

 

😶‍🌫️  고난

 

나는 많은 금칙어들 중 1개라도 있다면 메세지 전송을 하지 않고,
메세지 전송하려고 했던 유저에게 alert로 "금칙어가 포함되어 있어 전송할 수 없습니다." 라는 메세지를 보여주고자 했다.

 

결정했던 아호코라식 알고리즘을 이용해 구현하려 시도했다. 그러나...

큰 문제가 있었다. 

failure root경로 설정 과정이 경우에 따라 다양하게 존재하는데, 

아직 그부분이 이해가 가지 않았다. 

이해가 가지 않으니 직접 코드로 로직을 작성할 수 없었다..

몇시간동안 다양한 자료를 봤지만 오늘 안에 구현을 완료하기 어려울거라고 생각되었고,

프로잭트의 마감일을 생긱하며

결국 라이브러리를 사용하게 되었다.

 

aho-corasick
 
 
이라는 라이브러리가 있어서 이를 이용해 금칙어 기능을 구현했다.
 
 

금칙어라는 기능을 구현할 때에, 단순히 유저의 금칙어를 보여주지 않기로 했기 때문에

유저가 몇번이나 금칙어 전송을 시도했는지는 중요하지 않았다. 

따라서 count하거나, 유저가 입력한 금칙어를 보관하는 로직은 없다. 

단순 true, false로만 판별해서 값을 넘기는 로직으로 작성했다. 

 

 

import AhoCorasick from 'aho-corasick';

const word = [
	//금칙어가 담긴 배열
]

export const forbiddenWords = word.split(', ');

export async function searchProhibitedWords(text: string): Promise<Boolean> {
    const ac = await new AhoCorasick();

    forbiddenWords.forEach((word) => {
        ac.add(word, { word: word });
    });

    ac.build_fail();

    let isForbiddenWord = false;
    
    //입력값에 금칙어가 있는 경우에만 콜백함수가 실행된다. 
    await ac.search(text, async (found_word: string) => {
        return (isForbiddenWord = true);

    });
    return isForbiddenWord;
}

 

 

그러나 이는 허점이 많다. 

유저는 얼마든지 정해진 금칙어가 아닌 변형된 금칙어를 사용할 수 있고,

현재 작성한 로직에서는 유저가 변형한 금칙어를 절대 찾아낼 수 없다. 

정규표현식 또한 사용하기에 따라 딱 맞는 금칙어를 찾거나, 포함하는 금칙어를 찾을 수 있지만

많은 금칙어가 있는 경우 성능저하가 발생할 수 있다.

 

금칙어는 언제든지 계속 추가 가능해야만 하기때문에 현재는 적을지라도 확장성을 고려해야만 한다. 

 

자연어 기반 금칙어 처리도 완벽하진 않겠지만, 

현재 할수 있는 가장 베스트를 선택하고 싶었다. 

 

찾아본건 다양하게 찾아본것 같은데, 

막상 구현은 라이브러리를 사용해서 

너무 간단하게 끝나버렸다..

프로젝트가 끝나면 꼭 알고리즘에 대해 이해하고 직접 로직을 작성해 사용하고 싶다.....

 

 

또한 추후 msa 아키텍쳐를 작성할때 해당 로직에 파일을 따로 분리하려 한다.