프로필
프론트엔드 개발자
김동희입니다

실무에서 바로 써먹는 정규표현식(1)

※ 이 글은 정규표현식을 사용해본 적은 있지만 정규표현식에 자신이 없는 개발자들을 대상으로 하고 있습니다.

※ 이 글을 읽고 나면 정규표현식 작성에 조금 자신을 가지게 됩니다.

※ 이 글에서 정규표현식은 자바스크립트 정규표현식 리터럴의 형태로 기술합니다. 즉, /a(b|c)/와 같이 기술되어 있는 것은 모두 정규표현식입니다.

I. 정규표현식이란

정규표현식이란 문자열의 패턴을 표현하기 위한 작성법입니다

예를 들어 0부터 9 사이에 있는 하나의 숫자 라는 패턴을 정규표현식에서는 /0|1|2|3|4|5|6|7|8|9/ , /[0-9]/ 또는 /\d/로 나타낼 수 있습니다.

또한 apple, tomato, pineapple이 순서와 상관없이 모두 포함된 문자열/(?=.*apple)(?=.*tomato)(?=.*pineapple)/ 으로, tomato가 3번 이상 존재하는 문자열/(.*\btomato\b.*){3}/ 으로 나타낼 수 있습니다.

즉, 문자열의 패턴을 컴퓨터가 이해할 수 있게끔 명확하게 표현하는 방법이 정규표현식입니다.

주어진 문자열이 정규표현식에 일치한다란?

주어진 문자열에 정규표현식으로 기술한 문자열의 패턴이 포함되어 있는 경우, 주어진 문자열이 정규표현식에 일치한다 라고 합니다

예를 들어, a 는 숫자가 아니므로 /0|1|2|3|4|5|6|7|8|9/ 에 일치하지 않지만 3/0|1|2|3|4|5|6|7|8|9/에 일치합니다.

또한, 1bc, a2c, ab3 모두 정규표현식 /0|1|2|3|4|5|6|7|8|9/에 일치합니다. 왜냐하면 정규표현식이 의미하는 0부터 9 사이에 있는 하나의 숫자라는 패턴이 각 문자열에 모두 존재하기 때문입니다. 1bc는 첫번째 문자인 1이, a2c는 두번째 문자인 2가, ab3의 경우 세번째 문자인 30부터 9 사이에 있는 하나의 숫자에 해당하여 주어진 정규표현식에 일치합니다.

II. 정규표현식이 사용되는 상황

자바스크립트에서 정규표현식의 가장 기본적인 사용법은 주어진 문자열에서 정규표현식으로 기술되는 문자열의 패턴을 검색하는 것입니다.

검색한 결과가 있다면 주어진 문자열이 정규표현식에 일치하는 것을 알 수 있습니다. 검색한 결과가 없다면 주어진 문자열에 정규표현식으로 표현되는 패턴에 존재하지 않는다, 즉 주어진 문자열이 정규표현식에 일치하지 않는 것을 알 수 있습니다.

이를 응용하면 1) 정규표현식에 일치하는 부분 문자열을 특정하거나, 2) 특정된 부분 문자열을 다른 문자열로 치환하거나, 3) 빈문자열로 치환하여 삭제하는 것도 가능합니다.

또한, 정규표현식의 문자열 처음, 문자열 끝을 나타내는 메타문자를 활용한다면 문자열 전체를 대상으로 한 패턴을 기술할 수도 있습니다. 이를 이용하여 주어진 문자열 그 자체가 정규표현식에 일치하는지를 검색하여, 전체 문자열을 검증할 수도 있습니다.

III. 정규표현식 기본 연산자

정규표현식의 기본 연산자로 3가지가 있습니다. 이 3가지만 안다면 왠만한 정규표현식은 다 이해할 수 있으며 기술할 수 있게 됩니다.

1. 접합 연산자

알파벳 a/a/로 나타낼 수 있으며, 알파벳 b/b/로 나타낼 수 있습니다. 그렇다면 알파벳 a와 알파벳 b가 접합해 있는 문자열 ab를 정규표현식으로는 어떻게 나타낼까요? /ab/와 같이 나타냅니다. 즉, 접합 연산자는 별도의 기호가 없습니다.

2. 선택 연산자: |

a 또는 b 와 같은 패턴을 나타낼 때 선택 연산자 |를 사용하며, /a|b/ 와 같이 정규표현식으로 나타낼 수 있습니다.

예를 들어 banana 또는 apple 을 포함하고 있는 문자열 패턴은 /banana|apple/ 로 기술할 수 있습니다.

접합 연산자는 선택 연산자보다 우선순위가 높기 때문에 | 보다 banana, apple가 먼저 계산이 되어 의도한 대로 작동합니다.

만약 yellow banana 또는 yellow mango 를 포함하고 있는 문자열 패턴을 기술하기 위해서는 /yellow (banana|mango)/ 와 같이 괄호(())를 추가하여 우선순위를 명확히 해줘야 합니다. /yellow banana|mango/는 yellow banana 또는 mango를 의미합니다. (공백문자`` 도 문자이기 때문에 접합연산자의 대상이 됩니다)

3. 반복 연산자: *

연산자 앞의 문자가 0번 이상 반복됨을 나타냅니다.

/a*/는 빈문자열, a, aa, aaa, aaaa, aaaaa ... 과 일치합니다.

III. 정규표현식 기본 연산자와 관련된 문법들

1. 우선순위: 반복 연산자 > 접합 연산자 > 선택 연산자

반복 연산자(*)의 우선순위가 제일 높고 그 다음 접합 연산자, 마지막으로 선택 연산자(|)입니다.

예를 들어 /ab|c*/는 어떤 문자열 패턴을 의미할까요?

우선순위를 명확히 보여주기 위해 괄호를 추가해보겠습니다

/(ab)|(c*)/ 즉, ab 또는 c가 0번 이상 반복되는 문자열을 의미합니다.

ab, 빈문자열, c, cc, ccc ... 등이 위의 정규표현식에 일치합니다.

연산자 간의 우선순위를 바꾸고 싶다면 괄호(())를 추가하면 됩니다.

/a(b|c*)/ 는 이제 /ab|c*/ 와 다르게 ab 또는 ac* 를 의미합니다.

2. Syntax Sugar: [], 문자열 클래스, ?, +, {}

선택 연산자를 이용해서 0부터 9사이에 있는 하나의 숫자를 나타낸다면 /0|1|2|3|4|5|6|7|8|9/ 가 됩니다. 하지만 이는 너무 번거롭습니다. 0부터 9사이에 있는 하나의 숫자가 연속된 문자열, 즉 00, 01, 24, 35, 99 등을 나타내려면

/(0|1|2|3|4|5|6|7|8|9)(0|1|2|3|4|5|6|7|8|9)/ 와 같이 나타내야 합니다.

이를 간단하기 기술하기 위한 문법적 설탕, Syntax Sugar가 여러가지 있습니다.

1) []

[]는 선택 연산자를 간단하게 기술하기 위한 방법입니다. /[0123456789]//0|1|2|3|4|5|6|7|8|9/와 동일합니다. 즉, 여러개의 문자 중 하나만을 의미하게 하고 싶을 때에는 꺽쇠 괄호([]) 안에 여러개의 문자를 기술하면 됩니다. []는 하나의 문자만을 의미하기 때문에 /ab|c/ 와 같은 경우는 []를 이용해서 나타낼 수 없습니다.

또한 [] 내에서 - 를 사용하여 범위를 나타낼 수 있습니다. /[0123456789]//[0-9]/ 와 같이 나타낼 수 있습니다. -의 정확한 의미는 아스키 코드(또는 유니코드)로 나타낸 문자의 범위를 의미합니다. 0은 0x30 이며 1은 0x31, 2는 0x32 입니다. 9는 0x39이므로, /[0-9]/0x30 부터 0x39까지의 모든 문자를 나타내며 0x300x39도 포함됩니다.

- 를 사용할 때 주의점은 반드시 앞 뒤에 문자가 와야한다는 점입니다. /[-9]/- 또는 3을 나타내며, 0x00 부터 0x39 사이의 하나의 문자를 의미하지 않습니다. 또한 /[9-0]/과 같은 표현은 유효한 정규표현식이 아니기 때문에 선언시 에러가 발생합니다.

[^0-9] 와 같이 꺽쇠 괄호 내에 carrot(^) 을 추가할 수도 있는데요, ^은 부정의 의미를 나타내며 0부터 9사이에 있는 하나의 숫자가 아닌 모든 문자를 의미합니다. 즉, a $ ~ 모두 정규표현식과 일치하지만 0 1 2 등은 정규표현식과 일치하지 않습니다.

참고로 .은 정규표현식에서 쓰이는 특수기호로서 아무런 문자 하나, 즉 모든 문자 하나를 의미합니다. 빈문자열을 제외한 모든 문자열은 /./에 일치하게 됩니다.

2) 문자열 클래스

0부터 9사이에 있는 하나의 숫자, 공백문자, 알파벳 대소문자와 숫자 및 언더바(_) 등 자주 사용되는 문자들의 집합은 이미 정규표현식에 정의가 되어 있습니다.

예를 들어 0부터 9사이에 있는 하나의 숫자, 즉 [0-9]\d 로 사용할 수 있습니다. 스페이스, 탭, 뉴라인, 캐리지 리턴 등 각종 공백문자, 즉 /[f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/\s로 사용할 수 있습니다. 알파벳 대소문자와 숫자 및 언더바(_) , 즉 /[0-9a-zA-Z_]/\w로 사용할 수 있습니다.

\D, \S, \W/[^\d]/, /[^\s]/, /[^\w]/ 와 일치합니다.

?, +, {}

? 는 0개 또는 1개를 나타냅니다.

banana 또는 bananas를 나타내고 싶을 때 /banana(|s)/ 로 나타낼 수도 있지만 /bananas?/로 기술할 수도 있습니다.

+ 는 1개 이상을 의미합니다.

/a+//aa*/ 과 동일합니다.

{}를 이용하면 특정한 수량의 반복을 나타낼 수 있습니다. 예를 들어 banana 를 나타내기 위해 /ba(na){2}/ 와 같이 기술할 수 있습니다. nanana가 2번 반복되므로 반복되는 횟수를 {2}와 같이 적어서 표현하였습니다.

aaa 또는 aaaa 즉, a가 3번 이상 4번 이하 반복되는 문자열 패턴을 나타내기 위해서는 /a{3,4}/ 와 같이 기술할 수 있습니다.

a가 3번 이상 반복되는 문자열은 /a{3,}로 나타낼 수 있습니다.

?, +, {}는 반복 연산자와 동일한 우선순위를 갖습니다. 따라서 접합연산자보다 우선순위가 높습니다. 따라서 /bana{2}/ 와 같이 기술한다면 /banaa/를 의미하게 됩니다.

IV. 예시

간단한 예시를 통해 정규표현식 기본 연산자만으로 어떠한 것들을 할 수 있는지 알아보겠습니다.

1) 주문하기시 라이더님께 요청사항에 금지된 단어가 포함되어 있는지 확인

배달의 민족 어플의 주문하기 페이지에는 가게 사장님께 요청사항과 라이더님께 요청사항 기재란이 있습니다.

예를 들어 라이더님께 요청사항에 빨리 빠르게 스피디라는 단어는 포함할 수 없다고 가정해보겠습니다.

요청사항에 해당 문자열이 포함되어 있는지 어떻게 알 수 있을까요?

선택 연산자를 이용하면 빨리 또는 빠르게 또는 또는 스피디 라는 문자열 패턴을 간단하게 나타낼 수 있습니다.

따라서 요청사항이 해당 정규표현식에 일치한다면 요청사항에 빨리, 빠르게, , 스피디 라는 단어가 포함된 것임을 알 수 있습니다.

자바스크립트로 나타내면 다음과 같습니다.

function hasBannedWords(requestMessage) {
  return /빨리|빠르게|퀵|스피디/.test(requestMessage);
}

console.assert(hasBannedWords("빠르게 와주세요") === true);
console.assert(hasBannedWords("초인종 누르지 말아주세요") === false);

RegExp.prototype.test는 인자로 주어진 문자열이 정규표현식에 일치하는지 여부를 boolean으로 반환합니다.

2) 생년월일 검증

생년월일이 YYYY-MM-DD 형식으로 주어진다고 할 때 유효한 생년월일인지 정규표현식을 이용해서 검증해보겠습니다. 문제를 간단히 하기 위해 윤년은 고려하지 않으며, 유효한 연도는 1900년부터 2022년을 의미한다고 가정하겠습니다.

유효한 연도를 정규표현식으로 나타내기

선택 연산자를 이용한다면 /1900|1901|1902| ... 2021|2022/ 와 같이 나타낼 수 있습니다. 하지만 122개를 모두다 기술할 수는 없습니다. 먼저 1900 ~ 2022년을 3가지 구간으로 나누겠습니다.

  • 1구간: 1900년 ~ 1999년
  • 2구간: 2000년 ~ 2019년
  • 3구간: 2020년 ~ 2022년
1구간

1900 ~ 1999은 앞의 19가 고정임을 알 수 있습니다. 뒤의 두자리숫자는 00 ~ 99 까지 변화합니다. 따라서 /19[0-9][0-9]/ 또는 /19\d\d/ 또는 /19\d{2}/ 로 나타낼 수 있습니다.

\d\d 보다 \d{2}가 글자수가 많으므로 /19\d\d/로 하겠습니다.

2구간

2000 ~ 2019 는 앞의 20이 고정이며 뒤에서 두번째 숫자는 0 또는 1이며 마지막 자리수는 0~9 입니다. 따라서 /20[01][0-9]/ 또는 /20[01]\d/ 로 나타낼 수 있습니다.

3구간

2020 ~ 2022는 앞의 202가 고정이며 마지막 자리수가 0~2 입니다. 따라서 /202[0-2]/ 로 나타낼 수 있습니다.

1,2,3구간 조합

이 3가지 패턴을 선택 연산자로 나타내 보겠습니다 /19\d\d|20[01]\d|202[0-2]/로 나타낼 수 있습니다. 조금 더 축약을 해보자면 2구간과 3구간의 20이 중복되므로 /19\d\d|20([01]\d|2[0-2])/ 로도 할 수 있습니다.

유효한 월일을 정규표현식으로 나타내기

모든 월의 말일이 동일하지 않습니다. 1,3,5,7,8,10,12월은 31일까지 있고, 4,6,9,11월은 30일까지 있고, 2월은 28일까지 있습니다(윤년은 고려하지 않습니다).

따라서 3가지 구간으로 나눠서 생각해보겠습니다.

  • 1구간: 1,3,5,7,8,10,12월
  • 2구간: 4,6,9,11월
  • 3구간: 2월
1,3,5,7,8,10,12월

1,3,5,7,8,10,12월은 01일부터 31일까지 유효한 일입니다.

먼저 월은 /01|03|05|07|08|10|12/로 나타낼 수 있습니다. 1,3,5,7,8월은 앞의 0이 중복되고 10,12월은 앞의 1이 중복되네요. 이를 축약하면 /0[13578]|1[02]/로 나타낼 수 있습니다.

01일부터 31일을 0109일, 1029일, 3031일로 나눠서 생각해보겠습니다. 0109일은 /0[1-9]/로, 1029일은 /[12]\d/로, 3031일은 /3[01]/로 나타낼 수 있습니다. 이를 선택연산자로 합치면 /0[1-9]|[12]\d|3[01]/가 됩니다.

월과 일을 합쳐보겠습니다. 월과 일 사이에는 -이 있으며 선택 연산자의 우선순위가 접합 연산자보다 낮기 때문에 월과 일 각각의 선택 연산자의 우선순위를 높여주기 위해 월과 일을 각각 괄호로 감싸겠습니다.

/(0[13578]|1[02])-(0[1-9]|[12]\d|3[01])/

4,6,9,11월

4,6,9,11월은 01일부터 30일까지가 유효한 일입니다.

월은 /04|06|09|11/ 과 같이 나타낼 수 있고 4,6,9월의 앞 글자 0이 중복되므로 /0[469]|11/로 나타낼 수 있습니다.

일은 1,3,5,7,8,10,12월과 동일하나 31일만 제거하면 됩니다. /0[1-9]|[12]\d|30/으로 나타낼 수 있습니다.

따라서 월과 일을 조합하면 /(0[469]|11)-(0[1-9]|[12]\d|30)/ 이 됩니다.

2월

2월은 01부터 28일까지가 유효한 일입니다.

01일부터 28일을 0109일, 1019일, 2028일로 나눠서 생각해보겠습니다. 0109일은 /0[1-9]/로, 1019일은 /1\d/로, 2028일은 /2[0-8]/로 나타낼 수 있습니다. 이를 선택연산자로 합치면 /0[1-9]|1\d|2[0-8]/가 됩니다.

따라서 월과 일을 조합하면 /02-(0[1-9]|1\d|2[0-8])/ 이 됩니다.

유효한 월일 조합

유효한 월일을 선택 연산자를 이용하여 합치면 같습니다.

/(0[13578]|1[02])-(0[1-9]|[12]\d|3[01])|(0[469]|11)-(0[1-9]|[12]\d|30)|02-(0[1-9]|1\d|2[0-8])/

유효한 연도와 유효한 월일 합치기

유효한 연도와 유효한 월일을 합치면 다음과 같습니다.

/(19\d\d|20([01]\d|2[0-2]))-((0[13578]|1[02])-(0[1-9]|[12]\d|3[01])|(0[469]|11)-(0[1-9]|[12]\d|30)|02-(0[1-9]|1\d|2[0-8]))/

const re =
  /(19\d\d|20([01]\d|2[0-2]))-((0[13578]|1[02])-(0[1-9]|[12]\d|3[01])|(0[469]|11)-(0[1-9]|[12]\d|30)|02-(0[1-9]|1\d|2[0-8]))/;

function isValidBirthday(birthday) {
  return re.test(birthday);
}
검증 코드
// 연도 검증
console.assert(isValidBirthday("2999-08-28") === false);
console.assert(isValidBirthday("1899-08-28") === false);
console.assert(isValidBirthday("1999-08-28") === true);

// 월 검증
console.assert(isValidBirthday("1999-00-28") === false);
console.assert(isValidBirthday("1999-13-28") === false);

// 일 검증
console.assert(isValidBirthday("1999-08-32") === false);
console.assert(isValidBirthday("1999-08-00") === false);

// 1,3,5,7,8,10,12월 검증
console.assert(isValidBirthday("1999-01-31") === true);
console.assert(isValidBirthday("1999-03-31") === true);
console.assert(isValidBirthday("1999-05-31") === true);
console.assert(isValidBirthday("1999-07-31") === true);
console.assert(isValidBirthday("1999-08-31") === true);
console.assert(isValidBirthday("1999-10-31") === true);
console.assert(isValidBirthday("1999-12-31") === true);

// 4,6,9,11월 검증
console.assert(isValidBirthday("1999-04-31") === false);
console.assert(isValidBirthday("1999-06-31") === false);
console.assert(isValidBirthday("1999-09-31") === false);
console.assert(isValidBirthday("1999-11-31") === false);

// 2월 검증
console.assert(isValidBirthday("1999-02-28") === true);
console.assert(isValidBirthday("1999-02-29") === false);
console.assert(isValidBirthday("1999-02-30") === false);
console.assert(isValidBirthday("1999-02-31") === false);

// 기타
console.assert(isValidBirthday("1999-35-31") === false);
console.assert(isValidBirthday("1999-03-02") === true);
console.assert(isValidBirthday("1999-08-28") === true);
console.assert(isValidBirthday("1999-08-32") === false);
console.assert(isValidBirthday("1999-11-30") === true);
console.assert(isValidBirthday("1999-11-31") === false);
console.assert(isValidBirthday("1999-02-28") === true);
console.assert(isValidBirthday("1939-04-28") === true);
console.assert(isValidBirthday("1999-02-29") === false);
console.assert(isValidBirthday("1999-00-31") === false);
console.assert(isValidBirthday("2023-00-31") === false);
console.assert(isValidBirthday("2023-01-31") === false);
console.assert(isValidBirthday("2022-01-31") === true);
console.assert(isValidBirthday("1822-01-31") === false);
console.assert(isValidBirthday("1922-01-31") === true);

하지만 위의 식에는 치명적인 문제점이 있습니다.

console.assert(isValidBirthday("1999-08-281") === true);
console.assert(isValidBirthday("11999-08-28") === true);

이게 어찌된 영문일까요? 정규표현식은 문자열의 패턴을 표현하는 방법입니다.

1999-08-281 이라는 주어진 문자열의 부분 문자열 1999-08-28 은 정규표현식이 나타내는 문자열의 패턴과 일치합니다. 따라서 1999-08-281는 해당 정규표현식에 일치합니다.

우리는 주어진 문자열의 부분 문자열이 아닌 전체 문자열과 일치하는지 확인하고 싶습니다. 그럴 때에 ^, $ 를 이용해서 해결할 수 있습니다.

^는 문자열의 처음 위치를 나타내며 $은 문자열의 마지막 위치를 나타냅니다. ^$이 주어진 문자열의 첫번째 문자, 마지막 문자가 아닌 위치임을 주의깊게 봐주세요. 위치 개념은 다음 편의 탐색에서 자세히 다룰 개념입니다. 위치는 문자를 소비하지 않는다는 특징이 있습니다.

정규표현식의 처음과 끝에 ^$를 추가해보겠습니다.

/^(19\d\d|20([01]\d|2[0-2]))-((0[13578]|1[02])-(0[1-9]|[12]\d|3[01])|(0[469]|11)-(0[1-9]|[12]\d|30)|02-(0[1-9]|1\d|2[0-8]))$/

const re =
  /^(19\d\d|20([01]\d|2[0-2]))-((0[13578]|1[02])-(0[1-9]|[12]\d|3[01])|(0[469]|11)-(0[1-9]|[12]\d|30)|02-(0[1-9]|1\d|2[0-8]))$/;

function isValidBirthday(birthday) {
  return re.test(birthday);
}

console.assert(isValidBirthday("1999-08-28") === true);
console.assert(isValidBirthday("1999-08-281") === false);
console.assert(isValidBirthday("11999-08-28") === false);

V. 마무리

위의 예시에서는 정규표현식만으로 주어진 요구사항을 구현하려고 하였기에 정규표현식이 꽤 복잡해졌습니다. 유지보수성, 가독성 등을 고려하면 정규표현식만을 사용하지 않고, String.prototype 메서드와 조건문, 정규표현식 기본 연산자를 조합해서 쓰는 것이 더 좋은 방법인 경우가 많습니다. String.prototype 메서드와 조건문은 이미 익숙하시기에, 정규표현식 기본 연산자를 잘 아는 것만으로도 웬만한 건 다 하실 수 있으리라 생각합니다.

다음 편에서는 탐색, 캡쳐와 같은 기능을 알아보고 정규표현식에 일치하는 부분 문자열을 특정하고 치환하는 방법에 대해서 알아보겠습니다.