정규 표현식의 내부 — NFA와 DFA로 패턴을 매칭하는 법
^(a+)+$라는 정규 표현식에 "aaaaaaaaaaaaaab"를 매칭하면 왜 서버가 멈출까요?
정규 표현식의 내부
정규 표현식(regex)은 문자열에서 패턴을 검색하는 강력한 도구입니다. 하지만 그 내부에는 유한 오토마타(Finite Automata) 라는 이론적 기반이 있습니다. 이를 이해하면 성능 문제를 예방할 수 있습니다.
왜 필요한가
정규 표현식은 백엔드 개발에서 일상적으로 사용됩니다.
- **입력 검증 **: 이메일, 전화번호, URL 형식 확인
- ** 로그 파싱 **: 로그에서 특정 패턴 추출
- Spring @Pattern: Bean Validation에서 패턴 검증
- ** 보안 필터 **: 악성 입력 탐지
하지만 잘못 작성된 정규 표현식은 ReDoS 공격 에 취약해질 수 있어, 내부 동작을 이해하는 것이 중요합니다.
NFA와 DFA
DFA (Deterministic Finite Automaton)
결정적 유한 오토마타 입니다. 현재 상태와 입력 문자가 주어지면 다음 상태가 정확히 하나 로 결정됩니다.
정규 표현식: ab*c
DFA:
상태 0 --a--> 상태 1 --b--> 상태 1 (루프)
--c--> 상태 2 (수락)
- 장점: 입력 길이에 비례하는 O(N) 매칭, 백트래킹 없음
- 단점: 상태 수가 지수적으로 커질 수 있음, 역참조 불가능
NFA (Nondeterministic Finite Automaton)
비결정적 유한 오토마타 입니다. 하나의 입력에 대해 여러 상태로 동시에 전이할 수 있고, 입력 없이 전이하는 ε-전이 도 가능합니다.
정규 표현식: a|b
NFA:
시작 --ε--> 상태 1 --a--> 수락
--ε--> 상태 2 --b--> 수락
- 장점: 정규 표현식에서 자연스럽게 변환, 상태 수가 적음
- 단점: 구현 방식에 따라 성능이 크게 달라짐
Thompson NFA vs 백트래킹 NFA
NFA를 실행하는 방식이 두 가지 있고, 이 차이가 성능을 결정합니다.
Thompson NFA (동시 시뮬레이션)
가능한 모든 상태를 동시에 추적합니다. 상태 집합을 유지하면서 한 문자씩 처리합니다.
// 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
하나의 경로를 끝까지 따라가다가, 실패하면 ** 되돌아와서** 다른 경로를 시도합니다.
입력: "aaaaab"
패턴: (a+)+
백트래킹:
"aaaaa" → 실패 → "aaaa" + "a" → 실패 → "aaa" + "aa" → ...
→ 분할 방법이 지수적으로 증가!
- Java, Python, JavaScript 등 대부분의 언어가 이 방식
- 역참조(
\1), 후방 탐색 등 고급 기능 지원 가능 - ReDoS에 취약
정규 표현식 → NFA → DFA 변환
정규 표현식 → (Thompson 구성) → NFA → (부분집합 구성) → DFA → (최소화) → 최소 DFA
Thompson 구성
정규 표현식의 기본 연산을 NFA 조각으로 변환합니다.
// 단일 문자: 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의 하나의 상태가 됩니다.
// NFA 상태 {0, 1, 3} → DFA 상태 A
// NFA 상태 {2, 4} → DFA 상태 B
// ...
최악의 경우 DFA 상태 수가 2^(NFA 상태 수)가 될 수 있지만, 실제로는 훨씬 적습니다.
ReDoS (Regular expression Denial of Service)
위험한 패턴
// 위험! 중첩 반복
Pattern.compile("^(a+)+$"); // "aaaaab"에 지수 시간
Pattern.compile("^(a|a)+$"); // 같은 문제
Pattern.compile("^(a+b?)+$"); // 마찬가지
왜 지수적인가
(a+)+에 "aaaaab"를 매칭할 때, "aaaaa"를 그룹으로 나누는 방법의 수가 지수적입니다.
"aaaaa" 분할 방법:
(aaaaa), (aaaa)(a), (aaa)(aa), (aaa)(a)(a), (aa)(aaa), ...
→ 2^(n-1) 가지
각 분할마다 끝의 "b"에서 실패하므로, 모든 분할을 시도합니다.
방어 전략
// 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 정규 표현식 사용
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 컴파일 캐시
정규 표현식 컴파일은 비용이 높으므로 재사용해야 합니다.
// 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에서 정규 표현식으로 입력을 검증합니다.
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, JS | Go, 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를 고려해야 합니다