[Study] 정규 표현식 - 2편

정규 표현식 고급

안녕하세요, 저번 포스팅에 이어서 정규 표현식을 좀 더 다루어 보도록 하겠습니다. 이번 포스팅에서는 저번 포스팅에서 배우지 않은 몇몇 메타 문자의 의미를 살펴보고 더 다양한 활용을 하는 방법에 대해서 공부해 보겠습니다. 이전에도 말씀 드렸지만, 모든 내용은 위키독스에 수록되어 있는 점프 투 파이썬 강의 자료를 따르고 있으며, 저는 따라가면서 정리를 하는 정도임을 한번 더 말씀드립니다. 기존 강의자료를 보실 분들은 이 곳으로 가시면 되겠습니다!

메타문자

이번에 다룰 메타문자들은 저번에 살펴본 메타문자와 성격이 조금 다릅니다. 앞서 살펴본 +, *, [], {} 등의 메타문자는 매치가 진행될 때 현재 매치되고 있는 문자열의 위치가 변경됩니다. (문자열이 소비된다고도 표현합니다.) 이와 달리, 문자열을 소비시키지 않는 (zerowidth assertions) 메타 문자에 대해 배워 보겠습니다.

|

| 메타 문자는 or과 동일한 의미로 사용됩니다. A|B라는 정규식이 있다면 A 또는 B 라는 의미가 됩니다.

import re
p = re.compile('Crow|Servo')
m = p.match('CrowHello')
print(m)
<re.Match object; span=(0, 4), match='Crow'>

^

^ 메타 문자는 문자열의 맨 처음과 일치함을 의미합니다. 앞에서 살펴본 컴파일 옵션 re.MULTILINE을 사용할 경우에는 여러 줄의 문자열일 때 각 줄의 처음과 일치하게 됩니다.

print(re.search('^Life', 'Life is too short'))
<re.Match object; span=(0, 4), match='Life'>
print(re.search('^Life', 'My Life'))
None

$

$ 메타 문자는 ^ 메타 문자와 반대입니다. 즉, $는 문자열의 끝과 매치함을 의미합니다.

print(re.search('short$', 'Life is too short'))
<re.Match object; span=(12, 17), match='short'>
print(re.search('short$', 'Life is too short, you need python'))
None

\A

\A는 문자열의 처음과 매치됨을 의미합니다. ^ 메타 문자와 동일한 의미이지만 re.MULTILINE 옵션을 사용할 경우에는 다르게 해석됩니다. re.MULTILINE을 사용할 경우, ^는 각 줄의 문자열의 처음과 매치되지만 \A줄과 상관없이 전체 문자열의 처음하고만 매치됩니다.

\Z

\Z\A와 비슷하게, 문자열의 끝과 매치됩니다.

\b

\b는 단어 구분자(Word boundary)입니다. 보통 단어는 whitespace에 의해 구분됩니다.
아래 예시를 봅시다.

p = re.compile(r'\bclass\b')
print(p.search('no class at all'))
<re.Match object; span=(3, 8), match='class'>

\bcalss\b는 앞뒤가 whitespace로 구분된 class라는 단어와 매치되는 것을 확인할 수 있습니다.

print(p.search('the declassified algorithm'))
None

위 예의 경우, whitespace로 구분되지 않기 때문에 class가 매치되지 않습니다. \b를 사용할때 주의할 점은, \b가 파이썬 리터럴 규칙에 의해 백스페이스(BackSpace)를 의미하므로, 정규 표현식에서 단어 구분자로 사용하기 위해서는 반드시 Row string 기능인 r 기호를 붙여서 사용해야 합니다.

\B

\B 메타 문자는 \b 메타문자의 반대 경우이며, whitespace로 구분된 단어가 아닌 경우에만 매치됩니다.
즉, \b에서 whitespace로 구분되지 않아 매치가 안된 경우들은 \B를 사용하면 매치가 되겠죠~!

print(re.search(r'\Bclass\B', 'no class at all'))
None
print(re.search(r'\Bclass\B', 'the declassified algorithm'))
<re.Match object; span=(6, 11), match='class'>

Grouping

만약 ABC 문자열이 계속해서 반복되는지 조사하고 싶다면 어떻게 하면 될까요? 지금까지 배운것으로는 작성하기가 힘들어 보입니다.
이럴 때 필용한 것이 바로 Grouping 입니다.

위의 경우는 아래처럼 작성하면 됩니다.
(ABC)+

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

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

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

아래 예시를 봅시다.

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

\w+\s+\d+[-]\d+[-]\d이름 + "" + 전화번호 형태의 문자열을 찾는 정규식입니다.
그런데, 이렇게 매치된 문자열 중에서 이름만 뽑고싶다면 어떻게 하면 될까요?
grouping을 이용하면 group()함수로 원하는 부분을 추출해 낼 수 있습니다.
저는 이름, 전화번호 중간, 끝자리를 grouping 해보겠습니다.

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

이처럼, grouping된 부분에 한해 추출할 수 있는 것을 확인할 수 있습니다.

또한, 그룹의 중첩도 가능한데요, 그룹이 중첩될 경우, 바깥쪽부터 시작해서 안쪽으로 들어갈수록 인덱스가 증가합니다.

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

print(m.group(1))
print(m.group(2))
print(m.group(3))
park
010-1234-5678
010

Grouping 된 문자열 재참조 (Backreferences)

Grouping의 또 한가지 장점은, 문자열을 재참조(Backreferences)할 수 있다는 점입니다. 아래 예시를 봅시다.

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

위 예시에서 \1첫번째 그룹의 재참조를 의미 합니다. 즉, 첫번째 그룹은 \b\w+ 였으므로 (\b\w+)\s+(\b\w+)와 동일합니다. 이를 해석해보면, 첫번째 그룹이 1개 이상의 whitespace 간격을 두고 반복되는 경우를 찾는 것으로 해석됩니다. 따라서, 출력은 the the가 되는 것을 확인할 수 있습니다. 만약 두 번째 그룹을 재참조 하려면 \2를 사용하면 됩니다. 아주 좋은 장점인 것 같네요 ㅎㅎ

Grouping된 문자열에 이름 붙이기

정규식 안에 그룹이 아주 많아진다고 가정해 봅시다. 예를 들어서 정규식 안에 그룹이 10개 이상만 되어도 아주 혼란스러울 것 같네요. 설상가상으로, 정규식이 중간에 수정되면서 그룹이 추가되거나 삭제되면 더욱 복잡해질 것 같습니다 ㅠㅠ

이럴 때, 그룹을 인덱스가 아닌 이름으로(Named Group) 참조할 수 있다면 좋겠죠?

아래와 같은 방법으로 그룹을 이름으로 재참조 할 수 있습니다.

(?P<name>\w+)\s+((\d+)[-]\d+[-]\d+)

위 정규식은 앞에서 본 이름과 전화번호를 추출하는 정규식인데요, 기존과 달라진 부분은 다음과 같습니다.

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

즉, 이름으로 재참조하려면 grouping을 (?P<name> ... )으로 해주면 됩니다.

p = re.compile(r'(?P<group1>\w+)\s+\d+[-]\d+[-]\d+')
m = p.search('taeham 010-1234-5678')

print(m.group("group1"))
taeham

위 예제에서 저는 첫번째 그룹을 “group1” 이라는 이름으로 지정해주었기 때문에, 불러올 때도 group1이라는 이름을 통해 첫번째 그룹을 불러오는 것을 확인할 수 있습니다.

또한, 그룹 이름을 사용하면 정규식 안에서 재참조하는 것도 가능합니다.

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

이처럼, 첫번째 그룹인 ‘word’를 재참조 하려면 (?P=word)라고 해주면 됩니다 ㅎㅎ

전방 탐색 (Lookahead Assertions)

정규식을 배우기 시작하면서 사람들이 가장 어려워 하는 것이 바로 전방 탐색이라고 합니다.
전방 탐색을 사용하면 순식간에 정규식이 복잡해지기 때문인데요 ㅠㅠ.
하지만 이 전방 탐색은 매우 유용하고 꼭 필요할 때가 있기 때문에 알아두어야 합니다.

아래 예시를 보겠습니다

p = re.compile(".+:")
m = p.search("https://google.com")

print(m.group())
https:

정규식 .+:와 일치하는 문자열로 https:를 돌려주었습니다. 만약 여기서 :를 제외하고 출력하려면 어떻게 해야할까요? grouping을 할 수 없다는 조건까지 더해진다면 더욱 막막할겁니다. 왜냐하면 이미 compile을 통해 https: 까지가 소비되었기 때문이죠.

이럴때 사용할 수 있는 것이 바로 전방 탐색입니다. 전방 탐색에는 긍정(Postive)와 부정(Negative) 2 종류가 있고, 다음과 같이 표현합니다.

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

긍정형 전방 탐색

긍정형 전방 탐색을 사용해서 https:의 결과를 https로 바꾸어 봅시다.

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

정규식 중 :에 해당하는 부분에 긍정형 전방 탐색 기법을 적용하여 (?=:)로 변경했더니, 기존 정규식과 검색에서는 동일한 효과를 발휘하지만 :에 해당하는 문자열이 정규식 엔진에 의해 소비되지 않기 때문에 (검색에는 포함되지만 검색 결과에서는 제외되기 때문에) 최종 아웃풋은 :이 제거된 상태로 돌려줍니다.

다음으로, 또 다른 예제를 봅시다.

.*[.].*$

이 정규식은 파일이름 + . + 확장자를 나타내는 정규식입니다.

이 정규식에 확장자가 “bat인 파일은 제외해야한다” 라는 조건을 추가해 봅시다. 가장 먼저 생각할 수 있는건 아래와 같습니다.

.*[.][^b].*$

위 정규식은 확장자가 b로 시작하면 안된다는 의미입니다. 하지만 이렇게 하면 bat 말고도 bar 등의 다른 확장자들도 함께 차단합니다.

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

위 정규식은 확장자의 3자리 중 처음이 b가 아니거나, 중간이 a가 아니거나, 마지막이 t가 아닌 경우를 뜻합니다. 이렇게 하더라도 2자리 확장자는 커버할 수 없습니다. 이를 보완하기 위해 확장자의 문자 개수가 2개라도 통과할 수 있도록 만들면 아래와 같이 됩니다.

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

하지만 정규식은 더욱 복잡하고 이해하기 어려워졌습니다.

만약 여기서 exe파일도 제외하라는 조건이 추가로 생긴다면 더더욱 복잡해질겁니다 ㅠㅠ.

부정형 전방 탐색

이러한 상황의 구원투수가 바로 부정형 전방 탐색입니다. 위 예는 부정형 전방 탐색을 사용하면 간단하게 처리됩니다.

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

위 정규식은 확장자가 bat가 아닌 경우에만 통과된다는 의미입니다.
exe까지 제외하라는 조건이 추가 되더라도 간단히 처리 가능합니다.

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

문자열 바꾸기

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

다음 예를 봅시다.

p = re.compile('(blue|white|red)')
p.sub('colour', 'blue socks and red shoes')
'colour socks and colour shoes'

이렇게 아주 간편하게 바꿀 수 있습니다!
그런데, 딱 한번만 바꾸고 싶을 수도 있습니다. 이렇게 바꾸기 횟수를 제어라혀면 다음과 같이 세 번째 매개변수로 count 값을 넘기면 됩니다.

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

sub와 비슷한 subn

subn 메서드는 sub와 동일한 기능을 하지만, 반환 결과를 튜플로 돌려줍니다.
반환된 튜플의 첫 번째 요소는 변경된 문자열이고, 두 번째 요소는 바꾸기가 발생한 횟수입니다.

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

sub 메서드 사용 시 참조 구문 사용하기

sub 메서드를 사용할 때 참조 구문을 사용할 수 있습니다. 다음 예를 봅시다.

p = re.compile("(?P<name>\w+)\s+(?P<phone>(\d+)[-]\d+[-]\d+)")
print(p.sub("\g<phone> \g<name>", "park 010-1234-5678"))
010-1234-5678 park

위의 예는 이름 + 전화번호전화번호 + 이름으로 변환하는 예시 입니다.
첫번째 그룹으로 이름 부분에 ‘name’이라는 그룹명을 지어주고, 두 번째 그룹으로 번호 부분에 ‘phone’이라는 그룹명을 지어주었구요.
sub를 이용하여 phone 자리에는 park을, name 자리에는 번호를 넣어주었네요!
이 때 그룹 참조는 \g를 넣어주면 됩니다!

또한, 그룹 이름이 아니라 그룹 번호를 이용할 수도 있습니다.

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

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

sub 메서드의 첫 번째 매개변수로 함수를 넣을 수도 있습니다. 다음 예시를 봅시다.

def hexrepl(match):
    value = int(match.group())
    return hex(value)

p = re.compile(r'\d+')
p.sub(hexrepl, 'Call 65490 for printing, 49512 for user code.')
'Call 0xffd2 for printing, 0xc168 for user code.'

hexrepl 코드는 match 객체를 받아들인 후 16진수로 변환하여 돌려주는 함수입니다. sub의 첫 번째 매개변수로 함수를 사용할 경우에는 해당 함수의 첫 번째 매개변수에는 정규식과 매치된 match 객체가 입력됩니다. 그리고 매치되는 문자열은 함수의 반환 값으로 바뀌게 됩니다. 잘 사용한다면 정말 유용할 것 같습니다.

Greedy vs Non-Greedy

정규식에서 Greedy란 어떤 의미일까요? 아래 예제를 봅시다.

s = '<html><head><title>Title</title>'
print(len(s))
print(re.match('<.*>', s).span())
32
(0, 32)

<.*>의 결과로 <html>만 반환되길 기대했으나, greedy하게 문자열의 제일 끝까지 모두 소비해버렸습니다.

이를 방지하기 위해서, <.*?>을 사용하면 *의 탐욕을 제한할 수 있습니다.

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

non-greedy 문자인 ?*?, +?, ?? , {m,n}?와 같이 사용할 수 있습니다. 가능한 한 가장 최소한의 반복을 수행하도록 도와주는 역할을 합니다.

자, 이렇게 정규 표현식의 종류와 사용법에 대해서 공부해 보았는데요!
물론 앞으로 더욱 공부할 것이 많고 익숙해지기 위해서 많은 문제들을 풀어보는 것이 필요할거라 생각됩니다. 여러분들께도 많은 도움이 되었길 바라며, 이만 줄이겠습니다.

그럼, 다음 포스팅에서 뵙겠습니다 :)

Comments