채팅 중 '금칙어'를 막으려면? - (4) 왜 Pattern을 캐싱해야 할까?

Alex·2025년 1월 23일
0

Plaything

목록 보기
84/118

보통 정규표현식에서 Pattern을 사용할 때 match 메서드를 쓰곤 하는데

공식문서에는 아래와 같은 내용이 나온다.

If a pattern is to be used multiple times, compiling it once and reusing
* it will be more efficient than invoking this method each time.

여러번 사용하는 Pattern이라면 그 패턴을 캐싱해서 사용하라는 안내문이다.

그 이유는

matches 메서드가 매번 Pattern을 컴파일 하고서 작업을 하기 때문이다.
Pattern을 컴파일 하는 작업은 상당히 비싼 작업이다. 매번 정규표현식을 파싱하고 객체를 만들어야 하기 때문이다.

그래서 미리 Pattern 자체를 컴파일 해두고 사용하라는 것이다.

이렇게 Pattern 객체를 계속 만드는 방식은 GC 호출이 잦아지고 그만큼 성능에 영향을 줄 수밖에 없다고 한다.

cf)

참고로 String.matches도 동일하다. 내부적으로 Pattern.matches를 사용하기 때문이다.

캐싱 없이 테스트해보자

   public void filterWords(String words) {
        

        String temp =
                words.replaceAll("\\s", "");

//                WHITE_SPACE.matcher(words).replaceAll("");
        String finalResult1 =
                words.replaceAll("[^ㄱ-ㅎ]", "");
//
//                KEEP_ONLY_PATTERN.matcher(words).replaceAll("");
        String finalResult2 =
                temp.replaceAll("(?<=[가-힣a-zA-Zㄱ-ㅎㅏ-ㅣ])[^가-힣a-zA-Z]+(?=[가-힣a-zA-Zㄱ-ㅎㅏ-ㅣ])|" +
                        // 2) 단어 앞뒤의 특수문자/숫자 제거 (자음/모음은 유지)
                        "^[^가-힣a-zA-Zㄱ-ㅎㅏ-ㅣ]+|[^가-힣a-zA-Zㄱ-ㅎㅏ-ㅣ]+$|" +
                        "\\s", "");
//                FILTER_ALL_PATTERN.matcher(temp).replaceAll("");

        boolean hasBankAccount = finalResult2.matches("\\d{3,6}-\\d{2,6}-\\d{3,6}");
//                BANK_ACCOUNT.matcher(words).find();
        boolean hasPhoneNumber = finalResult2.matches("01[0-9]{1}-[0-9]{3,4}-[0-9]{4}");
//        PHONE_NUMBER.matcher(words).find();

        if (hasBankAccount || hasPhoneNumber) {
            throw new CustomException(ErrorCode.BAD_WORDS_FILTER);
        }

        Emit emit1 = filter.firstMatch(finalResult1);
        Emit emit2 = filter.firstMatch(finalResult2);
        if (emit1 != null || emit2 != null) {
            throw new CustomException(ErrorCode.BAD_WORDS_FILTER);
        }
    }

정규표현식을 Pattern으로 캐싱하지 않고 그냥 사용하는 버전이다.

이 상황에서 성능은 어떨까?

    @Test
    void testx() {

        List<String> messages = generateTestInputs(5000);
        int iterations = 10;
        long[] executionTimes = new long[iterations];

        for (int i = 0; i < iterations; i++) {
            long startTime = System.nanoTime();

            for (String chat : messages) {
                try {
                    filteringService.filterWords(chat);
                } catch (CustomException e) {
                    // 예외는 무시하고 계속 진행
                }

            }

            executionTimes[i] = System.nanoTime() - startTime;
        }

        // 평균 계산
        double averageTime = Arrays.stream(executionTimes).average().orElse(0) / 1000000.0;  // nano -> ms

        // 최소, 최대 시간
        double minTime = Arrays.stream(executionTimes).min().orElse(0) / 1000000.0;
        double maxTime = Arrays.stream(executionTimes).max().orElse(0) / 1000000.0;

        System.out.println("평균 실행 시간: " + averageTime + "ms");
        System.out.println("최소 실행 시간: " + minTime + "ms");
        System.out.println("최대 실행 시간: " + maxTime + "ms");
        System.out.println("메시지당 평균 처리 시간: " + (averageTime / messages.size()) + "ms");
    }


    private List<String> generateTestInputs(int count) {
        List<String> baseInputs = Arrays.asList(
                "안녕하세요ㅋㅋㅎㅎ",
                "xx~관련#단어",
                "xx테스트123ABC",
                "특수@#$문자!섞인~문장",
                "일반적인 대화문장",
                "계좌번호",
                "휴대폰번호"
        );

        Random random = new Random();
        List<String> testInputs = new ArrayList<>();

        for (int i = 0; i < count; i++) {
            // 기본 문장 선택
            String base = baseInputs.get(random.nextInt(baseInputs.size()));

            // 랜덤하게 변형
            StringBuilder modified = new StringBuilder(base);
            // 랜덤 위치에 특수문자 삽입
            modified.insert(random.nextInt(modified.length()), "!@#$%^&*".charAt(random.nextInt(8)));
            // 랜덤 위치에 숫자 삽입
            modified.insert(random.nextInt(modified.length()), String.valueOf(random.nextInt(10)));
            // 랜덤 위치에 자음/모음 삽입
            modified.insert(random.nextInt(modified.length()), "ㄱㄴㄷㄹㅁㅂㅅㅇㅈㅊㅋㅌㅍㅎ".charAt(random.nextInt(14)));

            testInputs.add(modified.toString());
        }
        return testInputs;
    }

캐싱을 하지 않아도 이정도면 굉장히 빠른 편이다.
5천개의 메시지를 54ms만에 필터링한 것이다.

캐싱하고 테스트해보자

public class PATTERN {

    public static final Pattern WHITE_SPACE = Pattern.compile("\\s");

    public static final Pattern FILTER_ALL_PATTERN = Pattern.compile(
            // 1) 한글 사이의 모든 문자(자음/모음/영문자/특수문자/숫자) 제거
            "(?<=[가-힣a-zA-Zㄱ-ㅎㅏ-ㅣ])[^가-힣a-zA-Z]+(?=[가-힣a-zA-Zㄱ-ㅎㅏ-ㅣ])|" +
                    // 2) 단어 앞뒤의 특수문자/숫자 제거 (자음/모음은 유지)
                    "^[^가-힣a-zA-Zㄱ-ㅎㅏ-ㅣ]+|[^가-힣a-zA-Zㄱ-ㅎㅏ-ㅣ]+$|" +
                    "\\s"
    );

    public static final Pattern KEEP_ONLY_PATTERN = Pattern.compile("[^ㄱ-ㅎ]");

    public static final Pattern BANK_ACCOUNT = Pattern.compile("\\d{3,6}-\\d{2,6}-\\d{3,6}");

    public static final Pattern PHONE_NUMBER = Pattern.compile("01[0-9]{1}-[0-9]{3,4}-[0-9]{4}");
}

이렇게 컴파일한 Pattern을 캐싱해서 사용하는 방식이다.

속도는 거의 2배 정도 빨라진 것으로 보인다. 캐싱을 안해도 워낙 빠른 상황이라서 큰 차이가 없긴 하다.

그럼에도 캐싱을 사용할 것이다.

그 이유는

  • EC2 CPU 리소스가 넉넉하지 않다.
  • 채팅이 리소스를 많이 사용한다.
  • 잦은 Pattern 객체 생성으로 GC가 자주 발생하면 작업이 멈추는 현상이 그만큼 발생한다는 뜻이다.
profile
답을 찾기 위해서 노력하는 사람

0개의 댓글