문자열 처리의 해결사. 정규표현식을 알아보자 ③

정규표현식에서 문자열 조작을 다뤄주는 캡처에 대해서 알아봅시다.

Featured image

이 포스트는 "정규식" 시리즈의 3번째 포스트 입니다.

들어가며

지난 글에서는, 정규 표현식을 사용해서, 패턴을 작성하는 법 위주로 알아보았다면, 이번 글에서는, 정규 표현식을 사용해서 문자열을 조작 하는 방법을 집중적으로 다루어 볼까 합니다.

정규 표현식에서의 “일치”

정규 표현식의 결과를 활용해서 문자열을 조작하는 방법을 설명하기 전에, 정규 표현식의 검색의 결과가 원하는 대로 나오게 하기 위한 지식을 먼저 하나 알고 가는것이 좋을 것 같습니다. 정규 표현식의 일치는 크게 다음 네가지로 구분할 수 있습니다.

앞 포스팅에서, ‘정규 표현식이 특정한 패턴에 일치한다’ 라고 했을 때의 의미는, 여기서 완전 일치의 예를 의미하는 것이었습니다. 예를 들어서 [ab]*a[ab]{2}라는 식은, a,b로 구성되어 있으며 뒤에서부터 세번째 문자가 a 인 문자열을 의미합니다. 이 때문에, ‘aab’나 ‘bababa’에는 완전 일치하지만, ‘babab’에는 완전 일치하지 않습니다. 하지만 부분 일치로 위의 식을 앞에서 말한 문자열들에 일치 시켜보면, 앞의 두개는 물론이고, 뒤의 ‘babab’에도 일치함을 볼 수 있습니다. 왜인지 살펴봅시다.

b    aba   b

위의 문자열에서 붉은색으로 표시된 해당 문자열의 부분 문자열이 [ab]*a[ab]{2}에 일치하기 때문에, 위의 문자열은 해당 정규 표현식에 부분 일치한다라고 할 수 있는 것입니다.

부분 일치 vs 완전 일치

부분 일치와 완전 일치는 사용처가 각각 다르기 때문에, 어느 한 방식이 다른 방식보다 우월하다라고 말 할수 없습니다. 완전 일치는 주로 문자열 검증에 사용됩니다. 백엔드 서버를 만들면서, 특정한 형태의 URL을 처리 할 정규 표현식을 작성한다면, 완전일치가 적절 하겠지요. 반면, 문자열 전체를 뒤지면서, “이렇게 생긴 문자열 있나요?” 라고 검색을 하거나, 그 검색 결과를 바탕으로 치환을 할 때는, 부분 일치가 십분 활용이 되겠죠. 이 때문에, grep이나 sed 같은 검색 및 치환 툴에는 부분일치가 기본 옵션으로 설정되어 있습니다. 자바스크립트를 비롯한 여러 언어 에서의 사용에서는 .search메소드로 부분 일치를, .match()로 완전 일치를 사용합니다.

앵커를 활용해서 여러 일치종류 표현하기

아까 grep이나 sed 같은 툴에는 기본 옵션이 부분일치 라고 하였는데, 사용 목적상 다른 종류의 일치가 필요 할 수도 있습니다. 이럴때는, 전 포스팅에서 맨 끝에 잠깐 다루고 넘어간 앵커를 사용하면 부분 일치가 기본인 상황에서, 다른 일치 종류를 구현해 낼 수 있습니다.

바로 행 처음을 나타내는 ^과, 행 끝을 나타내는 *을 사용하는 것입니다. 자세한 사항은 아래 표를 확인하면, 어렵지 않은 내용이기에 간단히 알 수 있을 것입니다.

일치 종류 정규 표현식
부분 일치 regex
완전 일치 ^regex$
전방 일치 ^regex
후방 일치 regex$
부분 일치가 기본 기능인 상태에서 다른 종류의 일치를 사용하는 법

비슷한 방법으로, 완전 일치가 기본인 상태에서 다른 종류의 일치 방법을 사용하는 방법 또한 있습니다. 이 때는, 앵커가 아닌, 대충 아무 문자 여러개에 일치한다는 의미의 정규 표현식인 .*을 활용해서 작성합니다.

일치 종류 정규 표현식
완전 일치 regex
부분 일치 .*regex.*
전방 일치 regex.*
후방 일치 .*regex
완전 일치가 기본 기능인 상태에서 다른 종류의 일치를 사용하는 법

서브 매치와 캡처

첫번쨰 포스팅에서, 괄호를 사용해서 연산자 결합 순위를 고려해서, 올바른 결과가 나올 수 있도록 정규 표현식을 작성하는 방법에 대해서 잠깐 다루어 보았습니다. 괄호는 특정한 식이 우선적으로 계산될 수 있도록 해주는 기능도 있지만, 또 다른 쏠쏠한 기능을 가지고 있습니다.

둥근 괄호로 모아진 정규표현식의 일부분을 서브 패턴(subpattern)이라고 합니다. 만약 주어진 문자열이 정규 표현식 전체에 일치한다면, 개별 서브 패턴에는 부분 문자열이 일치할 것인데, 서브 패턴에 일치한 부분 문자열을 서브 매치(submatch)라고 합니다. 예를 들어서, (\d+): (\w+) prime \.라는 정규 표현식에는 (\d+)(\w+)라는 두개의 서브 패턴이 포함되어 있습니다. 이 정규 표현식에 대해 ‘57: Grothendieck prime.’이라는 문자열은 완전일치 합니다. 이때, (\d+)에는 57이, (\w+)에는 Grothendieck 라는 문자열이 서브 패턴으로 대응한다고 할 수 있겠네요.

정규 표현식에는 이런 서브 매치를 추출해 낼 수 있는 방법을 제공합니다. 이를 캡처라고 하고, 이게 꽤나 강력한 기능입니다. 그러면 어떻게 캡처기능을 사용할 수 있는지에 대해 알아봅시다.

정규 표현식의 특정 서브 패턴에 대해서 서브 매치를 취득하려면 ‘이 서브 패턴의 서브매치를 원한다’라고 콕 집어서 말해줘야 합니다. 정규 표현식에서 특정한 서브 매치를 지정하는 방법에는

라는 두가지의 방법이 있습니다.

순서대로 지정하기

서브 패턴에는 ‘자연스러운 순서’라는것이 존재합니다. 1번부터 순서대로 번호를 붙이고, 왼쪽에 있는 괄호일수록 낮은 번호를 붙이는 것입니다. 상당히 직관적이죠. 예를 들어봅시다. 일/월/년(dd/mm/yyyy)를 표현하는 정규 표현식 (\d{2})/(\d{2})/(\d{4})에는 왼쪽 부터 차례로 1,2,3 이라는 번호가 붙는것입니다. n번째 서브매치를 $n이라고 표현한다고 하면, $3년$2월$1일은, 해당 년도 표기를 한국식 년도 표기로 바꿀 수 있는 표현법이 되겠지요. 이 방식은 펄이나 루비 에서 사용하는 방법입니다. 자바스크립트에서는, 정규 표현식을 /기호를

만약 괄호가 중첩되어 있다면 어떡할까요? 어렵게 생각할 필요 없이, 1번부터 순서대로 왼쪽부터 번호를 붙인다라는 원칙을 그대로 지키면 됩니다.

((\d)\d)

라는 정규 표현식에서 1번 서브매치는 표현식 전체를 감싸고 있는 괄호이고, 2번 서브매치는 괄호 안의 괄호가 됩니다.

이름으로 지정하기(이름 지정 캡처)

순서를 지정하는 방법은 가장 단순하면서, 빠른 방법이지만, 치명적인 결점이 여럿 있습니다. 바로, 사람이 알아먹기가 어렵다는 것이고, 수정에 너무 민감하다는 것입니다. 아까 설명했던 년도를 표시하는 정규 표현식인 (\d{2})/(\d{2})/(\d{4})을 전체를 괄호로 한번 감싸서 ((\d{2})/(\d{2})/(\d{4}))로 바꾸어 봤다고 해봅시다. 정규 표현식이 일치하는 문자열 패턴에 대한 정보는 변경된것이 없으나, 이 표현식에 대해서 $3년$2월$1일은 이상한 결과를 낼 것 입니다. 아래의 자바스크립트 코드를 직접 실행해서 확인해 보세요

const day = "12/08/2021";
let regexp = /(\d{2})\/(\d{2})\/(\d{4})/; // /를 이스케이프 해준것은 JS에서 정규표현식을 /두개로 감싸서 작성하기 때문
let match = day.match(regexp);
console.log(`${match[3]}${match[2]}${match[1]}일`); //2021년 08월 12일

regexp = /((\d{2})\/(\d{2})\/(\d{4}))/;
match = day.match(regexp);
console.log(`${match[3]}${match[2]}${match[1]}일`); //08년 12월 12/08/2021일

이렇게 괄호 하나 추가했다고, 번호로 지정한 서브 매치들의 번호가 밀려져서, 정규 표현식의 수정에 때라서, 결과 문자열도 수정을 해서 써야하게 되었습니다. 정규 표현식을 유지 보수하는 관점에서는 별로 좋은 일이 아닌것이 분명합니다.

그래서 등장한 것이 이름 지정 캡처(named capture) 입니다. 이름 지정 캡처는 서브 패턴에 원하는 이름을 붙여서 그 이름을 사용해 서브 매치를 취득하는 것입니다. 자바스크립트와 펄과 루비에서는 (?<name>)라는 구문을 이용해서 서브 패턴에 이름을 붙일 수 있습니다. 이름 지정 캡쳐로 아까의 코드를 수정해 봅시다.

const day = "12/08/2021";
let regexp = /(?<day>\d{2})\/(?<month>\d{2})\/(?<year>\d{4})/;
let match = day.match(regexp);
console.log(
  `${match.groups.year}${match.groups.month}${match.groups.day}일`
); //2021년 08월 12일

regexp = /((?<day>\d{2})\/(?<month>\d{2})\/(?<year>\d{4}))/;
match = day.match(regexp);
console.log(
  `${match.groups.year}${match.groups.month}${match.groups.day}일`
); //2021년 08월 12일

어때요? 이제 조금 더 깔끔한 코드를 작성할 수 있게 되었지요? 알아보기도 좀 더 좋아진것 같습니다. 코드 유지보수에 있어서, 정말 좋은 소식이지요.

캡쳐 없이 그룹화

캡처는 정말 좋은 기능이지만, 정규 표현식 엔진의 입장에서는 사용자가 캡처 결과를 요구할 수 있기 때문에, 서브 매치를 저장해야 할 필요가 있습니다. 만약에 서브매치 결과를 다시 사용하지 않는다면, 이것은 정규 표현식의 성능을 떨어트리는 결과를 가져오겠죠.

이러한 이유 등으로, 캡처를 하지 않는 그룹화도 존재합니다. 캡처를 지원하는 대부분의 정규 표현식 엔진이 (?:)라는 구문을 사용하고 있습니다. 자바스크립트도 그러합니다.

직접 확인해 봅시다.

const day = "12/08/2021";
let regexp = /(?:\d{2})\/(?:\d{2})\/(?:\d{4})/;
let match = day.match(regexp);
console.log(`${match[3]}${match[2]}${match[1]}일`); //undefined년 undefined월 undefined일

결과가 undefined년 undefined월 undefined일 이라고 나옵니다. 정말 자바스크립트가 매치 결과를 저장하지 않는다는 사실을 볼 수 있었습니다.

올바른 서브 매치 취득을 위한 우선순위

캡처 기능을 통해서, 정규 표현식의 서브 매치를 취득하여, 그것들을 활용하는 방법들을 알았지만, 정작 얻어낸 서브매치들이, 생각 했던것과 다른 형태로 되어 있으면, 정상적인 사용이 되지 않겠지요? 간단한 문제를 하나 내보겠습니다.

(a*)([ab]*)

라는 표현식에 ‘aaa’라는 문자열을 일치시키면, 각각의 서브 매치는 어떻게 할당될지 확실하게 알 수 없습니다. 가능성들을 표로 나타내면 아래와 같을 것입니다.

가능성 (a*) ([ab]*)
1 aaa  
2 aa a
3 a aa
4   aaa

예시로 든 정규 표현식은 짧고 간단해서 이렇게 끝나지만, 정규 표현식이 길어지고 방대해지면, 기억할 수 없을 정도로 많은 가능성이 펼쳐 질 수도 있습니다. 의도한 대로 캡쳐를 취득하기 위해서는 우선순위를 올바르게 알 필요가 있겠지요?

우선순위는 왼쪽부터 오른쪽

정규 표현식 일치는, 기본적으로 왼쪽부터 오른쪽으로 처리됩니다. 그게 일반적으로 우리가 문자열을 읽는 방향이기도 하니까요. 서브 매치를 할당하는 순서도, 이 상식적인 순서를 따릅니다. 이 때문에, 아까 예시로 들었던, (a*)([ab]*)라는 정규 표현식에 ‘aaa’라는 문자열을 일치 시켰을 때, ([ab]*)보다 왼쪽에 있는 (a*)에 매치가 할당되어, 위에 작성한 표에서 가능성 1번에 해당하는 서브매치가 구성됩니다.

직접 확인해 봅시다.

const testString = "aaa";
const regexp = /(a*)([ab]*)/;
const match = testString.match(regexp);
console.log(`match[1] : ${match[1]} match[2] : ${match[2]}`);

결과를 확인해 보면 match[1] : aaa match[2] : 라는 것을 확인해 볼 수 있습니다. 이 왼쪽부터 오른쪽이라는 원칙은, 선택 연산(|)에도 적용됩니다. 정규 표현식이 (fuga|fugah)이고, 대응시킬 문자열이 ‘fugah’라고 합시다. 부분 일치를 실행한다고 할 때, 해당 문자열에는 “fuga” 와 “fugah”중 어느 패턴에 일치할까요? 일반적인 느낌으로는, 100%일치하는 “fugah”에 일치하지 않을까 싶지만, 왼쪽에서 오른쪽이라는 대원칙에 의해서, “fuga”에 일치함을 알 수 있습니다. 직접 코드를 돌려서 진짜인지 확인해 봅시다.

const testString = "fugah";
const regexp = /(fuga|fugah)/;
const match = testString.match(regexp);
console.log(`match[1] : ${match[1]}`);

결과를 확인해보면 match[1] : fuga 라고 하는군요, 물론 이 원칙은 일치 할 떄만 적용됩니다. 위의 정규 표현식을 약간 수정해서, 완전일치를 수행하도록 하게 되면, 정규 표현식은, 왼쪽에 있지만, 일치하지 않는 fuga가 아닌, 오른쪽에 있지만, 일치하는 fugah를 선택할 것입니다.

const testString = "fugah";
const regexp = /^(fuga|fugah)$/;
const match = testString.match(regexp);
console.log(`match[1] : ${match[1]}`); //결과는 fugah

이 왼쪽에서 오른쪽이라는 원칙은, 어찌보면 당연하지만, 선택과 접합 두가지의 연산을 둘다 사용해도 적용됩니다. 긴 말 할 필요 없이, 아래의 코드를 살펴봅시다.

const testString = "fugahoge";
const regexp = /(fuga|fugah)(oge|hoge)/;
const match = testString.match(regexp);
console.log(`match[1] : ${match[1]} match[2] : ${match[2]}`);

정규 표현식을 왼쪽부터 오른쪽으로 읽으므로, (fuga|fugah)를 우선 평가합니다. fuga가 왼쪽에 있고, 일치하므로, fuga를 선택합니다. 이제 정규 표현식은 남은 문자열인 “hoge”를 평가해야 합니다. 이제 일치 하면서, 가장 오른쪽에 있는 hoge가 일치하게 됩니다.

마치며

이번 포스팅에서는 서브 패턴의 일치 결과를 캡처연산을 통해서 사용하는 방법과, 해당 캡처 연산을 올바르게 사용하기 위한 우선순위까지 직접 정규 표현식을 실행 시켜보면서 확인해 보았습니다.

왼쪽부터 오른쪽이라는 원칙 자체는 이해하기 어려운 것이 아니지만, 이 원칙을 잠시 빗겨나가고 싶은 경우와, 기본적으로 최대한 일치시키려고 하는 정규 표현식의 수량자의 욕심쟁이 성질 때문에, 예상과는 다른 서브매치를 취득하는 경우도 있습니다.

“(따옴표)로 감싸진 문자열들의 집합으로 이루어진 문자열을 파싱하기 위해서, 아래와 같이 정규 표현식을 작성하고 실행하면 이런 결과가 나옵니다.

const testString = '"apple", "banana", "melon"';
const regexp = /(".*")/;
const match = testString.match(regexp);
console.log(`match[1] : ${match[1]}`);

match[1] : "apple", "banana", "melon" 이라는 결과가요. 파싱을 목적으로 하기 위해서 이렇게 식을 작성했는데, 문자열 한 뭉텅이가 그냥 나와버리는 결과가 발생 했습니다. 이러한 문제를 정규 표현식에서 어떻게 해결하는지, 욕심 연산자와, 겸허 연산자를 다루는 다음 포스팅에서 알아보도록 하겠습니다.

이번 포스팅은 분량이 좀 길어서 읽기 힘들었을 것 같은데, 끝까지 읽어주신 여러분께 정말로 감사를 표합니다.

참고한 자료

다양한 언어로 배우는 정규표현식 최신 엔진 구현과 이론적 배경을 배우다(신야 료마 , 스즈키 유스케 , 타카타 켄 지음)

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Groups_and_Ranges : 자바스크립트 코드 작성 시 참고했던 mdn 페이지