^(a+)+$라는 정규 표현식에 "aaaaaaaaaaaaaab"를 매칭하면 왜 서버가 멈출까요?

정규 표현식의 내부

정규 표현식(regex)은 문자열에서 패턴을 검색하는 강력한 도구입니다. 하지만 그 내부에는 유한 오토마타(Finite Automata) 라는 이론적 기반이 있습니다. 이를 이해하면 성능 문제를 예방할 수 있습니다.

왜 필요한가

정규 표현식은 백엔드 개발에서 일상적으로 사용됩니다.

  • **입력 검증 **: 이메일, 전화번호, URL 형식 확인
  • ** 로그 파싱 **: 로그에서 특정 패턴 추출
  • Spring @Pattern: Bean Validation에서 패턴 검증
  • ** 보안 필터 **: 악성 입력 탐지

하지만 잘못 작성된 정규 표현식은 ReDoS 공격 에 취약해질 수 있어, 내부 동작을 이해하는 것이 중요합니다.

NFA와 DFA

DFA (Deterministic Finite Automaton)

결정적 유한 오토마타 입니다. 현재 상태와 입력 문자가 주어지면 다음 상태가 정확히 하나 로 결정됩니다.

PLAINTEXT
정규 표현식: ab*c

DFA:
  상태 0 --a--> 상태 1 --b--> 상태 1 (루프)
                       --c--> 상태 2 (수락)
  • 장점: 입력 길이에 비례하는 O(N) 매칭, 백트래킹 없음
  • 단점: 상태 수가 지수적으로 커질 수 있음, 역참조 불가능

NFA (Nondeterministic Finite Automaton)

비결정적 유한 오토마타 입니다. 하나의 입력에 대해 여러 상태로 동시에 전이할 수 있고, 입력 없이 전이하는 ε-전이 도 가능합니다.

PLAINTEXT
정규 표현식: a|b

NFA:
  시작 --ε--> 상태 1 --a--> 수락
       --ε--> 상태 2 --b--> 수락
  • 장점: 정규 표현식에서 자연스럽게 변환, 상태 수가 적음
  • 단점: 구현 방식에 따라 성능이 크게 달라짐

Thompson NFA vs 백트래킹 NFA

NFA를 실행하는 방식이 두 가지 있고, 이 차이가 성능을 결정합니다.

Thompson NFA (동시 시뮬레이션)

가능한 모든 상태를 동시에 추적합니다. 상태 집합을 유지하면서 한 문자씩 처리합니다.

JAVA
// Thompson NFA — 개념적 구현
public boolean thompsonMatch(String text) {
    Set<Integer> currentStates = epsilonClosure(Set.of(startState));

    for (char c : text.toCharArray()) {
        Set<Integer> nextStates = new HashSet<>();
        for (int state : currentStates) {
            // 현재 상태에서 c로 전이 가능한 모든 다음 상태
            nextStates.addAll(transition(state, c));
        }
        currentStates = epsilonClosure(nextStates);
    }

    return currentStates.contains(acceptState);
}
  • 시간: O(N × M) (N: 텍스트 길이, M: NFA 상태 수)
  • ** 백트래킹 없음** → ReDoS 불가능

백트래킹 NFA

하나의 경로를 끝까지 따라가다가, 실패하면 ** 되돌아와서** 다른 경로를 시도합니다.

PLAINTEXT
입력: "aaaaab"
패턴: (a+)+

백트래킹:
  "aaaaa" → 실패 → "aaaa" + "a" → 실패 → "aaa" + "aa" → ...
  → 분할 방법이 지수적으로 증가!
  • Java, Python, JavaScript 등 대부분의 언어가 이 방식
  • 역참조(\1), 후방 탐색 등 고급 기능 지원 가능
  • ReDoS에 취약

정규 표현식 → NFA → DFA 변환

PLAINTEXT
정규 표현식 → (Thompson 구성) → NFA → (부분집합 구성) → DFA → (최소화) → 최소 DFA

Thompson 구성

정규 표현식의 기본 연산을 NFA 조각으로 변환합니다.

JAVA
// 단일 문자: a
//   시작 --a--> 수락

// 연결: ab
//   NFA(a)의 수락 --ε--> NFA(b)의 시작

// 선택: a|b
//   새 시작 --ε--> NFA(a)의 시작
//          --ε--> NFA(b)의 시작
//   NFA(a)의 수락 --ε--> 새 수락
//   NFA(b)의 수락 --ε--> 새 수락

// 반복: a*
//   새 시작 --ε--> NFA(a)의 시작
//          --ε--> 새 수락
//   NFA(a)의 수락 --ε--> NFA(a)의 시작 (루프)
//                 --ε--> 새 수락

부분집합 구성 (NFA → DFA)

NFA의 상태 집합 이 DFA의 하나의 상태가 됩니다.

JAVA
// NFA 상태 {0, 1, 3} → DFA 상태 A
// NFA 상태 {2, 4}    → DFA 상태 B
// ...

최악의 경우 DFA 상태 수가 2^(NFA 상태 수)가 될 수 있지만, 실제로는 훨씬 적습니다.

ReDoS (Regular expression Denial of Service)

위험한 패턴

JAVA
// 위험! 중첩 반복
Pattern.compile("^(a+)+$");       // "aaaaab"에 지수 시간
Pattern.compile("^(a|a)+$");      // 같은 문제
Pattern.compile("^(a+b?)+$");     // 마찬가지

왜 지수적인가

(a+)+에 "aaaaab"를 매칭할 때, "aaaaa"를 그룹으로 나누는 방법의 수가 지수적입니다.

PLAINTEXT
"aaaaa" 분할 방법:
(aaaaa), (aaaa)(a), (aaa)(aa), (aaa)(a)(a), (aa)(aaa), ...
→ 2^(n-1) 가지

각 분할마다 끝의 "b"에서 실패하므로, 모든 분할을 시도합니다.

방어 전략

JAVA
// 1. 중첩 반복 피하기
// Bad:  ^(a+)+$
// Good: ^a+$

// 2. 원자 그룹/독점적 수량자 사용 (Java)
Pattern.compile("^(?>a+)+$");  // 원자 그룹 — 백트래킹 방지

// 3. 타임아웃 설정
// Java에서는 별도 스레드로 실행하고 타임아웃 적용
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Boolean> future = executor.submit(() -> pattern.matcher(input).matches());
try {
    return future.get(100, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
    future.cancel(true);
    return false;
}

// 4. 입력 길이 제한
if (input.length() > MAX_LENGTH) {
    throw new ValidationException("입력이 너무 깁니다");
}

Java Pattern의 내부

Java의 Pattern.compile()은 백트래킹 NFA 엔진을 사용합니다.

JAVA
// Java 정규 표현식 사용
Pattern pattern = Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
Matcher matcher = pattern.matcher("2026-03-19");

if (matcher.matches()) {
    System.out.println("날짜 형식 맞음");
}

// 그룹 캡처
Pattern p = Pattern.compile("(\\w+)@(\\w+\\.\\w+)");
Matcher m = p.matcher("user@example.com");
if (m.matches()) {
    String user = m.group(1);   // "user"
    String domain = m.group(2); // "example.com"
}

Pattern 컴파일 캐시

정규 표현식 컴파일은 비용이 높으므로 재사용해야 합니다.

JAVA
// Bad — 매 호출마다 컴파일
public boolean validate(String input) {
    return input.matches("\\d+"); // 내부에서 매번 Pattern.compile()
}

// Good — 한 번 컴파일하고 재사용
private static final Pattern DIGIT_PATTERN = Pattern.compile("\\d+");

public boolean validate(String input) {
    return DIGIT_PATTERN.matcher(input).matches();
}

백엔드/실무 연결

Spring @Pattern

Bean Validation에서 정규 표현식으로 입력을 검증합니다.

JAVA
public class UserDto {
    @Pattern(regexp = "^[a-zA-Z0-9_]{3,20}$", message = "올바른 사용자명이 아닙니다")
    private String username;

    @Pattern(regexp = "^\\+?\\d{10,15}$", message = "올바른 전화번호가 아닙니다")
    private String phone;
}

이때 ReDoS에 취약한 패턴을 쓰면 공격자가 악의적 입력으로 서버를 마비시킬 수 있습니다.

로그 파싱

Logback, Log4j에서 정규 표현식으로 로그를 파싱합니다. 대량의 로그를 처리할 때 패턴 성능이 중요합니다.

API Gateway 라우팅

Spring Cloud Gateway나 Nginx에서 URL 패턴을 정규 표현식으로 매칭합니다.

NFA vs DFA 비교

항목NFA (백트래킹)DFA
매칭 시간O(2^N) 최악O(N) 보장
구성 시간O(M)O(2^M) 최악
메모리적음많을 수 있음
역참조가능불가능
ReDoS 취약취약안전
사용 언어Java, Python, JSGo, RE2

주의할 점

백트래킹 기반 정규 표현식의 지수적 시간 복잡도 (ReDoS)

(a+)+$ 같은 패턴에 악의적 입력이 들어오면 NFA 백트래킹이 지수적으로 폭발합니다. 프로덕션에서는 입력 길이를 제한하거나 RE2 같은 DFA 기반 엔진을 사용해야 합니다.

탐욕적(Greedy) vs 게으른(Lazy) 매칭 혼동

.*는 가능한 많이 매칭(탐욕적)하고, .*?는 가능한 적게 매칭(게으른)합니다. HTML 태그 추출 같은 경우 <.*>는 여러 태그를 한꺼번에 삼키므로 <.*?>를 써야 합니다.


정리

  • 정규 표현식은 내부적으로 NFA 또는 DFA 를 구성하여 패턴을 매칭합니다
  • Thompson NFA 는 O(N × M)으로 안전하지만, 백트래킹 NFA 는 최악 지수 시간입니다
  • Java, Python, JavaScript는 백트래킹 NFA를 사용하므로 ReDoS에 취약 합니다
  • 중첩 반복 (a+)+ 같은 패턴을 피하고, 입력 길이를 제한하는 것이 방어의 핵심입니다
  • Pattern.compile()은 비싸므로 반드시 상수로 캐싱 하여 재사용해야 합니다
  • Spring @Pattern 사용 시 ReDoS를 고려해야 합니다
댓글 로딩 중...