오늘의하루

Java Pattern.matches의 오남용으로 인한 성능 문제와 최적화 방법 본문

JAVA

Java Pattern.matches의 오남용으로 인한 성능 문제와 최적화 방법

오늘의하루_master 2024. 12. 23. 12:14
반응형

Java에서는 Pattern.matches 메소드를 통해 정규표현식을 쉽게 사용할 수 있지만 내부 동작 원리를 제대로 이해하지 못하면 성능 문제가 발생할 수 있습니다.

Pattern.matches의 동작 원리

Pattern.matches(String regex, CharSequence input)는 아래와 같은 과정을 거쳐 입력 문자열을 매칭합니다.

> 정규표현식 컴파일

메소드 호출 시 입력받은 정규표현식(regex)을 NFA(비결정적 유한 오토마타, Non-deterministic Finite Automaton)에서 정규표현식에 따라 DFA(결정적 유한 오토마타, Deterministic Finite Automaton)로 변환하며 이 과정에서 정규표현식을 해석하고 효율적으로 실행될 수 있는 내부 구조를 생성하는데 상당한 비용이 소모됩니다.

> 매칭 실행

컴파일된 NFA or DFA를 사용하여 입력 문자열(input)을 매칭합니다.

Pattern.matches 내부 코드

    public static boolean matches(String regex, CharSequence input) {
        Pattern p = Pattern.compile(regex);
        Matcher m = p.matcher(input);
        return m.matches();
    }

 

Pattern p = Pattern.compile(regex);

  • 정규표현식(regex)을 NFA로 변환하고 복잡한 표현식의 경우 NFA 후에 다시 DFA로 변환합니다.
  • DFA는 매칭 속도가 NFA보다 빠르며 메모리 소모가 큽니다.

Matcher m = p.matcher(input);

  • 입력 문자열과 매칭을 위한 준비 단계로, Pattern 객체를 기반(NFA or DFA)으로 상태를 초기화합니다.

m.matches();

  • 입력 문자열과 정규표현식을 매칭하는 부분입니다.

결론

Pattern.matches 메소드를 매번 호출하면 다음과 같은 성능 문제가 발생할 수 있습니다.

  • 비싼 compile 과정
    • 매번 정규표현식을 NFA or DFA로 변환하는 과정이 반복되어 자원 낭비가 발생합니다.
  • 재사용 불가능 
    • 동일한 정규표현식을 가지고 매번 새롭게 Pattern 객체를 생성하므로 메모리와 자원 낭비가 발생합니다.

성능 테스트 코드

아래 테스트 코드를 사용하여 실제 성능 차이를 확인할 수 있습니다.

    @BeforeEach
    public void beforeEach() {
        inputs = createStrings(1000);
    }
    
    @Test
    public void prepareToCompile() {

        Pattern pattern = Pattern.compile(regex);
        
        long startTime = System.nanoTime();
        for (String input : inputs) {
            pattern.matcher(input).matches();
        }
        long endTime = System.nanoTime();
        long runTime = endTime - startTime;
        System.out.println("prepareToCompile 시간: " + runTime / 1_000_000 + " ms");
    }

    @Test
    public void notPrepareToCompile() {
        long startTime = System.nanoTime();
        for (String input : inputs) {
            Pattern pattern = Pattern.compile(regex);  // 매번 Pattern.compile 호출
            pattern.matcher(input).matches();
        }
        long endTime = System.nanoTime();
        long runTime = endTime - startTime;
        System.out.println("notPrepareToCompile 시간: " + runTime / 1_000_000 + " ms");
    }
  • prepareToCompile 시간: 79ms
  • notPrepareToCompile 시간: 200ms

테스트 결과 : Pattern.compile을 매번 호출하는 것과 한 번만 호출하는 것의 성능 차이가 있음을 알 수 있습니다.


적용 사례 (예시)

레거시 프로젝트를 유지보수하는 과정에서 아래와 같은 코드가 성능 문제를 일으킬 수 있음을 발견했습니다.

public class Example {

    private static final String regex = ".*[a-zA-Z].*\d.*[^a-zA-Z\d\s].*";
    
    public boolean isValidExample(List<String> inputs) {
        if (inputs.isEmpty()) {
            throw new IllegalArgumentException("Input list cannot be empty.");
        }

        for (String input : inputs) {
            if (!Pattern.matches(regex, input)) {
                return false;
            }
        }
        return true;
    }
}

이 코드에서는 Pattern.matches를 매번 호출하고 있기 때문에, inputs의 길이에 따라 정규표현식이 반복해서 컴파일되기 때문에 이를 최적화하려면 Pattern 객체를 미리 생성하여 재사용하는 방식으로 변경해야 합니다.

public class Example {

    private static final String regex = ".*[a-zA-Z].*\d.*[^a-zA-Z\d\s].*";
    private static final Pattern pattern = Pattern.compile(regex);
    
    public boolean isValidExample(List<String> inputs) {
        if (inputs.isEmpty()) {
            throw new IllegalArgumentException("Input list cannot be empty.");
        }
        for (String input : inputs) {
            if (!pattern.matcher(input).matches()) {
                return false;
            }
        }
        return true;
    }
}

이렇게 변경하면 Pattern 객체를 한 번만 만들게 되어 성능을 개선했습니다.

 

반응형
Comments