정규식을 처음 쓰다 보면 황당한 일을 겪어요. 태그 하나만 잡으려고 패턴을 적었는데 글이 통째로 잡히는 거죠. 저도 회사에서 게시글에 섞인 HTML 조각을 정리하다가 이걸 만나서 한참 멍했어요. 분명 패턴은 맞는 것 같은데 결과가 이상하니까요. 범인은 탐욕(greedy, 최대한 많이 먹으려는 성질)이에요. 오늘은 이 탐욕이 뭔지, 그리고 반대인 게으름(lazy, 최소한만 먹는 성질)으로 어떻게 딱 원하는 만큼만 잡는지 같이 볼게요. 기호 하나 차이인데 결과가 하늘과 땅이라, 이것만 잡아두면 정말 든든해요.

09.1 탐욕이 대체 뭔가요?

수량자, 그러니까 *(0개 이상), +(1개 이상), {2,4}(2개에서 4개) 같은 반복 기호는 기본적으로 최대한 많이 가져가요. 이 성질을 탐욕이라고 불러요. 여기에 .은 "아무 글자나 한 개"예요. 그래서 .*은 "아무 글자나 최대한 많이"가 돼요. 문제는 이 최대한이 우리 생각보다 훨씬 욕심을 부린다는 거예요. 탐욕이 붙은 수량자는 일단 갈 수 있는 데까지 쭉 간 다음, 패턴의 나머지 조건이 안 맞으면 그제서야 한 글자씩 뒤로 물러나며 타협해요. 이 "일단 최대한"이 오늘 이야기의 출발점이에요.

09.2 태그 하나만 잡으려다 전부 삼켜요

상황을 하나 볼게요. <b>hello</b>라는 글에서 앞 태그 <b> 하나만 잡고 싶었어요. 그래서 "꺾쇠로 시작해 아무거나 있다가 꺾쇠로 끝난다"는 뜻으로 <.*>라고 적었죠.


import re

re.findall(r"<.*>", "<b>hello</b>")


# ['<b>hello</b>']


원한 건 <b>였는데 첫 <부터 맨 끝 >까지 통째로 잡혔어요. 왜 그런지 천천히 따라가 볼게요. <로 시작점을 잡은 뒤, .*이 욕심을 부려 남은 글자 b>hello</b>전부 삼켜버려요. 그런데 패턴엔 마지막에 >가 남아 있죠. 다 먹어버려서 >에 쓸 글자가 없으니, .*이 마지못해 한 글자씩 뱉어내다가 문자열 맨 끝의 >를 만나 거기서 멈춰요. 그래서 </b>의 마지막 꺾쇠까지 쭉 늘어난 거예요. 이게 탐욕의 대표적인 함정이에요.

09.3 물음표 하나로 게으르게 바꿔요

[정규식 09] 탐욕과 게으름, .* 와 .*?

해결법은 의외로 간단해요. 수량자 뒤에 ?를 하나 붙이면 게으름으로 바뀌어요. 게으름은 "조건만 만족하면 최소한만 먹고 바로 멈춰"라는 성질이에요. 그래서 .*?는 "아무 글자나 최소한만"이 돼요. 대상은 똑같이 두고 패턴만 바꿔볼게요.


re.findall(r"<.*?>", "<b>hello</b>")


# ['<b>', '</b>']


이번엔 <b></b>따로따로 잡혔어요. 게으른 .*?는 처음엔 아무것도 안 먹고 바로 >를 찾아봐요. 안 되면 그때 딱 한 글자씩만 늘려가요. 그러다 b 하나를 먹은 순간 바로 다음이 >라 조건이 맞으니 거기서 멈춰요. 그래서 <b>만 잡히고, findall이 이어서 다음 태그를 또 찾아 </b>도 잡은 거예요. 같은 대상, 같은 패턴인데 ? 하나 붙였다고 결과가 완전히 달라졌죠. 이 비교가 오늘의 핵심이에요.

09.4 따옴표 안 내용도 똑같은 함정이에요

이 함정은 따옴표에서도 그대로 나와요. 설정 파일이나 로그에서 따옴표로 감싼 값을 뽑는 일이 흔하거든요. "a" and "b"처럼 따옴표 쌍이 두 개 있는 글에서, 따옴표로 감싼 부분을 잡으려고 ".*"라고 쓰면 어떻게 될까요?


re.findall(r'".*"', '"a" and "b"')


# ['"a" and "b"']


가운데 and까지 삼켜서 처음 따옴표부터 마지막 따옴표까지 하나로 잡혔어요. 탐욕이 "마지막 종결 문자"까지 늘어난 거죠. 값 두 개를 따로 얻고 싶었는데 하나로 뭉쳐버린 거예요. 여기에도 ?를 붙여 게으르게 만들면요.


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


# ['"a"', '"b"']


"a""b"가 깔끔하게 나뉘었어요. 태그, 따옴표, 괄호처럼 여는 기호와 닫는 기호가 짝을 이루는 걸 하나씩 잡을 땐 이 게으름 감각이 꼭 필요해요. 감싸는 짝이 여러 번 나오는 상황이면 거의 반사적으로 ?를 떠올리시면 돼요.

09.5 괄호 쌍도 하나씩 잡아볼까요?

괄호도 완전히 같은 원리예요. (a)(b)에서 괄호 쌍을 하나씩 잡고 싶어요. 여기서 ()는 정규식에서 특별한 뜻이 있는 기호라, 진짜 괄호 글자를 찾으려면 앞에 \를 붙여 \(, \)로 적어요. 이렇게 특수 기호의 능력을 끄는 걸 이스케이프라고 해요. 먼저 탐욕 버전이에요.


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


# ['(a)(b)']


역시나 두 괄호를 통째로 삼켰죠. 첫 (부터 마지막 )까지 쭉이에요. 이제 게으름 버전이에요.


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


# ['(a)', '(b)']


(a)(b)로 분리됐어요. 탐욕이면 통째로, 게으름이면 하나씩. 대상만 바뀌었을 뿐 태그, 따옴표에서 본 것과 똑같은 규칙이 반복되고 있어요. 이쯤 되면 "짝을 이루는 기호 + 탐욕 = 통째로 삼킴"이라는 공식이 몸에 익으실 거예요.

09.6 숫자를 셀 때도 탐욕이 나와요

탐욕은 .*에만 있는 게 아니에요. 개수를 정하는 {2,4} 같은 수량자도 기본이 탐욕이에요. \d{2,4}는 "숫자 2개에서 4개"인데, 매치될 때 가능한 한 많이, 즉 4개 쪽으로 붙어요. 여러 자릿수가 섞인 글에 돌려볼게요.


re.findall(r"\d{2,4}", "1 12 123 1234 12345")


# ['12', '123', '1234', '1234']


한 자리 1은 최소 2개 조건에 못 미쳐 빠졌고, 나머지는 있는 만큼 붙었어요. 눈여겨볼 건 12345예요. 다섯 자리인데 상한이 4라, 탐욕이 앞에서부터 최대 4개 1234를 떼어 가고 남은 5는 혼자 남아 버려졌어요. 이렇게 개수 수량자도 "잡을 수 있으면 최대한"이 기본이라는 걸 기억하면, 자릿수를 딱 맞춰야 할 때 실수를 줄일 수 있어요.

09.7 게으름 말고 더 안전한 방법도 있어요

사실 감싸는 짝을 잡을 땐 .*?보다 더 튼튼한 방법이 있어요. "닫는 기호가 아닌 글자만 모으기"예요. HTML 태그를 지워 본문만 남기는 상황으로 볼게요. [^>]는 "꺾쇠 >가 아닌 한 글자"라는 뜻이고, [^>]+는 그런 글자가 1개 이상이에요. 그래서 <[^>]+>는 "여는 꺾쇠, 닫는 꺾쇠가 아닌 글자들, 닫는 꺾쇠" 즉 태그 하나예요.


re.sub(r"<[^>]+>", "", "<b>Hello</b> <i>World</i>")


# 'Hello World'


re.sub은 찾은 부분을 다른 걸로 바꾸는 함수인데, 여기선 빈 문자열로 바꿔서 태그를 전부 지웠어요. 결과가 Hello World로 깔끔하죠. 이렇게 부정 문자 클래스(닫는 기호가 아닌 것만 모으기)를 쓰면, 애초에 닫는 꺾쇠를 넘어가지 못하니 욕심낼 여지가 없어요. 그래서 탐욕 함정도 피하고, 엔진이 뒤로 물러나며 다시 시도하는 일이 줄어 속도도 더 나아요. 괄호라면 \([^)]*\)처럼 같은 요령을 쓰면 돼요.

09.8 오늘 내용을 정리하면요

수량자는 기본이 탐욕이라 최대한 많이 먹어요. 그래서 .*은 종종 우리가 원한 것보다 훨씬 많이 잡고, {2,4} 같은 개수 수량자도 잡을 수 있으면 상한까지 붙어요. 뒤에 ?를 붙인 .*?게으름이라 최소한만 먹고 바로 멈춰서, 짝을 이루는 기호를 하나씩 잡을 때 딱이에요. 같은 대상에 .*.*?를 나란히 돌려보면 이 차이가 한눈에 들어와요. 그리고 태그나 괄호처럼 경계가 분명할 땐, 게으름보다 부정 클래스 [^...]가 더 안전하다는 것까지 챙기면 오늘 배운 걸 실무에서 바로 써먹을 수 있어요.