개요

웹이나 앱을 개발하다보면 사용자가 입력한 데이터를 검증하는 과정에서 어쩔 수 없이 빼놓는게 금지어였습니다. 왜냐하면 사용할만한 라이브러리가 존재하지 않았기때문입니다. (제가 못찾을 것일수도 있습니다.)

그래서 Spring과 JAVA에 대해서 공부도 할겸 직접 라이브러리를 만들어보았습니다.

 

 

고르곤졸라는 되지만 고르곤 졸라는 안 돼! 배달의민족에서 금칙어를 관리하는 방법 | 우아한형

안녕하세요! 셀러시스템팀에서 서버 개발을 하고 있는 김예빈이라고 합니다. 배달의민족에는 금칙어를 관리하는 "통합금칙어시스템"이라는 것이 있습니다. 금칙어란? 법 혹은 규칙으로 사용이

techblog.woowahan.com

 

아이디어는 우아한기술블로그에서 가져왔으며 기술블로그에는 금지어 라이브러리를 제공하고 있지않습니다. 그래서 제가 직접 만드는겁니다.

 

 

어떤식으로 만들까?

금지어 검사기에 대한 대략적인 명세는 다음과 같습니다.

  1. 금지어는 json 데이터를 기반으로 데이터를 수집해야합니다.
  2. 검사기는 한번 로드되고 Singleton 이 보장되어야 합니다.
  3. 금지어이지만 예외단어는 금지어가 아니어야합니다. (예, "고르곤졸라" 에서 "졸라"는 금지어이지만 "고르곤졸라"는 예외단어)
  4. 사용자가 추가로 금지어나 예외단어를 추가할 수 있어야합니다.
  5. 금지어와 예외단어를 검사할때 띄어쓰기는 무시되어야합니다. (예, "AB가 금지어일경우 "A B"도 금지어)
  6. 금지어를 블라인드 할 수 있는 메소드가 있어야합니다.

이 정도로 정의하고 다음으로 넘어가겠습니다.

 

자료구조

클래스 설명
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먹을래"

 

  1. 검사할 문자열을 inspect() 에 넣으면 금지된 단어의 문자열과 시작인덱스(includeIndex), 마지막인덱스(excludeIndex) 가 포함된 List 데이터가 반환 되어야 한다.검사할 문자열을 inspect() 에 넣으면 금지된 단어의 문자열과 시작인덱스(includeIndex), 마지막인덱스(excludeIndex) 가 포함된 List 데이터가 반환 되어야 한다.
    마지막인덱스는 띄어쓰기가 포함한 인덱스이다.
  2. 예외단어에 포함된 문자열은 금지어에서 제외한다.
  3. 검사할 문자열에서 금지된 단어를 블라인드하려면 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를 썼을까?

  1. replaceAll 보다 StringBuilder를 사용하는게 더 빠릅니다.
  2. 금지단어가 여러개일 경우 for 문을 사용해 순환할 때 문자열의 길이가 변경되어 다음 순환 시 StringIndexOutOfBoundsException 가 발생할 수 있습니다.
  3. 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 를 도입한 이유

  1. factory와 util 간의 메소드 분리
  2. 사용자로부터 build 메소드가 노출되지 않아야함
  3. 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 기본 데이터 외에 사용자가 금지어와 예외단어를 추가할 수 있습니다.

 

 

 

 

잘 동작합니다.

 

 

마무리하면서 느낀점

이번 금지어 검사기는 즉흥적으로 시작했던터라 생각보다 시간이 좀 걸렸던 것같습니다. 자그마한 프로젝트를 하면서 배운게 몇가지가 있는습니다.

  1. JAVA 문자열 처리
    앞서 말한대로 알고리즘을 사용하기 전에 직접 만들어보며 문자열에 대한 이해도가 높아졌고, 아호코라식 알고리즘을 알게 되고, 사용해본게 가장 유용했습니다.
  2. 기능분리
    위의 자료구조를 보면
    - 설정파트를 담당하는 InspectConfig, InnerInspectConfig
    - JSON을 불러오는 WordLoader
    - 설정파트에서 데이터를 로드해 Util을 Builder 해주는 WordFactoryBuilder
    - 금지어, 예외단어를 처리하는 BanWordUtil, ExceptWordUtil
    - 문자열 처리를 담당하는 WordUtil
    - 사용자가 사용하는 Inspector
    이렇게 나누어져있는걸 볼 수 있습니다. 이런 역할을 분리하고, 인터페이스화, 추상화 단계를 직접 처리하며 기능분리에대한 이해도가 높아진것같습니다.
  3. 외부로 부터 주입받는 데이터
    가장 고심했던게 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++;
        }
    }

}

직접 만들어본 자료구조입니다..

+ Recent posts