본문 바로가기

로딩 중...


정규식 테스터

1. 정규식 언제 써먹지?

학부생 시절, 특정 문자열 패턴을 처리하기 위해 정규식을 공부하고 시험을 치른 적이 있습니다. 언제나 그렇듯 그 당시에는 '대체 이 복잡한 외계어 같은 식을 어디에 쓴다는 거지?'라며 필요성을 전혀 느끼지 못했습니다. 하지만 실무에 투입되어 처음으로 회원가입 서비스를 개발할 때, 비로소 정규식의 진짜 위력을 깨닫게 되었습니다.

회원가입 로직에는 이메일, 아이디, 비밀번호 등 다양한 사용자 입력값이 들어옵니다. 이메일이 올바른 형식인지 체크하고, 비밀번호에 영문, 숫자, 특수문자를 반드시 포함시키거나 길이를 제한하는 등의 보안 정책을 적용해야 했죠. 이럴 때 가장 강력하면서도 필수적으로 쓰이는 무기가 바로 정규식(Regular Expression)이었습니다.

하지만 막상 실무에 적용하려다 보니 난관에 부딪혔습니다. 학부 시절 배운 기억을 더듬어, 혹은 구글링을 통해 식을 작성하더라도 "이 표현식이 정말 내가 원하는 대로 작동하는 게 맞을까?" 하는 검증이 반드시 필요했습니다. 물론 요즘은 AI를 통해 정규식을 쉽게 짜고 검증받을 수 있는 시대가 되었지만, 복잡한 식을 텍스트에 직접 대입해 보고 실시간으로 맞는지 틀린지 눈으로 확인할 수 있는 직관적인 온라인 도구가 있으면 훨씬 편하겠다는 생각이 들었습니다.

그렇게 저만의 '정규식 테스터기' 개발이 시작되었습니다.

돌이켜보면 정규식 문법을 굳이 달달 외울 필요까지는 없지만, 이것이 무엇이고 어떻게 작동하는지 원리를 아는 것은 개발자에게 매우 중요합니다. 특히 오랜 레거시 시스템을 마주하게 된다면, 과거 누군가 남기고 간 암호문 같은 정규식을 해독하고 재검증해야 하는 일이 반드시 생기기 때문입니다.

더불어, 제가 만든 이 테스터기는 단순한 입력값 검증(Validation)을 넘어 실무의 다양한 상황에서도 유용하게 활용될 수 있다고 생각합니다.

a. 로그 분석과 데이터 가공

수만 줄짜리 서버 로그 파일에서 특정 날짜의 에러 로그만 추출하거나, 접속 기록에서 IP 주소만 골라내는 작업에 사용할 수 있습니다. 또한 엉망인 CSV 데이터의 날짜 포맷을 MM/DD/YYYY에서 YYYY-MM-DD로 일괄 치환(Replace)하거나, 전화번호에서 하이픈을 제거하는 등의 데이터 정제 작업에서도 사용될 수 있습니다.

b. 웹 스크래핑

크롤링으로 가져온 HTML 소스는 대부분 지저분한 태그들로 가득하고, 내가 필요한 텍스트 정보는 그 사이 어딘가에 묻혀 있습니다. 이때 캡처 그룹 (...)을 활용하면 구조화되지 않은 데이터 속에서 딱 필요한 부분만 정교하게 추출할 수 있습니다.

예를 들어 <span class="price">(\d{1,3}(?:,\d{3})*)</span> 같은 패턴으로 상품 가격만 뽑아내거나, href="([^"]*)" 패턴으로 링크 URL만 수집하여 사용할 수 있습니다.
 

2. 정규식에 대해서

다음은 정규식에 대해서 기본적인 내용을 다루고 있습니다. 테스트 도구를 사용하시다가 정규식에 대해서 알고 싶은 분들을 위해 글을 작성하였습니다.

정규식(Regular Expression)은 문자열의 패턴을 기술하는 형식 언어입니다. 1956년 Stephen Cole Kleene가 정규 언어(regular languages) 이론을 발표한 데서 출발해, 1968년 Ken Thompson이 이를 텍스트 편집기에 도입하면서 실무 도구로 자리 잡았습니다. 현재 사용되는 거의 모든 정규식 엔진은 두 가지 흐름 중 하나를 따릅니다.

a. 정규식 기본 사용법(표)

기호의미예시
.줄바꿈을 제외한 임의의 한 문자a.cabc, a1c
*앞 문자가 0번 이상 반복ab*cac, abc, abbc
+앞 문자가 1번 이상 반복ab+cabc, abbc (ac 제외)
?앞 문자가 0번 또는 1번colou?rcolor, colour
{n}앞 문자가 정확히 n번 반복\d{4}2024
{n,m}앞 문자가 n번 이상 m번 이하 반복\d{2,4}12, 123, 1234
^문자열(줄)의 시작^HelloHello world
$문자열(줄)의 끝world$Hello world
[]문자 클래스 — 안의 문자 중 하나[aeiou] → 모음 한 글자
[^]부정 문자 클래스[^0-9] → 숫자가 아닌 문자
\d숫자 [0-9]\d+123
\D숫자가 아닌 문자\D+abc
\w영문자·숫자·밑줄 [A-Za-z0-9_]\w+hello_123
\W\w가 아닌 문자\W → 공백, !, @
\s공백 문자 (스페이스·탭·줄바꿈 등)\s+ → 공백 구간
\S공백이 아닌 문자\S+hello
\b단어 경계\bcat\bcat (catalog 제외)
|OR 조건cat|dogcat 또는 dog
()캡처 그룹(ab)+ab, abab
(?:)비캡처 그룹 — 그룹화만, 캡처 안 함(?:ab)+

b. 정규식 엔진 구조 : NFA vs DFA

정규식 엔진은 크게 **NFA(비결정적 유한 오토마타)**와 DFA(결정적 유한 오토마타) 두 방식으로 나뉩니다.

NFA는 우리가 일상적으로 쓰는 JavaScript, Python, Java 등 대부분의 언어에서 채택한 방식입니다. 패턴과 문자열을 비교할 때 여러 가능한 경로를 순서대로 시도하고, 실패하면 이전 분기점으로 되돌아가는 백트래킹(backtracking) 방식으로 동작합니다. 덕분에 역참조(\1)나 전후방 탐색((?=...)) 같은 강력한 기능을 지원하지만, 잘못 작성된 패턴은 입력 길이에 따라 처리 시간이 기하급수적으로 늘어날 수 있습니다. 이것이 ReDoS(정규식 서비스 거부 공격)의 근본 원인이기도 합니다..

DFA는 패턴을 미리 상태 머신으로 컴파일해 두고, 입력 문자를 한 글자씩 읽으며 상태를 전이시킵니다. 백트래킹이 없으므로 입력 길이에 선형 비례하는 안정적인 성능을 보장하지만, 컴파일 비용이 크고 역참조 같은 고급 기능은 지원하지 못합니다.

구분NFA (Backtracking)DFA
동작 방식가능한 경로를 시도하며 실패 시 되돌아감모든 가능한 상태를 동시에 추적
성능 특성단순한 패턴은 빠름, 복잡한 패턴은 폭발적으로 느려질 수 있음입력 길이에 비례한 안정적인 시간
기능 지원역참조, 전후방 탐색 등 풍부한 기능기본 정규식만 지원
대표 구현JavaScript, Python, Java, PCRERE2 (Go의 표준 라이브러리), grep

c. 플래그

정규식 패턴은 "무엇을 찾을 것인가"를 정의하고, 플래그는 "어떻게 찾을 것인가"를 결정합니다. 예를 들어 /hello/는 문자열에서 hello를 딱 한 번만, 대소문자를 구분해서 찾습니다. 여기에 g를 붙이면 모든 hello를, i를 붙이면 Hello·HELLO도 찾습니다. 패턴은 같지만 플래그에 따라 결과가 달라지는 것입니다.

플래그의미사용 상황
g (global)첫 매치 후 멈추지 않고 모든 매치를 찾음일괄 치환, 모든 발생 위치 추출
i (ignore case)대소문자 구분 없이 매치사용자 입력 검색
m (multiline)^, $가 각 줄의 시작/끝에도 매치여러 줄 로그 파싱
s (dotAll).이 줄바꿈 문자도 포함여러 줄에 걸친 블록 매치
u (unicode)유니코드 코드 포인트 단위로 처리한글, 이모지 등 BMP 외 문자 처리

 

3. 알아두면 좋은 ReDoS 공격

ReDoS(Regular Expression Denial of Service)는 정규식 엔진의 백트래킹 특성을 악용해 서버를 마비시키는 공격입니다. 복잡하게 설계된 입력 하나로 CPU를 수초~수분 동안 점유시킬 수 있으며, 실제로 Node.js, Cloudflare 등 여러 서비스에서 장애를 일으킨 사례가 있습니다.

a. 어떻게 터지는가

NFA 엔진은 패턴이 실패했을 때 이전 분기점으로 되돌아가며 다른 경우의 수를 시도합니다. 이 백트래킹 횟수가 입력 길이에 따라 지수적으로 증가하는 패턴을 악성 정규식(Evil Regex)이라고 부릅니다.

악성 정규식의 전형적인 구조는 다음 두 가지입니다.

  • 중첩 수량자: (a+)+, (a*)*, (a|a)+처럼 같은 문자열을 여러 방법으로 그룹화할 수 있는 패턴
  • 앰비규어스 수량자: (a|ab)+c처럼 동일한 부분이 여러 경로로 매치될 수 있는 패턴
// 중첩 수량자 — 매치 성공 시에는 빠름
// 하지만 매치 실패 시 백트래킹이 폭발
const evil = /^(a+)+$/;

evil.test("aaaaaaaaaaaaaaaaaaaaaaaa!");  // "!" 때문에 매치 실패
// → 백트래킹 횟수: 입력 길이 n에 대해 약 2^n
// → 입력이 30자면 10억 번 이상 시도

b. 실제 피해 사례

연도대상원인 패턴영향
2016Stack Overflow공백 처리 정규식34분간 서비스 중단
2019CloudflareWAF 정규식 규칙전 세계 CDN 27분 장애
2021npm ua-parser-jsUser-Agent 파싱공급망 공격과 함께 이슈화

c. 취약한 패턴 vs 안전한 패턴

// 취약 — 중첩 수량자
/^(\d+)+$/         // ❌
/^(a|a)+$/         // ❌
/^([a-z]+)*$/      // ❌

// 안전 — 수량자 중첩 없음
/^\d+$/            // ✅
/^[a-z]+$/         // ✅

d. 방어 방법

1. 패턴을 단순하게 유지한다

중첩 수량자, 앰비규어스 수량자를 피하고, 가능하면 ^/$ 앵커로 범위를 좁힌다.

2. 입력 길이를 제한한다

백트래킹 횟수는 입력 길이에 비례해 증가하므로, 사용자 입력을 정규식에 넣기 전에 최대 길이를 검사한다.

if (userInput.length > 200) throw new Error("입력이 너무 깁니다");
/^[a-z0-9]+$/.test(userInput);

3. 타임아웃을 건다

Node.js 22+의 vm 모듈이나 re2 같은 DFA 기반 라이브러리를 사용하면 선형 시간을 보장합니다. 서버에서 외부 입력을 정규식으로 처리해야 한다면 DFA 엔진을 고려할 만합니다.

4. 사용자 입력을 패턴에 직접 삽입하지 않는다

사용자가 입력한 문자열을 그대로 정규식으로 만들면 ReDoS뿐 아니라 정규식 인젝션 취약점도 생깁니다. 반드시 이스케이프 처리를 거쳐야 합니다.

// 위험
new RegExp(userInput).test(data);

// 안전
const escaped = userInput.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
new RegExp(escaped).test(data);

 

4. 정규식 관련 트러블 슈팅

정규식을 쓰다 보면, 분명 패턴이 맞는 것 같은데 매치되지 않거나, 기대보다 너무 많은 부분이 매치되는 상황을 자주 마주합니다. 흔하게 발생하는 에러에 대해서 모두 정리하였습니다.

a. 범위가 예상보다 크게 잡히는 경우 (Greedy / Lazy)

+, * 수량자는 기본적으로 조건을 만족하는 가장 긴 구간을 매칭합니다. 이 때문에 같은 구분자가 문자열에 여러 번 등장하면, 의도한 것보다 훨씬 넓은 범위가 잡힙니다.

'"apple" and "banana"'.match(/"(.+)"/);
// 원하는 결과: "apple"
// 실제 결과:   '"apple" and "banana"'  ← 마지막 " 까지 전부 포함됨

수량자 뒤에 ?를 붙이면 가장 짧은 구간에서 멈춥니다.

'"apple" and "banana"'.match(/"(.+?)"/);
// 결과: '"apple"'  ← 처음 닫히는 " 에서 멈춤

"", [], <> 같은 구분자 쌍 안의 값을 추출할 때는 +? 또는 *?를 사용하세요.

b. 메타문자 이스케이프 누락

., *, (, ), [, ], \, ?, +, {, }, ^, $, |, / 같은 문자는 정규식에서 특별한 의미를 갖습니다. 이것들을 있는 그대로 매치하려면 백슬래시로 이스케이프해야 합니다.

// 잘못된 예시 — `.`이 아무 글자나 매치
new RegExp("example.com").test("examplexcom"); // true 

// 올바른 예시
new RegExp("example\\.com").test("examplexcom"); // false

특히 사용자 입력을 정규식의 일부로 사용한다면 반드시 이스케이프 처리를 거쳐야 합니다. 그렇지 않으면 정규식 인젝션 취약점이 발생할 수 있습니다.

c. 플래그 누락

g 플래그 없이 replace를 호출하면 첫 매치만 치환된다는 점, m 플래그 없이 ^/$를 쓰면 전체 문자열의 시작/끝만 의미한다는 점은 너무 자주 잊어버리게 됩니다.

d. 한글·이모지가 정상적으로 매칭되지 않는 경우

이모지나 일부 특수문자는 UTF-16방식에서 2개의 코드 유닛으로 표현됩니다. 정규식 엔진도 기본적으로 이 코드 유닛 단위로 문자를 세기 때문에, 🎉 하나를 2글자로 취급합니다.

"🎉".length;       // 2  ← 이모지 1개인데 2로 카운트됨
/^.$/.test("🎉");  // false ← "한 글자"에 매칭 안 됨

u 플래그를 붙이면 엔진이 문자를 코드 유닛이 아닌 유니코드 코드 포인트 단위로 처리합니다. 이모지 1개를 정확히 1글자로 인식합니다.

/^.$/u.test("🎉");  // true  ← u 플래그 추가 후 정상 동작

한글은 기본 다국어 평면(BMP) 안에 있어서 u 플래그 없이도 대부분 동작하지만, 이모지나 희귀 문자를 다룰 때는 u 플래그를 습관적으로 붙이는 것이 안전합니다.

e. 작은 입력으로 점진적으로 검증하는 것

욕심쟁이 매칭 문제든, 이스케이프 문제든, 플래그 문제든, 정규식 트러블슈팅의 해결책에는 공통점이 있습니다. 패턴을 한 조각씩 추가하면서, 단순한 입력부터 시작해 점진적으로 검증하는 것입니다. 처음부터 완벽한 패턴을 만들려고 하면 어디서 어긋났는지 추적하기 어렵습니다. 이 도구의 실시간 하이라이팅이 그 점진적 검증 과정을 지원하기 위해 만들어졌습니다.

 

5. 생각해 볼거리

언제 정규식을 써야 하고 언제 다른 방법을 선택해야 하는지를 판단하는 것이 중요합니다. 정규식을 사용할 때 고려해볼 수 있는 질문들은 아래와 같습니다.

HTML이나 JSON을 정규식으로 파싱해도 될까요?

결론부터 말하면 권장하지 않습니다. 이런 형식들은 중첩 구조를 가질 수 있는 문맥 자유 언어(context-free language)이고, 정규식은 정의상 정규 언어만 다룰 수 있습니다. 단순한 추출은 가능하지만, 진지한 파싱이 필요하다면 전용 파서(DOMParser, JSON.parse)를 사용하는 것이 정답입니다.

정규식의 성능을 어떻게 측정해야 할까요?

실무에서는 다양한 길이의 입력으로 벤치마크를 돌려 보고, 특히 매치 실패 시점의 시간을 확인하는 것이 핵심입니다. 매치 성공 시는 빠르지만 실패 시 폭발적으로 느려지는 패턴이 ReDoS의 전형적인 모습이기 때문입니다.

정규식 대신 파서 콤비네이터를 써야 할 때는?

패턴이 200자를 넘어가거나, 중첩 구조가 등장하거나, 하나의 패턴이 너무 많은 책임을 지기 시작하면, 그것은 정규식이 한계에 도달했다는 신호입니다. 조각으로 나눈 단순 정규식 + 절차적 코드, 또는 본격적인 파서로 옮겨 가는 것을 진지하게 고려할 시점입니다.