개발하다 보면 사용자가 입력한 데이터를 검증해야 할 순간이 자주 온다. 이메일 형식이 맞는지, 전화번호가 올바른지, 특정 문자열 패턴을 찾아야 하는데 문제는 대부분의 개발자들이 정규표현식(Regex)을 복사해 붙이기만 할 뿐, 정확히 어떤 원리로 동작하는지 이해하지 못한 채 사용한다는 것이다. 결국 약간 다른 형식의 데이터가 들어오면 예상 밖의 결과가 나오거나 유지보수가 불가능해진다. 이번에는 정규표현식이 정확히 뭔지, 왜 필요한지, 어떻게 작성하고 실무에 어떻게 적용하는지 완벽하게 정리해서 소개하겠다.
정규표현식은 특정 문자열 패턴을 찾거나 매칭시키기 위한 표기법이다. 텍스트 검증, 문자열 치환, 데이터 추출 등 여러 목적으로 사용된다. JavaScript에서는 /패턴/플래그 형태로 작성하거나 new RegExp('패턴', '플래그') 생성자로 만든다.
먼저 정규표현식의 기본 구성 요소를 표로 정리해보자.
| 요소 | 설명 | 예시 |
|---|---|---|
| 메타문자 | 특수한 의미를 가진 문자 (. * + ? ^ $ | ( ) [ ] { }) | . = 모든 문자 1개 |
| 문자 클래스 | 대괄호 안의 문자 중 하나 ([abc], [a-z], [0-9]) | [a-z] = 소문자 한 글자 |
| 수량자 | 앞 패턴의 반복 횟수 (*, +, ?, {n,m}) | a+ = a가 1번 이상 |
| 앵커 | 문자열의 시작과 끝 (^, $) | ^abc = abc로 시작 |
| 플래그 | 정규표현식 동작 방식 (g, i, m) | i = 대소문자 구분 안함 |
. (점) = 줄바꿈을 제외한 모든 문자 1개를 의미한다. 예를 들어 a.c는 "abc", "adc", "a1c" 모두 매칭된다.
* (별) = 앞 패턴이 0번 이상 반복된다는 뜻이다. 예를 들어 ab*c는 "ac", "abc", "abbc", "abbbc" 모두 매칭된다.
+ (더하기) = 앞 패턴이 1번 이상 반복된다는 뜻이다. 예를 들어 ab+c는 "abc", "abbc"는 매칭되지만 "ac"는 매칭되지 않는다.
? (물음표) = 앞 패턴이 0번 또는 1번 나타난다는 뜻이다. 예를 들어 colou?r는 "color"와 "colour" 모두 매칭된다.
^ (캐럿) = 문자열의 시작을 의미한다. 예를 들어 ^hello는 "hello world"는 매칭되지만 "say hello"는 매칭되지 않는다.
$ (달러) = 문자열의 끝을 의미한다. 예를 들어 world$는 "hello world"는 매칭되지만 "world peace"는 매칭되지 않는다.
JavaScript에서 정규표현식을 실제로 사용하려면 문자열 메서드와 RegExp 메서드를 알아야 한다.
| 메서드 | 반환값 | 설명 |
|---|---|---|
| test() | boolean | 패턴이 문자열에 있으면 true, 없으면 false |
| exec() | 배열 또는 null | 첫 번째 매칭된 부분과 캡처 그룹 반환 |
| match() | 배열 또는 null | 모든 매칭된 부분 배열로 반환 (g 플래그 필요) |
| search() | 숫자 | 매칭된 부분의 인덱스 반환 (없으면 -1) |
| replace() | 문자열 | 매칭된 부분을 다른 문자열로 치환 |
| split() | 배열 | 정규표현식 패턴으로 문자열 분할 |
✓ 올바른 방법
const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
const testEmails = [
'user@example.com', // true
'invalid.email@', // false
'user@domain', // false
'user name@example.com' // false
];
testEmails.forEach(email => {
console.log(email + ': ' + emailRegex.test(email));
});
패턴 설명: ^[^s@]+@[^s@]+.[^s@]+$
^= 문자열 시작[^s@]+= 공백과 @를 제외한 문자가 1개 이상@= @ 기호[^s@]+= 도메인명 (공백과 @ 제외).= 점 기호 (이스케이프 필요)[^s@]+= 최상위 도메인 (공백과 @ 제외)$= 문자열 끝
✗ 잘못된 방법 (하드코딩으로 불편함)
// 각 경우마다 별도로 처리해야 함 → 유지보수 어려움
function formatPhoneWrong(phone) {
if (phone.length === 10) {
return phone.substring(0, 3) + '-' + phone.substring(3, 6) + '-' + phone.substring(6);
} else if (phone.length === 11) {
return phone.substring(0, 3) + '-' + phone.substring(3, 7) + '-' + phone.substring(7);
}
return phone;
}
console.log(formatPhoneWrong('01012345678')); // 010-1234-5678
✓ 올바른 방법 (정규표현식으로 간단하고 확장 가능)
function formatPhone(phone) {
// 숫자만 추출
const cleaned = phone.replace(/D/g, '');
// 국내 휴대폰 형식: 010-1234-5678
const regex = /^(d{3})(d{4})(d{4})$/;
return cleaned.replace(regex, '$1-$2-$3');
}
console.log(formatPhone('01012345678')); // 010-1234-5678
console.log(formatPhone('0101234 5678')); // 010-1234-5678
console.log(formatPhone('010-1234-5678')); // 010-1234-5678
패턴 설명: ^(d{3})(d{4})(d{4})$
d= 숫자 한 글자{3}= 정확히 3개()= 캡처 그룹 (나중에 $1, $2, $3로 참조 가능)$1-$2-$3= 첫 번째 그룹 - 두 번째 그룹 - 세 번째 그룹
✗ 잘못된 방법 (모든 < > 기호를 제거하면 부등호 연산자까지 없어짐)
const htmlText = '가격: 10,000 < 15,000
';
const wrongResult = htmlText.replace(//g, '');
console.log(wrongResult); // p>가격: 10,000 15,000/p> 이상함
✓ 올바른 방법 (HTML 태그 패턴만 정확히 제거)
const htmlText = '가격: 10,000 < 15,000
할인';
const regex = /<[^>]*>/g; // < 와 > 사이의 모든 문자 제거
const result = htmlText.replace(regex, '');
console.log(result); // 가격: 10,000 < 15,000할인
패턴 설명: <[^>]*>
<= < 기호 (이스케이프)[^>]= > 기호를 제외한 모든 문자*= 0번 이상 반복>= > 기호 (이스케이프)g플래그 = 모든 태그 제거
✗ 잘못된 방법 (부분 매칭으로 의도하지 않은 부분까지 바뀜)
const text = 'The cat is catching a cat in the catalog.';
const wrong = text.replace(/cat/g, 'dog');
console.log(wrong);
// The dog is dogging a dog in the doglog. ← "catalog" 와 "catching"도 바뀜
✓ 올바른 방법 (단어 경계 b로 정확한 단어만 치환)
const text = 'The cat is catching a cat in the catalog.';
const correct = text.replace(/bcatb/g, 'dog');
console.log(correct);
// The dog is catching a dog in the catalog. ← "cat"이라는 단어만 바뀜
패턴 설명: b = 단어 경계 (영숫자와 비영숫자 사이의 경계)
✓ 최적화된 방법 (정규표현식과 객체 활용)
function getUrlParams(url) {
const params = {};
const regex = /([^?=&]+)=([^&]*)/g;
let match;
while ((match = regex.exec(url)) !== null) {
params[decodeURIComponent(match[1])] = decodeURIComponent(match[2]);
}
return params;
}
const url = 'https://example.com?name=John&age=30&city=Seoul';
const result = getUrlParams(url);
console.log(result);
// { name: 'John', age: '30', city: 'Seoul' }
패턴 설명: ([^?=&]+)=([^&]*)
([^?=&]+)= 캡처 그룹 1: ? = & 를 제외한 문자 (파라미터 이름)== 등호 기호([^&]*)= 캡처 그룹 2: & 를 제외한 문자 (파라미터 값)g플래그 = 모든 파라미터 추출exec()= 루프로 반복 실행하여 모든 매칭 처리
// 잘못된 코드 → SyntaxError
const wrong = /d+/; // ← 리터럴 문법에서도 백슬래시가 필요
// 올바른 코드
const correct1 = /d+/; // 리터럴
const correct2 = new RegExp('\\d+'); // 생성자 (백슬래시 4개 필요!)
// 생성자 사용 시 백슬래시를 두 배로 이스케이프해야 함
console.log(correct1.test('123')); // true
console.log(correct2.test('123')); // true
const text = 'apple apple apple';
// 잘못된 코드 → 첫 번째 매칭만 치환됨
const wrong = text.replace(/apple/, 'orange');
console.log(wrong); // orange apple apple
// 올바른 코드 → 모든 매칭 치환
const correct = text.replace(/apple/g, 'orange');
console.log(correct); // orange orange orange
// 잘못된 코드 → . 이 모든 문자를 의미해서 예상 밖 결과
const wrong = /3.14/; // "314", "3a14" 모두 매칭됨
// 올바른 코드 → 점을 이스케이프
const correct = /3.14/; // "3.14"만 매칭됨
console.log(wrong.test('314')); // true (예상 밖!)
console.log(correct.test('314')); // false (원래 의도)
console.log(correct.test('3.14')); // true
// g 플래그가 있는 정규표현식은 내부 lastIndex 상태를 유지함
const regex = /d/g;
console.log(regex.test('abc')); // false
console.log(regex.test('123')); // true (첫 번째 호출)
console.log(regex.test('123')); // true (두 번째 호출, lastIndex = 3)
console.log(regex.test('123')); // false (lastIndex 초기화되며 결과 불안정!)
// 해결책: 리터럴 정규표현식 사용하거나 exec() 사용
const regex2 = /d/g;
regex2.lastIndex = 0; // 수동으로 초기화
console.log(regex2.test('123')); // true
// 이런 패턴은 읽기 어려움
const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$/;
// 해결책: 주석을 달거나 변수명으로 의도를 명확히
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$/; // 이메일
// 또는 더 나은 방법: 함수로 감싸기
function isValidEmail(email) {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$/;
return emailRegex.test(email);
}
console.log(isValidEmail('user@example.com')); // true
// 앞뒤 공백만 제거
const trimBoth = ' hello world '.replace(/^s+|s+$/g, '');
console.log(trimBoth); // "hello world"
// 모든 공백 제거
const removeAll = 'h e l l o'.replace(/s/g, '');
console.log(removeAll); // "hello"
// 중간 연속 공백을 하나로
const normalize = 'hello world'.replace(/s+/g, ' ');
console.log(normalize); // "hello world"
function formatNumber(num) {
return num.toString().replace(/B(?=(d{3})+(?!d))/g, ',');
}
console.log(formatNumber(1000)); // "1,000"
console.log(formatNumber(1000000)); // "1,000,000"
console.log(formatNumber(123)); // "123"
function isStrongPassword(password) {
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*d)(?=.*[@$!%*?&])[A-Za-zd@$!%*?&]{8,}$/;
return regex.test(password);
}
console.log(isStrongPassword('weak')); // false
console.log(isStrongPassword('Weak1234')); // false (특수문자 없음)
console.log(isStrongPassword('Strong@1234')); // true
정규표현식은 초반에 배우기 어렵지만, 한 번 이해하면 문자열 처리에서 강력한 무기가 된다. 이메일 검증, 데이터 추출, 문자열 치환 같은 작은 최적화들이 모여서 코드의 가독성과 유지보수성을 크게 향상시킨다는 점을 잊지 말자. 이 글의 기본 패턴과 메타문자 설명을 참고해 간단한 검증부터 시작하고, 점차 복잡한 패턴으로 확장해나가면, 정규표현식을 진정으로 마스터할 수 있을 것이다. 정규표현식 학습 사이트(regexr.com, regex101.com)를 활용하면 패턴을 실시간으로 테스트하며 학습할 수 있으니 적극 추천한다.