소개
정규 표현식(Regular Expression, 줄여서 regex 또는 regexp)은 검색 패턴을 정의하는 문자열의 시퀀스입니다. 정규 표현식은 개발자 도구함에서 가장 강력한 도구 중 하나로, 단 하나의 간결한 표현식으로 텍스트를 검색, 검증, 추출, 변환할 수 있습니다.
웹 폼에서 이메일 주소를 검증하거나, 로그에서 데이터를 추출하거나, 수천 개의 파일에서 복잡한 찾기-바꾸기를 수행할 때 정규 표현식은 거의 모든 프로그래밍 언어와 대부분의 텍스트 편집기가 이해할 수 있는 간결한 표기법으로 원하는 것을 정확하게 표현할 수 있게 해줍니다.
정규 표현식의 역사
정규 표현식의 역사는 이론 컴퓨터 과학의 기초로 거슬러 올라갑니다:
- 1951년 — 수학자 Stephen Kleene이 오토마타 이론 연구의 일환으로 정규 언어의 개념을 형식화하고 Kleene 스타(
*) 표기법을 도입. - 1968년 — Ken Thompson이 QED 텍스트 편집기에 정규 표현식을 구현하고, 이후
grep,sed,awk등의 Unix 도구에 도입하여 개발자의 일상 작업에 정규 표현식을 가져옴. - 1986년 — POSIX가 BRE(기본 정규 표현식)와 ERE(확장 정규 표현식) 두 가지 방언을 표준화하여 Unix 시스템 간 상호 운용성을 보장.
- 1997년 — Philip Hazel이 lookahead, lookbehind, 명명된 캡처 등 강력한 기능을 포함한 PCRE(Perl Compatible Regular Expressions) 라이브러리를 생성.
- 1999년 — ECMAScript 3이 JavaScript의
RegExp객체를 표준화. - 2015년 — ES6이
u(Unicode)와y(sticky) 플래그를 추가. - 2018년 — ES2018이 명명된 캡처 그룹(
(?<name>...))과 lookbehind 어서션을 추가.
POSIX vs PCRE vs JavaScript RegExp 비교
| 기능 | BRE/ERE (POSIX) | PCRE | JavaScript RegExp |
|---|---|---|---|
| Lookahead | ✗ | ✓ | ✓ |
| Lookbehind | ✗ | ✓ | ✓ (ES2018+) |
| 명명된 그룹 | ✗ | ✓ | ✓ (ES2018+) |
| 비탐욕 매칭 | ✗ | ✓ | ✓ |
| Unicode | 제한적 | ✓ | ✓ (u 플래그 사용 시) |
| 역참조 | ✓ | ✓ | ✓ |
핵심 문법 참조
빠른 참조 표
| 패턴 | 의미 |
|---|---|
. |
개행 문자를 제외한 임의의 문자 |
^ |
문자열 시작 / 줄 시작 (m 플래그 사용 시) |
$ |
문자열 끝 / 줄 끝 (m 플래그 사용 시) |
\d |
임의의 숫자 [0-9] |
\D |
임의의 비숫자 |
\w |
단어 문자 [a-zA-Z0-9_] |
\W |
비단어 문자 |
\s |
공백 문자 (스페이스, 탭, 개행 등) |
\S |
비공백 문자 |
[abc] |
문자 클래스 — a, b, 또는 c 중 하나와 일치 |
[^abc] |
부정 문자 클래스 — a, b, c를 제외한 모든 문자와 일치 |
[a-z] |
문자 범위 |
* |
0회 이상 (탐욕적) |
+ |
1회 이상 (탐욕적) |
? |
0회 또는 1회 (탐욕적) |
{n} |
정확히 n회 |
{n,m} |
n회 이상 m회 이하 (탐욕적) |
*? +? ?? |
게으른 (비탐욕적) 동등 형태 |
(abc) |
캡처 그룹 |
(?:abc) |
비캡처 그룹 |
(?<name>abc) |
명명된 캡처 그룹 |
| |
선택 — 왼쪽 또는 오른쪽과 일치 |
(?=...) |
긍정적 lookahead |
(?!...) |
부정적 lookahead |
(?<=...) |
긍정적 lookbehind |
(?<!...) |
부정적 lookbehind |
문자 클래스
문자 클래스를 사용하면 문자 집합에서 하나의 문자를 매칭할 수 있습니다. [aeiou]는 단일 모음에, [a-zA-Z]는 임의의 알파벳에, [^0-9]는 숫자가 아닌 임의의 문자에 매칭됩니다.
자주 사용되는 단축 표기:
\d는[0-9]와 동일\w는[a-zA-Z0-9_]와 동일\s는 스페이스, 탭(\t), 개행(\n), 캐리지 리턴(\r) 등의 공백 문자에 매칭
수량자: 탐욕적 vs 게으른
기본적으로 수량자는 탐욕적이며 가능한 한 많은 문자를 매칭하려 합니다. HTML 문자열 <b>굵게</b>와 <i>기울임</i>를 예로 들면:
<.*> → <b>에서 </i>까지 전체 문자열에 매칭 (탐욕적)
<.*?> → <b>, </b>, <i>, </i>에 각각 매칭 (게으른)
수량자 뒤에 ?를 추가하면 게으른(비탐욕적)이 되어, 전체 패턴이 성공하는 범위 내에서 가능한 한 적게 매칭합니다.
앵커
^는 문자열의 시작에 매칭 (m플래그 사용 시 줄 시작).$는 문자열의 끝에 매칭 (m플래그 사용 시 줄 끝).\b는 단어 경계에 매칭 — 단어 문자와 비단어 문자 사이의 위치.\B는 비단어 경계에 매칭.
그룹과 역참조
캡처 그룹 (...)은 매칭된 텍스트를 캡처하며, \1, \2 등의 역참조로 후속 패턴에서 참조하거나 매칭 결과 배열에서 접근할 수 있습니다.
비캡처 그룹 (?:...)은 캡처를 생성하지 않고 하위 패턴을 그룹화합니다. 캡처 값이 필요 없을 때 더 효율적입니다.
명명된 캡처 그룹 (?<year>\d{4})은 이름(JavaScript에서는 match.groups.year)으로 캡처를 참조할 수 있게 하여 패턴의 가독성을 크게 향상시킵니다.
Lookahead와 Lookbehind
이러한 제로 너비 어서션은 문자가 아닌 위치에 매칭됩니다:
\d+(?=원) → 뒤에 "원"이 올 때만 숫자에 매칭
\d+(?!원) → 뒤에 "원"이 오지 않을 때만 숫자에 매칭
(?<=\$)\d+ → 앞에 "$"가 있을 때만 숫자에 매칭
(?<!\$)\d+ → 앞에 "$"가 없을 때만 숫자에 매칭
Lookahead와 lookbehind는 문자를 소비하지 않으므로 매칭 결과에 해당 부분이 포함되지 않습니다.
플래그
| 플래그 | 이름 | 효과 |
|---|---|---|
i |
대소문자 무시 | [a-z]가 [A-Z]에도 매칭 |
g |
전역 | 첫 번째 매칭만이 아닌 모든 매칭을 검색 |
m |
멀티라인 | ^와 $가 줄 경계에 매칭 |
s |
dotAll | .이 개행 문자에도 매칭 |
u |
Unicode | 전체 Unicode 매칭 활성화; \p{}에 필요 |
y |
Sticky | lastIndex 위치에서만 매칭 |
x |
상세/확장 | 공백과 주석 허용 (PCRE/Python 전용) |
x(상세) 플래그는 복잡한 패턴을 문서화하는 데 특히 유용합니다:
import re
pattern = re.compile(r"""
^ # 문자열 시작
(?P<year>\d{4}) # 4자리 연도
-
(?P<month>0[1-9]|1[0-2]) # 월 01~12
-
(?P<day>0[1-9]|[12]\d|3[01]) # 일 01~31
$
""", re.VERBOSE)
자주 사용되는 패턴과 실제 예제
이메일 주소 검증
^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$
분석:
^[a-zA-Z0-9._%+-]+— 로컬 부분 (문자, 숫자, 특수 문자 일부)@— 리터럴 @ 기호[a-zA-Z0-9.-]+— 도메인 이름\.[a-zA-Z]{2,}$— 2자 이상의 TLD
URL 매칭
https?://(?:www\.)?[a-zA-Z0-9-]+(?:\.[a-zA-Z]{2,})+(?:/[^\s]*)?
ISO 날짜 (YYYY-MM-DD)
\b\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])\b
월 0112와 일 0131을 검증합니다. 월별 최대 일수(예: 2월 30일)는 검증하지 않습니다.
IPv4 주소
\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b
분석:
25[0-5]— 250~2552[0-4]\d— 200~249[01]?\d\d?— 0~199
미국 전화번호
\+?1?\s?[\(]?\d{3}[\)]?[-.\s]?\d{3}[-.\s]?\d{4}
(555) 123-4567, 555-123-4567, +1 555 123 4567 등의 형식에 매칭.
16진수 색상 코드
#(?:[0-9A-Fa-f]{3}){1,2}\b
3자리(#F00)와 6자리(#FF0000) 16진수 색상 모두에 매칭.
다양한 프로그래밍 언어에서의 정규 표현식
JavaScript
// 리터럴 문법
const regex = /^hello\s+world$/im;
const match = "Hello World".match(regex);
// 생성자 문법 (동적 패턴에 유용)
const term = "world";
const dynamic = new RegExp(`hello\\s+${term}`, "im");
// 전체 치환
const result = "foo bar foo".replaceAll(/foo/g, "baz");
// 명명된 캡처 (ES2018+)
const dateRegex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const { year, month, day } = "2024-03-15".match(dateRegex).groups;
Python
import re
# 재사용을 위한 컴파일
pattern = re.compile(r'^hello\s+world$', re.IGNORECASE | re.MULTILINE)
match = pattern.match("Hello World")
# 모든 매칭 찾기
dates = re.findall(r'\d{4}-\d{2}-\d{2}', text)
# 함수를 사용한 치환
result = re.sub(r'\b\d+\b', lambda m: str(int(m.group()) * 2), "1 더하기 2는 3")
# 명명된 그룹
m = re.search(r'(?P<year>\d{4})-(?P<month>\d{2})', "2024-03")
print(m.group('year')) # 2024
Java
import java.util.regex.*;
Pattern p = Pattern.compile("^hello\\s+world$",
Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);
Matcher m = p.matcher("Hello World");
boolean found = m.matches();
// 그룹 추출
Pattern datePattern = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher dm = datePattern.matcher("오늘은 2024-03-15입니다");
if (dm.find()) {
String year = dm.group(1);
}
Go
import "regexp"
re := regexp.MustCompile(`(?im)^hello\s+world$`)
match := re.FindString("Hello World")
// 모든 서브매칭 찾기
dateRe := regexp.MustCompile(`(\d{4})-(\d{2})-(\d{2})`)
all := dateRe.FindAllStringSubmatch(text, -1)
for _, m := range all {
year, month, day := m[1], m[2], m[3]
_ = year; _ = month; _ = day
}
// 명명된 그룹
namedRe := regexp.MustCompile(`(?P<year>\d{4})-(?P<month>\d{2})`)
match2 := namedRe.FindStringSubmatch("2024-03")
yearIdx := namedRe.SubexpIndex("year")
fmt.Println(match2[yearIdx]) // 2024
성능과 파국적 백트래킹
백트래킹의 작동 방식
대부분의 정규 표현식 엔진은 NFA(비결정적 유한 오토마톤) 기반 매칭을 사용합니다. 이는 한 경로가 실패하면 엔진이 패턴의 여러 경로를 시도할 수 있음을 의미합니다. 이 백트래킹 덕분에 lookahead와 역참조 같은 기능이 가능하지만, 성능 함정이 될 수도 있습니다.
파국적 사례
패턴 (a+)+를 문자열 "aaaaaX"에 적용하는 경우를 생각해보세요:
- 외부
+는 가능한 한 많은 그룹을 매칭하려 합니다. - 엔진이
X에 도달해 실패하면 백트래킹하여 그룹의 반복 사이에a문자를 다르게 분배하려 합니다. - 길이 n의 문자열에는 *2^(n-1)*개의 가능한 분배 방식이 있어 지수 시간 복잡도로 이어집니다.
(a+)+ 에 "aaaaaaaaaaaaaaaaaX" 적용 → 몇 초 또는 몇 분이 걸릴 수 있습니다!
다른 위험한 패턴으로 (a|aa)+, (\w+\s*)+ 및 겹치는 문자 클래스에 대한 중첩 수량자가 있습니다.
회피 방법
- 같은 문자 집합에 중첩 수량자를 사용하지 않기:
(a+)+→a+사용. - 원자 그룹
(?>...)또는 소유 수량자a++(PCRE)를 사용하여 이미 매칭된 그룹으로의 백트래킹 방지. - 구체성 높이기: 구분자를 제외하는 문자 클래스로
.*를 대체 (예: 인용된 문자열 내에서[^"]*). - 가능한 한 앵커 사용하여 엔진이 빠르게 실패할 수 있도록.
- 프로덕션 코드에서 타임아웃 설정 (특히 신뢰할 수 없는 입력 처리 시).
복잡한 정규 표현식 읽는 방법
^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$를 분해합니다:
^ → 앵커: 문자열 시작
[a-zA-Z0-9._%+-]+ → 로컬 부분에 허용된 1개 이상의 문자
@ → 리터럴 @
[a-zA-Z0-9.-]+ → 1개 이상의 도메인 문자
\. → 리터럴 점 (이스케이프됨)
[a-zA-Z]{2,} → TLD: 2자 이상의 알파벳
$ → 앵커: 문자열 끝
팁: 이 사이트의 정규 표현식 테스터를 사용하여 각 캡처 그룹을 하이라이트하고 각 토큰이 입력의 어느 부분과 매칭되는지 실시간으로 확인하세요. 가장 단순한 유효한 조각부터 시작하여 점진적으로 패턴을 구축하고, 검증 후 확장하세요.
모범 사례
- 한 번 컴파일하고 여러 번 사용하기. 패턴을 미리 컴파일하는 것(Python의
re.compile(), Java의Pattern.compile())이 매번 재파싱하는 것보다 훨씬 효율적입니다. - 캡처 값이 필요 없을 때는 비캡처 그룹
(?:...)선호. 의도를 명확히 하고 불필요한 메모리 할당을 피할 수 있습니다. - 패턴에 원시 문자열 사용. Python에서는
'\\d+'대신r'\d+'를 사용하여 이중 이스케이프를 방지. JavaScript의 리터럴 문법/\d+/는 이를 자동으로 처리합니다. - 캡처 그룹에 이름 붙이기.
(?<year>\d{4})는\1같은 인덱스 참조보다 훨씬 유지보수하기 쉽습니다. - 엣지 케이스로 테스트하기: 빈 문자열, 거의 매칭되지만 완전히는 아닌 문자열, Unicode 문자, 매우 긴 입력.
- 복잡한 패턴 문서화: Python/PCRE에서는
x(상세) 플래그를 사용하고, 코드에서는 인라인 주석 사용. - 정규 표현식으로 전체 HTML이나 XML을 파싱하지 않기. 적절한 파서 라이브러리를 사용하세요.
- 서버 측에서 입력 검증하기. 클라이언트 측 정규 표현식 검증은 UX를 향상시키지만 유일한 방어선이 되어서는 안 됩니다.
자주 묻는 질문 (FAQ)
Q: Python에서 match()와 search()의 차이는 무엇인가요?
A: re.match()는 문자열의 처음에서만 매칭합니다. re.search()는 전체 문자열을 스캔하여 매칭을 찾습니다. 패턴이 전체 문자열과 매칭되도록 하려면 re.fullmatch()를 사용하세요.
Q: 문자 클래스 [^abc] 내의 ^는 왜 의미가 다른가요?
A: 문자 클래스 내에서 ^가 첫 번째 문자로 나타나면 클래스를 부정합니다 — 해당 집합에 없는 임의의 문자와 매칭됩니다. 문자 클래스 밖에서 ^는 문자열 시작의 앵커입니다.
Q: 정규 표현식으로 HTML을 파싱할 수 있나요?
A: 알려진 HTML 구조에서의 단순하고 명확한 추출이라면 정규 표현식으로 작동할 수 있습니다. 그러나 HTML은 정규 언어가 아니며 임의의 중첩과 선택적 닫는 태그를 허용합니다. 강건한 파싱을 위해 적절한 HTML 파서(Python의 BeautifulSoup, JS의 DOMParser)를 사용하세요.
Q: 탐욕적 수량자와 소유 수량자의 차이는 무엇인가요?
A: 탐욕적 수량자는 백트래킹합니다 — 최대 매칭을 시도하고 필요시 문자를 반환합니다. 소유 수량자(예: PCRE의 a++)는 반환하지 않습니다 — 한번 매칭되면 잠금됩니다. 이는 파국적 백트래킹을 방지하지만 탐욕적 수량자였다면 성공했을 매칭이 실패할 수도 있습니다.
Q: 리터럴 점 .이나 괄호 (에 매칭하려면 어떻게 하나요?
A: 백슬래시로 이스케이프합니다: \.는 리터럴 점에, \(는 리터럴 왼쪽 괄호에 매칭됩니다.
Q: 정규 표현식은 기본적으로 대소문자를 구분하나요?
A: 네. i 플래그(JavaScript의 /pattern/i, Python의 re.IGNORECASE)를 사용하면 대소문자를 구분하지 않는 매칭이 활성화됩니다.
Q: \b는 무엇에 매칭되나요?
A: \b는 제로 너비 단어 경계 어서션입니다. 단어 문자(\w)와 비단어 문자(\W) 사이의 위치에 매칭됩니다.
Q: 전체 문자열이 패턴과 매칭되는지 테스트하려면 어떻게 하나요?
A: ^와 $로 앵커를 사용합니다: ^pattern$. Python에서는 re.fullmatch()도 사용할 수 있습니다. JavaScript에서는 앵커를 사용한 .test()를 사용하거나 match()[0].length === input.length를 확인합니다.
이 사이트의 정규 표현식 테스터를 사용하여 이 가이드의 모든 패턴을 실험해보세요. 패턴을 붙여넣고 테스트 문자열을 입력하면 매칭 결과가 실시간으로 하이라이트됩니다.