웹, HTML

정규 표현식에서 소괄호() 역할 그룹화(캡처링), lookahead 전방 탐색, lookbehind 후방 탐색 이해

디버그정 2020. 6. 16. 12:27

RegExp.txt
0.01MB


정규 표현식에서 메타기호 소괄호가 상당히 중요한 의미를 가진다. 그룹화(캡쳐링), 전방탐색, 후방탐색 등에서 사용된다.
참고로 후방탐색은 최근에 구현되어서 아직 지원 안하는 브라우저도 있다고 한다. 전방탐색만 이해하면 후방탐색은 순서만 바꾼 것이므로 바로 이해된다.
실전적 사례를 보면서 정규식에서 소괄호 의미를 확실하게 이해하자.
아래와 같은 스트링에서 원하는 URL 주소를 추출해서 배열로 저장해보자.

var r, s = "refresh: 82; url=https://adv.com/business?

api=A12Q82dDsA3&mem=green&age=20&speed=fast&url=http://real.dest.com/txXmDqm&loc=earth&air=good&link=https://www.etc.com/goodtime/fungame.php?Sf54G13dSp";
r = s.match(/https?:\/\/[^&]+/gi);	// http~를 찾고 복사해 나가다가 & 문자가 나오면 중지하고 결과 배열에 저장 후 다음 부분을 찾는 작업을 반복한다.
결과: ["https://adv.com/business?api=f12cf12DsA3", "http://real.dest.com/txXmDqm", "https://www.etc.com/goodtime/fungame.php?sf54sviSp"]

&로 끊으면 네임=밸류&네임=밸류... 식으로 인수를 전달하는 경우 그 앞에서 끊어버리므로 추출시 적합하지 않다.
&url=이나 &link= 등 다른 특정 문자열 단위로 끊으려면 어떻게 해야 할까?
우선 split 함수를 사용해서 끊을 문자열 인수를 정규식을 사용하여 지정할 수 있다.
/&url=|&link=/ 이렇게 인수를 주면 끊을 수는 있을 것이다.
그런데 이 함수는 끊을 문자열 인수 부분만 지정가능하므로 시작부분인 http~부분도 찾아서 편집하려면 별도의 과정을 또 거쳐야 할 것이다.
사례와 달리 URL 주소들이 다닥다닥 붙어있지 않고 산발적으로 광범위하게 띄엄띄엄 흩어져 있는 경우 split 함수는 사용하기에 부적합하다.
String.match(정규식)나 RegExp.exec(스트링) 함수를 사용하면 한번에 해결 가능하다. 조건들에 합치하는 문자열들을 배열로 저장해준다.

여기서 필요한 것은 한 문자 아닌 단어 단위로 끊어야 한다는 점이다.
문자는 위처럼 [^제외문자들]에 하나하나 넣을 수 있는데 문자열은 아쉽게도 대괄호에 넣어 인식시킬 수 없다.
[^(문자열)]처럼 소괄호 써서 묶어 넣어도 소괄호조차 하나의 배제할 문자 단위로 인식해버린다.
저 안에 복잡한 정규표현식 조건들을 넣을 수도 없다.

이 부분을 해결하기 위해 전방탐색(lookahead)를 알아야 한다.
말 그대로 해석하면 앞의 문자 찾기이다. 문자 뒤에 (?=뒷문자나 문자열) 나 (?!뒷문자나 문자열) 처럼 메타기호들을 이용해 제외시킬 뒷문자(열)을 조건으로 넣는다.
뒷문자(열) 조건을 정규표현식으로 입맛대로 편집해서 넣을 수도 있다.
직관적으로 = 기호가 붙으면 해당 뒷문자(열)이 있는 경우 뒷문자(열)을 제외하고 전방의 문자만 집어넣고
! 기호가 붙으면 부정(negative)의 의미로 뒷문자열이 없는 경우 결과에 집어넣는다.

이걸 사례에 적용하면 뒷문자열 부분에 &url 또는 &link 을 넣고 존재하지 않아야 하므로 부정(negative) 조건으로 구성하면 된다.

r = s.match(/https?:\/\/(.(?!&url|&link))+/gi);
결과: ["https://adv.com/business?api=A12Q82dDsA3&mem=green&age=20&speed=fas", "http://real.dest.com/txXmDqm&loc=earth&air=goo", "https://www.etc.com/goodtime/fungame.php?

Sf54G13dSp"]

결과가 나오긴 했는데 맨 끄트머리를 보면 한글자가 잘려버린다. fast가 되어야 되는데 fas로 나왔다.
위의 fast 부분을 주목해 보면 t 위치에서 조사시 뒷문자열 &url이 존재하므로 해당문자를 복사하지 못한채 루프는 끝나게 된다.
뒤에 .을 붙여서 마지막 문자를 복사하게 하면 된다.

r = s.match(/https?:\/\/(.(?!&url|&link))+./gi);
결과: ["https://adv.com/business?api=A12Q82dDsA3&mem=green&age=20&speed=fast", "http://real.dest.com/txXmDqm&loc=earth&air=good", "https://www.etc.com/goodtime/fungame.php?

Sf54G13dSp"]

***** 위보다 좋은 방법이 .앞에 전방탐색을 위치시키는 것이다.
r = s.match(/https?:\/\/((?!&url|&link).)+/gi);
왜 탐방조건을 앞에 붙이는가? 도대체 뭐지? 낯설고 의아할 수 있는데 이 경우 구조상 https://의 끝문자인 /부터 전방탐색이 반복되는 것이다.
(정확히 기술하면 현재 문자 포인터가 / 뒤에 있는 상태에서 전방탐색 서브루틴이 수행되고 리턴한다.)
구체적으로 /(전방탐색) 성공을 리턴받고 현재 문자(.) 수행해 포인터 증가 → a(전방탐색) 상동 → d(전방탐색) 상동 →... → s(전방탐색) 상동
→ t(전방탐색) 실패를 리턴받고 현재 문자 포인터 위치(t 바로 뒤에 있는 상태)를 다시 t 문자 위치로 돌림(해당 문자를 성공결과에 포함시키면 안되기 때문이다.)
→ 현재 위치 문자(.) 수행해 t 입력 후 탈출 식으로 반복문이 구성, 수행된다.
앞에서처럼 마지막에 별도로 .문자를 붙일 필요 없으니 구성상 간편하고 의미상으로도 http(s):// 이후부터 바로 해당 문자열 존재여부를 판단해야하므로 이게 맞다.
전자로 구성시에는 사실상 http(s):// + 1 위치부터 조사하게 되므로 불완전하다.
잘 이해가 안 되면 일단 마지막 한 글자를 안 잘리고 포함시키려면 뒤가 아닌 앞에 위치시키면 되는 걸로 파악하고 틈틈이 고민해보자.

※ 전방이나 후방 탐색문을 꼭 문자의 뒤에 위치해야 하는 개념으로 보지말고
현재 문자 포인터 위치에서 지정된 조건에 매치되는지 판단해 호출부에 결과를 알려주는 역할을 하는 서브루틴으로 이해하면 된다.
반드시 유념할게 서브루틴인 전(후)방탐색문에서는 수행 후 문자 포인터를 호출부에서 호출 당시의 위치로 원상복귀시킨다.
마치 프로그램에서 함수 호출해 수행하고 리턴시 스택을 이전 상태대로 원상 복귀시키는 것과 유사하다.
서브루틴 결과를 받은 호출부에서 결과값에 따라서 문자 포인터를 처리한다.
var r = "123abcde".match(/(?!a)./gi); 결과) ["1", "2", "3", "b", "c", "d", "e"]
처럼 표현가능하며 문자포인터가 맨 첫위치인 상태에서 전방탐색을 수행한다.
https://www.regular-expressions.info/lookaround.html 개념 참조

후방탐색은 전방의 조건이 충족되면 후방의 문자를 취하는 방식이다. 최신에 이르러 구현된 기능이라고 한다.
긍정형 (?<=앞문자열) 또는 부정형(옆의 구성에서 = 대신 !) 형태로 전방탐색에서 < 부호만 추가해서 표현된다. 전방탐색과 전후만 바뀌었지 구조, 원리는 똑같다.
URL에서 https://hello.world.com에서 앞의 https:// 부분을 제외한 순수 도메인 부분만 추출할 때 사용할 수 있을 것이다.


여기서 한가지 더 주목해 볼게 한 그룹으로 묶는(그룹화) 기능을 하는 소괄호 부분이다.
(.(?!&url|&link))+ 부분 즉 (.전방탐색)+ 구조에서 .과 전방탐색 부분이 소괄호에 의해 한 그룹으로 묶였고 +에 의해 한덩어리로 취급된다.
괄호를 빼버리면 다음과 같은 결과가 나오게 된다.

r = s.match(/https?:\/\/.(?!&url|&link)+./gi);	// 이전) . 뒤에 전방탐색 조건 위치
결과) ["https://ad", "http://re", "https://ww"]

r = s.match(/https?:\/\/(?!&url|&link).+/gi);	// 최신) . 앞에 전방탐색 조건 위치. 전방탐색 조건 앞의 /부터 최초 전방탐색 시작
결과) ["https://adv.com/business?api=A12Q82dDsA3&mem=green&age=20&speed=fast&url=http://real.dest.com/txXmDqm&loc=earth&air=good&link=https://www.etc.com/goodtime/fungame.php?

Sf54G13dSp"]

전자) 첫문자만 전방탐색을 수행하고 참이므로 복사하고 이후 + 기호는 .부분을 포함하지 않아 의미없이 통과하고 뒤의 .으로 넘어가서 한문자를 더 복사 후 벗어난다.
반드시 소괄호로 그룹화해야 유의미한 반복이 수행된다.
후자) /(전방탐색)이 참값이어서 복사 후 다름 위치부터 .+ 명령이 시작되므로 전부 복사하게 된다.

이처럼 소괄호 그룹화하면 한 그룹으로 묶어서 같이 처리하므로 명령문의 순서를 사용자의 의도에 따라 제어할 수 있다.
| 기호와 관련해서 이 기능이 잘 나타난다. | 부호는 또는(or)를 의미한는 기호인데 정규 표현식에서 상당히 늦게 수행된다.
프로그래밍적으로 설명하면 연산자 우선순위가 매우 낮아서 다른 연산을 수행 후 막바지 부근에 처리한다고 보면 된다.
가령 /aaa|bbb|ccc.*xyz/에서 사용자의 목적은 aaa 또는 bbb 또는 ccc 가 포함되는 문자열 중 다시 xyz가 포함되는 문자열을 구하는 목적이었는데
단순히 저렇게 표현하면 | 처리 순서가 매우 낮으므로 ccc.*xyz 부분이 한 덩어리로 묶여 수행된다.
r = "aaaaabbbxyzcccccxyzaaabbbccc".match(/aaa|bbb|ccc.*xyz/gi);
결과) ["aaa", "bbb", "cccccxyz", "aaa", "bbb"]
사용자의 의도에 맞추려면 /(aaa|bbb|ccc).*xyz/ 식으로 | 연산을 소괄호를 써서 묶어야 한다.
이처럼 소괄호로 그룹으로 묶어 하나로 처리해서 명령문 순서를 입맛대로 바꿀 수 있다.
r = "aaaaabbbxyzcccccxyzaaabbbccc".match(/(aaa|bbb|ccc).*xyz/gi);
결과) ["aaaaabbbxyzcccccxyz"]

정규표현식에서 저렇게 소괄호로 그룹화하면 명령문의 흐름을 바꿀 수 있는 것 외에 한가지 더 추가되는게
별도로 메모리에 그룹화된 부분을 저장한다는 점이다. 이걸 캡처링이라고 한다.
이렇게 저장한 부분은 같은 정규표현식 내에서나 replace 함수 등에서 사용할 수 있다.
캡처 순서에 따라 $1, $2,...혹은 \1, \2,,,, 등(프로그램마다 다르다고 한다)으로 표시해 사용한다. 사용례는 검색하면 많다.
소괄호 ()로 묶기만 하면 무조건 캡처링이 일어난다. 자주 쓰는 기능이어서인지 디폴트로 지정해 놓은 듯 하다.
이것을 피하려면 (?:) 식으로 앞에 ?:을 넣으면 된다. 이걸 논캡처링이라고 한다.
캡처링 부분을 사용할 일이 없으면 논캡처링으로 표시해 별도의 객체 생성, 메모리 낭비를 줄일 수 있다.

match 함수 사용시 /g 옵션이 없으면 캡쳐링된 부분 역시 결과값 배열에 저장하고 있음을 확인할 수 있었다.
/g 옵션이 없으면 하나만 검색하고 결과를 담은 배열을 리턴하므로 배열.length가 항상 1이라고 착각할 수 있는데
캡처링 부분이 존재하는 경우 여러 개일 수 있다. 배열의 [0]에는 결과 스트링, [1]부터 캡쳐링 부분을 저장하고 있었다.
r = "aaabbccc".match(/(a+)(b+)c/);
결과) ["aaabbc", "aaa", "bb"]	// 결과 스트링 옆에 캡쳐링 부분이 차근차근 저장된다.

앞에서 URL 추출 식을 논캡쳐링 구성해서 메모리 낭비를 줄이면 다음과 같다.
r = s.match(/https?:\/\/(?:(?!&url|&link).)+/gi);
참고로 전방탐색이나 후방탐색 (?=○○○),... 연산이 소괄호로 묶였다고 해서 캡처링이 일어나진 않는다. 해당 연산의 표시부호일 뿐이다.
(?:?=○○○) 식으로 쓸 필요 없다.
전방탐색이나 후방탐색에서 캡처링을 하고 싶으면 의도한 부분을 소괄호로 묶어주면 된다.

최종적으로 URL 추출시 다른 구분 문자열을 추가하고 싶으면 전방탐색 부분에 | 부호를 붙여 추가하면 된다.
하나의 문자가 구분자 역할을 하는 경우에는 전방탐색 부분에 추가할 필요 없이 .문자 대신 [^\s\"\r\n] 형태로 구성할 수 있다.
참고로 URL에는 보통 공백, 겹따옴표, 개행문자는 올 수 없다. URL 인코딩을 마쳐 실제로 네트워크에 전송가능한 시점의 형태를 말한다.
$-_.+!*'(), 문자는 사용가능하다. 겹따옴표는 안되지만 홑따옴표는 사용가능함을 주의한다.

r = s.match(/https?:\/\/(?:(?!&url|&link)[^\s\"\r\n])+/gi);

구분자가 1글자 초과하는 문자열이거나 정규표현식으로 복잡한 조건을 구성하는 경우 전방탐색문에 (?!○|○|○...) 식으로 추가하거나 편집, 삭제하고
1글자로 표현가능한 경우에 예외문자 집합소에 [^○○○...] 형태로 적재적소에 넣는게 효율적이다.
필요한 상황에 따라 입맛대로 처리한다.