Skip to main content
Advertisement

정규표현식 심화

정규표현식(Regular Expression)은 문자열 패턴을 기술하는 강력한 도구입니다. ES2018 이후 Named Groups, Lookbehind 등 강력한 기능이 추가되었습니다.


정규표현식 기초 복습

// 생성 방법
const re1 = /pattern/flags;
const re2 = new RegExp('pattern', 'flags'); // 동적 패턴에 유용

// 주요 메서드
const str = 'Hello, World! Hello, JavaScript!';

// test: boolean 반환
/Hello/.test(str); // true

// match: 매칭 결과 배열 (g 플래그 없으면 첫 번째만)
str.match(/Hello/); // ['Hello', index: 0, ...]
str.match(/Hello/g); // ['Hello', 'Hello']

// matchAll: 모든 매칭 이터레이터 (g 플래그 필수)
[...str.matchAll(/Hello/g)];

// search: 첫 번째 매칭 인덱스
str.search(/World/); // 7

// replace/replaceAll
str.replace(/Hello/g, 'Hi'); // 모두 교체
str.replaceAll('Hello', 'Hi'); // replaceAll (ES2021)

// split
'a,b,,c'.split(/,+/); // ['a', 'b', 'c']

기본 패턴

// 문자 클래스
/[abc]/ // a, b, c 중 하나
/[^abc]/ // a, b, c 제외
/[a-z]/ // 소문자
/[A-Z]/ // 대문자
/[0-9]/ // 숫자 = \d
/[a-zA-Z0-9_]/ // 단어 문자 = \w

// 메타 문자
/./ // 줄바꿈 제외 모든 문자
/\d/ // [0-9]
/\D/ // [^0-9]
/\w/ // [a-zA-Z0-9_]
/\W/ // [^\w]
/\s/ // 공백 문자 (스페이스, 탭, 줄바꿈)
/\S/ // 비공백 문자

// 수량자
/a?/ // 0 또는 1
/a*/ // 0개 이상
/a+/ // 1개 이상
/a{3}/ // 정확히 3개
/a{2,4}/ // 2~4개
/a{2,}/ // 2개 이상

// 앵커
/^hello/ // 시작
/world$/ // 끝
/\bhello\b/ // 단어 경계

// 그룹
/(abc)/ // 캡처 그룹
/(?:abc)/ // 비캡처 그룹

Named Capture Groups (ES2018)

// 기존: 번호 기반 캡처 그룹
const dateStr = '2024-03-15';
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})/);
// match[1] = '2024', match[2] = '03', match[3] = '15'
// 순서 의존적이라 유지보수가 어려움

// Named Capture Groups: (?<name>pattern)
const namedMatch = dateStr.match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
const { year, month, day } = namedMatch.groups;
// year = '2024', month = '03', day = '15'

// 직관적이고 자기 문서화됨
const TIME_REGEX = /(?<hour>\d{2}):(?<minute>\d{2})(?::(?<second>\d{2}))?/;
const timeMatch = '14:30:45'.match(TIME_REGEX);
const { hour, minute, second = '00' } = timeMatch.groups;

// replace에서 Named Groups 참조
const formatted = '2024-03-15'.replace(
/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/,
'$<month>/$<day>/$<year>' // MM/DD/YYYY 형식으로
);
// '03/15/2024'

// 함수로 교체
const kebabToCamel = str => str.replace(
/-(?<char>[a-z])/g,
(_, char) => char.toUpperCase()
);
kebabToCamel('hello-world-foo'); // 'helloWorldFoo'

// matchAll과 Named Groups 조합
const text = 'John Doe (30), Jane Smith (25)';
const PERSON_REGEX = /(?<name>[A-Z][a-z]+ [A-Z][a-z]+) \((?<age>\d+)\)/g;

for (const match of text.matchAll(PERSON_REGEX)) {
const { name, age } = match.groups;
console.log(`${name}: ${age}`);
}
// John Doe: 30세
// Jane Smith: 25세

Lookbehind Assertions (ES2018)

앞에 오는 내용 기반으로 패턴 매칭 (값 자체는 포함하지 않음)

// 기존 Lookahead (전방탐색)
// (?=pattern) : 뒤에 pattern이 오는 경우
// (?!pattern) : 뒤에 pattern이 오지 않는 경우

// \d(?=px) : px 앞의 숫자
'font-size: 16px'.match(/\d+(?=px)/)?.[0]; // '16'

// ES2018: Lookbehind (후방탐색)
// (?<=pattern) : 앞에 pattern이 오는 경우 (양의 후방탐색)
// (?<!pattern) : 앞에 pattern이 오지 않는 경우 (음의 후방탐색)

// (?<=$)\d+ : $ 뒤의 숫자 (달러 기호 제외)
const prices = 'Apple: $1.50, Banana: $0.75, Cherry: $2.00';
const dollarAmounts = [...prices.matchAll(/(?<=\$)\d+\.\d+/g)].map(m => m[0]);
// ['1.50', '0.75', '2.00']

// (?<!-)\d+ : 음수 부호가 없는 숫자
const numbers = '100 -50 200 -30 400';
const positives = [...numbers.matchAll(/(?<!-)\d+/g)].map(m => m[0]);
// ['100', '200', '400']

// 양의 후방탐색으로 접두사 제거
const cssValues = ['padding: 16px', 'margin: 8px', 'font-size: 14px'];
cssValues.map(s => s.match(/(?<=: )\d+/)?.[0]);
// ['16', '8', '14']

// 음의 후방탐색: HTTP URL에서만 도메인 추출
const urls = ['https://example.com', 'http://test.com', 'ftp://files.com'];
const httpsOnly = urls.filter(url => /(?<=https:\/\/)\w+/.test(url));
// ['https://example.com']

dotAll 플래그 s (ES2018)

// 기본적으로 .은 줄바꿈(\n)을 매칭하지 않음
const multiline = `Hello
World`;

/Hello.World/.test(multiline); // false
/Hello[\s\S]World/.test(multiline); // true (기존 우회법)

// s 플래그: .이 줄바꿈도 매칭
/Hello.World/s.test(multiline); // true

// 실전: HTML 태그 내용 추출
const html = `<div>
<p>첫 번째 단락</p>
<p>두 번째 단락</p>
</div>`;

// s 플래그 없이는 여러 줄 매칭 어려움
const match = html.match(/<div>(.*?)<\/div>/s);
// match[1]: '\n <p>...</p>\n <p>...</p>\n'

// 모든 파라그래프 추출
const paragraphs = [...html.matchAll(/<p>(.*?)<\/p>/gs)].map(m => m[1]);
// ['첫 번째 단락', '두 번째 단락']

// 주석 제거
const code = `
/* 이것은
여러 줄
주석입니다 */
const x = 1; /* 인라인 주석 */
`;
code.replace(/\/\*.*?\*\//gs, '').trim();
// 'const x = 1;'

Unicode 플래그 u (ES2015+)

// u 플래그: 유니코드 완전 지원
// 이모지, 특수 문자 등 BMP 이상 문자 처리

// u 없이는 서로게이트 페어가 2개 문자로 취급
/^.$/.test('😀'); // false (이모지는 2코드 유닛)
/^.$/u.test('😀'); // true

// 유니코드 이스케이프
/\u{1F600}/u.test('😀'); // true

// 유니코드 카테고리 매칭 (\p{...})
// u 플래그와 함께 사용
/\p{Emoji}/u.test('😀'); // true
/\p{Letter}/u.test('A'); // true
/\p{Decimal_Number}/u.test('5'); // true
/\p{Korean}/u.test('한'); // true 등

// \P: 반대 (대문자 P)
/\P{Emoji}/u.test('A'); // true (이모지가 아님)

// 실전: 이모지 제거
const withEmojis = '안녕 😀 세상 🌍!';
const withoutEmojis = withEmojis.replace(/\p{Emoji}/gu, '').trim();
// '안녕 세상 !'

// 한국어만 추출
const mixed = 'Hello 안녕 World 세상';
const korean = mixed.match(/\p{Script=Hangul}+/gu) ?? [];
// ['안녕', '세상']

Sticky 플래그 y

// y 플래그: lastIndex 위치에서만 매칭
const str = 'aababab';
const re = /a/y;

re.lastIndex = 0;
re.test(str); // true (index 0: 'a')
re.lastIndex; // 1

re.test(str); // false (index 1: 'a' 이지만 연속 매칭)
// 아니, index 1이 'a'이므로 true
re.lastIndex; // 2

// g vs y 차이
const reG = /\d+/g;
const reY = /\d+/y;
const numStr = '123 456 789';

reG.lastIndex = 4;
reG.exec(numStr)?.[0]; // '456' (4번 이후 어디서든)

reY.lastIndex = 4;
reY.exec(numStr)?.[0]; // '456' (정확히 4번 위치부터)

reY.lastIndex = 3;
reY.exec(numStr); // null (3번 위치가 공백이라 매칭 실패)

// 실전: 토크나이저 (파서 구현에 유용)
function tokenize(input) {
const tokens = [];
const patterns = {
NUMBER: /\d+/y,
STRING: /"[^"]*"/y,
KEYWORD: /\b(if|else|while|for)\b/y,
IDENTIFIER: /[a-zA-Z_]\w*/y,
WHITESPACE: /\s+/y,
};

let pos = 0;
while (pos < input.length) {
let matched = false;

for (const [type, pattern] of Object.entries(patterns)) {
pattern.lastIndex = pos;
const match = pattern.exec(input);

if (match) {
if (type !== 'WHITESPACE') {
tokens.push({ type, value: match[0] });
}
pos += match[0].length;
matched = true;
break;
}
}

if (!matched) throw new SyntaxError(`예상치 못한 문자: ${input[pos]}`);
}

return tokens;
}

String.matchAll()

// g 플래그를 가진 정규식의 모든 매칭 이터레이터
const text = '2024-01-15, 2024-06-20, 2024-12-31';

// exec 루프 방식 (번거로움)
const regex1 = /(\d{4})-(\d{2})-(\d{2})/g;
const dates1 = [];
let match;
while ((match = regex1.exec(text)) !== null) {
dates1.push(match);
}

// matchAll 방식 (간결)
const regex2 = /(\d{4})-(\d{2})-(\d{2})/g;
const dates2 = [...text.matchAll(regex2)];

// Named Groups와 matchAll
const ADDR_RE = /(?<street>[^,]+),\s*(?<city>[^,]+),\s*(?<state>[A-Z]{2})/g;
const addresses = `
123 Main St, Springfield, IL
456 Oak Ave, Chicago, IL
789 Pine Rd, Naperville, IL
`;

for (const { groups } of addresses.matchAll(ADDR_RE)) {
console.log(`${groups.street}${groups.city}, ${groups.state}`);
}

실전: URL 파싱

// URL 파싱 정규표현식
const URL_REGEX = /^(?<protocol>https?):\/\/(?<host>[^/:?#]+)(?::(?<port>\d+))?(?<path>\/[^?#]*)?(?:\?(?<query>[^#]*))?(?:#(?<fragment>.*))?$/;

function parseUrl(url) {
const match = url.match(URL_REGEX);
if (!match) throw new Error('유효하지 않은 URL');

const { protocol, host, port, path = '/', query = '', fragment = '' } = match.groups;

return {
protocol,
host,
port: port ? parseInt(port) : (protocol === 'https' ? 443 : 80),
path,
query: parseQuery(query),
fragment,
href: url
};
}

function parseQuery(queryStr) {
if (!queryStr) return {};
return Object.fromEntries(
queryStr.split('&').map(pair => {
const [key, value = ''] = pair.split('=');
return [decodeURIComponent(key), decodeURIComponent(value)];
})
);
}

const parsed = parseUrl('https://api.example.com:8080/users?page=1&limit=10#results');
console.log(parsed);

실전: 이메일 검증

// RFC 5322 기준 (완전한 검증은 매우 복잡)
const EMAIL_REGEX = /^(?<local>[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+)@(?<domain>[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)$/;

function validateEmail(email) {
const match = email.match(EMAIL_REGEX);
if (!match) return { valid: false, reason: '형식 오류' };

const { local, domain } = match.groups;
if (local.length > 64) return { valid: false, reason: 'local 부분이 너무 긺' };
if (domain.length > 255) return { valid: false, reason: 'domain이 너무 긺' };
if (!domain.includes('.')) return { valid: false, reason: 'TLD 없음' };

return { valid: true, local, domain };
}

validateEmail('alice@example.com'); // { valid: true, ... }
validateEmail('not-an-email'); // { valid: false, reason: '형식 오류' }

실전: 날짜 추출

// 다양한 날짜 형식 파싱
const DATE_PATTERNS = {
ISO: /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/g,
US: /(?<month>\d{1,2})\/(?<day>\d{1,2})\/(?<year>\d{4})/g,
KOREAN: /(?<year>\d{4})\s*(?<month>\d{1,2})\s*(?<day>\d{1,2})/g,
};

function extractDates(text) {
const results = [];

for (const [format, regex] of Object.entries(DATE_PATTERNS)) {
for (const match of text.matchAll(regex)) {
const { year, month, day } = match.groups;
results.push({
format,
original: match[0],
date: new Date(Number(year), Number(month) - 1, Number(day)),
index: match.index
});
}
}

return results.sort((a, b) => a.index - b.index);
}

const document = '회의는 2024-03-15에 시작하여, 3/20/2024까지 계속됩니다. 최종 보고는 2024년 4월 1일에 제출.';
const dates = extractDates(document);
dates.forEach(d => console.log(`${d.format}: ${d.original}${d.date.toLocaleDateString()}`));

고수 팁

1. 정규표현식 컴파일 캐싱

// 반복 사용 시 정규표현식을 미리 컴파일
const PATTERNS = {
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
PHONE: /^\+?[\d\s-]{10,}$/,
URL: /^https?:\/\/.+/
};

// new RegExp를 루프 내에서 매번 생성 금지
function validateField(value, type) {
return PATTERNS[type]?.test(value) ?? false;
}

2. 복잡한 정규표현식 주석 달기

// verbose 모드가 없는 JS에서 가독성 높이기
const PASSWORD_REGEX = new RegExp([
'^',
'(?=.*[a-z])', // 소문자 포함
'(?=.*[A-Z])', // 대문자 포함
'(?=.*\\d)', // 숫자 포함
'(?=.*[!@#$%^&*])', // 특수문자 포함
'.{8,}', // 최소 8자
'$'
].join(''));

PASSWORD_REGEX.test('Passw0rd!'); // true

3. 정규표현식 성능 최의화

// Catastrophic backtracking 방지
// 나쁜 예 (지수 복잡도)
/^(a+)+$/.test('aaaaaab'); // 매우 느림!

// 좋은 예 (원자적 그룹 패턴)
/^a+$/.test('aaaaaab'); // 빠름

// 앵커 사용으로 불필요한 역추적 방지
/^prefix/.test(str); // 처음부터만 탐색
/suffix$/.test(str); // 끝에서만 탐색
Advertisement