텍스트 뭉치에서 큰따옴표로 감싸인 값만 콕 집어 뽑아내야 하는 순간이 있어요. 예를 들어 로그 한 줄 안에 결과 메시지가 큰따옴표로 두 번 들어있는데, 딱 하나씩 정확하게 잘라내고 싶은 거예요. 분명 패턴은 맞게 짠 것 같은데 결과가 이상하게 뭉쳐서 나온다면, 십중팔구 오늘 다룰 수량자의 탐욕적인 성질 때문일 확률이 높아요. 오늘은 수량자가 정확히 뭘 하는 기호인지, 그리고 정규식이 왜 우리 생각보다 욕심을 많이 부리는지 처음부터 풀어볼게요.


03.1 수량자가 정확히 뭘 하는 기호인가요?

수량자(quantifier)는 바로 앞에 있는 문자나 그룹을 몇 번 반복할지 정해주는 기호예요. \d{4}처럼 숫자 네 번 반복이라고 쓰는 걸 본 적 있을 텐데, 그 중괄호 부분이 바로 수량자예요. 종류가 몇 개 안 되니까 하나씩 짚어볼게요.


가장 자주 쓰는 세 가지부터 볼게요. *는 바로 앞 문자를 0번 이상 반복하라는 뜻이에요, 아예 없어도 되고 여러 번이어도 상관없어요. +는 1번 이상, 최소 한 번은 반드시 있어야 해요. ?는 0번 또는 1번, 있어도 되고 없어도 된다는 뜻이에요. 셋 다 몇 번인지는 정해주지 않고 범위만 정해주는 기호라는 공통점이 있어요.


횟수를 정확히 정하고 싶을 때는 중괄호를 써요. {4}는 정확히 4번, {2,}는 2번 이상 몇 번이든, {2,4}는 2번 이상 4번 이하로 반복하라는 뜻이에요. 휴대폰 번호 가운데 자리가 3자리인 경우도 있고 4자리인 경우도 있잖아요, 이럴 때 {3,4}로 둘 다 받아줄 수 있어요.


import re
re.fullmatch(r"01[016789]-\d{3,4}-\d{4}", "010-123-5678")
결과: 매칭 성공
re.fullmatch(r"01[016789]-\d{3,4}-\d{4}", "010-1234-5678")
결과: 매칭 성공
re.fullmatch(r"01[016789]-\d{3,4}-\d{4}", "010-12-5678")
결과: None


가운데 자리가 3자리든 4자리든 다 통과하는데, 2자리는 {3,4} 범위 밖이라서 실패하는 거 보이시죠. 이렇게 수량자는 몇 번 반복되는지 범위로 융통성 있게 잡아줄 수 있어요.


03.2 기본 수량자는 왜 이렇게 욕심을 부리나요?

여기서부터가 오늘의 진짜 핵심이에요. 지금까지 소개한 수량자들은 전부 기본값으로 탐욕적(greedy)으로 동작해요. 탐욕적이라는 말은, 전체 패턴이 매칭에 성공하는 선에서 최대한 많은 글자를 삼키려고 든다는 뜻이에요.


직접 겪어보면 이해가 빨라요. 자막 텍스트에 큰따옴표로 감싼 문구가 두 개 들어있다고 해볼게요. 자막 "안녕하세요" 그리고 "반갑습니다". 여기서 큰따옴표로 감싼 부분만 각각 뽑아내고 싶어서 ".*"라는 패턴을 써봤어요.


import re
텍스트 = '자막 "안녕하세요" 그리고 "반갑습니다"'
re.findall(r'".*"', 텍스트)
결과: ['"안녕하세요" 그리고 "반갑습니다"']


원했던 건 큰따옴표 문구 두 개가 따로따로 나오는 거였는데, 실제로는 첫 번째 여는 큰따옴표부터 마지막 닫는 큰따옴표까지 통째로 한 덩어리로 잡혀버렸어요. 이유는 간단해요, .*가 일단 문자열 끝까지 최대한 삼킨 다음에, 뒤에 있는 큰따옴표 하나와 맞춰지도록 딱 필요한 만큼만 뒤로 물러나거든요. 그런데 뒤로 물러나다가 제일 마지막에 나오는 큰따옴표에서 조건이 충족되니까 거기서 멈춰버리는 거예요. 중간에 있던 닫는 큰따옴표는 그냥 지나쳐버린 거죠.


03.3 게으른 수량자를 쓰면 해결되나요?

이 문제를 해결하려고 만들어진 게 바로 게으른(lazy) 수량자예요. 앞서 본 수량자 뒤에 물음표 하나만 더 붙이면 게으른 버전이 돼요. *?, +?, ??, {2,4}? 이런 식으로요. 게으른 수량자는 반대로 가능한 한 적게 삼키다가, 정말 필요할 때만 한 글자씩 늘려가요.


아까 그 예시를 게으른 수량자로 다시 써볼게요.


re.findall(r'".*?"', 텍스트)
결과: ['"안녕하세요"', '"반갑습니다"']


이번엔 딱 원했던 대로 두 문구가 따로따로 나왔어요. 동작 방식을 풀어보면, .*?는 일단 0글자로 시작해요. 그다음 큰따옴표를 만났는지 확인하고, 안 만났으면 한 글자씩 늘려가면서 다시 확인해요. 그러다가 제일 가까운 큰따옴표를 만나는 순간 바로 멈추는 거예요. 그래서 탐욕적 수량자는 최대한 넓게, 게으른 수량자는 최대한 좁게 잡는다고 기억해두시면 돼요.


03.4 게으른 수량자면 무조건 안전한가요?

여기서 많은 분들이 착각하는 게 있어요. 게으른 수량자를 쓰면 성능 문제나 오작동이 완전히 사라진다고 생각하시는 경우가 많은데, 사실은 그렇지 않아요. 게으른 수량자도 한 글자씩 늘려가며 계속 확인하는 과정 자체는 여전히 거치거든요. 텍스트가 아주 길고 큰따옴표가 자주 안 나오는 상황이라면, 게으른 수량자도 한참을 늘려가면서 확인해야 하니까 느려질 수 있어요.


이럴 때 더 확실하고 빠른 방법이 있어요. 바로 부정 문자 클래스를 쓰는 거예요. 큰따옴표가 아닌 문자만 반복하도록 [^"]*라고 쓰면, 애초에 큰따옴표를 만나는 순간 딱 멈추기 때문에 뒤로 물러나거나 늘려가는 과정 자체가 필요 없어요.


re.findall(r'"[^"]*"', 텍스트)
결과: ['"안녕하세요"', '"반갑습니다"']


결과는 게으른 수량자랑 똑같은데, 동작 원리가 더 명확하고 훨씬 빨라요. 그래서 저는 경계를 나타내는 문자가 명확할 때는 게으른 수량자보다 부정 문자 클래스로 경계를 딱 지정하는 쪽을 먼저 써보시길 추천해요. 게으른 수량자는 경계 문자가 애매하거나 여러 종류일 때 쓰는 차선책 정도로 생각하시면 돼요.


03.5 {n,m} 수량자도 탐욕과 게으름이 나뉘나요?

네, {n,m} 형태도 똑같이 탐욕과 게으름 버전이 있어요. 숫자로 확인해볼게요.


re.findall(r"\d{2,4}", "12345")
결과: ['1234']
re.findall(r"\d{2,4}?", "12345")
결과: ['12', '34']


탐욕적인 {2,4}는 일단 최대치인 4자리, 그러니까 1234를 통째로 가져가버려요. 그다음 남은 5는 한 글자뿐이라 최소 조건인 2자리를 못 채워서 매칭이 안 돼요. 반면 게으른 {2,4}?는 최소치인 2자리부터 시작해서 12를 먼저 잡고, 이어서 남은 자리에서 또 최소치인 34를 잡아요. 결과 개수 자체가 달라지는 걸 보면, 탐욕과 게으름의 차이가 단순히 매칭 범위 문제만이 아니라 몇 개가 뽑히는지에도 영향을 준다는 걸 알 수 있어요.


03.6 그래서 실전에서는 뭘 기준으로 골라야 하나요?

정리해볼게요. 아무 수식어 없는 기본 수량자는 탐욕적이라서 매칭 가능한 한 최대한 많이 삼켜요. 뒤에 ?를 붙이면 게으른 버전이 돼서 최소한만 삼키다가 필요할 때만 늘려가요. 그리고 경계 문자가 명확하다면 부정 문자 클래스로 아예 경계를 지정해버리는 게 제일 안전하고 빨라요.


실무에서는 이런 순서로 판단하시면 편해요. 먼저 정말 탐욕적으로 넓게 잡아도 되는 상황인지 생각해보세요, 예를 들어 한 줄 전체를 다 가져가야 하는 경우라면 탐욕적 수량자 그대로 써도 문제없어요. 반대로 특정 구간만 딱 잘라야 한다면, 경계 문자가 뭔지부터 확인하고 그 문자를 뺀 문자 클래스로 짜보세요. 그게 안 되는 특수한 상황에서만 게으른 수량자를 꺼내 쓰시면 돼요.


오늘 나온 세 가지 패턴, ".*"".*?""[^"]*"를 regex101에 나란히 붙여넣고 같은 텍스트에 돌려보시면 차이가 한눈에 보일 거예요. 탐욕과 게으름 감만 잡아두면, 나중에 복잡한 패턴에서 왜 원하는 결과가 안 나오는지 스스로 진단할 수 있게 돼요.