정규식 실전 패턴: 앵커·수량자·캡처 그룹
요약 (TL;DR)
2019년 7월 2일, Cloudflare가 단 하나의 정규식 — WAF의 새 규칙에 들어간 .*(?:.*=.*)처럼 거의 매치되다 실패하는 패턴 — 으로 27분 동안 글로벌 트래픽이 50% 가까이 떨어졌습니다. 백트래킹이 폭발했고, 단일 코어가 100%로 막히면서 모든 요청이 멈췄습니다. 그 사고가 정규식에 대해 가르쳐주는 가장 솔직한 한 가지는 이겁니다 — 정규식은 자기 비용을 잘 숨깁니다. 평소엔 작고 빠르고 매력적이지만, “잘못된 입력 한 줄”에 한 시스템이 멈출 수 있습니다. 그래서 이 글의 어조는 약간 회의적입니다. 실무 정규식은 대부분 몇 가지 조각 위에 세워집니다. 시작·끝·단어 경계를 지정하는 앵커, “이 문자들 중 하나”를 기술하는 문자 클래스, “몇 번”을 정하는 수량자, 그리고 매치를 캡처하거나 참조하거나 선택지를 묶는 그룹입니다. 잘 쓰면 이메일 대략 매치, 로그에서 필드 추출, 전화번호 정규화 같은 흔한 문제가 짧고 읽히는 패턴으로 해결됩니다. 잘못 쓰면 너무 많이 혹은 너무 적게 매치하거나, 위 사례처럼 엔진을 세우게 됩니다. HTML·JSON·XML은 정규식으로 파싱하지 마세요. 정규 언어는 균형 잡힌 중첩을 기술하지 못합니다. 엔진별 차이도 큽니다 — ECMAScript, PCRE(Perl/PHP), 파이썬 re, Go RE2는 룩어라운드·역참조·유니코드 처리에서 다르고, Cloudflare 사고도 PCRE의 백트래킹 모델 때문이었습니다(Go RE2였다면 안 일어났을 일입니다).
배경/개념
정규식은 문자열 매치를 위한 작은 문법입니다. 자주 쓰는 조각들을 이름으로 분리해서 정리하면 다음과 같습니다.
앵커는 문자를 소비하지 않고 위치를 단언합니다. ^는 입력 시작(멀티라인 모드에서는 줄 시작), $는 끝, \b는 단어 경계입니다. 앵커 없는 abc는 긴 문자열 안 어디든 매치하지만, ^abc$는 문자열 전체가 abc일 때만, \babc\b는 독립된 단어 abc일 때만 매치합니다.
문자 클래스는 한 글자가 “어떤 집합에 속하는지”를 표현합니다. [abc]는 a, b, c 중 하나, [a-z]는 소문자, [^0-9]는 “숫자 아닌 모든 것”입니다. 흔한 경우엔 축약이 있습니다. \d(숫자), \w(단어 문자 — 문자, 숫자, 밑줄), \s(공백). 대문자 \D, \W, \S는 보수. .는 기본적으로 줄바꿈을 제외한 모든 문자를 매치하고, s(dotall) 플래그가 이 동작을 바꿉니다.
수량자는 바로 앞 토큰에 붙어 반복 횟수를 정합니다. *는 0회 이상, +는 1회 이상, ?는 0회 또는 1회, {n,m}은 “n에서 m회”(각 경계는 생략 가능). 기본은 탐욕(greedy): 가능한 한 많이 매치한 뒤, 뒤가 실패할 때에만 한 칸씩 양보합니다. ?를 붙여 지연(lazy) 으로 바꾸면 — *?, +?, ?? — 가능한 한 적게 매치하고, 뒤가 강제하면 확장합니다. 일부 엔진은 소유(possessive) 수량자(*+, ++)를 지원해 탐욕적으로 매치하되 실패해도 양보하지 않습니다. 이는 병적인 입력에서 백트래킹 폭발을 막아 줍니다 — 위 Cloudflare 사례에서 정확히 빠진 도구입니다.
그룹은 부분 패턴을 감쌉니다. (pattern)은 캡처 그룹으로, 여는 괄호 순서대로 1부터 번호가 붙고, 패턴 안에서는 \1로, 호스트 언어에서는 인덱스 접근자로 참조할 수 있습니다. (?<name>pattern)은 이름 있는 캡처. (?:pattern)은 비캡처 그룹으로, 순전히 선택((?:cat|dog))이나 수량자 묶음용으로 쓰며 매치를 기록하지 않습니다. 그룹 안의 |는 대안(alternation)입니다.
마지막으로 플래그가 엔진 동작을 조정합니다. i는 대소문자 무시, m은 멀티라인(^·$가 줄 경계에서도 매치), s는 dotall, u는 유니코드. 자바스크립트에서 u 플래그는 \p{L}(“모든 글자”) 같은 유니코드 속성 이스케이프도 켜 줍니다.
비교/데이터
| 수량자 | 탐욕(기본) | 지연(? 접미) | 소유(지원 시) |
|---|---|---|---|
* / *? / *+ | 최대한 많이, 실패 시 양보 | 최대한 적게, 강제되면 확장 | 최대한 많이, 양보 없음 |
+ / +? / ++ | 1회 이상, 탐욕 | 1회 이상, 지연 | 1회 이상, 양보 없음 |
? / ?? / ?+ | 0–1, 1 선호 | 0–1, 0 선호 | 0–1, 양보 없음 |
{n,m} / {n,m}? / {n,m}+ | 범위, 탐욕 | 범위, 지연 | 범위, 양보 없음 |
탐욕이 기본인 이유는 대체로 그것이 맞기 때문입니다. 지연이 필요한 건 “뒤 패턴”이 너무 관대할 때입니다. HTML 발췌에서 <b>...</b> 한 쌍을 꺼내려면 <b>(.*?)</b>처럼 지연을 써야 여러 태그를 삼키지 않습니다(그래도 진짜 HTML에는 정규식을 쓰지 마세요). 소유 수량자와 원자 그룹 (?>...)은 거의 매치되다 실패하는 패턴에서 기하급수적 역탐색을 차단할 때 유용합니다. PCRE/Java/Ruby의 Oniguruma는 이를 지원하고, ECMAScript와 Python re는 지원하지 않습니다(Python은 3.11부터 re 모듈에 원자 그룹을 추가했습니다).
실전 시나리오
시나리오 1 — 실무적인 이메일 매치. RFC 5322의 완전한 이메일 문법은 주석·인용된 로컬 파트·IP 리터럴 중첩까지 허용하므로 이를 다 덮는 정규식은 악명 높게 거대하고(가장 유명한 시도는 6,425자), 그래놓고도 진짜 파서는 못 됩니다. 제가 운영에서 쓴 패턴은 거의 항상 ^[^\s@]+@[^\s@]+\.[^\s@]+$ — “비어 있지 않고, 공백 없고, @가 엉뚱한 곳에 없고, 도메인에 점이 하나는 있다” — 정도였습니다. 명백한 오타를 거르는 데 충분하고, 주소가 실제로 존재하는지는 메일 한 통 보내야 알 수 있습니다. 모양은 정규식으로, 존재는 이메일로.
시나리오 2 — 국제 포맷을 포함한 전화번호. +82 2-1234-5678, (02) 1234-5678, 82-2-1234-5678은 같은 서울 번호의 다른 표기입니다. ^\+?\d{1,3}[-\s().]*\d{1,4}[-\s().]*\d{3,4}[-\s().]*\d{3,4}$ 같은 패턴으로 일반적 구두점을 수용하고, 이후 정규화 단계에서 구두점을 제거해 숫자만 남긴 정식 표현으로 바꾸는 흐름이 일반적입니다. 라우팅·저장·다이얼처럼 진짜로 중요한 처리에는 Google의 libphonenumber를 쓰세요. 제가 한 팀에서 본 가장 빠른 회의 결론은 “정규식으로는 안 한다”였고, 그날 한 주 분의 기술부채를 막았습니다. 정규식의 역할은 “전화번호처럼 보이는가”의 표면 검사뿐입니다.
시나리오 3 — 로그 한 줄에서 필드 추출. 2026-04-13T02:11:05Z 192.0.2.42 "GET /search?q=foo HTTP/1.1" 200 1534 같은 라인은 하나의 패턴으로 쪼갤 수 있습니다. ^(?<ts>\S+)\s+(?<ip>\S+)\s+"(?<method>\w+)\s+(?<path>\S+)\s+\S+"\s+(?<status>\d+)\s+(?<bytes>\d+)$. 여기서 이름 있는 그룹의 가치가 드러납니다. 결과 매치 객체는 딕셔너리처럼 접근되고, 각 필드가 이름으로 꺼내집니다. 로그 포맷이 바뀌면 이 패턴 자체가 그 포맷의 문서 역할을 합니다.
자주 하는 오해
“정규식으로 이메일을 완벽히 검증할 수 있다.” 모양까지만입니다. RFC 5322는 합리적으로 정규식에 인코딩할 수 있는 수준을 벗어나 있고, 설령 인코딩해도 “주소 모양은 맞다”가 “메일함이 존재한다”를 뜻하지 않습니다. 업계 표준은 간단한 정규식 + 확인 메일입니다.
“탐욕은 항상 지연보다 느리다.” 사실이 아닙니다. 수량자의 서브패턴이 매우 제한적이면 탐욕 매치가 한 번의 긴 전진으로 끝나 더 빠르기도 합니다. 지연이 이기는 지점은 <b>(.*?)</b>처럼 뒤 패턴이 매치를 앵커링해 줄 때입니다. 반사적으로 ?를 덧붙이기 전에 실제 입력으로 벤치마크하세요.
“정규식 엔진은 다 같다.” 아닙니다. ECMAScript는 소유 수량자와 원자 그룹이 없고(v 플래그로 약간 개선되긴 했습니다), 파이썬 re는 독자적인 유니코드 속성 집합을 가지며, PCRE는 역참조·재귀 패턴까지 지원합니다. Ruby와 Rust의 onig 크레이트가 쓰는 Oniguruma는 또 다른 변형을 가집니다. Go의 RE2는 역참조와 룩어라운드를 포기하는 대신 입력 길이에 선형인 실행 시간을 보장합니다 — Cloudflare가 사고 후 이전을 검토한 바로 그 엔진입니다. Perl 튜토리얼에서 복사한 패턴이 자바스크립트에서 안 도는 건 흔한 일입니다.
“정규식으로 HTML(혹은 JSON, XML)을 파싱할 수 있다.” 정규 언어는 균형 잡힌 중첩을 기술하지 못합니다. 특정 속성값 같은 잘 정의된 부분 패턴을 추출하는 데까지는 정규식이 유용하지만, 트리 전체를 올바르게 파싱할 수는 없습니다. 중첩 포맷은 전용 파서(DOMParser, JSON.parse, XML 라이브러리, CSV 리더)를 써야 합니다. Stack Overflow의 “정규식 vs HTML” 전설(2009년 답변)은 토론이 아니라 경고입니다.
체크리스트 또는 의사결정 플로우
- 입력은 무엇이고 반례는 무엇인가? 둘 다 패턴 작성 전에 써 두세요.
- 데이터가 중첩·재귀 구조인가? 그렇다면 파서를 쓰세요. 정규식은 틀린 도구입니다.
- 어떤 엔진을 대상으로 하는가? JS·파이썬·Go·PCRE는 룩어라운드·역참조·유니코드에서 차이가 있습니다.
- 매치 자체가 필요한가 아니면 예/아니오면 충분한가? 수량자나 대안만을 위한 그룹은
(?:...)비캡처로 두세요. - 패턴이 사용자 입력인가, 신뢰할 수 없는 입력에 적용되는가? 원자 그룹·시간 제한·RE2 같은 선형 시간 엔진으로 폭발적 백트래킹을 막으세요. Cloudflare처럼 됩니다.
- 이후 텍스트 정규화가 있는가? 모든 걸 한 패턴에 우겨넣지 말고, 단순한 모양 검사와 가벼운 후처리로 나누세요.
- 정규식은 문서화됐는가?
x(extended) 모드의 주석이나 위에 한 줄 설명만 달아도 다음 사람에게 큰 보험이 됩니다.
관련 도구
Patrache Studio의 정규식 테스터는 샘플 입력에 패턴을 돌려 캡처를 인라인으로 보여 주므로, 탭을 왔다갔다 하는 것보다 빠릅니다. 매치 대상이 로그 속 JSON처럼 구조화된 문자열이라면 JSON 포매팅·검증·JSON Schema의 차이와 실무 활용과 조합해 추출한 조각을 두 번째 정규식이 아니라 제대로 된 파서로 검증하세요. 정규식의 흔한 목표물 중 하나가 URL에 박힌 UUID인데, UUID v1·v4·v7 비교와 DB 기본키 설계에서 같은 36자 문자열이 버전 비트에 따라 왜 다른 의미를 가질 수 있는지 확인할 수 있습니다.
참고 자료
- MDN, “Regular Expressions” 가이드 — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions
- IETF RFC 5322, “Internet Message Format”(이메일 문법) — https://datatracker.ietf.org/doc/html/rfc5322
- regex101, 엔진 선택이 가능한 인터랙티브 테스터 — https://regex101.com/
- Google, RE2 — 선형 시간 정규식 엔진 — https://github.com/google/re2/wiki/Syntax