"어떤 문제에 직면했을 때 이렇게 생각하는 사람이 있습니다. '흠, 이제 정규식을 써야할 때군'. 이제 그 사람의 문제는 두 개로 늘어 났습니다."
— 제이미 자윈스키(Jamie Zawinski)
들어갑니다
대용량의 텍스트 덩어리에서 원하는 일부분만 찾는 일은 결코 쉽지 않습니다. 물론 파이썬의 문자열은 검색과 치환을 할 수 있는 index(), find(), split(), count(), replace() 같은 메소드를 제공하지만, 아무래도 기능이 제한적입니다. 가령 index() 메소드는 하드코딩 된(hard-coded) 하나의 문자열만 검색할 수 있고, 대소문자를 반드시 구별해야 합니다. 대소문자에 상관없이 검색하고 싶은 경우에는 문자열의 메소드인 lower() 나 upper() 메소드를 불러 검색 대상이 되는 원문 전체를 대문자나 소문자로 바꿔주어야 합니다. 아주 번거롭지요. replace()나 split() 같은 함수를 사용할 때에도 마찬가지입니다.
문자열 메소드만으로 충분한 상황이라면 그냥 사용하면 됩니다. 빠르도 간단하면서 읽기도 쉽습니다. 하지만 좀 더 복잡하고 특수한 기능을 구현해야 하는 경우에는 정규표현식(regular expressions)을 쓰는 편이 나을 수도 있습니다. 많은 문자열 메소드와 if 문이 섞여 코드가 범벅이 되거나, 문자열을 이리 저리 잘라 붙이기 위해 split()과 join() 메소드를 연속으로 붙여 써야 한다면 특히 더 고려해 봐야 합니다.
정규표현식은 복잡한 패턴의 문자열을 검색, 치환하거나 파싱(parsing)할 때 사용할 수 있는 강력하고 (거의) 표준화된 방법입니다. 정규표현식의 문법이 엄격하고 일반 코드와 좀 달라 이상하게 보일 수도 있지만, 익숙해지면 문자열 함수로 뒤범벅이 된 코드보다 훨씬 간단하고 읽기 쉬워질 겁니다. 정규표현식 안에 주석을 다는 방법도 있어서 깔끔하게 문서화 할 수도 있습니다.
Perl, JavaScript, PHP 같은 언어에서 정규표현식을 사용해 본 경험이 있다면, 파이썬의 정규표현식 또한 어렵지 않게 익히실 수 있을겁니다. 파이썬의 re 모듈을 보면 제공되는 함수와 인자에 대한 전체적인 정보를 얻을 수 있습니다.
⁂
사례 연구: 거리 주소
여기서 사용된 예제는 제가 실제 업무에서 해결해야 했던 문제에서 발췌해 왔습니다. 몇 년 전, 저는 오래된 시스템에서 사용자 주소를 추출하여 표준화한 뒤 새 시스템으로 이전해야 했는데, 그 때 제가 사용했던 해결 방식을 재구성 해보았습니다.
- 제 목표는 거리의 주소를 표준화 하는 것이었는데, 가령 'ROAD'는 항상 'RD.'로 줄여서 표시해야 했습니다. 얼핏 보기에 간단해 보여서 그냥 replace() 메소드를 이용했습니다. 모든 주소 데이터가 이미 대문자로 되어 있는 상태였기에 대소문자를 구분할 필요가 없었고, 검색해야 하는 문자도 'ROAD'로 일정했습니다. s.replace() 코드를 실행했더니 제대로 작동 하더군요.
- 하지만 세상에 쉬운 일은 없죠. 저는 곧 문제에 부딪혔습니다. 이 주소에는 'ROAD'가 두 번 나옵니다. 거리 이름인 'BROAD'에 예기치 않게 'ROAD'가 포함되어 있기 때문이죠. replace() 메소드는 이 두 'ROAD'를 모두 'RD.'로 바꿔서 일을 망쳐 버렸습니다.
- 하나 이상의 'ROAD'가 나타나는 경우에 대처하기 위해 이렇게도 한 번 해보았습니다. 주소의 마지막 네 글자만 치환하고(s[-4:]) 나머지는 그대로 두는 겁니다(s[:-4]). 하지만 별로 좋은 방법은 아닌 것 같은게, 검색 패턴을 치환하려는 문자열의 길이에 따라 바꿔야 합니다. 예를 들어, 'STREET'를 'ST.'로 바꾸는 경우에는 네 글자가 아니라 여섯 글자를 바꿔야 하므로, s[:-6]이나 s[-6:].replace(...)와 같은 식이 되겠지요. 나중에 버그가 생길 것이 뻔해 보이는군요.
- 그럼 정규표현식을 한 번 써볼까요. 파이썬의 모든 정규식 관련 기능은 re라는 모듈에 포함되어 있습니다.
- 첫 번째 인자인 'ROAD
$'는 아주 간단한 정규표현식으로, 'ROAD'가 문자열의 맨 끝에 오는 경우를 뜻합니다. $ 는 "문자열의 끝"을 의미하는 기호입니다. ("문자열의 시작"을 의미하는 기호는 캐럿이라고 불리는 ^입니다.) re.sub() 함수는 s라는 문자열에서 정규표현식 'ROAD$'에 해당하는 부분을 찾은 뒤 'RD.'로 바꾸는 일을 합니다. 문자열의 끝에 있는 ROAD는 찾아서 바꾸지만, 문자열 중간에 있는 BROAD는 바꾸지 않는 것을 볼 수 있습니다.
주소와 사투를 벌이던 제 이야기는 여기서 끝나지 않습니다. 주소 끝의 'ROAD'를 찾아 바꾸는 좀전의 방법이 그렇게 좋지 않다는 사실을 금세 발견했기 때문이죠. 어떤 주소는 맨 끝에 'ROAD'를 생략하고 도로명으로 끝나기도 했습니다. 심지어 그 도로명이 'BROAD'인 경우는 정규표현식이 끝의 'ROAD'를 찾아 바꿔버리기도 했습니다. 제가 원하는 게 아니었죠.
- 제가 정말로 원했던 'ROAD'는 맨 마지막에 나타날 뿐만 아니라, 더 긴 문자열의 부분이 아니어야 합니다. 정규표현식에서 이를 표현하려면 '\b' 기호를 사용합니다. "단어의 경계가 그 위치에서 일어나야 한다"는 의미입니다. 약간 복잡한 부분은, 파이썬에서 '\'는 일반 문자가 아니라 특수한 기호로 처리된다는 점입니다(escape 문자라고 합니다). 그래서 "문자" '\'를 표현하기 위해서는 '\'와 같이 '\'를 두 번 써야 합니다. 이를 좀 유식한 말로 backslash plague라고 합니다. Perl에는 이런 문제가 없기 때문에 편리한 면이 있습니다. 하지만 단점도 있는데, 버그가 있을 때 문제가 정규표현식 때문인지 다른 문법의 문제인지 구별하기가 힘들다는 것입니다.
- 문자열 앞에 문자 r을 붙여서(raw string이라고 합니다) 이 문제를 피해갈 수도 있습니다. 파이썬은 이런 문자열의 내용을(escape 처리하지 않고) 있는 그대로 인식합니다. 예를 들어, '\t'는 탭 문자이지만 r'\t'는 역슬래쉬와 t의 두 문자로 구성된 문자열입니다. 파이썬에서 정규표현식을 사용할 때는 항상 r 문자를 붙여서 사용하시기를 권장합니다. 그렇지 않으면 곧 혼란스러워 질겁니다. (정규표현식 그 자체만도 헷갈리지요.)
- 하... 불행하게도 제 정규식이 실패하는 주소를 또 발견했습니다. 'ROAD'가 있긴 있는데 문자열의 끝에 위치하지 않는 경우였습니다. 아파트 번호가 'ROAD' 다음에 나왔네요. 문장 끝에 있지 않으니 re.sub()를 호출하더라도 아무 것도 바뀌지 않고 원본 문자열이 그대로 출력되었습니다.
- 이 문제를 해결하기 위해 $ 문자를 빼버리고 그 자리에 \b를 넣었습니다. 그러면 정규표현식은 'ROAD'라는 온전한 문자열이 문장의 어디에 있든지 찾게 됩니다. 처음에 있든, 중간에 있든, 끝에 있든 상관없이요.
사례 연구: 로마 숫자
로마 숫자를 적어도 한 번은 본 적이 있을 겁니다. 오래된 영화나 TV쇼의 저작권이나("Copyright 1946" 대신 "Copyright MCMXLVI"를 쓴다거나), 아니면 도서관이나 대학의 기념비 같은 곳("established 1888" 대신 "established MDCCCLXXXVIII")에서 말이죠. 책의 개요나 참고 목록에서 보셨을 수도 있습니다. 그 이름에서 짐작할 수 있듯이, 이 숫자는 고대 로마 시대부터 쓰였습니다.
로마 숫자는 일곱 개의 문자를 여러 방식으로 합하고 반복하면서 많은 숫자를 표현합니다.
- I = 1
- V = 5
- X = 10
- L = 50
- C = 100
- D = 500
- M = 1000
로마 숫자를 합치는 일반적인 규칙에 대해서도 좀 알아볼까요.
- 어떤 경우에 문자는 서로 합해집니다. I은 1, II는 2, III은 3이지요. VI는 6(5+1이죠), VII은 7, VIII은 8입니다.
- 1의 문자(I, X, C, M)는 최대 세 번 반복될 수 있습니다. 네 번째 반복에 해당하는 숫자를 표현하기 위해서는, 그 다음으로 높은 5의 문자(V, L, D)에서 하나를 빼는 방식을 사용합니다. 예를 들자면, 4는 IIII로 쓰는 것이 아니라, IV와 같이 표현합니다("1을 5에서 뺀" 것이죠). 40은 XL("10을 50에서 뺍니다")이고, 41은 XLI, 42는 XLII, 43은 XLIII 처럼 됩니다. 44는 XLIV("10을 50에서 빼고, 1을 5에서 뺀" 숫자입니다)이겠지요.
- 문자 뒤에 5가 아닌 1의 문자가 올 때에도 빼야하는 상황이 있습니다. 예를 들어, 9 같은 경우 그 다음으로 높은 1의 문자에서 하나를 빼야 합니다. 8은 VIII이지만, 9는 VIIII가 아니라(1의 문자는 네 번 반복될 수 없죠) IX("1을 10에서 뺀 값")입니다. 90은 XC이고, 900은 CM입니다.
- 5의 숫자는 반복되지 않습니다. 10은 항상 X이지 VV가 아닙니다. 100은 언제나 C이고 LL이라 쓰지는 않습니다.
- 로마 숫자는 왼쪽에서 오른쪽으로 읽으므로 문자의 순서는 매우 중요합니다. DC는 600이지만 CD는 400입니다("100을 500에서 뺀 값"이니까요). CI는 101인데 IC는 제대로 된 로마 숫자도 아닙니다(1을 100에서 바로 뺄 수는 없습니다). 99를 쓰고 싶었던 거라면, XCIX로 써야 합니다(10을 100에서 빼고, 1을 10에서 뺀 값").
천의 자리 확인하기
어떤 문자열이 제대로 된 로마 숫자인지 아닌지 판단하려면 어떻게 해야 할까요? 로마 숫자는 큰 숫자부터 작은 숫자 순으로 쓰는 것이 보통이므로, 큰 것부터 한 번 봅시다. 천의 자리 말입니다. 1000보다 큰 숫자는 M 문자를 연속적으로 써서 표현합니다.
- 이 패턴은 세 부분으로 되어 있습니다. 먼저 ^는 문자열의 시작을 의미합니다. 이 문자를 빼먹으면, M이 문자열 중간에 처음 등장하는 경우도 찾게 됩니다. M은 가장 큰 숫자이므로 나온다면 가장 처음에 나와야 하기에 ^를 써줘야 합니다(아예 나오지 않을 수는 있겠습니다). 이어 나오는 M?은 문자 M이 0 혹은 1개 나오는 경우를 찾는 표현입니다. M?가 세 번 반복되어 있으므로, 문장 시작부분에 M이 0-3개 나올 경우를 찾습니다. $는 문자열의 끝을 의미합니다. 문장 처음의 ^와 함께 쓰이면, 원본 문자열 전체가 검색 대상이 됩니다. 따라서 이 전체 정규표현식은 M 말고는 아무런 문자도 포함하지 않는 문자열을 검색한다는 뜻입니다. 앞서 말했듯이, M은 수는 0-3개 사이면 모두 검색합니다.
- re 모듈의 정수는 search() 함수에 담겨 있습니다. 정규표현식 하나(pattern)와 문자열('M') 하나를 인자로 받은 뒤, 문자열에 정규표현식에 맞는 표현이 있는지 검색합니다. 맞는 표현이 있다면 search() 함수는 그와 관련된 여러 정보를 포함하고 있는 객체 하나를 반환하고, 맞는 표현을 발견하지 못했다면 None(파이썬의 null입니다)을 반환합니다. 일단은 search() 함수의 반환값을 통해 문자열에서 정규표현식에 해당하는 부분이 있는지 없는지 알 수 있다는 사실만 기억하십시오. 'M'은 이 정규표현식의 '^M?' 부분에 해당합니다. 그 뒤의 'M?M?$'은 M이 0개 인 것에 해당 되겠지요. 그래서 반환값이 None이 아닌 객체가 됩니다.
- 'MM' 역시 'M?M?'과 매치되고, 세 번째 'M?'은 무시됩니다.
- 'MMM'은 'M?M?M?' 전체에 해당되겠네요.
- 'MMMM'은 매치되지 않습니다. 우리의 정규표현식은 세 개의 'M'을 찾고 난 뒤 문장이 끝나야 한다고 생각합니다($ 문자 때문에요). 그래서 search() 함수는 None을 반환하게 됩니다.
- 흥미롭게도, 빈 문자열은 이 표현식에 해당합니다. 모든 'M' 문자는 필수적으로 등장할 필요가 없기 때문입니다.
백의 자리 확인하기
백의 자리는 천의 자리보다 어렵습니다. 다음과 같이 각 백의 숫자 별로 표현 방식이 제각각이기 때문입니다.
- 100 = C
- 200 = CC
- 300 = CCC
- 400 = CD
- 500 = D
- 600 = DC
- 700 = DCC
- 800 = DCCC
- 900 = CM
총 네 가지 가능한 패턴이 있겠네요:
- CM
- CD
- 0-3개의 C 문자(0이면 백의 자리 숫자가 0입니다)
- D 이후 0-3개의 C 문자
마지막 두 개의 패턴은 하나로 쓸 수 있습니다.
- 처음 나오는 D는 0-1개 나오고, 이어서 0-3개의 C가 나옵니다.
아래 예제는 로마 숫자에서 백의 자리를 확인하는 방법을 보여줍니다.
- 이 패턴은 이전 예제에서 만들었던 표현에 백의 자리 표현을 덧붙입니다. 문자열의 시작이 ^ 문자로 표현되어 있고, 천의 자리를 확인하는 패턴이 나옵니다(M?M?M?). 이제 소괄호로 싸인 새로운 부분이 나오는데, 수직선(|)으로 나뉜 세 가지 서로 다른 패턴인 CM, CD, D?C?C?C(0-1개의 D에 이은 0-3개의 C)입니다. 정규표현식은 이 세 가지 패턴을 왼쪽에서 오른쪽으로 순서대로 검사하며, 첫 번째 표현에서 매칭되면 나머지 부분은 무시합니다.
- 'MCM'은 매칭되는데, 첫 번째 M은 M?에 매칭되고(나머지 M?는 무시) CM은 소괄호 첫 번째에 있는 CM과 일치합니다. 나머지 CD와 D?C?C?C?는 무시되겠지요. MCM은 1900을 표현한 것입니다.
- 'MD'는 첫 M?과 소괄호 세 번째에 있는 D?C?C?C?에 매칭됩니다. 물론 C?C?C? 부분은 무시되었습니다. MD는 1500입니다.
- 'MMMCCC' 역시 매칭됩니다. MMM은 M?M?M?에 해당하고, CCC는 D?C?C?C?와 맞습니다. D?는 무시되었지요. MMMCCC는 3300입니다.
- 'MCMC'는 매칭되지 않습니다. 첫 M은 M?에 매칭되고 CM은 소괄호 첫 부분의 CM과 매칭됩니다. 그리고는 문장이 끝나지요($). 나거지 C는 더 이상 매칭될 곳이 없습니다. CM 패턴이 먼저 매칭되었으므로, 이 C는 D?C?C?C? 부분에 해당하지 않습니다.
- 빈 칸은 여전히 잘 매칭되네요. 모든 M?과 소괄호 세 번째에 있는 D?C?C?C?는 필수가 아니기 때문입니다.
휴! 정규표현식이 얼마나 빨리 읽기 힘들어지는지 보셨지요? 그런데 아직 천과 백의 자리만 표현했습니다. 그래도 이 예제를 잘 따라오셨다면, 십과 일의 자리는 쉬울거에요. 지금까지 한 것과 거의 비슷하거든요. 하지만 그 전에 패턴을 표현하는 다른 방법을 먼저 살펴볼 필요가 있습니다.
{n, m} 문법 사용하기
이전 섹션에서 같은 문자가 최대 세 번 반복되는 패턴을 다루었습니다. 이를 좀 더 읽기 쉬운 다른 방식으로 표현할 수도 있습니다. 먼저, 이전 예제에서 사용했던 방법을 봅시다.
- 이 문자열은 문자열의 시작(^)과 첫 번째 M?에 매칭되지만, 그 뒤의 M?M?는 무시되고(뭐, 필수가 아니니까 괜찮습니다) 문자열이 끝납니다($).
- 문자열의 시작(^)과 M?M? 두 개에 매치되고 세 번째 M?는 무시되면서 문자열이 끝납니다.
- 세 M?에 모두 매치되면서 문자열이 끝나지요.
- 세 M?에 모두 매치되고 나서 문자열이 끝나기 때문에 네 번째 M은 남겨집니다. 그래서 None이 반환되지요.
- 이 패턴의 의미는 다음과 같습니다. "문자열이 시작(^)된 후 0-3개의 M이 있는지 확인하고 문자열을 끝내라($)". 여기서 0과 3은 아무 숫자가 와도 괜찮습니다. M 문자가 0-3개가 아니라 1-3개를 매칭하고 싶으면 M{1,3}이라고 쓸 수 있습니다.
- 문자열이 시작된 후 M이 하나 있으므로 매칭되고 끝납니다.
- 문자열이 시작된 후 M이 두 개 있으므로 매칭되고 끝납니다.
- 문자열이 시작된 후 M이 세 개 있으므로 매칭되고 끝납니다.
- 문자열이 시작된 후 M이 세 개 매칭되고, 네 번째 M이 그대로 남은 채 문자열이 끝납니다. 정규표현식이 최대 세 개의 M을 허용하고 있므로, 패턴 매칭이 실패하고 None을 반환하게 됩니다.
십과 일의 자리 확인하기
자, 이제 로마 숫자 십과 일의 자리를 확인할 수 있는 정규표현식을 만들어 볼까요. 아래 예제는 먼저 십의 자리에 대한 코드입니다.
- 문자열이 시작(^)된 후, M, CM, XL이 각각 M?, CM, XL에 매칭되고 끝납니다(
$). (A|B|C) 문법은 A, B, C 중 대응되는 문자(열)을 하나만 찾는 것임을 명심하세요. XL에 매칭되었으므로, XC와 L?X?X?X?는 무시되고 매칭은 끝납니다($ ). MCMXL은 1940입니다. - 문자열이 시작된 후, M, CM, L?X?X?X?에 매칭됩니다. L?X?X?X?에서는 L이 매칭된 후 나머지 X?X?X?는 그냥 지나갑니다. 그리고 문자열의 끝에 다다르지요. MCML은 1950입니다.
- 문자열이 시작된 후, M, CM, 그리고 L?X?X?의 첫 번째 L?과 X?에 매칭됩니다. 두, 세 번째 X?는 그냥 지나치고 문자열이 끝납니다. MCMLX는 1960입니다.
- 문자열이 시작된 후, M, CM, 그리고 L?X?X?X?의 모든 문자(L과 세 개의 X)에 매칭된 후 끝납니다. MCMLXXX는 1980입니다.
- 문자열이 시작된 후, M, CM, L?X?X?X? 모두에 매칭이 되지만, 아직 남아있는 네 번째 X가 있으므로 문장의 끝($)과 매칭할 수 없습니다. 그래서 전체 패턴에 대응되는 부분을 찾지 못하고 None을 반환합니다. MCMLXXXX는 유효한 로마 숫자가 아닙니다.
일의 자리를 확인하는 표현도 비슷합니다. 이제 여러분 스스로 할 수 있으리라 믿고 결과만 써두겠습니다.
좀전에 배운 {n,m} 문법을 이용해서 이 표현을 다시 한 번 써보면 어떻게 변할까요? 한 번 보시죠.
- 문자열의 시작(^) 이후, M은 M{0,3}가 하나인 경우에, D는 D?C{0,3}에서 D가 하나 C가 0개인 경우에 매치됩니다. 계속 진행하면, L은 L?X{0,3}에서 L이 하나 X는 0개인 경우에, V는 V?I{0,3}에서 V가 하나 I가 0개인 경우에 잘 매칭됩니다. 그리고 문장의 마지막($)에 매칭되면서 끝납니다. MDLV는 1555입니다.
- 문자열의 시작(^) 이후, MM는 M{0,3}이 두 개인 경우에, DC는 D?C{0,3}에서 D와 C가 각각 하나인 경우에, LX는 L?X{0,3}에서 L과 X가 각각 하나인 경우에, VI는 V?I{0,3}에서 V와 I가 각각 하나인 경우와 매칭됩니다. 그리고 문장의 마지막($)이네요. MMDCLXVI는 2666입니다.
- 문자열의 시작(^) 이후, MMM은 M{0,3}이 세 개인 경우에, DCCC는 D?C{0,3}에서 D가 하나 C가 세 개인 경우에, LXXX는 L?X{0,3}에서 L이 하나 X가 세 개인 경우에, VIII는 V?I{0,3}에서 V가 하나 I가 세 개인 경우와 매칭됩니다. 그리고 문장의 끝과 매칭됩니다. MMMDCCCLXXXVIII는 3888이고, 다른 추가 문법을 정의하지 않는 한 우리가 쓸 수 있는 가장 큰 로마 숫자입니다.
- 주의깊게 보세요. (마치 제가 마법사가 된 것 같네요. "잘 봐, 얘들아. 이 모자에서 아저씨가 토끼 를 꺼낼거야.") 문자열의 시작(^) 이후, M{0,3}이 0개인 경우, D?C{0,3}에서 D와 C가 0개인 경우, L?X{0,3}에서도 L과 X가 0개인 경우에 매칭됩니다. 그리고 I는 V?I{0,3}에서 V가 0, I가 하나인 경우와 매칭됩니다. 그리고 문자열의 마지막(^)을 매칭하고 끝납니다. 아이고.
만약 정규표현식을 처음 보는데도 이 모든 것을 다 이해하셨다면, 여러분은 제 생각 이상으로 굉장한 분입니다. 그렇지 않다고 해도 너무 걱정할 필요는 없습니다. 정상이에요. 다른 사람이 만든 거대한 프로그램의 함수에 쓰인 정규표현식을 이해하려 한다거나, 아니면 자신이 쓴 정규표현식을 몇 달 뒤에 다시 본다고 생각 한 번 해보세요. 제가 해봤는데 엄청 어려워요.
좀 더 알아보기 쉬운 정규표현식을 만들 수 있는 방법에 대해 알아보는 것이 좋겠습니다.
정규표현식에 설명 달기
지금까지는 좀 "축약된" 정규표현식만 다루었습니다. 읽기기 어렵고죠. 설사 한 번 이해했다고 하더라도, 6개월 뒤에 다시 볼 때 금방 이해할 수 있을 것 같지는 않습니다. 정말 필요한 것은 주석을 달아두는 것입니다.
파이썬에서는 정규표현식에 주석을 달 수 있습니다(verbose 정규표현식이라고 합니다). 이는 "축약된" 정규표현식과는 두 가지 점에서 다릅니다.
- 공백은 무시됩니다. 스페이스, 탭, 개행문자(carriage return) 모두 완전히 무시됩니다. (verbose 정규표현식에서 공백을 무시하지 않으려면, 공백 앞에 역슬래쉬를 붙여서 그 문자를 escape해야 합니다.)
- 주석은 무시됩니다. verbose 정규표현식에서 주석은 파이썬 코드의 주석과 같습니다. # 문자로 시작하고 줄이 끝날 때까지 유지됩니다. 여러 줄로 이루어진 문자열의 내부에 주석을 다는 것이라고 생각할 수 있겠지요.
예를 들어보면 더 쉽게 이해할 수 있을겁니다. 앞서 다루었던 축약된 정규표현식을 verbose 정규표현식으로 다시 써보지요. 아래 예제를 보세요.
- verbose 정규표현식을 쓸 때 기억해야 할 가장 중요한 점은, re.search() 함수에 re.VERBOSE라는 추가 인자를 넘겨야 한다는 겁니다. re.VERBOSE는 re 모듈에 정의되어 있는 상수로, pattern 인자가 verbose 정규표현식이라는 것을 알려줍니다. 이 예제의 pattern에 많은 수의 공백과 주석이 포함되어 있는데, re.VERBOSE 인자를 넘겨주면 모두 무시합니다. 공백과 주석을 무시하고 나면 이 pattern은 이전 예제에서 본 축약된 표현과 정확히 일치합니다. 그럼에도 훨씬 읽기가 편하죠.
- 문자열의 처음(^)과 매칭된 후, 각 줄 별로 M, CM, LXXX, IX에 매칭되고 문자열의 끝($)을 찾습니다.
- 문자열의 시작(^) 이후, 각 줄 별로 MMM, DCCC, LXXX, VIII가 매칭되고 끝납니다($).
- 1번에서 쓴 것과 동일한 이 문자열은 pattern과 매칭되지 않습니다. re.VERBOSE를 인자로 전달하지 않아서 그렇습니다. re.search() 함수는 pattern을 축약된 정규표현식으로 생각하고 공백과 주석을 매칭할 때 포함해버리지요. 파이썬은 정규표현식이 축약된 형태인지 verbose 형태인지 자동으로 구분하지 못합니다. 파이썬은 여러분이 따로 말해주지 않는 한 모든 정규표현식을 축약된 형태라고 가정합니다.
사례 연구: 전화번호 해석하기
지금까지는 문자열 전체를 매칭하는 예제를 보았습니다. 패턴이 매칭되던지 아니던지, 둘 중 하나였습니다. 하지만 정규표현식은 훨씬 더 강력한 기능을 가지고 있습니다. 정규표현식이 매칭 되었을 때, 그 일부분만을 받아올 수도 있고, 어떤 부분이 어느 위치에서 매칭되었는지 알아낼 수도 있습니다.
아래에 설명할 예제도 제가 이전 직장에서 일할 때 실제로 마주쳤던 문제입니다. 미국의 전화번호를 해석하는 일이었지요. 제 고객은 입력 필드 하나를 통해 받은 전화번호를 지역 코드, 트렁크(trunk), 숫자, 확장 번호로 각각 나누어 회사의 데이터베이스에 저장하고 싶어했습니다. 저는 곧 웹을 검색해서 이런 종류의 문제를 해결할 수 있는 정규표현식을 찾아보았지만, 제 마음에 딱 드는 건 없었습니다.
여기 제가 해석해야 했던 다양한 형식의 전화번호를 보여드리지요. 같은 전화번호를 다르게 표현한 겁니다.
- 800-555-1212
- 800 555 1212
- 800.555.1212
- (800) 555-1212
- 1-800-555-1212
- 800-555-1212-1234
- 800-555-1212x1234
- 800-555-1212 ext. 1234
- work 1-(800) 555.1212 #1234
정말 다양하네요! 저는 이 각각의 문자열을 해석해서 지역 코드는 800, 트렁크는 555, 나머지 번호는 1212라는 것을 알아내야 했습니다. 확장 번호가 있는 경우 그것이 1234라는 것도 포함해서요.
이런 전화번호를 해석하는 방법을 한 번 만들어 봅시다. 그럼 시작해볼까요.
- 정규표현식은 왼쪽에서 오른쪽으로 읽습니다. 이 패턴은 문자열이 시작한 후(^) (\d{3})에 해당하는 표현을 찾습니다. 그런데 \d{3}이 뭘까요? \d는 0-9 사이의 아무 숫자를 말합니다. {3}가 뒤에 붙으면 "정확히 세 개의 숫자"를 의미하지요. 좀전에 보셨던 {n,m} 문법의 변형입니다. 소괄호를 포함한 (\d{3})의 의미는, "정확히 세 개의 숫자를 찾고, 나중에 알아볼 수 있게 그룹으로 묶어두어라"는 것입니다. 그리고 하이픈 하나를 찾습니다. 이어서 숫자 세 개로 구성된 그룹을 또 찾는군요. 하이픈이 다시 나옵니다. 마지막으로 정확히 네 개의 숫자로 구성된 그룹을 끝으로($) 하는 표현을 찾습니다.
- 정규표현식이 기억해 둔 그룹은 search() 메소드가 반환한 객체에 groups() 메소드를 적용하면 얻을 수 있습니다. 그룹이 몇 개가 되든지 튜플로 묶어서 돌려줍니다. 이 예제에서는 숫자 세 개, 숫자 세 개, 숫자 네 개로 구성된 총 세 개의 항목을 묶어 반환하네요.
- 아직 갈 길이 남아있습니다. 확장 번호가 있는 전화번호는 처리하지 못하니까요. 정규표현식을 좀 더 확장 해야겠습니다.
- 여기서는 search()와 groups() 메소드를 바로 연결해서 쓰면 안 되는 이유를 알 수 있습니다. search() 메소드가 매칭되는 표현을 찾지 못하고 None을 반환하면, None.groups()를 호출하게 되어 None은 groups() 메소드를 가지고 있지 않다는 오류를 내게 됩니다. (이 오류가 복잡한 코드 깊은 곳에서 발생했다면 생각보다 발견하기 쉽지 않을 수도 있습니다. 제가 그랬어요.)
- 이 정규표현식은 이전 것과 거의 같습니다. 문자열이 시작(^)된 후 숫자로 이루어진 그룹 세 개를 매칭하지요. 새로 추가된 부분은 하이픈 하나와 하나 이상의 숫자로 구성되는 또 다른 그룹(\d+)입니다. 이 부분까지 매칭된 후에 문자열이 바로 끝나는지($) 검사하겠네요.
- groups() 메소드가 이제 네 개의 항목으로 이루어진 튜플을 반환합니다. 확장 번호가 추가되었으니 네 개가 맞지요?
- 하지만 여전히 문제는 남아 있습니다. 전화번호의 각 부분이 하이픈으로 나누어져 있다고 가정을 했기 때문이죠. 빈 칸 하나나 쉼표(,) 혹은 점(.)으로 구분한 사람이 있다면 어쩌겠어요. 이런 부분까지 다 처리하려면 좀 더 일반적인 표현을 만들어야 하겠습니다.
- 아니, 그런데 문제가 더 나빠졌습니다. 하이픈 이외의 문자로 연결된 확장 번호를 인식하지 못하는 것뿐만 아니라, 확장 번호가 없는 전화 번호조차 인식하지 못하고 있네요. 절대로 여러분이 원하는 바가 아니지요. 확장 번호가 있다면 물론 인식해야 하지만, 확장 번호가 없더라도 나머지 전화 번호는 인식해야 합니다.
다음 예제에서 전화 번호의 각 부분들을 연결하는 문자를 먼저 처리해봅시다.
- 유심히 살펴보세요. 문자열이 시작한(^) 뒤, 숫자 세 개를 매칭하고(\d{3}) \D+가 나왔습니다. 이건 또 뭘까요? \D는 숫자를 제외한 모든 문자를 찾는 표현입니다. +는 이미 보셨겠지만 "하나 이상 반복"된다는 의미이지요. 그러니 \D+는 숫자가 아닌 문자가 하나 이상 나올 때 매칭한다는 것입니다. 하이픈 대신에 이 표현을 쓰면서 다양한 번호 구분자에 모두 대처할 수 있게 되었습니다.
- 하이픈 대신 \D+를 쓰면서, 전화 번호의 각 부분이 빈 칸으로 나뉘어 있어도 잘 인식하게 되었습니다.
- 하이픈으로 나뉘어진 전화번호도 물론 잘 인식됩니다.
- 그래도 문제는 있습니다. 번호 구분자가 무조건 존재한다고 가정하는 것 말이지요. 만약 전화번호가 어떤 하이픈이나 구분자 없이 숫자만으로 되어 있다면 어떤가요.
- 그리고 아직 확장 번호가 없는 경우에 대한 대처도 안 되어 있습니다. 이제 이 두 가지 문제가 남았는데, 사실 하나의 방법으로 모두 해결할 수 있습니다.
다응 예제를 보시죠. 구분자가 없는 전화번호를 다룰 수 있습니다.
- 이전 예제와 비교해 유일하게 다른 부분은 +를 모두 로 바꾸었다는 것입니다. 각 부분 사이에 \D+를 쓰는 대신 \D를 썼습니다. +가 "하나 이상"을 의미하는 것 기억하시죠? *는 "0개 이상"을 뜻합니다. 그래서 번호 구분자가 하나도 존재하지 않는 경우에도 전화 번호를 제대로 인식할 수 있는 것이지요.
- 이것 보세요, 정말 작동하지요? 문자열 시작 이후 세 개의 숫자로 이루어진 그룹 하나(800), 0개의 숫자 아닌 문자, 세 개의 숫자로 이루어진 그룹 하나(555), 0개의 숫자 아닌 문자, 네 개의 숫자로 이루어진 그룹 하나(1212), 0개의 숫자 아닌 문자를 찾은 후 문장이 끝나는지 살펴봅니다.
- 다른 형식도 문제 없습니다. 하이픈 대신 점(.)이나 빈 칸과 x를 이어 붙인 구분자를 써도 잘 찾고 그룹을 나누어 줍니다.
- 그리고 골치아픈 문제도 결국 해결했습니다. 확장 번호가 없어도 문제가 없습니다. 확장 번호가 없으면 groups() 메소드는 여전히 네 개의 항목을 가진 튜플을 반환하지만, 네 번째 항목은 그냥 빈 문자열이 됩니다.
- 제가 나쁜 소식을 떠벌리는 스타일은 아니지만, 아직 끝나지는 않았다는 소식은 알릴 수밖에 없네요. 문제가 뭘까요. 지역 코드 앞에 문자가 추가로 들어가 있는데('('), 정규표현식은 지역 코드의 첫 숫자가 문자열의 처음에 나온다고 인식하는군요. 문제 없습니다. 지역 코드 앞에 "0개 이상의 숫자가 아닌 문자"가 인식될 수 있게 바꾸면 됩니다.
다음 예제에서 해결해보지요.
- 정규표현식 제일 앞에 0개 이상의 숫자가 아닌 문자를 매칭(^\D*)한다는 점만 빼면 이전과 다를 바가 없습니다. 이 문자는 소괄호로 묶여 있지 않으므로 groups() 메소드를 호출했을 때 따로 반환되지 않는 것을 확인해보세요. 첫 문자로 숫자가 아닌 문자가 나오면 매칭만 하고 그냥 지나가 버립니다. 소괄호로 묶인 지역 코드부터 기억하겠지요.
- 지역 번호 앞에 '('가 있어도 번호를 잘 나눌 수 있습니다. (지역 번호 바로 뒤에 있는 ')'는 \D*로 이미 잘 매칭되어 있습니다.)
- 잘 작동하던 기능이 여전히 괜찮은지 확인하려고 다시 한 번 써봤습니다. 첫 문자는 0개여도 문제가 없으므로, 문자열 시작(^) 이후, 0개의 숫자 아닌 문자, 숫자 세 개의 그룹(800), 숫자 아닌 문자 하나(-), 숫자 세 개의 그룹(555), 숫자 아닌 문자 하나(-), 숫자 네 개의 그룹(1212), 0개의 숫자 아닌 문자, 숫자 0개의 그룹, 마지막으로 문자열의 끝($)에 매칭됩니다.
- 정말... 여기서는 제 눈알을 파버리고 싶었습니다. 왜 매치가 안 될까요. 지역 코드 전에 숫자 1이 나왔기 때문입니다. 정규표현식은 지역 코드 이전에는 숫자 이외의 문자만 나온다고 생각합니다(\D*). 하아...
잠깐만 물러나 생각해 봅시다. 지금까지 모든 정규표현식은 문자열의 처음부터 매칭했습니다. 하지만 지금 보니, 전화 번호 앞에 길이를 알 수 없는 임의의 내용이 나올 수도 있습니다. 다 무시해버리고 싶군요. 일일이 다 매칭하려 하지 말고 그냥 무시해버리는 법은 없을까요. 좀 다른 방식으로 생각해보세요. 꼭 문장의 처음부터 매칭할 필요가 있을까요. 다음 예제를 보면서 무슨 말인지 알아봅시다.
- 정규표현식 맨 앞에 ^가 없는 것을 주목하세요. 문자열의 첫 시작 부분은 더 이상 고려하지 않습니다. 여러분의 정규표현식이 꼭 문자열 전체를 검색할 필요는 없습니다. 정규표현식의 보이지 않는 엔진이 문자열의 어디서부터 매치할지, 그리고 어디까지 매치할지 알아서 결정 해줍니다.
- 이제 전화 번호가 어떻게 시작하든지, 또 구분자의 종류나 길이가 어떻든지 관계없이 모두 매칭할 수 있습니다.
- 잘 되네요.
- 이것도요.
정규표현식이 얼마나 헷갈리고 다루기 힘든지 보셨습니다. 이전 예제중 아무거나 한 번 보세요. 그 다음 예제에 있는 것과 무슨 차이가 있는지 금방 알아보기 쉽지가 않지요?
마지막 정규표현식을 잘 이해했다고 하더라도(정말 마지막이에요. 안 되는 거 빼고는 다 되잖아요. 안 되는 건 더 이상 쳐다보지 않을래요) verbose 정규표현식으로 주석을 좀 달아두는 것이 현명합니다. 몽땅 다 잊어버리기 전에요.
- 여러 줄에 걸쳐 쓴 것을 빼면 마지막 정규표현식과 정확히 일치합니다. search() 함수가 제대로 동작한다고 해서 놀라지 마세요.
- 마지막 확인입니다. 네. 동작하네요. 끝났어요.⁂
Summary
여기서 설명한 것은 정규표현식으로 할 수 있는 일의 빙산의 일각도 안 됩니다. 믿으세요. 이 챕터의 내용이 대단한 것처럼 보일지도 모르겠지만, 아직 여러분은 아무 것도 못 본 것과 마찬가지에요.
어쨌는 이제 다음과 같은 내용은 좀 익숙하지요?
- ^는 문자열의 시작을 뜻합니다.
- $는 문자열의 끝을 의미합니다.
- \b는 단어의 경계입니다.
- \d는 0-9 사이의 숫자입니다.
- \D는 숫자를 제외한 문자입니다.
- x?는 문자 x가 없거나 하나 있는 경우를 뜻합니다.
- x*는 문자 x가 0개 이상인 경우를 의미합니다.
- x+는 문자 x가 하나 이상인 경우를 의미합니다.
- x{n,m}는 문자 x가 n번 이상 m번 이하 나오는 경우를 말합니다.
- (a|b|c)는 a, b, c 중 하나를 의미합니다.
- (x)는 그룹입니다. re.search() 메소드가 객체를 반환할 경우, groups() 함수를 적용해서 x의 값을 얻을 수 있습니다.
정규표현식은 매우 강력하지만, 모든 문제에 적용할 필요는 없습니다. 어떤 경우는 적절하게 사용해서 문제를 비교적 간단하게 해결할 수도 있지만, 또 다른 경우는 오히려 문제를 더 만들 수도 있습니다. 적절한 사용처를 아는 것이 중요합니다.
이 강의는 영어로 된 원문과 부분적으로 진행된 한글화 프로젝트를 기초로 작성되었으며, Creative Commons Attribution Share-Alike 라이센스하에 자유로운 변경, 배포가 가능합니다
토론이 없습니다