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

[REGEX] 하위 표현식 (Subexpression)

by 별준 2022. 4. 8.

References

  • Learning Regular Expressions

Contents

  • Subexpressions 이해
  • Subexpression으로 그룹화하기
  • 중첩된 Subexpressions

지정한 문자를 검색하는 기능과 메타 문자는 정규표현식을 뒷받침하는 기본 기능입니다. 이번 포스팅에서는 하위 표현식(subexpression)을 사용해 여러 표현식을 어떻게 묶는지 살펴보겠습니다.

 


Subexpressions 이해하기

[REGEX] 반복 찾기에서는 단어 하나가 여러 번 일치하는 경우를 살펴봤습니다. '\d+'는 하나 이상 연속된 숫자와 일치하고, 'https?:\/\/'는 http://나 https://와 일치합니다. 이 두 예시에서 '?', '*', '{2}'와 같은 반복 메타 문자는 자기 바로 앞에 있는 문자나 메타 문자에 적용합니다.

 

예를 들어 HTML 개발자는 단어 사이에 공백을 확실히 유지할 목적으로 주로 non-breaking space($nbsp; 사용)를 단어 사이에 넣습니다. 이제 이러한 공백을 다른 것으로 대체하고자, HTML에서 반복해 나오는 non-breaking space(줄바꿈없는 공백)를 모두 찾아야 한다고 가정해봅시다.

Hello, my name is Ben Forta, and I am
the author of multiple books on SQL (including
MySQL, Oracle PL/SQL, and SQL Server T-SQL),
Regular  Expressions, and other subjects.

' {2,}' 패턴을 사용한 결과는 다음과 같습니다.

아무것도 일치하지 않습니다.

$nbsp;는 HTML non-breaking space를 나타내는 참조 항목입니다. ' {2,}' 패턴은  와 두 개 이상 일치해야 합니다. 하지만 일치하지 않았습니다. 이는 '{2,}'가 바로 앞에 있는 문자와 연속해서 반복된 횟수만을 표현하기 때문입니다. 이 경우 앞에 놓인 문자가 세미콜론이므로,  ;;;;;는 일치하지만,   와는 일치하지 않습니다.

 


Grouping with Subexpressions

위의 문제를 해결하기 위해서 하위표현식이 필요합니다. 하위표현식은 큰 표현식 안에 속한 일부 표현식을 한 항목으로 다루도록 한데 묶은 것입니다. 하위표현식은 괄호 사이에 사용합니다.

괄호는 메타 문자입니다. 실제로 여는 괄호 '('와 닫는 괄호 ')'를 찾으려면 각각 '\('와 '\)'로 이스케이프해야 합니다.

 

따라서 앞서 나온 예문에 다음의 정규표현식을 사용하면 원하는 결과를 얻을 수 있습니다.

'( ){2,}'

여기서 '( )'는 하위표션식이며 한 항목으로 취급합니다. 따라서, '{2,}'은 세미콜론이 아니라 이 하위표현식 전체가 반복하는 횟수를 나타냅니다.

 

다른 예제를 살펴보겠습니다. 이번에는 정규표현식으로 IP 주소를 찾습니다. IP주소는 숫자 네 묶음을 마침표로 구분해 이루어지며 12.159.46.200과 같은 형식입니다. 각 묶음에는 숫자가 한 자리 숫자부터 세 자리 숫자까지 들어갈 수 있으므로 이 숫자 묶음과 일치시키려면 패턴을 '\d{1,3}'으로 표현합니다.

Pinging hog.forta.com [12.159.46.200]
with 32 bytes of data:

'\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}' 패턴을 적용한 결과는 다음과 같습니다.

각 '\d{1,3}'은 IP 주소 숫자들 가운데 한 묶음과 일치합니다. 숫자 네 묶음은 마침표로 구분하므로 '\.'로 이스케이프합니다.

 

최대 세 자리 정수 뒤에 마침표가 오는 '\d{1,3}\.' 패턴 자체도 3번 반복되므로 반복 표현으로 표현할 수 있습니다. 즉, 다음의 패턴으로 동일한 결과를 얻을 수 있습니다.

'(\d{1,3}\.){3}\d{1,3}'

사용자에 따라 가독성을 높이고자 표현식 일부를 하위표현식으로 묶기도 하는데, 앞서 나온 패턴을 '(\d{1,3}\.){3}(\d{1.3})'처럼 나타내기도 합니다. 이 방식도 문법에 문제가 없으며 실제로 제대로 동작하는데 문제도 없습니다. 하지만 사용하는 정규표현식 구현에 따라 성능 문제가 발생할 수는 있습니다.

 

하위표현식으로 묶는 방식은 굉장히 중요합니다. 한 가지 예제를 더 살펴보겠습니다.

ID: 042
SEX: M
DOB: 1967-08-17
Status: Active

위 예문에서 연도를 일치시키고자 다음의 정규표현식 패턴을 사용해보겠습니다.

'19|20\d{2}'

결과는 다음과 같습니다.

여기서 하위표현식은 사용하지 않았습니다. 사용한 패턴을 통해 4자리 숫자로 이루어진 연도를 일치시키고자 했으며, 더 정확한 결과를 얻기 위해서 앞 두 자리 숫자는 명확하게 19와 20으로 정했습니다. '|'은 OR 연산자를 의미합니다. 따라서 '19|20'은 19 혹은 20과 일치하고, 따라서 '19|20\d{2}'는 19나 20으로 시작하는 4자리 숫자와 일치할 것이라고 생각했습니다. 하지만 의도한 대로 일치하지 않았습니다. 여기서 '|' 연산자는 자신의 왼쪽편과 오른쪽편에 각각 무엇이 있는지 살펴보는데, '19|20\d{2}'를 19 혹은 20\d{2}, 즉, '\d{2}' 역시 20으로 시작하는 표현의 일부로 여겼기 때문입니다. 그래서 19와 일치하든지 20으로 시작하는 4자리 연도와 일치할 텐데, 여기서는 19와 일치했습니다.

 

이 문제의 해결 방법은 '19|20'을 하위표현식으로 묶는 것입니다.

'(19|20)\d{2}'

결과는 다음과 같습니다.

'|'의 모든 선택 사항이 모두 하위 표현식 안에 있으므로 '|' 연산자는 묶음 안에 있는 숫자 가운데 하나를 일치시키려 한다는 사실을 알게 됩니다. 그래서 '(19|20)\d{2}'는 정확하게 1967과 일치하고, 19 혹은 20으로 시작하는 다른 네 자리 숫자와도 일치할 것 입니다.

 


Nesting Subexpressions

하위표현식을 중첩해 사용하기도 합니다. 사실 하위표현식은 다른 하위표현식을 중첩하고, 그 하위표현식도 또 다른 하위표현식을 중첩하기도 합니다.

어떤 모습인지 상상이 가시나요? 이처럼 중첩되는 기능 덕분에 하위표현식은 매우 강력한 표현을 만들 수 있습니다. 하지만 반대로 표현식을 뒤엉키게 해 읽거나 분석하기 어렵고, 그 복잡한 모습에 겁을 먹게 만들기도 합니다. 하지만 사실 복잡해 보이도록 중첩된 하위표현식은 거의 사용되지 않습니다.

 

중첩된 하위표현식을 어떻게 사용하는지 알아보기 위해서 위에서 살펴본 IP 주소 예제를 다시 살펴보겠습니다. 위에서 '(\d{1,3}\.){3}\d{1,3}' 패턴을 사용했었는데, 이 패턴에서는 하위표현식이 3번 반복된 다음 마지막 숫자가 나타납니다.

 

이 패턴은 무엇이 잘못되었을까요? 문법적으로는 잘못된 것이 없습니다. IP 주소는 실제로 숫자 네 묶음으로 구성되었고, 각 묶음은 한 자리에서 세 자리 숫자이며 마침표로 구분합니다. 형식도 맞고, 올바른 IP 주소도 찾아낼 것입니다. 하지만 유효하지 않은 IP 주소까지 찾아낸다는 점이 문제입니다.

IP 주소는 4바이트로 구성되고, 12.159.46.200으로 나타내는 IP 주소는 바로 이 4바이트를 표현한 것입니다. 따라서 IP 주소에서 숫자 네 묶음은 각각 한 바이트 값을 나타내고, 이 값의 범위는 0부터 255 사이에 속합니다. 즉, IP 주소에 255보다 큰 숫자는 들어갈 수 없음을 의미합니다. 하지만 앞서 본 패턴을 사용하면, 345, 700, 999 같이 IP 숫자로 올바르지 않은 숫자와도 일치합니다.

 

유효한 값의 범위를 지정할 수 있으면 매우 좋겠지만, 정규표현식은 문자를 일치시킬 뿐이지 문자가 의미하는 바에 대해서는 아무런 지식이 없습니다. 또 수학 연산을 사용할 수도 없습니다.

정규표현식을 만들려면 일치해야 할 것과 일치해서는 안되는 것을 명확하게 정의해야 합니다. 다음은 IP 주소를 구성하는 각 숫자 묶음을 유효한 조합으로 정의하는 규칙입니다.

  • 모든 한 자리 혹은 두 자리 숫자
  • 1로 시작하는 모든 세 자리 숫자
  • 2로 시작하면서 두 번째 자리 숫자가 0부터 4인 모든 세 자리 숫자
  • 25로 시작하면서 세 번째 자리 숫자가 0부터 5 사이인 모든 세 자리 숫자

위와 같이 순서대로 표현하면 실제로 패턴이 명확하게 동작합니다.

Pinging hog.forta.com [12.159.46.200]
with 32 bytes of data:

위에서 살펴본 예문에 아래의 패턴을 적용하면 올바른 결과를 얻을 수 있습니다.

'(((25[0-5])|(2[0-4]\d)|(1\d{2})|(\d{1,2}))\.){3}(((25[0-5])|(2[0-4]\d)|(1\d{2})|(\d{1,2})))'

여기서 사용한 패턴이 제대로 동작하는 이유는 하위표현식이 연속해서 중첩되었기 때문입니다. 하위표현식 4개가 중첩된 '(((25[0-5])|(2[0-4]\d)|(1\d{2})|(\d{1,2}))\.)'로 시작합니다. '(\d{1,2})'는 한 자리 혹은 두 자리 숫자, 즉 0부터 99 사이의 숫자와 일치합니다. '(1\d{2})'는 1로 싲작하는 모든 세 자리 숫자, 즉, 100부터 199 사이의 숫자와 일치합니다. '(2[0-4]\d)'는 200부터 249 사이의 숫자와 일치하며, '(25[0-5])'는 250부터 255 사이의 숫자와 일치합니다. 여기에 나온 하위표현식은 더 큰 하위표현식으로 묶이고 이렇게 묶인 하위표현식 사이에는 OR('|') 연산자가 위치합니다. 이 연산자를 통해 전부가 아닌 네 가지 하위표현식 가운데 하나만 일치합니다.

숫자 범위가 나온 다음에는 '\.'가 마침표와 일치하고 이렇게 설정된모든 숫자 범위와 '\.'를 또 다른 하위표현식으로 묶어 {3}으로 3번 반복합니다. 마지막으로 IP 주소의 마지막 숫자와 일치하는 숫자 범위를 반복합니다.

네 묶음의 범위를 각각 0에서 255 사이로 제한하여 이 패턴은 실제로 올바른 IP 주소와는 일치하고 바르지 않은 주소는 일치하지 않습니다.

 

여기서 네 가지 표현식에 대해 논리적인 순서에 대해 주의할 필요가 있습니다. 위에서 사용한 패턴에서 네 가지 숫자 표현식의 순서만 반대로 변경한 다음 패턴을 사용한 결과를 살펴보겠습니다.

'(((\d{1,2})|(1\d{2})|(2[0-4]\d)|(25[0-5]))\.){3}(((\d{1,2})|(1\d{2})|(2[0-4]\d)|(25[0-5])))'

이번에는 마지막 0이 일치하지 않았습니다. 왜 이런 결과가 나왔을까요?

패턴이 왼쪽에서 오른쪽으로 평가되기 때문에, 일치하는 식이 4개가 있다면 첫 번째 식을 시도하고, 두 번째 식을 시도하는 식으로 일치합니다. 만약 패턴이 일치하면 다른 옵션은 시도되지 않습니다. 여기서는 '(\d{1,2})'가 마지막 200의 20과 일치해서, 다른 옵션(여기서 필요한 '(25[0-5])' 패턴을 포함해서)들은 평가되지 않았습니다.

'프로그래밍 > 정규표현식' 카테고리의 다른 글

[REGEX] 전방탐색과 후방탐색  (1) 2022.04.10
[REGEX] 역참조와 치환 작업  (0) 2022.04.09
[REGEX] 위치 찾기 (Position Matching)  (0) 2022.04.07
[REGEX] 반복 찾기  (0) 2022.04.06
[REGEX] 메타 문자  (0) 2022.04.05

댓글