thumbnail
기술아티클
특정한 규칙을 가진 문자열을 정규표현식으로 표현하기
정규표현식의 문법과 사용사례를 살펴봅니다
March 3, 2024

프로그래밍을 하면서 정규표현식을 한번도 사용해보지 않은 개발자는 많지 않을것입니다. 자주 사용되지는 않더라도 문자열의 패턴을 간단하게 표현할수 있기에 꼭 필요한곳이 있기 때문입니다.

하지만 정규표현식은 특유의 가독성 때문에 처음 접하면 매우 복잡하고 난해해 보여서 정확하게 익히고 사용하기 보다는 구글이나 chat gpt에게 사례에 맞는 정규표현식을 물어본뒤 해당 정규표현식을 그대로 사용하는 경우가 많습니다.

사실 일반적인 상황에서는 정규표현식 자체가 간단하다보니 유효성을 증명하기 쉬워 이러한 방식에 큰 문제가 없지만, 다양한 조건을 고려하는 정규표현식을 작성해야하는경우 정규표현식이 복잡하다보니 생각치도 못한 케이스가 포함되거나 원하는 케이스가 누락되는 상황이 발생 할 수 있습니다.

따라서 생각 보다 어렵지도 않고 언젠간 마주칠 복잡한 상황을 위해서라도 정규표현식은 한번쯤 정리해두는것이 좋다고 생각하므로 이번 아티클에서는 정규표현식의 문법을 살펴보고 실무에서 정규표현식을 통해 해결할수 있는 상황들을 소개합니다. 그리고 정규표현식을 사용할때 주의해야할 한가지 상황에 대해서도 살펴봅니다.

해당 아티클에서는 Javascript와 V8엔진을 기준으로 정규표현식을 설명합니다. 다른 언어나 엔진을 사용할시 문법과 동작이 다소 상이할 수 있으므로 참고해주세요

정규표현식 문법 이해하기

정규표현식이란 특정한 규칙을 가진 문자열의 집합을 표현하는데 사용하는 형식 언어입니다. 0101011와 같이 표기하는 이진수로 조건과 반복을 의미하도록 작성하기는 어렵기 때문에 프로그래밍 언어가 조건과 반복을 의미할수 있도록 if, for 등을 제공하는것처럼 정규표현식은 모든 문자열들을 나열하여 표기해야할것을 기호로 표기할 수 있게 해줍니다.

예를 들어 두글자이면서 모두 숫자로 구성되어야한다는 규칙을 가진 문자열들을 생각해봅시다. 규칙을 만족하는 모든 문자열을 집합으로 표현하면 { "00" , "01" , "02", ... "98" , "99" } 와 같이 100개의 문자열을 모두 적어야 이 규칙을 표현할수 있습니다. 하지만 정규표현식으로 작성할경우 간단하게 \d{2}와 같이 표기할 수 있습니다.

자바스크립트 정규표현식

본격적으로 정규표현식 문법을 살펴보기에 앞서서 자바스크립트에서 정규식의 생성, 매칭함수, 플래그에 대한 기본적인 이해가 필요하므로 이를 먼저 살펴보겠습니다.

정규식의 생성

자바스크립트에서 정규표현식을 생성하기 위해서는 두가지 방법을 사용할 수 있습니다. 바로 리터럴과 RegExp 생성자입니다. 리터럴을 사용하는 경우 스크립트가 로드될때 컴파일되므로 성능상 약간의 이점이 있고, RegExp 생성자를 사용하는 경우 정규식이 실행시점에 컴파일되므로 성능상 약간의 손해가 있지만 동적으로 정규표현식을 생성할때 유리합니다. 앞으로 예제에서는 직관적인 표기를 위해 리터럴 표기법을 사용합니다.

const literalRegex = /a/;
const constructorRegex = new RegExp('a');

매칭함수

생성한 정규표현식이 문자열과 매치되는지 알려면 정규표현식에 적용가능한 자바스크립트 내장 메서드를 알아야합니다. RexExp내장 객체의 메서드인 test, execString내장 객체의 메서드인 match, replace, search, split를 사용할 수 있습니다.

문자열 내부에 패턴과 일치하는 부분이 존재하는지만 알아내려면 패턴 매치결과에 대한 참거짓 혹은 인덱스를 반환하는 testsearch를 사용하고 매치결과에 관한 추가 정보가 필요하면 일치정보가 담긴 배열을 반환하는 execmatch메서드를 사용하는것이 좋습니다. 만약 정규표현식을 통해 문자열 교체를 진행하는 경우 replacereplaceAll를 사용하는것이 좋습니다.

아래 예제에서는 상세한 매치 결과 표기를 위해 match를 사용할 예정이며 자세한 사용법은 필요한 경우 예제에서 설명하겠습니다.

플래그

정규식을 생성할때 보다 자세한 검색을 위해 플래그를 설정할수 있습니다. 이러한 플래그는 리터럴 방식인지 생성자 방식인지에 따라 설정하는 방식이 조금 다릅니다.

const literalRegex = /a/g;
const constructorRegex = new RegExp('a','g');

플래그는 d, g, i, m, s, u, y 7종류가 존재하며 이들은 순서에 상관없이 한꺼번에 여러개를 지정할 수도 있습니다. 아래에서는 주로 사용하는 플래그인 g, i에 대해서 살펴보며, 나머지 플래그에 대해서는 MDN문서를 참고해보세요

// g(전역검색): 모든 a를 찾아냅니다.
const input = "abbba"
const globalRegex = /a/g; 
input.match(globalRegex) // [ 'a', 'a' ]


// i(대소문자 미구분): a 또는 A를 찾아냅니다.
const input = "A"
const globalRegex = /a/i; 
input.match(globalRegex) // [ 'A', index: 0, input: 'A', groups: undefined ]

문자열 매칭

정규표현식의 가장 기본적인 형태는 문자만을 이용하는것입니다. 이렇게 표기하는 경우 정규표현식내 문자열로 구성된 문자만 결과로 출력됩니다.

const input = 'hello, my name is bandi'
const regex = /hello/g

input.match(regex) // [ 'hello' ], 문자열내에 'hello'가 하나뿐이므로 배열내 요소가 'hello'하나 뿐입니다.

OR 연산자

여러 문자열 후보군을 표기하고자 한다면 문자열 사이에 |(or) 연산자를 넣어 여러 케이스가 가능함을 표기할수 있습니다.

const input = 'hello, my name is bandi'
const regex = /hello|my/g

input.match(regex) // [ 'hello', 'my' ], hello, my 모두 매치됩니다.

메타문자

정규표현식의 가장 기본적인 형태는 기호를 사용하지 않고 문자만을 표기하는것입니다. 입력 문자열 내에서 패턴내 문자열의 포함여부가 매칭의 결과가 됩니다.

하지만 이러한 표기방법만으로는 원하는 문자열 규칙을 모두 표기하기 어렵습니다. 따라서 이를 위해 정규식 엔진에게 어떤 단일 문자를 매칭할지 알려주는 특수문자의 일종인 메타문자를 사용하게 됩니다.

모든 문자열 표기하기

점(.)을 사용하면 모든 문자열에 해당한다는 의미를 표현할 수 있습니다. 이때 줄바꿈(\n)은 표함하지 않습니다.

const input = 'hello world'
const regex = /./
input.match(regex) // [ 'h', index: 0, input: 'hello world', groups: undefined ]

숫자, 글자, 공백을 표기하기

메타문자 중에서 특정 그룹의 의미를 가진 문자들이 있습니다. \d의 경우 10진수, \w의 경우 영어 대소문자와 숫자, 언더스코어를 포함하고, \s는 공백 문자로 스페이스, 탭, 폼피드, 줄바꿈 문자등을 포함한것입니다. 만약 부정표현(역집합)을 사용한다면 각 문자의 대문자인 \D, \W, \S를 사용하면됩니다.

const input = 'today is 20/02/12'
const positivedigitRegex = /\d/g
const positivewordRegex = /\w/g
const positivewhitespaceRegex = /\s/g
const negativedigitRegex = /\D/g
const negativewordRegex = /\W/g
const negativewhitespaceRegex = /\S/g

input.match(positivedigitRegex) // [ '2', '0', '0', '2', '1', '2' ]
input.match(positivewordRegex) // ['t', 'o', 'd', 'a', 'y', 'i', 's', '2', '0', '0', '2', '1', '2' ]
input.match(positivewhitespaceRegex) // [ ' ', ' ' ]
input.match(negativedigitRegex) // [ 't', 'o', 'd', 'a', 'y', ' ', 'i', 's', ' ', '/', '/' ]
input.match(negativewordRegex) // [ ' ', ' ', '/', '/' ]
input.match(negativewhitespaceRegex) // [ 't', 'o', 'd', 'a', 'y', 'i', 's', '2', '0', '/', '0', '2', '/', '1', '2' ]

이스케이프

정규표현식 내에는 앞서 배운 . 이외에도 ^, $, ? 등의 문법으로 인식되는 기호들이 존재합니다. 때문에 만약 이 기호들을 문자로 쓰기 위해 입력하더라도 기호로 동작하기에 의도와 다른 결과가 출력될 가능성이 있습니다. 따라서 이 기호들을 문자로 사용하기 위해서는 기호 앞에 \ 라는 이스케이프 기호를 붙여주면됩니다.

const input = 'hello?'
const regex = /hello\./
input.match(regex) //null \.은 모든 문자가 아닌 .과 일치합니다.

문자 그룹(Character Set)

특정 문자가 아닌 여러문자를 허용하고자 한다면 대괄호([])를 사용하여 원하는 문자의 후보군을 지정할수 있습니다. 대괄호내에 원하는 문자의 후보군을 입력할때는 구분자를 포함하지 않고 원하는 문자를 이어서 입력하면됩니다.

const input1 = 'hello!'
const input2 = 'hello?'
const input3 = 'hello.'
const regex = /hello[!?.]/g

// 마지막 자리에  !, ?, .중 어떤것을 포함해도 됩니다.
input1.match(regex) // ['hello!']
input2.match(regex) // ['hello?']
input3.match(regex) // ['hello.']

이때 문자의 후보군이 연속적이라면 대시(-)를 사용하여 보다 간략하게 표기할 수 있습니다. 알파벳, 숫자, 한글에 대해서 가능합니다.

// 아래 예제에서는 문자의 시작부터 끝까지만을 표기하였지만, 특정 지점부터 특정지점까지를 표기하는것도 가능합니다. ex) `/[c-g]/`, `[ㄴ-ㅍ]`

// 영어
const alphabetRangeRegex = /[A-Z]/;
const alphabetOriginalregex = /[ABCDEFGHIJKLMNOPQRSTUVWXYZ]/;

const lowercaseAlphabetRangeRegex = /[a-z]/;
const lowercaseAlphabetOriginalregex = /[abcdefghijklmnopqrstuvwxyz]/;

// 숫자
const numberRegex = /[0-9]/;
const numberRegex = /[0123456789]/;

// 한글
const regex5 = /[ㄱ-ㅎ]/;
const regex5 = /[ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ]/;

const vowelRangeRegex = /[ㅏ-ㅣ]/;
const vowelOriginalRegex = /[ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ]/;

const regex5 = /[가-힣]/;
const hangulRegex5 = /[가각갂....힢힣]/; // 너무많아서 생략 하였습니다. 유니코드 U+AC00부터 U+D7A3에 해당합니다.

대괄호내부의 시작위치에 캐럿(^)을 사용하면 역집합을 의미하는것으로 바뀌어 대괄호 내부의 문자 후보군에 해당하지 않는경우를 참으로 판단합니다.

const input = '2024-02-01T11:00:23'
const positiveRegex = /[0-9]/g // 0에서 9까지의 숫자
const negativeRegex = /[^0-9]/g // 0에서 9까지의 숫자가 아닌 모든것

console.log(input.match(positiveRegex)) // [ '2', '0', '2', '4', '0', '2', '0', '1', '1', '1', '0', '0', '2', '3' ]
console.log(input.match(negativeRegex)) // [ '-', '-', 'T', ':', ':' ]

위치 표현

일반적으로 정규표현식의 기호들은 대부분 해당 위치에 특정 문자가 존재함을 의미하는 패턴에 해당합니다. 하지만 위치를 의미하는 특수한 기호들이 몇가지 존재합니다.

앵커

정규표현식에는 시작과 끝이라는 위치를 의미하는 기호가 있습니다. 바로 캐럿(^)과 달러기호($)입니다. ^는 문자열의 시작을 의미하며 $는 문자열의 마지막을 의미합니다. 두 기호는 같이 사용하여도 되지만, 하나만 사용하여 문자열의 시작부분 혹은 마지막 부분에 일치한다는 의미를 가지도록 할 수 있습니다.

const input = 'hello, my name is bandi'
const regex = /^hello$/
input.match(regex) // null, hello가 맨처음 나오기는하지만 o의 다음이 문자열의 끝은 아니기 때문입니다.
const input = 'hello, my name is bandi'
const regex = /^hello/
input.match(regex)  // [ 'hello', index: 0, input: 'hello, my name is bandi', groups: undefined ], 문자열 시작부분에서부터 hello를 찾을수 있습니다.
const input = 'hello, my name is bandi'
const regex = /hello$/
input.match(regex)  // null, hello를 찾을수는 있지만 o의 다음은 ,이지 문자열의 끝이 아니기 때문입니다.

경계표현

\b를 사용하여 화이트스페이스(공백을 의미하며 탭, 스페이스등이 해당합니다.)에 구분되는 경계를 표현할수 있습니다. \B를 사용하면 반대의 의미인 화이트스페이스로 구분되지 않는 경계를 의미하도록 할 수 있습니다.

const input = 'declassified class'
const positiveRegex = /\bclass\b/
const negativeRegex = /\Bclass\B/

input.match(positiveRegex) // [ 'class', index: 13, input: 'class', groups: undefined ] index가 13인것으로 보아, 단어 경계로 구분된 뒤쪽의 class가 출력되었음을 알수 있습니다.
input.match(negativeRegex) // [ 'class', index: 2, input: 'declassified', groups: undefined ] index가 2인것으로 보아 앞쪽의 declassified의 일부 class가 출력되었음을 알 수 있습니다.

수량자

그룹(아래에서 배울수 있습니다. 소괄호로 표기합니다) 또는 메타문자를 반복적으로 표현하기 위해 수량을 지정하는 키워드를 수량자 라고 합니다. 살펴볼 수량자들은 그룹이나 메타문자 뒤에 사용하여 앞의 패턴이 얼마나 반복되는지를 지정할 수 있습니다.

기본 기호

수량자에는 가장 기본적으로 사용되는 세가지 기호가 존재합니다. 바로 *, ?, + 입니다.

  • *: 0번 이상 일치(x>=0)
  • ?: 0번 또는 1번 일치(x=0 or x=1)
  • +: 1번이상 일치(x>=1)
const input = 'aaaabb'

const asteriskRegex = /a*b/g
const questionmarkRegex = /aaaabbc?/g
const plugRegex = /a+b/g

input.match(asteriskRegex) // [ 'aaaab', 'b' ] a를 문자열의 앞쪽부터 b가 처음 등장하는 위치까지 매치시킨뒤 aaaab라는 첫번째 결과를 출력하고, 남은 문자 b에 대해서는 a가 0번 있어도 괜찮으므로 두번째 결과로 b가출력됩니다.
input.match(questionmarkRegex) // [ 'aaaabb' ] 원본 문자열에 c는 없지만 ?를 붙임으로써 0번 존재할수 있기 때문에 전체 문자열이 출력됩니다.
input.match(plugRegex) // [ 'aaaab' ] 앞선 첫번째 결과와 다르게 a가 하나이상 존재해야하기 때문에 첫번째 출력이후 문자열에 남은 마지막 남은 b는 출력되지 않습니다.

중괄호 표현법

앞서 수량자를 표기하는 세가지 기호에 대해서 학습하였는데, 사실 수량자를 가장 표기하는 가장 일반적인 방법이 바로 중괄호 표기법입니다. 이 표현을 사용하면 앞서 사용한 기호의 의미를 가진 수량자를 똑같이 표기할 수 있습니다. 숫자를 하나만 써서 정확히 n번 일치함을 표기하거나, 숫자를 두개써서 n번 이하 m번 이하 일치 하는 식으로 표기할수 있습니다. 또한 숫자를 하나만 쓰면서 ,를 유지하면 첫번째 숫자를 비운경우 0을 의미하고, 두번째 숫자를 비운경우 무한대를 의미하게됩니다.

  • {1}: 정확히 1번 일치(x=1)
  • {1,2}: 1번이상 2번이하 일치(1<=x<=2)
  • {,2}: 2번 이하 일치(x<=2)
  • {2,}: 2번 이상 일치(x>=2)
const input = 'aaaabb'
const excatMatchRegex = /a{4}b{2}/g // a가 4개, b가 2개
const questionmarkRegex = /a{4,6}b{2,3}/g // a가 4개에서 6개 사이, b가 2개에서 3개 사이
const plugRegex = /a{,4}b{2,}/g // a가 4개 이하, b가 2개 이상

console.log(input.match(excatMatchRegex)) // [ 'aaaabb' ]
console.log(input.match(questionmarkRegex)) // [ 'aaaabb' ]
console.log(input.match(plugRegex)) // [ 'aaaabb' ]

탐욕적 수량자와 게으른 수량자

앞서 수량자는 그룹이나 메타문자가 N회 반복됨을 나타내기 위한 기호임을 말씀드렸습니다. 이때 수량자는 기본적으로 탐욕적이므로 다양한 패턴이 매칭될때 가능한 긴 패턴을 반환합니다. 만약 게으르게 동작하도록하고 싶다면 수량자 뒤에 ?를 붙이면 되며 이경우 다양한 경우의 패턴이 매칭될때 가능한 짧은 패턴을 반환합니다.

위 정의만으로 탐욕적, 게으른 수량자를 이해하는것기 어렵기 때문에 간단한 예제와 함께 탐욕적, 게으른 수량자의 동작과정을 따라가 보겠습니다. 예제로 사용할 입력은 'a "witch" and her "broom" is one' 이고 정규표현식은 탐욕적인경우 /".+"/g이며 게으른 경우 /".+?"/g입니다. 먼저 탐욕적인 경우 부터 살펴봅시다.

  1. 정규표현식의 첫번째 기호가 "이기 때문에 입력문자의 3번째에 위치하는 "에 매치됩니다.
  2. 그다음 표현이 .이므로 다음문자인 w와 매치됩니다.
  3. 다음 표현은 +이므로 .을 반복합니다. 이때 탐욕적으로 동작하므로 문자열의 끝까지 .을 적용합니다.
  4. 문자열의 끝에서 다음 정규표현식 문자인 "를 매칭하려 하지만 문자열의 끝이므로 일치하지 않습니다. 정규표현식은 너무 많이 왔음을 인지하고 다시 뒤로 돌아가 "와 일치하는 문자가 있는지 살펴봅니다.
  5. 뒤로 돌아가다보면 broom 이라는 문자열 뒤에 존재하는 "를 처음으로 만나게 되고 최종적으로 "witch" and her "broom"라는 문자열을 출력합니다.

위와 같이 탐욕적인 수량자는 최대한 많이 반복한뒤 너무 많이 반복하였다고 생각되면 이전 단계로 이동하여 다음 표현식을 매치(역추적)합니다. 반면 게으른 수량자는 최소한의 횟수로 반복합니다. 이번에는 게으른 수량자의 동작을 살펴봅시다.

  1. 정규표현식의 첫번째 기호가 "이기 때문에 입력문자의 3번째에 존재하는 "에 매치됩니다.
  2. 그다음 표현이 .이므로 다음문자인 w와 매치됩니다.
  3. 다음 표현은 +이기에 .을 반복해야하지만 게으르게 동작하므로 .를 매치시키기전에 다음 표현식인 "와 현재 타겟 문자열인 i가 일치하는지 확인합니다 만약 일치하지 않는다면 .를 적용하고 이과정을 반복합니다.
  4. 위와 같은 과정을 반복하다보면 9번째에 존재하는 "가 정규표현식의 "와 일치하게 되고 "witch"라는 결과를 얻게 됩니다.
  5. g플래그가 켜져있어 그다음 검색을 10번인덱스 부터 시작하며 동일한 과정을 거쳐 "broom"이라는 결과를 얻을수 있습니다.
const input = 'a "witch" and her "broom" is one';

const greedyRegex = /".+"/g
const lazyRegex = /".+?"/g

input.match(greedyRegex) // [ '"witch" and her "broom"' ] 문자열 가장 마지막에 위치한 "를 찾아냅니다.
input.match(lazyRegex) // [ '"witch"', '"broom"' ] 문자열에서 두번째로 만나는 "를 매치합니다.

탐욕적 수량자와 게으른 수량자는 상황에 따라 전혀 다른 결과를 출력할수 있으므로 난이도가 있더라도 가급적 정확하게 이해하고 넘어가시는것을 추천드립니다. 탐욕적 수량자와 게으른 수량자에 대한 더 자세한 설명은 링크를 참고해보세요.

그룹화와 캡쳐링

그룹화는 표현식 내부에서 동일한 표현식을 사용해야할때 유용하며 소괄호를 사용해 표기합니다. 예를들어 2024-02-11과 매치되는 정규표현식을 (\d{4})-(\d{2})-(\d{2})으로 표기할수 있습니다.

캡쳐링은 그룹화한 표현식의 결과를 저장하는것입니다. 예를들어 (\d{4})-(\d{2})-(\d{2})라는 정규표현식을 사용한경우, 첫번째 괄호, 두번째 괄호, 세번째 괄호의 결과를 따로 저장해 둡니다. 여기서 저장한 결과는 출력 뿐만 아니라 정규표현식 내부에서도 사용할 수 있습니다.

순서 기반 캡쳐

캡쳐한 그룹을 왼쪽부터 순서대로 저장합니다. 저장한 결과는 정규식 내부 혹은 정규식 결과에서 사용가능합니다.

정규식 내부에서 사용할 경우 \1와 같은 형식(백슬래시 뒤에 순서를 입력)으로 입력해야합니다. 결과에서 사용할경우 배열의 두번째 요소부터(첫번째 요소는 정규표현식의 결과값) 순서대로 조회하면 되고 만약 match 함수가 아닌 replace 함수등을 사용해 교체할 정규표현식을 명시해야한다면 $1과 같이 표기하면됩니다.

const input = "010-1234-1234"
const regexp = /(\d{3})-(\d{4})-(\2)/; // \2를 정규표현식 내부에 사용한경우 두번째 소괄호(\d{4})가 캡쳐한 결과인 1234를 그대로 넣어주기 때문에 (\2)는 1234와 동일합니다.
let match = input.match(regexp)

match[1],match[2],match[3] // 010 1234 1234 배열의 첫번째는 정규표현식 결과이며 두번재 요소부터 소괄호의 내용이 출력됩니다.

이름 지정 캡쳐

순서 기반 캡쳐는 처음 작성하기는 쉽지만 사람이 이해하기 어렵고, 서브 패턴이 추가되는 경우 순서를 파악한뒤 수정해야한다는 단점이 있습니다. 이러한 상황에서는 순서대신 이름을 지정해두고 사용하는 이름 지정 캡쳐를 사용할 수 있습니다. 서브 패턴마다 이름을 지정해두고 정규표현식 내부 혹은 결과에서 사용하는것입니다.

정규식 내부에서 사용할 경우 \k<name>와 같은 형식(\k는 고정이고 name만 사용하고자하는 이름으로 변경)으로 입력해야합니다. 결과에서 사용할경우 groups 프로퍼티에서 저장한 이름 프로퍼티를 조회하면 되며 만약 match 함수가 아닌 replace 함수등을 사용해 교체할 정규표현식을 명시해야한다면 $<name>과 같이 표기하면됩니다.

const input = "010-1234-1234"
const regexp = /(?<first>\d{3})-(?<second>\d{4})-(?<third>\k<second>)/; // \2를 정규표현식 내부에 사용한경우 두번째 소괄호(\d{4})가 캡쳐한 결과인 1234를 그대로 넣어주기 때문에 (\2)는 1234와 동일합니다.
let match = input.match(regexp)

match.groups.first,match.groups.second,match.groups.third

비 캡쳐링 그룹화

그룹화를 시행할때마다 사용하지도 않을 캡쳐링을 매번 수행하는것은 엔진낭비입니다. 따라서 캡쳐링 결과를 사용하지 않을 경우 더 빠른 연산을 위해 그룹에 캡쳐링을 하지 않겠다는 기호인 ?:를 소괄호 첫번째에 넣어주면됩니다.

const input = "010-1234-1234"
const regexp = /(?:\d{3})-(?:\d{4})-(?:\d{4})/; // 
let match = input.match(regexp)

match[1],match[2],match[3] // undefined undefined undefined

전후방 탐색

전후방 탐색은 주어진 패턴보다 좌측 혹은 우측에 있는 문자열 일치하는지를 판별하는 패턴입니다. 이때 괄호에 포함된 식은 결과값에 포함되지 않습니다. 즉 괄호 내부의 패턴이 입력 문자열을 소비하지 않습니다.

후방탐색과 부정 후방탐색은 V8엔진 기반의 크롬, 엣지 등의 브라우저가 아닌 꽤 최근(2023.1.23) 버전까지 의 사파리나 ie 전버전에서 지원하지 않기 때문에 사용하실때는 지원예정인 브라우저 및 버전을 확인해야합니다.

전방탐색

전방탐색은 X(?=Y)와 같은 형식으로 사용하며 Y를 만족하면서 전방(왼쪽)에 X가 있는 경우를 나타냅니다.

const input = '2 apple is 10₩';

const regex = /[0-9]+(?=₩)/

input.match(regex) // [ '10', index: 14, input: 'This apple is 10$', groups: undefined ] // 달러기호 없이 금액 만을 뽑아 낼 수 있으며 달러가 뒤에 있지 않은 2는 포함되지 않습니다. 

부정 전방탐색

부정 전방탐색은 X(?!Y)와 같은 형식으로 사용하며 Y를 만족하면서 전방(왼쪽)에 X가 없는 경우를 나타냅니다.

const input = '2 apple is 10₩';

const regex = /[0-9]+(?!₩)/

input.match(regex) // [ '2', index: 14, input: 'This apple is 10$', groups: undefined ] // 원화기호가 뒤에 없는 숫자인 2만 출력해낼수 있습니다.

후방탐색

후방 탐색은 (?<=Y)X와 같은 형식으로 사용하며 Y를 만족하면서 후방(오른쪽)에 X가 있는 경우를 나타냅니다.

const input = '2 apple is $10';

const regex = /(?<=\$)[0-9]+/

input.match(regex) // [ '10', index: 14, input: 'This apple is 10$', groups: undefined ] //  달러기호 없이 금액 만을 뽑아 낼 수 있으며 달러가 뒤에 있지 않은 2는 포함되지 않습니다. 

부정 후방탐색

부정 후방 탐색은 (?<!Y)X와 같은 형식으로 사용하며 Y를 만족하면서 후방(오른쪽)에 X가 없는 경우를 나타냅니다.

const input = '2 apple is $10';

const regex = /(?<!\$)[0-9]+/

input.match(regex) // [ '10', index: 14, input: 'This apple is 10$', groups: undefined ] // 달러기호가 앞에 없는 숫자인 2만 출력해낼수 있습니다.

구체적인 상황과 함께 살펴보기

이제까지 정규표현식의 다양한 문법들을 살펴보았습니다. 모든 문법을 전부 살펴본것은 아니지만, 이정도 문법이면 원하는 규칙을 만족하는 정규표현식을 만들어내는데는 무리가 없을것입니다. 하지만 문법을 배운다고해서 실전에서 곧바로 사용하는것은 어렵습니다. 숙달이 되어있지 않으면 언제 이러한 문법을 사용해야할지 알기가 어렵기 때문입니다. 따라서 실무에서 마주칠만한 상황을 가지고 정규표현식을 만들어 보면서 앞서 배운 정규표현식을 적용해보겠습니다

commit 메시지 컨벤션 작성하기

git hooks을 이용하여 commit message가 컨벤션과 일치하는지 검사할수 있습니다. 이때 일반적으로 정규표현식을 사용하게 되므로 commit 메시지 컨벤션을 의미하는 정규표현식을 만들어봅시다.

  • type: feat, fix, type, style 네가지 타입이 가능합니다.
  • subject: 10자 이상 30자 이하의 길이를 가지며 어떤 문자든지 가능합니다.
  • body: 필수는 아니며 제목에서 한줄을 띄운뒤 본문이 담기게되며 여러줄을 입력할수 있습니다.

type의 경우 네가지 문자만 가능하므로, 그룹화와 or연산자를 사용하여 표현할 수 있습니다. 이때 그룹의 캡쳐결과를 사용하지는 않을것이기 때문에 비 캡쳐링 그룹화를 적용하게 되면 (?:feat|fix|type|style) 와 같은 정규표현식으로 표현할 수 있습니다.

subject의 경우 30자 미만의 길이를 가질수 있으면서 이외에는 특별한 조건이 없기 때문에 모든 문자열을 의미하는 .과 수량자{10,30}을 명시하여 .{10,30}과 같은 정규표현식으로 표현할 수 있습니다.

body의 경우 필수가 아니기 때문에 모든 표현식을 소괄호내에 작성한뒤 ? 연산자를 사용해 해당 패턴이 비필수 있임을 명시해줍니다. 그룹내에서는 줄바꿈 두번을 먼저 포함해야하고 그뒤에는 줄바꿈을 포함한 모든 문자를 표기할수 있으므로 . 대신 [\s\S]와 같은 표기를 사용해 모든 문자를 표현한뒤 *를 사용해 갯수제한이 없음을 표현해줍니다. 또한 앞서 했던것 처럼 비 캡쳐링 그룹화를 적용하면 (?:\n\n[\s\S]*)? 와 같은 정규표현식으로 표현할 수 있습니다.

const input = `feat: this is test case code

- check test
- todo list
- everything is good
`
const regex = /(?:feat|fix|type|style): .{0,30}(?:\n\n[\s\S]*)?/

input.match(regex) // ['feat: this is test case code\n' + '\n' +' - check test\n' +'- todo list\n' + - everything is good\n', index: 0, input: 'feat: this is test case code\n' + '\n' + '- check test\n' + '- todo list\n' + '- everything is good\n', groups: undefined ]

비밀번호 유효성 검사

정규표현식은 입력폼의 유효성을 검사하는데 자주 사용되며 그중에서도 일반적으로 복잡한 조건이 적용되는 비밀번호의 유효성을 검사하는 정규표현식을 만들어봅시다. 예제로 사용할 규칙은 다음과 같습니다.

  • 소문자, 대문자, 숫자, 특수문자가 모두 포함되어야합니다.
  • 같은 문자가 3번 반복되면 안됩니다.
  • 8글자 이상이어야합니다.

조건을 모두 풀어보면 소문자, 대문자, 숫자, 특수문자가 모두 포함되어한다는 조건 4가지와 같은문자가 3번 반복되면 안된다는 조건과 8글자 이상이어야하는 조건까지 하여 총 6개의 조건이 적용되어야합니다. 이 조건들을 개별로 적용하는 정규표현식을 작성해 유효성 검사를 하면 다음과 같습니다.

const input = "qwerASDF1234!"

const lowercaseAlphabetRegex = /[a-z]/
const uppercaseAlphabetRegex = /[A-Z]/
const numberAlphabetRegex = /[0-9]/
const specialCharactersRegex= /[\W]/ // 특수문자를 모두 명시할수 없어서 글자의 역집합인 \W를 명시하였습니다.
const sameCharactersRegex = /(.)\1{2}/ // 해석하자면, .으로 인해 모든 문자열에 매치가 가능하고, 이 결과를 캡쳐하여 바로 뒤에 \1로 두번 사용하여 첫번째 캡쳐한 결과가 뒤에 두번 연속해 나오는지 검사하는것입니다. 
const lengthRegex = /.{8,}/

const result = []

if(!lowercaseAlphabetRegex.test(input)) result.push("소문자 입력해주세요")
if(!uppercaseAlphabetRegex.test(input)) result.push("대문자 입력해주세요")
if(!numberAlphabetRegex.test(input)) result.push("숫자 입력해주세요")
if(!specialCharactersRegex.test(input)) result.push("특수문자 입력해주세요")
if(sameCharactersRegex.test(input)) result.push("세번이상 반복되는 문자를 지워주세요")
if(!lengthRegex.test(input)) result.push("길이가 8자 미만입니다.")

console.log(result.join("\n"))

실무에서는 이러한 방식의 정규표현식을 작성해 주어야 에러메시지를 더욱 상세하게 표기할 수 있기에 위와 같은 방법을 많이 사용하겠지만 앞선 6개 조건을 한번에 검사하는 경우도 있을수 있기에, 모든 조건을 한번에 검사하는 정규표현식을 만들어보겠습니다.

사실 어떻게 만들어야할지 고민해보면 일반적인 방법으로는 만들기가 쉽지 않을것 같다는 생각이 들게됩니다. 왜냐하면 하나의 정규표현식으로 앞선 조건문처럼 원본 입력값에 대해 여러 조건을 적용시키는것이 불가능해보이기 때문입니다. 하지만 앞서 배운 전방탐색을 이용하면 앞서 작성한 조건문처럼 정규표현식을 작성할수 있습니다.

전방탐색의 고유한 특징은 소괄호 내의 표현식을 검사에만 사용하고 결과에 출력하지 않는다는것입니다. 이를 바꾸어 말하면 그다음 문자를 검사할때 소비하지는 않지만, 일치하였는지 여부만 검사하는 마치 if문 처럼 사용할수 있다는것입니다. 따라서 전방탐색을 사용하면 입력 문자열을 소비하지 않으면서 특정 조건을 만족시키는지 검사할수 있습니다.

const input = 'qwerASDF1234!'
const regex = /(?=.*[a-z]).*/

console.log(input.match(reg)) // [ 'qwerASDF1234!', index: 0, input: 'qwerASDF1234!', groups: undefined ]
  1. 첫번째 정규표현이 소괄호 이고 ?=로 이어지므로 다음의 패턴에 매치되는 전방 탐색을 시작합니다.
  2. .*[a-z]는 앞쪽에 어떤 표현이 오더라도 소문자가 오면 일치하였다고 평가합니다. 여기서는 'q'가 매치되며 소괄호 패턴이 종료됩니다. 하지만 문자열을 소비하지 않습니다.
  3. 다음 정규표현식 .*을 매치할때는 전체 문자열 'qwerASDF1234!'의 첫번째부터 다시 시작하므로 모든 문자열이 출력됩니다.

결국 위와같이 전방 탐색을 사용하면 소괄호내 패턴에 맞지 않은 경우 탐색을 종료하고 패턴에 맞는경우 입력문자열을 전혀 사용하지 않고 마치 새롭게 입력을 받은것 처럼 다음 표현식과 매치할수 있게 되므로 if문 처럼 사용할수 있게 됩니다.

따라서 앞서 여러 조건문에서 사용했던 정규표현식을 이어붙이면 다음과 같이 표현할수 있게 됩니다.

const input = 'qwerASDF1234!'
const regex = /(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*\W)(?!.*(.)\1{2}).{8,}/ // ?!.*(.)\1{2} 만 부정 전방탐색인경우는 앞선 조건문에서도 보았듯이 패턴자체가 존재하면 안되는 패턴이기 때문입니다.

input.match(reg) // [ 'qwerASDF1234!', index: 0, input: 'qwerASDF1234!', groups: undefined ]

특정 문자열 교체하기

입력된 이메일의 도메인을 변경해야하는 상황을 가정해 봅시다. 현재 사용중인 이메일 도메인은 abc.com, def.com , hij.com 도메인 세가지가 있습니다. 이 도메인을 모두 test.com 도메인으로 변경하려합니다. 이때 아이디는 유지해야합니다.

아이디와 도메인은 @를 기준으로 분리할수 있고, 변경을 위해서는 아이디를 캡쳐한뒤 재사용하고 새로운 도메인을 붙여 이메일을 만들어야합니다.

캡쳐후 재사용할때 이름을 붙인경우 $<name>, 순서를 사용한경우 $1 와 같은 식으로 사용해야합니다.

const input = "hello@abc.com"

const regex = /(?<id>.*)@(abc|def|ghi)\.com/

input.replace(regex,"$<id>@test.com") // hello@test.com

만약 코드내에서 정규표현식을 사용하는것이니라 ide를 이용하여 mdx와 같은 형식의 파일내 컨텐츠를 수정하는경우 ide의 찾아바꾸기 기능을 이용하여 검색에는 /(?<id>.*)@(abc|def|ghi)\.com/를, 교체에는 $<id>@test.com를 입력하면됩니다.

정규표현식이 만들어내는 블로킹, 치명적인 역추적

일부 정규표현식의 경우 문자열에 대한 검사를 수행하는데 있어서 많은 시간을 소요하여 브라우저를 먹통상태로 만드는 경우가 있습니다. 아래 예제를 브라우저 콘솔이나, 정규식테스트 사이트에서 실행해보면 세번째 테스트 케이스가 오랜시간을 소요하는것을 알 수 있습니다.

let regexp = /^(\w+\s?)*$/;

alert(regexp.test("A good string")); // true
alert(regexp.test("Bad characters: $@#")); // false
alert(regexp.test("aaaaaaaabbbbbbbbcccccccdddddddd")); // 매우 오랜 시간이 소요됩니다.

V8엔진을 사용중인경우 8.8 버전(크롬 88버전)이후부터는 실험적으로 비역추적 알고리즘을 제공하고 있기에 브라우저가 멈추지 않을수 있습니다.

발생원인

앞서 살펴보았던 정규표현식인 ^(\w+)*$"aaaaaaaabbbbbbbbcccccccdddddddd!" 를 적용시키는 과정을 살펴보면서 정규표현식을 적용하는데 있어서 오랜시간이 소요되는 이유를 이해해봅시다.

  1. (\w+)는 앞쪽의 문자열부터 매치를 시작하며 마지막 느낌표 직전의 문자열(d)까지 매치하게됩니다.
(aaaaaaaabbbbbbbbcccccccdddddddd)!
  1. 마지막 문자열이 !이므로 소괄호내의 패턴매치는 종료되고 * 또한 더이상 매치시킬 문자열이 남아있지 않으므로 아무일도 일어나지 않습니다. 하지만 !가 남아있어 종료되지 않으며 정규표현식은 너무 많이 왔음을 느끼고 한칸 뒤로(끝에서 두번째 위치의 d) 이동한뒤 다시 매칭을 시작하여 *에 의해 새로운 소괄호가 생성됩니다.
(aaaaaaaabbbbbbbbcccccccddddddd)(d)!
  1. 마지막 문자열이 !이므로 위와 동일하게 정규표현식은 종료되지 않고 한칸뒤(끝에서 세번째 위치의 d)로 이동한뒤 다시 매칭을 시작하여 두번째 소괄호에는 d가 추가됩니다.
(aaaaaaaabbbbbbbbcccccccdddddd)(dd)!
  1. 2번과 3번의 과정을 첫번째 소괄호가 a하나만 남아있는 과정까지 오게 되면 2번과 같이 새로운 소괄호를 생성하게됩니다.
(a)(aaaaaaabbbbbbbbcccccccdddddddd)(d)!
  1. 위와 같은 순서를 모두 거치게 되면 대략 2의 n 지수승 만큼의 연산횟수를 가지게 되어 n이 30번만 되어도 대략 10억번의 연산횟수가 소요되므로 문제가 발생합니다.

수량자에 대한 역추적 금지

위와같은 역추적을 발생시킬 가능성이 존재하는 정규표현식은 브라우저에서 이슈가될 가능성이 매우큽니다. 따라서 이를 방지하기 위한 해결방법이 필요합니다. 물론 정규표현식 자체를 수정하여 이를 해결할수도 있지만, 정규표현식이 복잡해질 가능성이 크고 역추적 자체를 금지하면 되기때문에 효과적인 방법이 아니므로 수량자에 대한 역추적을 금지하는 방법을 사용하는것이 좋습니다.

사람의 입장에서 볼때 위에서 사용한 예제의 경우 !가 마지막에 위치하기 때문에 역추적을 수행할 필요가 없음을 알고 있습니다. 하지만 컴퓨터는 이를 알 수 없으므로 컴퓨터에게 역추적이 필요없음을 알려주어야합니다. 최신의 정규표현식에서는 \w++와 같은 형식으로 사용하면 역추적을 금지하지만, 자바스크립트에서는 지원하지 않으므로 다른방식이 필요합니다.

우리가 사용할방식은 바로 \w(?=(\w+))\1와같이 표기하는것입니다. 표현식을 분석해보면, 현재위치에서 시작하여 가장 긴단어를 찾습니다. 이렇게 찾은 결과는 전방탐색으로 찾은것이기 때문에 기억되지 않으며 캡쳐한 결과를 \1에 의해 그대로 재사용하므로 전체 단어를 정확하게 매치하지만 수량자는 더이상 존재하지 않으므로 역참조가 수행되지 않습니다.

let regexp = /^(?=(\w+))\1*$/;

alert(regexp.test("aaaaaaaabbbbbbbbcccccccdddddddd!")) // 정상적으로 수행합니다.

마치며

정규표현식의 여러 문법들과 이를 적용한 예제 몇가지를 살펴보았습니다.

이 아티클 만으로 정규표현식에 대한 완벽하게 이해할수는 없습니다. 그렇지만 이번 아티클을 통해 정규표현식에 대한 이해를 높여 정규표현식을 구글링하거나 chat gpt를 통해 사용하더라도 의미를 정확히 이해하고 사용할 수 있었으면 좋겠습니다.

참고자료

정규표현식 완전정복 Greedy and lazy quantifiers 정규식은 어떻게 사용되는 것일까? 문자열 처리의 해결사. 정규표현식을 알아보자 ③