
프론트엔드 개발을 하다보면 정규표현식이 없이도 form validation을 구현할 수 있습니다. 하지만 세세하게 조건을 걸려면 코드량이 많아지고, 어떤 경우에는 동일한 validation 로직을 반복하게 되는 경우도 자주 접할 수 있습니다. 따라서 정규표현식을 사용하면 한번에 여러 validation 조건을 구현할 수 있어서 좋은 선택지가 되는 경우가 있습니다.
그런데 정규표현식이 무조건 좋나? 라고 생각해보면 그건 아닙니다. 딱 보고 바로 직관적으로 어떤 의미를 담는지 알기는 어렵죠. 저도 실무에서 잘못된 정규표현식 때문에 validation 로직이 어색하게 동작했던 적이 있는데, 찾는 데 시간에 꽤나 걸렸던 걸로 기억해요.
저는 오히려 가독성 측면에서는 zod나 ajv, yup 등의 라이브러리를 이용한 validation이 훨씬 좋지 않을까 생각을 합니다. 그럼에도 정규식을 사용하는 것이 효율적인 상황이 온다면 저는 정규식을 변수나 함수로 다루면서 네이밍을 신중하게 고민할 것 같네요.
그럼 시작해보겠습니다.
정규표현식(정규식)은 특정한 규칙을 가진 문자열의 집합을 표현하는 데 사용하는 형식 언어이다. 정규표현식을 이용하면 문자열 검색, 교체가 가능하기 때문에 웹사이트의 사용자 입력을 검사하거나, 이메일 주소, URL, 전화번호 등의 문자열 찾는 것에 매우 유용하다.
정규식을 만드는 방법은 리터럴 문법을 이용하는 방법과 RegExp 생성자로 만드는 방법이 있다. 리터럴 문법을 이용하여 정규식을 만드는 것이 더 간편하며, 동적 정규표현식을 만들 때에는 RegExp 생성자를 이용한다.
const regByLiteral = /string/; //리터럴 방식
const regByConstructor = new RegExp("string"); //생성자 방식
정규식을 검색하는 방법은 String.prototype.match와 RegExp.prototype.test를 이용하는 방법이 있고, 교체하는 방법으로는 String.prototype.replace가 있다. replaceAll이 나오기 전에는 replace의 첫번째 인자로 정규식을 넣어 사용하곤 했다. 아래는 주어진 문자열 str에서 대소문자 상관없이 다섯 글자 이상의 문자열을 찾는 정규식이다.
const str = "Hello, my name is Stefan";
//match 메소드를 이용한 방법
str.match(/\w{5,}/gi);
// ["Hello", "Stefan"] 배열 형식으로 출력된다.
str.match(/\w{10,}/gi);
// 정규식이 찾는 문자열에 해당하지 않는다면, null이 반환된다.
// test 메소드를 이용한 방법
/\w{5,}/gi.test(str);
//true
/\w{11,}/gi.test(str);
// false (11글자 이상의 문자가 없다. 따라서 false 반환)
// replace 메소드를 이용하여 교체하기
str.replace(/\w{5,}/gi, "****");
// "**** my name is ****" 문자열 형식으로 해당 문자열을 반환한다.
str.replace(/\w{10,}/gi, "****");
// 정규식에 해당하는 문자열이 없다면, 기존의 문자열을 그대로 반환한다.
// "Hello, my name is Stefan"
정규식은 무조건 문자열의 왼쪽에서 오른쪽으로 검색하면서 찾는 값이 존재할 가능성을 판단한다. 가능성이 없는 경우에는 바로 해당 문자를 소비하고, 가능성이 있으면 소비하지 않고, 계속 진행한다. 그러다 일치하는 문자열을 찾으면 찾은 것을 모두 소비한다.
const target = ["KOR", "KOREA", "KOREAN", "KETCHUP", "CHIPS"];
const str = "KOREANKETCHUPPCHIPS";
문자열 str에서 target에 담겨있는 값을 찾는 예제이다. 정규식은 무조건 문자열의 왼쪽에서 오른쪽으로 본다.
K O R E A N K E T C H U P P C H I P S
👆K로 시작하는 문자열이 target에 존재하므로 소비하지 않는다.
K O R E A N K E T C H U P P C H I P S
👆 "KO" 가능한 문자열이 있다. -> 소비하지 않는다.
K O R E A N K E T C H U P P C H I P S
👆 "KOR" 문자열을 찾았다. "KOR"을 한번에 소비한다.
K O R E A N K E T C H U P P C H I P S
👆 E로 시작하는 문자열이 없다. 소비한다.
K O R E A N K E T C H U P P C H I P S
👆 A로 시작하는 문자열이 없다. 소비한다.
K O R E A N K E T C H U P P C H I P S
👆 N로 시작하는 문자열이 없다. 소비한다.
이러한 방식으로 문자열을 검색한다. 따라서 "KOR", "KETCHUP", "CHIPS"를 찾을 수 있다. "KOREA", "KOREAN"은 "KOR"을 찾자마자 모두 소비하기 때문에 "EA", "EAN"으로만 인식한다. 따라서 찾지 못한다.

정규식은 /로 시작해서 /로 끝난다. 정규식 뒤에 오는 문자를 flag라고 하는데, 자주 쓰이는 플래그에는 i와 g, m이 있다. 각 플래그의 의미는 아래와 같다.
i: 대소문자를 가리지 않음g: 전체 범위 검색 ( g플래그가 없으면 일치하는 것중 첫 번째만 반환 ) m: 줄바꿈이 있어도 계속 탐색정규식에서 |는 or(또는)을 뜻하고, [ ]는 범위 설정할 때 사용한다.
const str = "Hello79 ";
const findNum = str.match(/0|1|2|3|4|5|6|7|8|9|/g); // 비효율적이다.
const findNUm = str.match(/[0123456789]/g); //또는
const findNum = str.match(/[0-9]/g); //이와 같이 쓸 수 있다.
const findAll = str.match(/[\-0-9a-z.]/gi); //문자와 숫자를 모두 찾는 정규식
const findAll = str.match(/[.a-z0-9\-]/gi); //와 같이 쓸 수 있다.
/* 하이픈(-)은 escape해야한다. escape안하는 경우에는 범위를 표시하는 메타문자로 간주하기 때문이다.
* escape는 해당 문자 앞에 역슬래쉬(\)를 하면 된다.
-------------------------------------------------------------------------*/
//특정 문자 또는 범위를 제외하고 찾을때 ^를 이용한다.
//문자와 숫자를 제외하고 마지막 인덱스에 위치한 공백을 찾는다.
const findExc = str.match(/[^\-0-9a-z.]/);
자주 쓰는 문자셋은 줄여서 쓸 수 있다. 다음에서 살펴보자.
| 문자셋 | 같은 표현 | 설명 |
|---|---|---|
| \d | [0-9] | 모든 숫자를 검색한다. |
| \D | [^0-9] | 모든 숫자를 제외하고 검색한다. |
| \s | [ \t\v\n\r] | 스페이스, 탭, 세로 탭, 줄바꿈 검색한다. |
| \S | [^ \t\v\n\r] | 스페이스, 탭, 세로 탭, 줄바꿈 제외하고 검색한다. |
| \w | [a-zA-Z_] | 하이픈과 마침표를 제외하고 알파벳 대소문자를 검색한다. |
| \W | [^a-zA-Z_] | \w와 반대. |
얼마나 많이 일치해야 하는지 지정할 때 쓴다.
const findMany = str.match(/[0-9][0-9][0-9]|[0-9][0-9]|[0-9]/);
한 자리, 두 자리, 세 자리 숫자가 연속되는 것을 찾는다. 정규식의 특성상 조건에 해당하는 문자열을 찾았을 때, 바로 소비하기 때문에 세 자리, 두 자리, 한 자리를 찾는 정규식 순서대로 썼다.
const findMany = str.match(/[0-9]+/);
위의 정규식은 네 자리가 연속되는 숫자는 찾지 못하기 때문에 '+'를 써서 숫자가 연속하는 경우를 찾아낸다.
이 밖의 경우에도 반복되는 내용을 찾을 때 중괄호{}를 활용한다. {n}은 정확히 n개의 문자가 연속하는 경우를 찾아낸다. 위에서 언급한 \d를 활용하여 \d{5}와 같이 쓰면 다섯 자리의 숫자를 찾아낼 수 있다. 대표적인 예로 우편번호가 있다. 우편번호는 항상 다섯 자리로 이루어져 있기 때문에 정규식 /\d{5}/로 쉽게 찾아낼 수 있다.
n개 이상의 문자를 찾아내는 정규식은 {n,}이다. n뒤에 ,를 붙이면 n개 이상의 문자를 찾아낼 수 있다.
// 세 자리 이상 연속하는 문자 찾기
const findChar = str.match(/\w{3,}/gi);
대소문자 구분없이 전체 범위에서 세 자리 이상 연속하는 문자를 찾는다.
위에서는 정확히 n개로 이루어진 문자와 n개 이상으로 이루어진 문자를 찾는 방법을 살펴보았다. 찾는 글자 수의 범위를 지정하여 찾을 수도 있다. n개 이상, m개 이하의 문자 또는 숫자를 찾을 때에는 {n, m}와 같이 표기한다. 아래에는 3자리 이상, 5자리 이하의 갯수로 연속하는 숫자를 찾는 정규식을 표현해보았다.
const findNum = str.match(/\d{3,5}/);
지금까지 살펴본 반복 메타 문자를 정리해서 살펴보자.
| 반복 메타 문자 | 의미 | 예시 | 설명 |
|---|---|---|---|
| {n} | n개 만큼 반복되는 문자/숫자 찾기 | /\d{5}/ | 다섯 자리 숫자를 검색한다. |
| {n,} | n개 이상 반복되는 문자/숫자 찾기 | /\w{6,}/ | 여섯 자리 이상의 알파벳을 검색한다. |
| {n,m} | n개 이상, m개 이하 | /\d{3,5}/ | 세 자리 이상 다섯 자리 이하의 숫자를 검색한다. |
| ? | (? 앞의 문자/숫자의) 존재여부 | /[a-z]\d?/ | 알파벳 소문자가 있고 그 뒤의 숫자의 존재를 검색한다. |
| * | (* 앞의 문자/숫자의) 반복여부 | /[a-z]\d*/ | 알파벳 소문자가 있고 그 뒤의 숫자의 반복을 검색한다. |
| + | (+ 앞의 문자/숫자의) 반복을 표현 | /[a-z]\d+/ | 알파벳 소문자가 있고 그 뒤의 숫자가 반복되는 경우를 검색한다. |
.는 줄바꿈 문자를 제외한 모든 문자에 일치하는 메타 문자이다. 입력 소비를 할 때 주로 사용한다. \는 메타 문자로 지정된 문자를 문자 그대로의 값으로 변환할 때 쓰인다.const gugudan = "(9 * 9.0) = 81";
const find = gugudan.match(/\(\d \* \d\.\d\) = \d+/); //출력 "(9 * 9.0) = 81"
*와 .는 정규식에서 메타 문자로 사용되고, (와 )는 정규식에서 그룹 등을 나타낼 때 쓰이기 때문에 escape하여 그대로의 문자로 사용할 수 있다.
정규식은 기본적으로 적극적 일치 기반의 검색을 한다.
const tt =
"Welcome to JavaScript World!\n" + "<div>const</div> and <div>let</div>";
tt.replace(/<div>(.*)<\/div>/gi, "<span>$1</span>");

다음과 같이 <span>const</div> and <div>let</span>으로 나온다. 기본적인 정규식은 적극적 일치 기반의 검색을 한다고 앞서 언급했다. 정규식은 <div>를 만나면 </div>를 못 찾을 때까지 확장하여 검색을 진행한다. 기존의 문자열에는 </div>가 두 개 있다. 따라서 정규식은 뒤의 </div>를 검색할 때 일치한다고 판단하고, 문자열을 교체한다.
그렇다면 어떻게 이 문제를 해결할 수 있을까?
tt.replace(/<div>(.*?)<\/div>/gi, "<span>$1</span>");

정규식 내부의 그룹에 ?를 하나 추가했다. *연산자 뒤에 ?를 쓰면 소극적 일치를 의미한다. 즉 <div>태그 뒤로 오는 문자열을 $1에 담고 </div>를 만나자마자 바로 일치하는 것을 찾았다고 판단한다. 또한 적극적 일치와 달리 검색 범위를 확장하려고 하지 않는다.
사용자의 이름이 배열에 저장되어 있을 때, 문자열에서 배열에 일치하는 이름을 찾는 상황에서 이용할 수 있다.
const labourers = ["stefan", "james", "cornor", "dew", "ha"];
const diary =
"Labourer @stefan got up at 6am, " + "@ha and @dew were preparing to work";
const labRegExp = new RegExp(`@(?:${labourers.join("|")})\\b`, "g"); //단어 경계 메타 문자 b앞 \\두개 표시. 정규식 리터럴 b로 인식하는 것을 방지하기 위함.
diary.match(labRegExp);
//결과 ["@stefan", "@ha", "@dew"]
정규식 리터럴로는 사용자의 이름을 알아낼 수 없기 때문에 정규식 생성자를 이용하여 동적으로 정규식을 작성하였다.
지금까지 정규표현식을 공부한 내용을 정리해보았다. 정규식은 잘 쓰기만 하면 강력한 기능이지만, 동시에 가독성을 저하시키기도 한다. 따라서 정규표현식을 사용할 땐 신경써서 naming한 변수나 함수로 사용하는 것이 좋다고 생각한다.