정규식을 배우다 보면 누구나 비슷한 실수를 해요. 저도 신입 때 이 함정들에 하나씩 다 걸려봤어요. 그런데 신기하게도, 왜 그런 일이 벌어지는지 원리만 알면 다시는 안 걸려요. 실수마다 분명한 이유가 있거든요. 오늘은 초보가 가장 많이 넘어지는 함정 몇 개를 짚고, 마지막으로 잘못 쓰면 서버까지 멈추게 하는 무서운 현상 하나를 개념만 안전하게 살펴볼게요. 이번 편을 알아두면 앞으로 정규식을 훨씬 자신 있게 다룰 수 있을 거예요.

19.1 점을 안 막아서 엉뚱한 게 걸려요

가장 흔한 실수예요. .(점)은 정규식에서 아무 글자나 한 개라는 특수한 뜻을 가져요. 그래서 진짜 마침표를 찾고 싶으면 앞에 역슬래시를 붙여 \.특수 능력을 꺼야(이스케이프) 해요. 소수점 숫자 3.14 모양을 검사한다고 생각해볼게요. 점을 제대로 막은 패턴은 \d+\.\d+예요.


import re


re.search(r'\d+\.\d+', '3x14')


결과는 None이에요. 즉 매치 안 됨이에요. 3x14는 가운데가 진짜 점이 아니니까 걸러진 거죠. 잘한 거예요. 그런데 만약 점을 안 막고 \d+.\d+라고 썼다면 어땠을까요? 그럼 가운데 .이 아무 글자나가 되어서 x도 통과시켜요. 3x14를 소수로 잘못 인정하는 거죠. 점을 찾을 땐 반드시 \.로 막아야 한다는 걸 꼭 기억하세요.

19.2 탐욕 때문에 너무 많이 걸려요

두 번째 함정은 탐욕이에요. *.* 같은 수량자는 기본적으로 최대한 많이 먹으려고 욕심을 부려요. 괄호 쌍을 하나씩 잡으려고 \(.*\)라고 써볼게요. 여는 괄호 \(, 아무 글자나 여러 개 .*, 닫는 괄호 \)라는 뜻이에요.


re.findall(r'\(.*\)', '(a)(b)')


결과는 ['(a)(b)']이에요. 어라, 괄호 두 쌍을 통째로 삼켜버렸죠? 우리는 (a)(b)를 따로 얻고 싶었는데 말이에요. .*이 욕심을 부려서 맨 마지막 닫는 괄호까지 늘어나버린 거예요. 첫 여는 괄호를 만난 뒤 .*이 끝까지 쭉 먹어치우고, 그다음에야 마지막 닫는 괄호를 찾으니 가운데 부분까지 다 삼킨 셈이죠. 이게 탐욕의 대표적인 함정이에요. 태그를 잡을 때도, 따옴표 안 내용을 잡을 때도 똑같이 벌어져요.

19.3 게으름과 부정 클래스로 고쳐요

이걸 고치는 방법이 두 가지 있어요. 첫째, 수량자 뒤에 ?를 붙여 게으름(최소한만 먹기)으로 바꾸는 거예요. \(.*?\)처럼요.


re.findall(r'\(.*?\)', '(a)(b)')


결과는 ['(a)', '(b)']이에요. 이제 괄호 쌍이 하나씩 딱딱 잡혔죠? *?는 조건을 만족하는 즉시 멈추니까 첫 닫는 괄호에서 끊어줘요. 둘째 방법은 부정 문자 클래스를 쓰는 \([^)]*\)예요. [^)]*는 닫는 괄호가 아닌 글자만 먹으라는 뜻이라, 애초에 다음 괄호를 넘볼 수가 없어요. 이 방법은 되돌아가는 일(백트래킹)이 줄어서 더 안전하기까지 해요. 이 두 방법을 상황에 맞게 골라 쓰면 탐욕 함정은 걱정 없어요.

19.4 포함되나와 전체가 맞나는 달라요

이건 검증할 때 정말 조심해야 할 함정이에요. 어떤 입력이 전화번호 형식이 맞는지 검사한다고 해볼게요. 패턴은 \d{3}-\d{4}이고, 검사할 값은 123-4567xx예요. 뒤에 이상한 xx가 붙어 있으니 형식에 안 맞다고 판정해야 맞겠죠?


re.search(r'\d{3}-\d{4}', '123-4567xx')


그런데 re.search를 쓰면 123-4567 부분을 찾아내서 매치 있음으로 판정해요. xx는 무시하고요. search는 어딘가에 포함되어 있나를 보는 함수라서 그래요. 이러면 잘못된 입력을 통과시키는 버그가 생겨요. 이럴 땐 re.fullmatch를 써야 해요.


re.fullmatch(r'\d{3}-\d{4}', '123-4567xx')


결과는 None이에요. fullmatch는 전체가 통째로 형식에 맞나를 보기 때문에, 뒤에 xx가 붙은 순간 불합격 처리해요. 정리하면 포함 여부를 볼 땐 search, 전체 형식을 검증할 땐 fullmatch(또는 앞뒤에 ^$ 앵커를 붙이기)를 써야 해요. 여기서 ^는 문자열의 시작, $는 끝을 뜻해서, ^\d{3}-\d{4}$처럼 앞뒤를 막으면 fullmatch와 같은 효과를 내요. 회원가입 폼에서 전화번호나 아이디 형식을 검사할 때 이 실수를 하면, 뒤에 이상한 글자가 붙은 값도 통과시키는 심각한 구멍이 생기니 꼭 구분하세요.

19.5 파국적 백트래킹을 조심해요

마지막은 개념만 알아 둘게요. 겉보기엔 멀쩡한 정규식이 특정 입력을 만나면 서버를 통째로 멈추게 하는 무서운 현상이 있어요. 파국적 백트래킹이라고 하고, 이걸 악용한 공격을 ReDoS(정규식으로 서비스를 마비시키는 공격)라고 불러요.


원인은 중첩된 수량자예요. 반복 안에 또 반복이 있는 (a+)+$ 같은 구조요. 여기에 aaaaaaaa!처럼 뒤에 안 맞는 글자를 붙인 긴 입력을 넣으면, 엔진이 실패를 확정하기 전에 같은 글자들을 나누는 모든 경우의 수를 시도해요. 그 조합이 입력 길이에 따라 폭발적으로 늘어나서, 정규식 하나가 CPU를 붙잡고 몇 초, 심하면 몇 분씩 멈춰버려요. 실제로 이 문제로 큰 서비스가 다운된 사례가 여러 번 있었어요.


여기서 왜 폭발하는지 조금만 더 들여다볼게요. aaaa(a+)+로 나누는 방법을 떠올려보면, 안쪽 a+가 네 개를 통째로 먹을 수도 있고, 세 개와 한 개로, 두 개와 두 개로, 한 개씩 네 번으로 쪼갤 수도 있어요. 매치가 성공하면 첫 조합에서 끝나 괜찮은데, 뒤에 안 맞는 글자가 있어서 실패가 확정되기 전까지는 엔진이 이 모든 쪼개는 방법을 하나도 빠짐없이 시도해요. 입력이 길어질수록 그 조합 수가 눈덩이처럼 불어나는 거죠.


이런 위험한 패턴은 실제로 실행하지 마세요. 눈으로만 구조를 익혀 두면 돼요. 예방법은 어렵지 않아요. 첫째, 중첩 수량자를 피하세요. (a+)+$ 대신 a+$처럼 단순하게요. 둘째, 앞에서 배운 부정 문자 클래스([^)]* 같은 것)로 경계를 명확히 하세요. 되돌아가는 일 자체가 줄어들어요. 셋째, 사용자가 입력한 값을 정규식에 그대로 넣지 마세요. 정 필요하면 입력 길이를 제한하고요. 이 몇 가지만 지켜도 대부분의 사고를 막을 수 있어요.

19.6 정리

오늘 짚은 실수들을 모아볼게요. 점을 찾을 땐 \.이스케이프해서 엉뚱한 글자가 걸리지 않게 하고, .*탐욕이 너무 많이 먹으면 *?(게으름)나 [^...]*(부정 클래스)로 고쳐요. 입력을 검증할 땐 포함을 보는 search전체를 보는 fullmatch를 정확히 구분하고요. 마지막으로 중첩 수량자가 만드는 파국적 백트래킹은 서버까지 멈추게 하니, 구조를 단순하게 짜고 경계를 명확히 하는 습관을 들이세요. 이 함정들은 하나같이 원리만 알면 피할 수 있는 것들이에요. 패턴을 짜고 나서 잠깐 멈춰 "혹시 점을 안 막았나, 너무 욕심을 부리진 않았나, 전체를 검사해야 하는데 포함만 보고 있진 않나" 하고 스스로 점검하는 버릇 하나면 충분해요. 이 함정들만 피해도 여러분의 정규식은 훨씬 튼튼하고 안전해질 거예요.