개요
웹이나 앱을 개발하다보면 사용자가 입력한 데이터를 검증하는 과정에서 어쩔 수 없이 빼놓는게 금지어였습니다. 왜냐하면 사용할만한 라이브러리가 존재하지 않았기때문입니다. (제가 못찾을 것일수도 있습니다.)
그래서 Spring과 JAVA에 대해서 공부도 할겸 직접 라이브러리를 만들어보았습니다.
고르곤졸라는 되지만 고르곤 졸라는 안 돼! 배달의민족에서 금칙어를 관리하는 방법 | 우아한형
안녕하세요! 셀러시스템팀에서 서버 개발을 하고 있는 김예빈이라고 합니다. 배달의민족에는 금칙어를 관리하는 "통합금칙어시스템"이라는 것이 있습니다. 금칙어란? 법 혹은 규칙으로 사용이
techblog.woowahan.com
아이디어는 우아한기술블로그에서 가져왔으며 기술블로그에는 금지어 라이브러리를 제공하고 있지않습니다. 그래서 제가 직접 만드는겁니다.
어떤식으로 만들까?
금지어 검사기에 대한 대략적인 명세는 다음과 같습니다.
- 금지어는 json 데이터를 기반으로 데이터를 수집해야합니다.
- 검사기는 한번 로드되고 Singleton 이 보장되어야 합니다.
- 금지어이지만 예외단어는 금지어가 아니어야합니다. (예, "고르곤졸라" 에서 "졸라"는 금지어이지만 "고르곤졸라"는 예외단어)
- 사용자가 추가로 금지어나 예외단어를 추가할 수 있어야합니다.
- 금지어와 예외단어를 검사할때 띄어쓰기는 무시되어야합니다. (예, "AB가 금지어일경우 "A B"도 금지어)
- 금지어를 블라인드 할 수 있는 메소드가 있어야합니다.
이 정도로 정의하고 다음으로 넘어가겠습니다.
자료구조
클래스 | 설명 |
Word | 금지어의 데이터 |
Inspector | 사용자가 금지어 유무를 확인하는 최상위 클래스 |
InspecConfig | 사용자가 금지어나 예외단어를 직접 추가할 수 있도록 하기위한 설정클래스 |
WordLoader | Json 파일에서 단어들을 읽어오는 클래스 |
WordFactory | 사용자가 factory에 직접 접근하지 않고 오로지 단어만 추가할 수 있도록 하는 클래스 |
WordFactoryBuilder<T> | 금지어와 예외단어를 WordFactory를 통해 Json과 사용자로부터 주입받아 build 해주는 클래스 |
AbstractWordFactory<T> | Factory의 공통 메소드 관리, 주입받은 단어들의 중복을 제거하고 build를 통해 WordUitl 에 데이터를 로드하는 클래스 |
BanWordFactoryImpl, ExceptWordFactoryImpl | 생성자 주입용 구현체 |
BanWordUtil, ExceptWordUtil | 자료구조인 WordUtil 를 통해 데이터를 제어하는 클래스 |
AbstractWordUtil | BanWordUtil, ExceptWordUtil 의 공통 메소드 관리 |
BanWordUtil, ExceptWordUtil | WordUtil, AbstractWordUtil의 구현체 |
WordUtil | 데이터 자료구조 |
요청 시 예상 응답
// 금지어 : "바나나", "사과", "오렌지", "수박", "멜론"
// 검사할 문자열 : "바나 나 먹을래"
List<Word> words = inspector.inspect(검사할문자열);
// 예상 결과 : Word(word="바나나", startIndex=0, endIndex=4)
// 금지어 : "바나나", "사과", "오렌지", "수박", "멜론"
// 예외단어 : "사과주스"
// 검사할 문자열 : "사과주스 먹을래"
List<Word> words = inspector.inspect(검사할문자열);
// 예상 결과 : words.isEmpty() == true
// 금지어 : "바나나", "사과", "오렌지", "수박", "멜론"
// 검사할 문자열 : "사과먹을래"
String mask = inspector.mask(검사할문자열);
// 예상결과 : "?먹을래"
// 금지어 : "바나나", "사과", "오렌지", "수박", "멜론"
// 검사할 문자열 : "사과먹을래"
String mask = inspector.mask(검사할문자열, "X");
// 예상결과 : "X먹을래"
- 검사할 문자열을 inspect() 에 넣으면 금지된 단어의 문자열과 시작인덱스(includeIndex), 마지막인덱스(excludeIndex) 가 포함된 List 데이터가 반환 되어야 한다.검사할 문자열을 inspect() 에 넣으면 금지된 단어의 문자열과 시작인덱스(includeIndex), 마지막인덱스(excludeIndex) 가 포함된 List 데이터가 반환 되어야 한다.
마지막인덱스는 띄어쓰기가 포함한 인덱스이다. - 예외단어에 포함된 문자열은 금지어에서 제외한다.
- 검사할 문자열에서 금지된 단어를 블라인드하려면 mask() 메소드를 사용한다. 두번째인자로 "?" 대신 변경할 문자열을 넣으면 해당 문자로 변경되어 반환한다.
구현
1. Inspector
public interface Inspector {
List<Word> inspect(String word);
default String mask(String word) {
return mask(word, "?");
}
default String mask(String word, String replace) {
StringBuilder sb = new StringBuilder(word);
List<Word> data = inspect(word);
for (int i = data.size() - 1; i >= 0; i--) {
sb.replace(data.get(i).startIndex(), data.get(i).endIndex(), replace);
}
return sb.toString();
}
}
가장 먼저 Inspector 인터페이스를 만들어줍니다.
메소드 | 설명 |
inspect(String word) : List<Word> | 검사할 문자열이 인자로 들어오면 금지어 목록을 반환합니다. |
default mask(String word) : String | 금지어들을 replace 할 기본 문자열을 "?" 로 정의해 반환합니다. |
default mask(String word, String replace) : String | 금지어들을 replace 문자열로 변환합니다. |
※ 왜 StringBuilder를 썼을까?
- replaceAll 보다 StringBuilder를 사용하는게 더 빠릅니다.
- 금지단어가 여러개일 경우 for 문을 사용해 순환할 때 문자열의 길이가 변경되어 다음 순환 시 StringIndexOutOfBoundsException 가 발생할 수 있습니다.
- replaceAll에는 예외를 둘 수가 없습니다.
2번을 보완해 기존 데이터를 살려두고 replaceAll을 하는 경우에는 금지단어가 제외되지만 같은 단어로써 예외단어까지 영향을 미칠 수 있습니다.
// 금지어 : ["사과", "바나나"]
// 예외단어 : ["사과주스"]
String word = "사 과랑바나나";
List<Word> banWords = [
(Word: word="사과", startIndex=0, endIndex=3),
(Word: word="바나나", startIndex=4, endIndex=7)
];
// 1.
for (Word data : banWords) {
word = word.replaceAll(word.substring(data.startIndex, data.endIndex), replace);
// StringIndexOutOfBoundsException 발생
}
// 2.
String word = "사과랑 사과주스";
String replace = "?";
String temp = word;
for (Word data : banWords) {
word = word.replaceAll(temp.substring(data.startIndex, data.endIndex), replace);
}
// 예상한 결과 : ?랑 사과주스
// 실제 결과 : ?랑 ?주스
2. Inspector 구현체
@Component
public class InspectorImpl implements Inspector {
private final BanWordUtil banWordUtil;
private final ExceptWordUtil exceptWordUtil;
@Autowired
public InspectorImpl(InnerInspectConfig config) {
banWordUtil = config.getBanWordUtil();
exceptWordUtil = config.getExceptWordUtil();
}
private List<Word> executeBanWord(String word) {
return banWordUtil.filter(word);
}
private List<Word> executeExceptWord(String word, List<Word> beforeWords) {
return exceptWordUtil.filter(word, beforeWords);
}
@Override
public List<Word> inspect(String word) {
return executeExceptWord(word, executeBanWord(word));
}
}
메소드 | 설명 |
생성자 | Configuration을 주입받아 BanWordUtil, ExceptWordUtil을 설정해줍니다. |
mask() 메소드는 default 에 정의해놓았기때문에 Override를 하지 않았습니다.
executeExceptWord(word, executeBanWord(word));
메소드는 예외단어처리(검사할문자열, 금지어단어목록) 으로 보시면됩니다.
3. Config
@Component
public class InnerInspectConfig {
private final WordFactoryBuilder<BanWordUtil> banWordFactory;
private final WordFactoryBuilder<ExceptWordUtil> exceptWordFactory;
private final WordLoader wordLoader;
private InspectConfig inspectConfig;
@Autowired
public InnerInspectConfig(WordFactoryBuilder<BanWordUtil> banWordFactory, WordFactoryBuilder<ExceptWordUtil> exceptWordFactory, WordLoader wordLoader) {
this.banWordFactory = banWordFactory;
this.exceptWordFactory = exceptWordFactory;
this.wordLoader = wordLoader;
}
@Autowired(required = false)
public void setInspectConfig(InspectConfig inspectConfig) {
this.inspectConfig = inspectConfig;
}
@PostConstruct
private void onApplicationReady() {
if (inspectConfig != null) {
inspectConfig.addBanWords(banWordFactory);
inspectConfig.addExceptWords(exceptWordFactory);
}
banWordFactory.add(wordLoader.readBanWords());
exceptWordFactory.add(wordLoader.readExceptWords());
}
public BanWordUtil getBanWordUtil() {
return banWordFactory.build();
}
public ExceptWordUtil getExceptWordUtil() {
return exceptWordFactory.build();
}
}
내부 설정을 담당하는 클래스입니다.
이곳에서 BanWordUtil과 ExceptWordUtil을 build해서 Inspector에 전달해주는 역할을 하게 만들었습니다.
메소드 | 설명 |
setInspectConfig | 사용자로부터 금지어 및 예외단어를 추가할 수 있게 하기 위해 setter 주입으로 구현했습니다. Autowired(required = false)를 함으로써 사용자가 Config를 구현하지 않더라도 문제가 없도록 구현하기 위해 required = false를 했습니다. |
onApplicationReady | 의존성 주입이 완료되고(@PostConstruct) 사용자가 추가한 데이터(inspectConfig)와 기본데이터인 json 데이터(wordLoader.read~)를 각 factory에 추가합니다. |
getBanWordUtil getExceptWordUtil |
build 된 WordUtil을 Inspector에서 get 하는 메소드입니다. |
4. WordLoader
public interface WordLoader {
List<String> readBanWords();
List<String> readExceptWords();
}
@Component
public class WordLoaderImpl implements WordLoader {
private final Log logger = LogFactory.getLog(WordLoaderImpl.class);
@Override
public List<String> readBanWords() {
return read("static/BanWords.json");
}
@Override
public List<String> readExceptWords() {
return read("static/ExceptWords.json");
}
private List<String> read(String path) {
try {
return new ObjectMapper().readValue(new ClassPathResource(path).getInputStream(), new TypeReference<>() {});
} catch (IOException e) {
logger.error(e);
return Collections.emptyList();
}
}
}
Json 데이터를 읽어오는 클래스입니다.
5. WordFactory
public interface WordFactory {
WordFactory add(List<String> words);
}
public interface WordFactoryBuilder<T extends AbstractWordUtil> extends WordFactory {
T build();
}
WordFactory 인터페이스는 사용자로부터 금지어, 예외단어를 주입받기 위해 사용하는 클래스이고,
WordFactoryBuilder<T extends AbstractWordUtil> 는 Config에서 WordUtil을 build 하기 위해 사용되는 인터페이스입니다.
※ WordFactoryBuilder 를 도입한 이유
- factory와 util 간의 메소드 분리
- 사용자로부터 build 메소드가 노출되지 않아야함
- BanWordFactory, ExceptWordFactory에 build는 무조건 실행되어야 하기때문에 interface로 강제하기위함
구현체
public abstract class AbstractWordFactory<T extends AbstractWordUtil> implements WordFactoryBuilder<T> {
private final T wordUtil;
private final Set<String> distinctWordSet = new HashSet<>();
public AbstractWordFactory(T wordUtil) {
this.wordUtil = wordUtil;
}
@Override
public WordFactory add(List<String> words) {
distinctWordSet.addAll(words);
return this;
}
@Override
public T build() {
distinctWordSet.forEach(wordUtil::addWord);
wordUtil.build();
return wordUtil;
}
}
@Component
public class BanWordFactoryImpl extends AbstractWordFactory<BanWordUtil> {
@Autowired
public BanWordFactoryImpl(BanWordUtil banWordUtil) {
super(banWordUtil);
}
}
@Component
public class ExceptWordFactoryImpl extends AbstractWordFactory<ExceptWordUtil> {
public ExceptWordFactoryImpl(ExceptWordUtil exceptWordUtil) {
super(exceptWordUtil);
}
}
add된 words 는 Set으로 중복제거를 거쳐 마지막에 build로 wordUtil을 반환시킨다. Impl 인 구현체들은 생성자 주입을 담당하는 역할만 수행합니다.
6. AbstractWordUtil
public abstract class AbstractWordUtil {
protected final WordUtil wordUtil;
public AbstractWordUtil(WordUtil wordUtil) {
this.wordUtil = wordUtil;
}
public void addWord(String word) {
wordUtil.addWord(word);
}
public void build() {
wordUtil.build();
}
}
@Component
public class BanWordUtil extends AbstractWordUtil {
public BanWordUtil(@Qualifier("ban") WordUtil wordUtil) {
super(wordUtil);
}
public final List<Word> filter(String word) {
return wordUtil.search(word);
}
}
@Component
public class ExceptWordUtil extends AbstractWordUtil {
public ExceptWordUtil(@Qualifier("except") WordUtil wordUtil) {
super(wordUtil);
}
public final List<Word> filter(String newWord, List<Word> banWords) {
return (banWords.isEmpty()) ? List.of() : expectFilter(newWord, banWords);
}
private List<Word> expectFilter(String newWord, List<Word> banWords) {
List<Word> exceptWords = wordUtil.search(newWord);
if (exceptWords.isEmpty()) return banWords;
List<Word> newWords = new ArrayList<>();
a:for (Word banWord : banWords) {
for (Word exceptWord : exceptWords) {
if (banWord.isInclude(exceptWord)) continue a;
}
newWords.add(banWord);
}
return newWords;
}
}
자료구조를 제어하는 클래스입니다. BanWordUtil과 ExceptWordUtil에 반드시 존재해야하는 addWord(), build() 메소드를 추상클래스로 정의했습니다. 자료구조는 WordUtil 자리에 들어갈겁니다.
7. WordUtil
public interface WordUtil {
void addWord(String word);
void build();
List<Word> search(String word);
}
가장 중요한 부분입니다. 바로 문자열 검사에 대한 알고리즘이 들어가는 부분인데요. 처음에 이 부분을 직접 구현해서 사용했었는데, 위의 우아한기술블로그에서 소개한 아호코라식 알고리즘 을 기반으로 커스텀해서 띄어쓰기를 허용한 데이터를 반환하게 구성했습니다.
@Component
public class AhoCorasickWordUtil implements WordUtil {
private final TrieNode root;
public AhoCorasickWordUtil() {
this.root = new TrieNode();
}
static private class TrieNode {
Map<Character, TrieNode> children = new HashMap<>();
TrieNode failureLink = null;
Set<String> output = new HashSet<>();
}
@Override
public void addWord(String word) {
TrieNode node = root;
for (int i = 0; i < word.length(); i++) {
node = node.children.computeIfAbsent(word.charAt(i), k -> new TrieNode());
}
node.output.add(word);
}
@Override
public void build() {
Queue<TrieNode> queue = new LinkedList<>();
root.failureLink = root;
for (TrieNode node : root.children.values()) {
node.failureLink = root;
queue.add(node);
}
while (!queue.isEmpty()) {
TrieNode current = queue.poll();
for (Map.Entry<Character, TrieNode> entry : current.children.entrySet()) {
char c = entry.getKey();
TrieNode child = entry.getValue();
TrieNode failure = current.failureLink;
while (failure != root && !failure.children.containsKey(c)) {
failure = failure.failureLink;
}
if (failure.children.containsKey(c) && failure.children.get(c) != child) {
child.failureLink = failure.children.get(c);
} else {
child.failureLink = root;
}
child.output.addAll(child.failureLink.output);
queue.add(child);
}
}
}
@Override
public List<Word> search(String word) {
List<Word> result = new ArrayList<>();
if (word == null || word.isEmpty()) return result;
TrieNode node = root;
int realIndex = 0;
int nonSpaceIndex = 0;
Map<Integer, Integer> startIndices = new HashMap<>();
for (int i = 0; i < word.length(); i++) {
char c = word.charAt(i);
if (Character.isWhitespace(c)) {
realIndex++;
continue;
}
startIndices.put(nonSpaceIndex, realIndex);
while (node != root && !node.children.containsKey(c)) {
node = node.failureLink;
}
if (node.children.containsKey(c)) {
node = node.children.get(c);
}
for (String pattern : node.output) {
int start = startIndices.getOrDefault(nonSpaceIndex - (pattern.length() - 1), -1);
int end = realIndex + 1;
if (start != -1) {
result.add(new Word(pattern, start, end));
}
}
nonSpaceIndex++;
realIndex++;
}
return result;
}
}
8. Word
public record Word(String word, int startIndex, int endIndex) {
public boolean isInclude(Word word) {
return word.startIndex <= startIndex && word.endIndex >= endIndex;
}
}
금지어의 domain 객체입니다.
startIndex = includeIndex 이고, endIndex = excludeIndex 입니다.
테스트도 완료!
실행결과
@Configuration
public class TestConfig implements InspectConfig {
@Override
public void addBanWords(WordFactory factory) {
factory
.add(List.of("사과", "바나나", "오렌지", "수박"))
.add(List.of("오이", "감자", "고구마"));
}
@Override
public void addExceptWords(WordFactory factory) {
factory.add(List.of("사과주스", "호박고구마"));
}
}
json 기본 데이터 외에 사용자가 금지어와 예외단어를 추가할 수 있습니다.
잘 동작합니다.
마무리하면서 느낀점
이번 금지어 검사기는 즉흥적으로 시작했던터라 생각보다 시간이 좀 걸렸던 것같습니다. 자그마한 프로젝트를 하면서 배운게 몇가지가 있는습니다.
- JAVA 문자열 처리
앞서 말한대로 알고리즘을 사용하기 전에 직접 만들어보며 문자열에 대한 이해도가 높아졌고, 아호코라식 알고리즘을 알게 되고, 사용해본게 가장 유용했습니다. - 기능분리
위의 자료구조를 보면
- 설정파트를 담당하는 InspectConfig, InnerInspectConfig
- JSON을 불러오는 WordLoader
- 설정파트에서 데이터를 로드해 Util을 Builder 해주는 WordFactoryBuilder
- 금지어, 예외단어를 처리하는 BanWordUtil, ExceptWordUtil
- 문자열 처리를 담당하는 WordUtil
- 사용자가 사용하는 Inspector
이렇게 나누어져있는걸 볼 수 있습니다. 이런 역할을 분리하고, 인터페이스화, 추상화 단계를 직접 처리하며 기능분리에대한 이해도가 높아진것같습니다. - 외부로 부터 주입받는 데이터
가장 고심했던게 InspectConfig 를 사용하는 것이었습니다. 사용자가 인터페이스를 상속하지 않더라도 문제가 없어야하고, 상속했다면 데이터를 넣어주어야 하는것에 대해서 어떻게 해야하는지 고민이 많았습니다. 그 결과로 required = false의 setter 주입을 통해 이 문제를 해결했고, null 체크를 해서 데이터를 외부로부터 주입받는 방법으로 이 문제를 해결했습니다.
뭐 아무튼 그렇고, 이 문자열 검사기는 아직 미완성입니다. 완성이 되면 Maven Central Repository 에 업로드할 예정입니다.
9월 30일 업데이트!
WordLoader 객체를 삭제해서 json 파일을 더 이상 사용하지 않게 변경되었고,
https://mvnrepository.com/artifact/io.github.kimseungwo/wordinspector
0.0.10 버전부터 적용되었습니다.
public interface WordUtil {
default void addWord(String word) { push(word, 0);}
void push(String word, int index);
int find(String str, int index, int deep, boolean ignoreSpace);
}
@Component
public class WordUtilImpl implements WordUtil {
private final Map<Character, WordUtilImpl> data = new HashMap<>();
@Override
public void push(String word, int index) {
if (word.length() <= index) return;
char c = word.charAt(index);
if (!data.containsKey(c)) data.put(c, new WordUtilImpl());
data.get(c).push(word, index + 1);
}
@Override
public int find(String str, int index, int deep, boolean ignoreSpace) {
WordUtilImpl wordUtil = this;
while (true) {
if (wordUtil.data.isEmpty()) return deep == 0 ? -1 : deep;
if (str.length() <= index) return -1;
if (ignoreSpace && str.charAt(index) == ' ') {
if (deep == 0) return -1;
index++; deep++;
continue;
}
if (!wordUtil.data.containsKey(str.charAt(index))) return -1;
wordUtil = wordUtil.data.get(str.charAt(index));
index++; deep++;
}
}
}
직접 만들어본 자료구조입니다..
'FrameWork > Spring' 카테고리의 다른 글
[Spring Security] 스프링 시큐리티 Anonymous과 ExceptionHandling (4) (0) | 2024.12.15 |
---|---|
[Spring Security] 스프링 시큐리티 RememberMe (3) (0) | 2024.12.13 |
[Spring Security] 스프링 시큐리티 시작하기 (로그인, 로그아웃과 권한) (2) (0) | 2024.12.12 |
[Spring Security] 스프링 시큐리티 이해하기 (1) (0) | 2024.12.10 |
[Spring Boot] @Scheduled 스케줄 적용 (0) | 2024.11.11 |