왜 이 글을 쓰나
정규식은 쓸 때마다 구글링한다. 이메일 검증 패턴, URL 추출, 전화번호 포맷 — 머릿속에 완전히 외워지지 않는다. 매번 Stack Overflow나 Regex101에서 찾는데, 검색 결과가 너무 다양해서 어떤 게 맞는 건지 확인하는 데 시간이 또 걸린다.
자주 쓰는 것만 한 곳에 정리했다. JavaScript 기준이지만, 패턴 자체는 Python, PHP, Java 등 대부분의 언어에서 그대로 쓰거나 조금만 고치면 된다.
정규식 기본 문법 요약
코드를 읽기 전에, 자주 등장하는 메타문자를 빠르게 훑고 간다.
| 문자 | 의미 | 예시 |
|---|---|---|
. |
임의의 한 문자 (줄바꿈 제외) | a.c → abc, aXc |
* |
0회 이상 반복 | ab* → a, ab, abb |
+ |
1회 이상 반복 | ab+ → ab, abb (a 단독 불가) |
? |
0회 또는 1회 | colou?r → color, colour |
{n,m} |
n~m회 반복 | \d{2,4} → 2~4자리 숫자 |
^ |
문자열 시작 | ^Hello |
$ |
문자열 끝 | world$ |
[] |
문자 클래스 | [aeiou], [0-9] |
[^] |
부정 문자 클래스 | [^0-9] → 숫자가 아닌 것 |
\d |
숫자 ([0-9]) |
|
\w |
단어 문자 ([a-zA-Z0-9_]) |
|
\s |
공백 문자 (스페이스, 탭, 줄바꿈) | |
\b |
단어 경계 | \bcat\b |
(...) |
그룹 | (ab)+ |
(?:...) |
캡처 없는 그룹 | (?:ab)+ |
a|b |
a 또는 b | cat|dog |
JavaScript에서 정규식은 /패턴/플래그 리터럴이나 new RegExp('패턴', '플래그')로 쓴다. 주요 플래그:
| 플래그 | 의미 |
|---|---|
g |
전역 검색 (모든 매치) |
i |
대소문자 무시 |
m |
멀티라인 (^, $가 각 줄에 적용) |
s |
.이 줄바꿈도 포함 |
자주 쓰는 패턴 카테고리별 정리
이메일
가장 자주 쓰는 것 중 하나다. RFC 5322 완전 준수 패턴은 너무 복잡해서 실용적이지 않다. 실무에서 쓰기에 충분한 수준으로 정리했다.
// 기본 이메일 검증
const emailRegex = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/;
emailRegex.test('user@example.com'); // true
emailRegex.test('user.name+tag@sub.co.kr'); // true
emailRegex.test('invalid@'); // false
emailRegex.test('@nodomain.com'); // false
{2,}로 TLD를 최소 2글자로 제한했다. .a같은 건 막힌다. .museum, .photography 같은 긴 TLD도 통과한다.
URL
// HTTP/HTTPS URL
const urlRegex = /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&\/=]*)$/;
urlRegex.test('https://example.com'); // true
urlRegex.test('http://sub.domain.co.kr/path?q=1&p=2'); // true
urlRegex.test('ftp://not-http.com'); // false
// URL에서 도메인만 추출
const extractDomain = (url) => {
const match = url.match(/^https?:\/\/(?:www\.)?([^\/\?#]+)/i);
return match ? match[1] : null;
};
extractDomain('https://www.example.com/path?q=1'); // 'example.com'
전화번호
한국 번호 형식이 다양해서 여러 케이스를 다 커버해야 한다.
// 한국 전화번호 (010, 011, 016, 017, 018, 019)
const mobileRegex = /^01[016789]-?\d{3,4}-?\d{4}$/;
mobileRegex.test('010-1234-5678'); // true
mobileRegex.test('01012345678'); // true
mobileRegex.test('0101234567'); // false (9자리)
// 유선 전화 포함
const phoneRegex = /^(0[2-9]\d?)-?\d{3,4}-?\d{4}$/;
phoneRegex.test('02-123-4567'); // true
phoneRegex.test('031-1234-5678'); // true
phoneRegex.test('010-1234-5678'); // false (유선 전용)
// 전화번호 포맷 통일 (하이픈 추가)
const formatPhone = (num) => {
const digits = num.replace(/\D/g, '');
if (digits.startsWith('02')) {
return digits.replace(/^(02)(\d{3,4})(\d{4})$/, '$1-$2-$3');
}
return digits.replace(/^(\d{3})(\d{3,4})(\d{4})$/, '$1-$2-$3');
};
formatPhone('01012345678'); // '010-1234-5678'
formatPhone('0212345678'); // '02-1234-5678'
날짜
날짜 포맷은 서비스마다 달라서 여러 형식을 다뤄야 할 때가 많다.
// YYYY-MM-DD
const dateISO = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
dateISO.test('2026-03-17'); // true
dateISO.test('2026-13-01'); // false (월 범위 초과)
dateISO.test('2026-03-32'); // false (일 범위 초과)
// YYYY.MM.DD 또는 YYYY/MM/DD
const dateFlexible = /^\d{4}[.\/-](0[1-9]|1[0-2])[.\/-](0[1-9]|[12]\d|3[01])$/;
dateFlexible.test('2026.03.17'); // true
dateFlexible.test('2026/03/17'); // true
// 날짜에서 연/월/일 추출
const parseDate = (str) => {
const match = str.match(/^(\d{4})[.\/-](0[1-9]|1[0-2])[.\/-](0[1-9]|[12]\d|3[01])$/);
if (!match) return null;
return { year: match[1], month: match[2], day: match[3] };
};
주의: 정규식으로 날짜 포맷은 검증할 수 있지만, 2026-02-30 같은 논리적으로 존재하지 않는 날짜는 통과한다. 최종 검증은 new Date()로 한 번 더 확인하는 게 안전하다.
비밀번호
보안 정책마다 요건이 다르다. 자주 쓰는 조건들을 레벨별로 정리했다.
// 최소 8자, 영문 + 숫자 조합
const pwBasic = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/;
// 최소 8자, 영문 대소문자 + 숫자 + 특수문자
const pwStrong = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]).{8,}$/;
// 최소 10자, 3가지 이상 조합 (lookahead로 각 조건 체크)
const pwCustom = /^(?=(.*[a-z]){1,})(?=(.*[A-Z]){1,})(?=(.*\d){1,})(?=(.*[!@#$%]){1,}).{10,}$/;
pwBasic.test('mypassword1'); // true
pwBasic.test('password'); // false (숫자 없음)
pwStrong.test('MyPass1!'); // true
pwStrong.test('mypass1!'); // false (대문자 없음)
// 비밀번호 강도 체크 함수
const checkPasswordStrength = (pw) => {
const checks = {
length: pw.length >= 8,
lowercase: /[a-z]/.test(pw),
uppercase: /[A-Z]/.test(pw),
number: /\d/.test(pw),
special: /[!@#$%^&*]/.test(pw),
};
const score = Object.values(checks).filter(Boolean).length;
return { checks, score, level: score <= 2 ? 'weak' : score === 3 ? 'medium' : 'strong' };
};
(?=...) 는 긍정 전방 탐색(positive lookahead)이다. 실제로 소비하지 않고 해당 패턴이 존재하는지만 확인한다. 여러 조건을 AND로 묶을 때 이 방식을 쓴다.
카드번호 / 주민번호
// 신용카드 번호 (Visa, MasterCard, Amex 등 공통)
const cardRegex = /^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12})$/;
// 카드번호 포맷 (4자리씩 하이픈)
const formatCard = (num) => num.replace(/\D/g, '').replace(/(.{4})/g, '$1-').replace(/-$/, '');
formatCard('4111111111111111'); // '4111-1111-1111-1111'
// 주민등록번호 형식 검증 (포맷만, 실제 유효성은 별도 로직 필요)
const ssn = /^\d{6}-[1-4]\d{6}$/;
ssn.test('900101-1234567'); // true
ssn.test('900101-5234567'); // false (두 번째 부분 첫 자리는 1~4)
주민번호는 포맷 검증만으로는 부족하다. 실제 유효성(체크섬 계산)은 별도 로직이 필요하다.
IP 주소
// IPv4
const ipv4 = /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
ipv4.test('192.168.1.1'); // true
ipv4.test('255.255.255.0'); // true
ipv4.test('256.0.0.1'); // false
ipv4.test('192.168.1'); // false
// IPv6 (간략 버전)
const ipv6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
25[0-5]는 250
255, 2[0-4]\d는 200
249, [01]?\d\d?는 0
199를 커버한다. 이 세 그룹으로 0
255를 완전히 커버한다.
한글 / 영문 / 숫자 체크
// 한글만
const korean = /^[가-힣]+$/;
korean.test('안녕하세요'); // true
korean.test('hello'); // false
// 영문만
const english = /^[a-zA-Z]+$/;
// 한글 + 공백 (이름 등)
const koreanName = /^[가-힣\s]{2,10}$/;
// 영문 + 숫자 (아이디)
const userId = /^[a-zA-Z0-9]{4,20}$/;
// 특수문자 포함 여부 확인
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{}|;':",.<>?\/\\`~]/;
hasSpecial.test('hello!'); // true
hasSpecial.test('hello'); // false
// 공백 제거
const trimAll = (str) => str.replace(/\s+/g, '');
const trimEdges = (str) => str.replace(/^\s+|\s+$/g, ''); // trim()과 동일
숫자 포맷
// 정수만 (부호 포함)
const intRegex = /^[+-]?\d+$/;
// 소수점 포함
const floatRegex = /^[+-]?\d+(\.\d+)?$/;
// 천 단위 콤마 추가
const addComma = (num) => String(num).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
addComma(1234567); // '1,234,567'
addComma(1234.56); // '1,234.56'
// 콤마 제거 후 숫자 변환
const removeComma = (str) => Number(str.replace(/,/g, ''));
\B(?=(\d{3})+(?!\d)) 이 패턴이 핵심이다. \B는 단어 경계가 아닌 위치, (?=(\d{3})+(?!\d))는 뒤에 3의 배수 개 숫자가 있는 위치를 탐색한다. 소수점 뒤에는 적용되지 않는다.
HTML 태그 / 코드 처리
// HTML 태그 제거
const stripTags = (html) => html.replace(/<[^>]*>/g, '');
stripTags('<p>Hello <strong>World</strong></p>'); // 'Hello World'
// 특정 태그만 추출 (예: src 속성)
const extractSrc = (html) => {
const matches = html.matchAll(/src="([^"]+)"/g);
return [...matches].map(m => m[1]);
};
// 스크립트 태그 제거 (XSS 방어 1차)
const removeScript = (html) => html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
// HTML 엔티티 이스케이프
const escapeHtml = (str) => str.replace(/[&<>"']/g, (m) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[m]));
HTML을 정규식으로 파싱하는 건 일반적으로 권장하지 않는다. 완전한 파싱이 필요하면 DOMParser나 전용 라이브러리를 써야 한다. 위 코드는 간단한 처리 용도다.
파일 경로 / 확장자
// 파일 확장자 추출
const getExt = (filename) => {
const match = filename.match(/\.([^.]+)$/);
return match ? match[1].toLowerCase() : '';
};
getExt('photo.JPG'); // 'jpg'
getExt('archive.tar.gz'); // 'gz'
getExt('noextension'); // ''
// 특정 확장자만 허용 (이미지)
const isImage = /\.(jpg|jpeg|png|gif|webp|svg)$/i;
isImage.test('photo.jpg'); // true
isImage.test('file.pdf'); // false
// Windows/Unix 경로에서 파일명만 추출
const getFilename = (path) => path.match(/[^/\\]+$/)[0];
getFilename('/usr/local/bin/node'); // 'node'
getFilename('C:\\Users\\Luka\\file.txt'); // 'file.txt'
패턴 요약 테이블
| 카테고리 | 패턴 | 플래그 | 주요 특징 |
|---|---|---|---|
| 이메일 | /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/ |
TLD 2자 이상 | |
| URL | /^https?:\/\/(www\.)?...$/ |
http/https만 | |
| 전화번호(모바일) | /^01[016789]-?\d{3,4}-?\d{4}$/ |
하이픈 선택적 | |
| 날짜(ISO) | /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/ |
월/일 범위 체크 | |
| 비밀번호(강) | /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%]).{8,}$/ |
lookahead 조합 | |
| IPv4 | /^(?:25[0-5]|2[0-4]\d|[01]?\d\d?\.){3}...$/ |
0~255 정확히 | |
| 한글 | /^[가-힣]+$/ |
자모 단독 불가 | |
| 천 단위 콤마 | /\B(?=(\d{3})+(?!\d))/g |
g |
replace에 사용 |
| HTML 태그 제거 | /<[^>]*>/g |
g |
완전 파싱 불가 |
| 파일 확장자 | /\.([^.]+)$/ |
i |
마지막 . 이후 |
삽질 기록
1. test()에 g 플래그 쓰면 결과가 달라진다
이게 꽤 고약한 버그다.
const re = /\d+/g;
re.test('abc123'); // true
re.test('abc123'); // false ← ???
re.test('abc123'); // true
g 플래그가 붙은 정규식 객체는 lastIndex를 내부적으로 기억한다. 두 번째 test() 호출 시 이전 매치가 끝난 위치부터 탐색을 시작하기 때문에 이런 일이 생긴다.
해결 방법은 두 가지다:
// 방법 1: 매번 새 정규식 리터럴 사용
/\d+/.test('abc123'); // 매번 새로 생성
// 방법 2: lastIndex 리셋
const re = /\d+/g;
re.test('abc123');
re.lastIndex = 0;
re.test('abc123'); // 이제 true
test()에는 g 플래그가 필요 없는 경우가 대부분이다. 있으면 빼는 게 낫다.
2. match()와 matchAll()의 차이
const str = 'cat bat sat';
// g 플래그 없으면 첫 번째 매치만, 캡처 그룹 포함
str.match(/[a-z]at/); // ['cat', index: 0, ...]
// g 플래그 있으면 모든 매치, 캡처 그룹 없음
str.match(/[a-z]at/g); // ['cat', 'bat', 'sat']
// matchAll: 모든 매치 + 캡처 그룹도 포함 (g 필수)
[...str.matchAll(/([a-z])(at)/g)];
// [['cat', 'c', 'at', ...], ['bat', 'b', 'at', ...], ['sat', 's', 'at', ...]]
캡처 그룹과 전체 매치를 동시에 뽑아야 할 때는 matchAll()을 써야 한다.
3. 이메일 정규식의 함정
const emailRegex = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/;
// 이건 실제로 유효한 이메일인데 통과 못 함
emailRegex.test('user@xn--p1ai'); // 퓨니코드 도메인 - 통과는 함
emailRegex.test('"user name"@example.com'); // RFC 허용, 하지만 우리 패턴에서 false
// 이건 통과되면 안 되는데 통과됨
emailRegex.test('user@example.c'); // true (TLD 1자짜리라 안 되는데...)
// 위 패턴에서 {2,}이니까 실제론 false. 확인 필요
// 이건 실제로 통과되면 안 되는 케이스
emailRegex.test('user@@example.com'); // false (정상 동작)
emailRegex.test('.user@example.com'); // true ← 실제로 유효하지 않은 경우
완벽한 RFC 준수 이메일 정규식은 수백 글자가 넘는다. 실무에서는 "서버 전송 후 인증 메일로 최종 확인"이 진짜 검증이고, 정규식은 명백한 오타만 걸러내는 1차 필터로 쓰는 게 맞다.
4. .을 이스케이프 안 하는 실수
// 잘못된 패턴: .이 임의 문자로 해석됨
const wrong = /192.168.0.1/;
wrong.test('192X168Y0Z1'); // true (의도치 않게)
// 올바른 패턴
const correct = /192\.168\.0\.1/;
correct.test('192X168Y0Z1'); // false
도메인이나 IP를 패턴으로 쓸 때 .을 \.으로 이스케이프하지 않으면 엉뚱한 문자열도 매칭된다. 이건 진짜 자주 하는 실수다.
마무리
정규식은 완벽하게 외울 필요가 없다. 동작 방식을 이해하고, 자주 쓰는 패턴을 어디서 찾는지 알면 된다.
이 글의 패턴들은 실무에서 충분히 쓸 수 있는 수준이다. 더 복잡한 케이스가 생기면 Regex101에서 실시간으로 테스트하고 디버깅하는 게 가장 빠르다. 패턴 설명까지 자동으로 나온다.
한 가지 원칙만 기억한다. 정규식은 포맷 검증에 쓰고, 로직 검증은 코드로 한다. 날짜 유효성, 주민번호 체크섬, 카드번호 유효성 같은 건 정규식만으로 해결하려 하면 패턴이 지나치게 복잡해지거나 버그가 생긴다.
언어: JavaScript (패턴은 Python, PHP, Java 등에서도 대부분 호환)
테스트 도구: Regex101, RegExr
'개발 팁' 카테고리의 다른 글
| Linux 서버 초기 세팅 — 처음 서버 받으면 이것부터 (0) | 2026.03.18 |
|---|---|
| 백업 없이 MySQL/MariaDB 데이터 복구하기 (0) | 2026.03.18 |
| GitHub Actions 입문 — Push하면 자동으로 배포되는 CI/CD 세팅 (0) | 2026.03.18 |
| .env & 환경변수 관리 실전 — 팀 프로젝트부터 실수 대처법까지 (0) | 2026.03.17 |
| 터미널 생산성 세팅 — zsh + CLI 도구 실사용 정리 (0) | 2026.03.17 |