[Python] 정규표현식(7) 그루핑, 전방 탐색, 문자열 바꾸기, Greedy vs Non-Greedy

미남로그·2021년 9월 16일
0
post-thumbnail

저는 점프투파이썬 교재로 공부합니다. 이 교재를 바탕으로 공부한 걸 정리합니다.


정규 표현식 시리즈

1️⃣ 정규표현식이란? 문자 클래스, Dot(.), 반복(*), 반복(+), 반복({m,n}, ?)
2️⃣ 정규 표현식(2) - 문자열 검색(match, search, findall, finditer)
3️⃣ 정규표현식(3) - match 객체의 메서드(group, start, end, span)
4️⃣ 정규표현식(4) - COMPILE 옵션 - (DOTALL, IGNORECASE, MULTILINE, VERBOSE)
5️⃣ 정규 표현식(5) - 백슬래시 문제('\n')
6️⃣ 정규 표현식(6) 메타 문자 - |, ^, $, \A, \Z, \b, \B
7️⃣ 정규표현식(7) 그루핑, 전방 탐색, 문자열 바꾸기, Greedy vs Non-Greedy



그루핑

ABC 문자열이 계속해서 반복되는지 조사하는 정규식을 작성하고 싶습니다. 어떻게 하면 좋을까요?

여기서 새로 나오는 개념이 그루핑(Grouping)입니다.

(ABC)+

그룹을 만들어주는 메타 문자는 바로 ( ) 입니다!

import re

p = re.compile('(ABC)+')
m = p.search("ABCABCABC OK?")
print(m)
print(m.group(0))

<re.Match object; span=(0, 9), match='ABCABCABC'>
ABCABCABC

반복되는 'ABCABCABC'를 잘 찾았고, Grouping이 그 값을 뽑아주었습니다.

import re
p = re.compile(r"\w+\s+\d+[-]\d+[-]\d+")
m = p.search("park 010-1234-1234")
print(m)

<re.Match object; span=(0, 18), match='park 010-1234-1234'>

"\w+\s+\d+[-]\d+[-]\d+" 해당 정규식은 '이름 + "" + 전화번호" 형태의 문자열을 찾는 정규식입니다.

여기서 매치된 문자열 중에 이름만 뽑아내고 싶다면 어떻게 해야 할까요?

반복되는 문자열을 찾을 때 그룹을 사용합니다. 그룹을 사용하는 보다 큰 이유는 위에서 볼 수 있듯이 매치된 문자열 중에서 특정 부분의 문자열만 뽑아내기 위해서인 경우가 더 많습니다.


특정 부분의 문자열만 뽑아내기

이름과 전화번호를 따로 뽑아보겠습니다.

import re
p = re.compile(r'(\w+)\s+(\d+[-]\d+[-]\d+)')
m = p.search('park 010-1234-1234')
print(m.group(1))
print(m.group(2))

park
010-1234-1234

위의 정규식과 달라진 점은 무엇인가요?

r"\w+\s+\d+[-]\d+[-]\d+"

r'(\w+)\s+(\d+[-]\d+[-]\d+)'

저도 한 눈에 보이지 않아서 error를 한 10번은 내고 말았습니다.🤣

찾으셨나요?

'\w+' vs '(\w+)'

앞의 해당 부분에 ( ) 괄호가 추가되었는데 여기선느 이를 그룹으로 만들어주었다고 말합니다.

'(\w+)' 그룹으로 만들면, match 객체의 group(인덱스)에서 메서드를 사용하여 그루핑된 부분의 문자열만 뽑아낼 수 있습니다.

그리고 뒤에도 괄호가 추가되었습니다.

(\d+[-]\d+[-]\d+)

이 부분은 '숫자-숫자-숫자' 형태의 전화번호를 그루핑 해준 결과라 생각할 수 있습니다.

그런데 우리는 이름과 전화 번호를 불러오기 위해

print(m.group(1))
print(m.group(2))

위와 같이 출력했습니다. 뭔가 인덱스 0부터 시작해야 될 것만 같은 기분인데🤔 그루핑은 그렇지 않습니다.

group(인덱스)설명
group(0)매치된 전체 문자열
group(1)첫 번째 그룹에 해당하는 문자열
group(2)두 번째 그룹에 해당하는 문자열
group(n)n 번째 그룹에 해당하는 문자열

이렇게 설명을 들었으니 한 번 전체 문자열을 불러와보겠습니다.

import re
p = re.compile(r'(\w+)\s+(\d+[-]\d+[-]\d+)')
m = p.search('park 010-1234-1234')
print(m.group(0))

park 010-1234-1234

원하는 결과가 잘 나왔습니다.


그룹을 중첩되게 사용하는 경우

국번이 무엇인지 뽑아보겠습니다.

import re
p = re.compile(r'(\w+)\s+((\d+)[-]\d+[-]\d+)')
m = p.search('park 010-1234-1234')
print(m.group(0))
print(m.group(1))
print(m.group(2))
print(m.group(3))

park 010-1234-1234
park
010-1234-1234
010

위와 같이 결괏값이 나옵니다.

r'(\w+)\s+((\d+)[-]\d+[-]\d+)'

해당 부분만 수정하였으며 중첩된 경우는 인덱스가 가장 마지막 번호로 들어갔습니다. 그루핑의 규칙이라고 합니다.

그룹이 중첩되어 있는 경우 바깥쪽부터 시작하여 안쪽으로 들어갈 수록 인덱스가 증가한다.


그루핑된 문자열 재참조하기

import re
p = re.compile(r'(\b\w+)\s+\1')
print(p.search('Paris in the the spring').group())

the the

그룹의 또 다른 좋은 점은 한 번 그루핑한 문자열을 재참조(Backreferences)할 수 있다는 점입니다.

정규식 '(\b\w+)\s+\1'은 '(그룹)+**+그룹과 동일한 단어'와 매치됩니다.

이렇게 정규식을 만들게 되면 2개의 동일한 단어를 연속적으로 사용해야만 매치 됩니다.

이걸 가능하게 해주는 건 재참조 문자인 \1 입니다. \1은 정규식 그룹 중 첫 번째 그룹을 가리킵니다.


그루핑된 문자열에 이름 붙이기

정규식 안에 그룹이 많아진다면 🤯 머리가 터지겠죠.

정규식이 수정되고 그룹이 추가, 삭제 등을 원할 때 이 그룹을 참조한 프로그램도 모두 변경해주어야 할 수도 있습니다.

만약 그룹을 인덱스가 아닌 이름으로 참조할 수 있다면 어떨까요? 그러면 변경의 문제에서 조금 벗어날 수 있지 않을까 싶어 만들어진 것이라고 합니다.

import re

p = re.compile(r'(?P<name>\w+)\s+((\d+)[-]\d+[-]\d+)')
m = p.search('park 010-1234-1234')
print(m.group('name'))

park

해당 정규식에서 달라진 점은

(\w+) 👉 (?P<name>\w+)

해당 부분입니다.

여기는 그룹에 name 이라고 이름 붙인 것에 불과합니다. 앞의 물음표(?)는 정규 표현식의 확장 구문입니다.

그룹에 이름을 지어주기 위해서는 확장 구문은 필수입니다.

그래서 print(m.group('name')) 을 입력했을 경우 park가 결괏값으로 출력됩니다.

이것도 재참조가 가능합니다.

import re

p = re.compile(r'(?P<word>\b\w+)\s+(?P=word)')
print(p.search('Paris in the the spring').group())

the the

재참조시엔 (?P=그룹이름)이란 확장 구문을 사용하여야 합니다.


전방 탐색

정규식에서 막 입문한 사람들이 가장 어려워지는 파트라고 합니다. 정규식 안에 확장 구문을 사용하면 순식간에 암호문처럼 보이기 때문에 전방탐색이 꼭 필요하다고 합니다.

import re

p = re.compile(".+:")
m = p.search("http://google.com")
print(m.group)

http:

위에선 정규식 ".+:"와 일치하는 문자열로 http: 를 돌려주었습니다.

만약 위 검색 결과에서 :을 제외하고 출력하는 방법이 있을까요?

복잡한 정규식에 그루핑이 불가하다는 조건까지 있다면 전방 탐색 방법을 사용해야 합니다.

전방 탐색에는 2종류가 있습니다.

정규식종류설명
(?=...)긍정적 전방 탐색...에 해당하는 정규식과 매치되어야 하며 조건이 통과되어도 문자열이 소비되지 않는다.
(?!...)부정적 전방 탐색...에 해당하는 정규식과 매치되지 않아야 하며 조건이 통과되어도 문자열이 소비되지 않는다.

긍정적 전방 탐색

긍정적 전방 탐색을 사용하면 http:의 결과를 http로 바꿀 수 있습니다.

import re
p = re.compile(".+(?=:)")
m = p.search("http://google.com")
print(m.group())

http

(?=:)

기존에 사용했던 정규식 표현에 위의 코드가 추가되었고, 추가함으로써 기존 정규식 검색에서 :에 해당하는 문자열이 소비되지 않아 검색 결과에서 :를 제거해주는 효과를 보여줍니다.

책에서는 여러 종류의 긍정적 전방 탐색의 예를 보여줍니다. 몇 가지 보고 가겠습니다.

.[.].$

이 정규식은 '파일 이름 + . + 확장자'를 나타내는 정규식입니다. 이 정규식은 foo.bar, autoexec.bat, sendmail.cf 같은 형식의 파일과 매치될 수 있습니다.

위의 정규식에서 bat 파일을 제외하는 조건을 추가해봅시다. 한 번 안 보고 해보세요!

.[.][^b].$

네. 저는 제가 생각해서 적은 코드가 틀렸습니다. 위는 맞는 답이니 안심하십시오...

^는 not을 의미했고 b로 시작하는 파일을 거절한다고 적어두었네요. 근데 왜 굳이 이렇게... bat를 지우자 해놓고 b만 적으면 당연히 b로 시작하는 파일 확장자도 걸러내겠죠.

그럼 다른 방법을 사용해보겠습니다.

.*[.][^b]..|.[^a].|..[^t])$

이렇게 하면 또 무슨 문제가 생길까요? .cf 같은 확장자명이 두 글자인 경우에, 오류를 범할 수 있습니다.

그래서 최종적으로 바꾸고자 하는 방법은 바로바로...

.*[.]([^b]./?.?|.[^a]?.?|..?[^t]?)$

해당 정규식 표현이 마크다운 문법에 의해 생략되어서 (원인을 못찾음) 코드바 안에 넣었습니다.

확장자의 문자 개수가 2개여도 통과되는 정규식이라고 합니다. 치면서도 짜증이 날 정도로 답답...한 코드였네요.

이걸 활용할 수 있을까요? 점프투파이썬 스타일 상 뒤에 다른 쉬운 방법을 알려주길 바라며...


부정적 전방 탐색

네. 바로 구원 투수가 이거였나 봅니다.🤣 부정형 전방 탐색 방법을 사용해 봅시다.

.*[.](?!bat$).*$

아까 앞보다 숨통이 트이는 쉬운 방법입니다. bat가 아닌 것은 통과 시키라는 의미입니다. 문자열이 소비되지 않으므로 bat가 아니라고 판단 시에 바로 정규식 매치가 진행됩니다.

exe도 bat와 같이 제외하라는 조건을 넣어봅시다.

.*[.](?!bat$|exe$).*$

입니다. 간단해졌네요.


문자열 바꾸기

이제 막바지를 달려가고 있습니다.

sub 메서드를 사용해 정규식과 매치되는 부분을 다른 문자로 쉽게 바꿀 수 있다고 합니다.

예시 코드를 보겠습니다.

# 문자열 바꾸기

import re
# sub 매서드
p = re.compile('(blue|white|red)')

print(p.sub('colour', 'blue socks and red shoes'))

colour socks and colour shoes

보니까 뒤의 문자열 blue, red가 colour로 변경되었는데요.

sub 메서드의 첫 번째 매개변수가 '바꿀 문자열(replacement)'가 된다고 합니다. 두 번째 매개변수는 '대상 문자열'입니다.

위의 경우와 다르게, 바꾸기 횟수를 제한하는 방법도 있습니다.

print(p.sub('colour', 'blue socks and red shoes', count = 1))

colour socks and red shoes

count를 1로 설정하니 앞의 blue만 colour로 대체되었습니다.

count를 지정해 줄 경우, 앞에서부터 카운트 한다는 것을 알 수 있습니다.


subn

subn 역시 동일한 기능을 하지만 변환 결과를 튜플로 돌려주는 차이가 있습니다.

반환 해주는 튜플의 첫 번째 요소는 변경된 문자열이고, 두 번째 요소는 바꾸기가 발생한 횟수라고 합니다.

import re
# sub 매서드
p = re.compile('(blue|white|red)')

print(p.subn('colour', 'blue socks and red shoes'))

('colour socks and colour shoes', 2)

잘 이해가 되셨나요?


sub 메서드를 사용할 때 참조 구문 사용하기

sub 메서드를 사용할 때 참조 구문을 사용할 수 있습니다. 참조 구문은 아까 앞에서 한 번 봤었는데요.

아래 예시는 '이름 + 전화번호' 를 '전화번호 + 이름'으로 바꿔주는 예라고 합니다.

sub의 바꿔줄 문자열 부분에 '\g<그룹이름>' 을 사용하면 정규식의 그룹 이름을 참조하게 됩니다.

import re

p = re.compile(r'(?P<name>\w+)\s+(?P<phone>(\d+)[-]\d+[-]\d+)')

print(p.sub('\g<phone> \g<name>', 'park 010-1234-1234'))


010-1234-1234 park

위처럼 사용하기 위해선

  1. 정규 표현식 사용 방법 알기
  2. 참조 구문 사용하는 방법 알기
  3. sub 메서드 사용 방법 알기

꽤 어려운 파트 같습니다...

print(p.sub('\g<2> \g<1>', 'park 010-1234-1234'))

010-1234-1234 park

아래처럼 그루핑의 인덱스를 사용해서 변경도 가능합니다.


sub 메서드의 매개변수로 함수 넣기


import re

def hexrepl(match):
	value = int(match.group())
    	return hex(value)
        
p = re.compile(r'\d+')
p.sub(hexrepl, 'Call 65490 for printing, 49152 for user code.')

Call 0xffd2 for printing, 0xc000 for user code.

hexrepl 함수는 match 객체를 입력으로 받아서 16진수로 변환하여 돌려주는 함수였습니다.

sub의 첫 번째 매개변수로 함수를 사용할 경우, 해당 함수의 첫 번째 매개변수에는 정규식이 매치된 match 객체가 입력되며, 매치되는 문자열이 함수의 반환값으로 바뀝니다.


Greedy vs Non-Greedy

진짜 마지막입니다! 미리 수고하셨습니다~

정규식에서 Greedy는 어떤 의미일까요?

import re

s = '<html><head><title>Title</title>'
print(len(s))

32
# Greedy
print(re.match('<./>', s).span())
print(re.match('<.*>', s).group())

(0,32)
(<html><head><title>Title</title>)

'<.*>' 정규식의 매치 결과로

<html>

문자열이 나올 것 같지만 * 란 메타 문자가 문자열을 모두 소비해버렸습니다.

어떻게하면 html 문자열까지만 소비하도록 막을 수 있을까요?

print(re.match('<.*?>', s).group())

<html>

?는 non-greedy 문자입니다. ?는

*?

+?

??

{m,n}?

와 같이 사용할 수 있습니다. 가능한 한 가장 최소한의 반복을 수행하도록 도와주는 역할을 합니다.

모든 문자열을 소비하겠다는 greedy 성격을 띄는 * 를 제지하는 역할이군요~

정규 표현식을 처음부터 끝까지 달려왔는데, 솔직히 제가 이해한 것은 50% 정도 되는 것 같고, 활용하려면 더 많은 예시 코드를 쳐보며 연습하거나 해석(?)하는 연습이 필요할 것 같습니다.

이렇게 방대한 분량의 학습일 지도 몰랐네요...

정리?

정규 표현식 파트에서 배운 것을 크게 분류해보자면

  • 기초 메타 문자
  • 문자열 검색
  • 그루핑
  • 전방 탐색

이렇게 되는 것 같습니다.

그럼 각자 복습 파이팅합시다.😉

profile
미남이 귀엽죠

0개의 댓글