본문 바로가기
프로그래밍/정규표현식

[REGEX] 반복 찾기

by 별준 2022. 4. 6.

References

  • Learning Regular Expressions

Contents

  • 하나 이상의 문자 찾기 ('+')
  • 문자가 없거나 하나 이상 연속하는 문자 찾기 ('*')
  • 문자가 없거나 하나인 문자 찾기 ('?')
  • 구간 지정하기 ({})
  • greedy/lazy quantifiers

[REGEX] 메타 문자

지난 포스팅에서 다양한 메타 문자와 특별한 클래스 집합을 사용해 개별 문자를 찾는 방법에 대해서 살펴봤습니다. 이번 포스팅에서는 여러 번 반복해 나타나는 문자나 문자 집합을 어떻게 찾는지 살펴보겠습니다.

 


몇 번 일치하는가?

지금까지의 포스팅들을 통해 정규표현식 패턴이 어떻게 일치하는지에 대한 기초에 대해 살펴봤습니다. 하지만 살펴봤던 예제에는 모두 심각한 한계가 한 가지 존재합니다. 예를 들어 이메일 주소와 일치하는 정규표현식을 작성한다고 생각해봅시다. 이메일의 주소의 기본 형식은 다음과 같습니다.

text@text.text

그렇다면, 이전 포스팅에서 살펴 본 메타 문자를 사용해 다음과 같은 정규표현식을 만들 수 있습니다.

'\w@\w\.\w'

'\w' 문자는 모든 영숫자 문자와 일치합니다. 더불어 밑줄도 함께 찾는데, 밑줄 역시 이메일 주소에서 사용하는 문자입니다. '@'은 이스케이프하지 않아도 되지만, '.'은 이스케이프 해야 합니다.

 

이 패턴은 문법에 완벽하게 맞는 정규표현식이긴 하지만, 전혀 쓸모가 없는 편에 가깝습니다. 이 패턴으로는 a@b.c 같은 이메일 주소만 찾을 수 있기 때문입니다. 문법은 맞지만, 분명히 유효한 메일 주소는 아닙니다. 문제는 '\w'이 문자 하나하고만 일치하는데, 얼마나 많은 문자를 검사해야할 지 모른다는 점입니다. 다음 이메일 주소들은 모두 유효한 주소이지만, @ 문자 앞에 나온 문자 수는 서로 다릅니다.

b@forta.com
ben@forta.com
bforta@forta.com

이를 위해서 문자를 여러 개 찾는 방법을 알아야 하는데, 이는 많은 특수 메타 문자들 중 하나를 사용해서 수행할 수 있습니다.

 

하나 이상의 문자 찾기

문자나 집합에 속한 요소를 하나 이상 찾으려면 간단히 문자 뒤에 더하기('+') 문자를 붙이면 됩니다. '+'는 문자가 하나 이상일 때 일치합니다(최소한 하나와 일치하고, 없을 때는 일치하지 않습니다). 'a'가 a를 찾는 데 반해, 'a+'는 하나 이상 연속된 a를 찾습니다. 비슷하게 '[0-9]'는 자릿수가 하나인 숫자를 찾지만, '[0-9]+'는 한 자리 이상 연속된 숫자를 찾습니다.

문자 집합에 더하기(+)를 사용할 때는 집합 바깥에 위치시켜야 합니다. 즉, '[0-9+]'가 아니라 '[0-9]+'가 맞는 표현입니다. 사실 '[0-9+]'가 잘못된 문법은 아니지만, 하나 이상의 숫자와 일치하지는 않고, 숫자 0부터 9와 +로 집합을 정의한 것을 의미합니다.

 

다시 이메일 주소 예제를 살펴보겠습니다. 이번에는 '+'를 사용해 하나 이상의 문자와 일치시킵니다.

Send personal email to ben@forta.com. For questions
about a book use support@forta.com. Feel free to send
unsolicited email to span@forta.com (wouldn't it be
nice if it were that simple, huh?).

위 예문을 가지고, 정규표현식 패턴 '\w+@\w+\.\w+'를 사용한 결과는 다음과 같습니다.

이번에 사용한 패턴은 주소 3개와 모두 정확하게 일치했습니다. 정규표현식 '\w+'를 사용해 우선 하나 이상 연속된 영숫자 문자를 찾고, 이어서 @을 찾은 다음, 다시 '\w+'로 하나 이상 연속된 문자를 찾습니다. 그리고 마침표 문자를 \로 이스케이프하여 '.'를 찾았으며 마지막으로 '\w+'를 한 번 더 사용해 주소의 마지막 부분을 찾았습니다.

'+'는 메타 문자입니다. 문자 그대로 '+'를 찾으려면 '\+'로 이스케이프해야 합니다.

 

'+'는 문자 집합이 하나 이상인 경우에도 사용합니다. 정규표현식은 동일하게 사용하고, 다음 예문으로 실험해보도록 하겠습니다.

Send personal email to ben@forta.com or
ben.forta@forta.com. For questions about a
book use support@forta.com. If your message
is urgent try ben@urgent.forta.com. Feel
free to send unsolicited email to
spam@forta.com (wouldn't it be nice if
it were that simple, huh?).

결과는 다음과 같습니다.

정규표현식으로 5개의 주소를 찾았지만, 그중 2개(ben.forta@forta.com, ben@urgent.forta.com)는 제대로 검색되지 않았습니다. 왜 그럴까요?

'\w+@\w+\.\w+'는 @ 앞에 나오는 마침표를 일치시키지 못하고, @ 뒤에 문자열을 둘로 나누는 마침표는 하나만 검색되도록 설정되었기 때문입니다. '\w'는 영숫자와 일치하지만, 문자열 중간에 있는 마침표와는 일치하지 않기 때문에 ben.forta@forta.com이 올바른 이메일 주소라고 하더라도, 이 정규표현식은 ben.forta 대신 forta만 찾는 것입니다.

 

이제 '\w'나 마침표와 일치하도록, 정규표현식 문법에 따라 '[\w.]' 집합을 정의해야 합니다.

패턴을 '[\w.]+@[\w.]+\.\w+'로 수정하면 다음의 결과를 얻을 수 있습니다.

결과를 보다시피 '[\w.]+'를 사용하니, 문자, 밑줄, 마침표가 하나 이상 일치해 ben.forta를 제대로 찾아냈습니다. 그리고 @ 뒤에서 더 깊은 단계의 도메인 주소도 올바르게 찾습니다.

 

여기서 사용한 패턴을 보면, 집합 안에서는 마침표를 이스케이프하지 않아도 '.'와 일치한다는 사실을 알 수 있습니다. 일반적으로 마침표나 더하기와 같은 메타 문자들이 집합의 구성원일 때는 문자 그대로 취급되므로 굳이 이스케이프할 필요가 없습니다. 하지만, 이스케이프 한다고 문제가 발생하지도 않습니다. [\w.]는 [\w\.]와 동일하게 동작합니다.

 

문자가 없거나 하나 이상 연속하는 문자 찾기

더하기(+)는 하나 이상 연속된 문자를 찾습니다. 문자가 없는 경우는 아예 찾지 못하고, 최소한 하나는 일치해야 합니다. 하지만 있을 수도 있고 없을 수도 있는 문자와 일치시키려면 어떻게 해야 할까요?

이럴 때는 메타 문자인 별표(*)를 사용하면 됩니다. '*'는 '+'와 거의 비슷하게 사용하는데, 문자나 집합 바로 뒤에 위치시키면 찾고자 하는 문자나 집합이 없는 경우 또는 하나 이상 연속하는 경우에 일치합니다. 따라서 'B.* Forta' 패턴은 B Forta, B. Forta, Ben Forta와 같은 조합과도 일치합니다.

 

다음의 이메일 예문을 통해서 '*'의 사용법을 살펴보겠습니다.

Hello .ben@forta.com is my email address.

'[\w.]+@[\w.]+\.\w+' 패턴을 사용한 결과는 다음과 같습니다.

'[\w.]+'가 영숫자 문자와 마침표로 구성된 한 글자 이상의 요소와 일치하므로 .ben과 일치합니다. 이 예문에는 명백하게 오자가 존재하지만(텍스트 중간에 마침표가 잘못 찍혀 있음), 오자가 문제는 아닙니다. 더 큰 문제는 이메일 주소에 마침표를 쓸 수는 있지만, 이메일 주소 맨 처음에 사용하지는 않는다는 사실입니다.

 

다시 말해, 정말 일치시키려는 문자는 더 있을 수도 있고, 없을 수도 있는 영숫자 문자라는 것입니다.

따라서 다음의 정규표현식 패턴을 사용하면 올바르게 이메일 주소를 일치시킬 수 있습니다.

'\w+[\w.]*@[\w.]+\.\w+'

이 패턴을 사용한 결과는 다음과 같습니다.

패턴이 더 복잡해진 것 같지만, 실제로 그렇지는 않습니다.

'\w+'는 마침표를 제외한 영숫자 문자와 모두 일치합니다. 즉, 이메일 주소의 시작으로 유효한 문자들만 포함됩니다. 우선 첫 문자가 유효하면, 그 다음에는 마침표가 하나 나오거나 문자가 더 나올 수 있습니다. '[\w.]*'는 문자가 없는 경우를 포함해 여러 개의 영수자 혹은 마침표와 일치하는데, 이것이 바로 여기서 찾고자 한 텍스트입니다.

 

'*'는 주어진 문자가 있는 경우에 일치시키는 선택적 메타 문자로 생각하면 됩니다. 반드시 하나 이상 일치해야 하는 '+' 문자와 달리, '*'는 일치하는 텍스트가 있다면 얼마든지 일치하지만, 반드시 있어야 하는 것은 아닙니다.

 

 

문자가 없거나 하나인 문자 찾기

'+', '*'와 더불어 유용한 메타 문자로 물음표('?')가 있습니다. '*'처럼 '?'는 문자가 있는 경우 일치하고 문자가 없어도 일치하지만, '*'와 달리 문자나 집합이 없거나 하나만 있는 경우에만 일치하며 하나 이상은 일치하지 않습니다. 즉, 물음표는 문자 묶음 안에서 있는지 없는지 확실하지 않은 특정한 문자 하나만 찾을 때 유용합니다.

 

다음 예문을 통해 살펴보겠습니다.

The URL is http://www.forta.com/, to connect
securely use https://www.forta.com/ instead.

위 예문에서 'http:\/\/[\w.\/]+' 패턴을 사용하면 다음의 결과를 확인할 수 있습니다.

사용한 패턴은 URL을 일치시키고자, 'http:\/\/'를 사용했고, 문자 그대로 찾기 때문에 오직 해당 문자와만 일치합니다. 이어지는 '[\w.\/]+'가 영숫자 문자, 마침표, 슬래시로 이루어진 집합의 구성 요소 가운데 하나 이상과 일치합니다. 이 패턴은 처음에 있는데 http://로 시작하는 URL과는 일치하지만 두 번째에 있는 https://로 시작하는 URL과는 일치하지 않습니다. 그렇다고 s가 없거나 하나 이상 연속될 때 일치하는 's*'가 적합하지도 않는데, 이는 httpsssss:// 와도 일치하기 때문입니다.

 

이를 해결하기 위해서는 아래의 패턴처럼 's?'를 사용하면 됩니다.

'https?:\/\/[\w.\/]+'

결과는 다음과 같습니다.

여기서 사용한 패턴은 'https?:\/\/'로 시작합니다. '?'는 자기 앞에 있는 문자가 없거나 그 문자가 하나만 있는 경우와 일치합니다. 여기서는 s인데, 'https?://'는 http://나 https://와는 일치하지만, 그 외에는 일치하지 않습니다.

 

덧붙여 '?'를 사용하면 이전 포스팅에서 언급했던 줄바꿈 문자를 검색하는 예제에서의 문제점을 해결할 수 있습니다. '\r\n'으로 줄의 끝을 일치시키는 예제를 살펴봤는데, 유닉스나 리눅스 환경에서는 '\n'만 사용합니다. 이때 \n앞에 \r가 있을 경우에만 일치시키는 것이 이상적인 해결책인데, 이는 다음의 패턴으로 해결할 수 있습니다.

'[\r]?\n[\r]?\n'

위의 패턴을 사용하면 \r이 있을 경우에만 \r과 일치하고, \n과는 반드시 일치합니다.

 

사용한 패턴에서 '\r?'이 아닌 '[\r]?'을 사용했음에 주의합니다. '[\r]'은 메타 문자가 하나 포함된 집합을 정의하는데, 집합의 구성원이 하나이므로 '[\r]?'은 실제로 '\r?'와 같은 기능을 합니다. 집합([])은 일반적으로 문자 집합을 정의하는 데 쓰지만, 일부 개발자들은 혼란을 방지하고자 문자가 하나일 때도 집합을 사용하여, 바로 뒤에 나오는 메타 문자가 정확하게 어디에 적용되는지 확실하게 표현합니다.

 


구간 지정하기

'+', '*', '?' 메타 문자는 정규표현식을 쓰면서 발생하는 많은 문제들을 해결해 주지만, 충분하지 않을 때도 있습니다. 다음과 같은 상황들에 대해 생각해봅시다.

  • '+', '*'는 일치하는 문자 수에 제한이 없다. 문자가 최대 몇 개까지 일치하는지 정할 수 없다.
  • '+', '*', '?'가 일치하는 문자 수의 최솟값은 0이나 1이다. 일치하는 문자 수의 최솟값을 명시적으로 정할 수 없다.
  • 정확히 원하는 만큼만 일치하도록 문자 수를 정의할 수 없다.

이러한 문제들을 해결하고, 연속하는 문자를 찾을 때 검색 조건을 더 구체적으로 지정하고자 정규표현식에서는 구간(interval)을 사용합니다. 구간을 중괄호({}) 안에 표시합니다.

중괄호({}) 역시 메타 문자이므로 문자 그대로 사용하려면 역슬래시를 사용해 이스케이프해야 합니다. 그러나 많은 정규표현식 구현에서는 역슬래시로 이스케이프하지 않더라도 중괄호({})가 문제 그대로 사용되는지 혹은 메타 문자로 사용되는지 정확하게 구별할 수 있습니다. 하지만 문자 그대로 찾을 때는 이런 기능에 의존하기보다는 이스케이프 하는 편이 좋습니다.

 

정확한 구간 찾기

문자가 일치하는 수를 정확히 정하려면 여는 중괄호({)와 닫는 중괄호(}) 사이에 숫자를 넣습니다. 즉, '{3}'은 바로 앞에 있는 문자나 문자 집합이 3번 역속해서 일치하는지 확인합니다. 만약 요소가 2개만 있다면 패턴이 일치하지 않습니다.

 

위에서 살펴본 RGB 예제를 수정해 어떻게 동작하는지 살펴보겠습니다. RGB값은 2개씩 짝지어진 16진수 숫자 집합 3개로 이루어집니다. 처음에는 RGB값을 찾기 위해서 다음의 패턴을 사용했습니다.

'#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]'

POSIX 문자 클래스를 사용하면 다음과 같은 패턴을 사용할 수 있습니다.

'#[[:xdigit]][[:xdigit]][[:xdigit]][[:xdigit]][[:xdigit]][[:xdigit]]'

 

문제는 두 패턴 모두 문자 집합 혹은 클래스를 정확하게 여섯 번 반복해서 명시해 주어야 한다는 점입니다. 이 예제에서 구간 찾기를 사용하여, 다음의 패턴을 사용하면 더욱 간결하게 표현할 수 있습니다.

'#[A-Fa-f0-9]{6}'

 

 

범위 구간 찾기

값(일치 횟수)의 범위, 다시 말해 일치시키려는 요소 수의 최솟값과 최댓값을 나타낼 때도 구간을 사용합니다. 범위는 '{2,4}'처럼 표현합니다. 이 표현은 최소 두 번에서 최대 네 번까지 일치시킨다는 의미입니다. 날짜 형식을 찾는 정규표현식이 범위 구간(range interval) 찾기를 보여 주는 예제로 적절한데, 아래의 예문으로 테스트해보겠습니다.

4/8/17
10-6-2018
2/2/2
01-01-01

'\d{1,2}[-\/]\d{1,2}[-\/]\d{2,4}' 패턴으로 찾은 결과는 다음과 같습니다.

여기에는 사용자가 입력할 법한 값이 나열되었습니다. 이 값들은 날짜 형식에 맞게 입력되어야 합니다. '\d{1,2}'는 한 자리 혹은 두 자리 숫자와 일치해 날짜와 월을 검사하고, '\d{2,4}'는 연도와 일치하며, '[-\/]'는 날짜 구분선은 하이픈(-)이나 슬래스(/)와 일치합니다. 결과적으로 연도가 너무 짧아서 일치하지 않는 2/2/2를 제외한 나머지 날짜와 모두 일치했습니다.

 

중요한 것은 이 패턴으로는 날짜가 옮은지 검사하지는 못한다는 점입니다. 54/67/9999처럼 값이 틀려도 검사를 통과합니다. 이 패턴은 오직 날짜 형식에 맞는지만 검사할 뿐입니다. 그래서 이 단계는 주로 날짜 자체가 유효한지 검사하기 전에 이루어집니다.

 

구간은 0부터 시작하기도 합니다. '{0,3}'은 요소가 없는 경우나 요소가 한 번 또는 두 번이나 세 번 일치함을 의미합니다.
앞서 살펴봤듯이, 물음표(?)는 물음표 앞에 주어진 요소가 없는 경우나 요소 한 개와 일치합니다. 즉, '?'는 '{0,1}'과 같은 기능을 합니다.

 

 

'최소' 구간 찾기

마지막으로 구간 검색은 최댓값 없이 찾고자 하는 요소의 최솟값을 지정할 수도 있습니다. 이 패턴에서 쓴 구간 문법은 범위와 비슷하지만, 최댓값이 없다는 점만 다릅니다. 예를 들어 '{3,}'은 최소한 요소가 3번 일치함을 의미합니다. 다시 말해, 요소가 3번 이상 일치한다는 것입니다. 다음 예문을 통해서 이번 포스팅에서 배운 것들을 조합해 살펴보겠습니다.

1001: $496.80
1002: $1290.69
1003: $26.43
1004: $613.42
1005: $7.61
1006: $414.90
1007: $25.00

여기서 주문 금액이 100달러 이상인 주문을 모두 찾으려면 다음의 정규표현식 패턴을 사용합니다.

'\d+: \$\d{3,}\.\d{2}'

결과는 다음과 같습니다.

여기서 사용한 정규표현식은 처음에 '\d+:'를 사용해서 주문 번호를 찾습니다. 주문 번호를 포함한 전체 행이 아니라 단순히 금액만 찾고 싶다면 이 패턴은 빼도 무방합니다. '\$\d{3,}\.\d{2}'는 금액과 일치하는 패턴입니다. '\$'는 달러 기호, '\d{3.}'은 최소 3자리 숫자(즉, 100달러 이상), '\.'는 '.', 마지막으로 '\d{2}'는 소수점 이하 두 자리 숫자와 일치합니다.

'+'는 '{1,}와 동일한 기능입니다.

 


과하게 일치하는 상황 방지하기

'?'는 제한된 범위만큼 일치시키고(없거나 하나만 있는 경우), 구간을 쓰면 정확히 지정한 만큼 일치하거나 지정한 범위 안에서만 검색을 수행합니다. 하지만 이번 포스팅에서 소개한 패턴들은 일치하는 횟수에 제한이 없기 때문에 때로는 너무 많이 일치하기도 합니다.

 

그래서 지금까지 나온 예제는 모두 일치하지 않아도 되는 텍스트까지 과도하게 일치하는 상황이 없도록 주의 깊게 패턴을 사용했지만, 다음 예제를 생각해보겠습니다. 아래의 예문은 웹 페이지의 일부이고, HTML 태그인 <b>가 포함되어있습니다. 여기서 정규표현식으로 <b> 태그로 둘러싸인 텍스트를 모두 일치시켜야 한다고 가정해보겠습니다.

This offer is not available to customers
living in <b>AK</b> and <b>HI</b>.

정규표현식 '<[Bb]>.*<\/[Bb]>'를 사용하면 다음의 결과를 출력합니다.

처음 '<Bb>'는 <b> 시작 태그와 일치하고, '<\/[Bb]>'는 </b> 종료 태그와 일치합니다. 두 경우 모두 대소문자를 구분하지 않습니다. 하지만 두 번이 아니라 오직 한 번만 일치했습니다. '.*'는 처음 나온 <b>부터 마지막에 나온 </b> 사이에 있는 모든 텍스트와 일치하므로 AK</b> and <b> HI가 일치한 것입니다. 즉, 우리가 원하는 텍스트를 포함하긴 하지만, 찾으려 하지 않은 텍스트도 포함되었습니다.

 

'*'와 '+'와 같은 메타 문자는 greedy하기 때문에 가능한 한 가장 큰 덩어리를 찾으려 합니다. 이런 메타 문자는 찾으려는 텍스트를 앞에서부터 찾는 게 아니라, 텍스트 마지막에서 시작해 거꾸로 찾습니다. 이는 의도적으로 quantifier를 탐욕적(greedy)으로 설계했기 때문입니다.

 

하지만 우리가 이러한 일치를 원하지 않는다면 어떻게 해야 할까요?

이런 경우에는 greedy quantifier를 'lazy' quantifier로 변경하여 이 문제를 해결할 수 있습니다. '게으른'이라고 부르는 이유는 문자가 최소로 일치하기 때문입니다. lazy quantifier는 기존 quantifier 뒤에 '?'를 붙여서 표현합니다. 아래의 표는 greedy quantifier에 각각 대응되는 lazy quantifier를 보여줍니다.

 

'*?'는 '*'의 lazy quantifier 인데, 이 문자를 이용해서 방금 위에서 살펴본 예제에 적용해보겠습니다.

'<[Bb]>.*?<\/[Bb]>' 패턴을 사용하면 다음의 결과를 얻을 수 있습니다.

결과를 보다시피, '*?'를 사용해 먼저 <b>AK</b>만 일치시켰고, 뒤이어 <b>HI</b>를 찾아 두 부분을 따로 일치시켰습니다.

댓글