정규표현식 치트시트: 메타문자, 그룹, Lookaround 완전 참고
정규식은 텍스트를 매칭하기 위한 작은 패턴 언어입니다. \d+는 “숫자 하나 이상”, ^Error는 “Error로 시작하는 줄”을 의미하며, 본질은 그게 전부입니다. 이 치트시트는 메타문자, 수량자, 앵커, 그룹, Lookaround, 플래그를 한 페이지에 정리하고 JavaScript와 Python에 바로 붙여 넣을 수 있는 15가지 이상의 패턴을 함께 담았습니다.
문자열이 무엇인지 이미 알고 있고 둘러보기가 아니라 참고 자료가 필요한 개발자를 위한 글입니다. 기호만 빠르게 찾고 싶으시면 아래의 빠른 참조 표로 바로 넘어가시면 됩니다. 정규식 때문에 서버가 멈춰본 경험이 있으시다면 Lookaround와 함정 섹션을 먼저 보시기 바랍니다.
1. 정규식이란 무엇이고 2026년에도 여전히 필요한 이유
정규식은 상태 머신으로 컴파일된 패턴이며, 문자열을 스캔해 매칭에 성공하거나 실패합니다. 문법 자체는 작지만 활용 범위는 그렇지 않습니다.
AI가 패턴 초안을 만들어 줄 수는 있어도 사람이 직접 정규식을 쓰는 편이 합리적인 작업이 아직 남아 있습니다.
- 로그 파싱입니다.
nginx액세스 로그 천만 줄에서 특정 user agent의 5xx 요청만 골라내는 작업의 경우,grep -E에 40자 정도의 정규식 하나면 몇 초 만에 끝납니다. 줄마다 LLM을 호출하는 방식으로는 비용도 시간도 맞출 수 없습니다. - 폼과 필드 유효성 검사입니다. 전화번호, 우편번호, ISO 타임스탬프, 라이선스 키 같은 값을 검증할 때 패턴은 입력 필드 옆에서 키 입력마다 동작합니다.
- 대량 찾기와 치환입니다. 천 개의 파일에서 이름을 캡처해 다시 끼워 넣는 리팩터링이라면
sed,ripgrep, 에디터의 “Replace in files” 기능 모두 정규식을 기본으로 지원합니다.
같은 도구 상자의 JSON 쪽 작업은 jq 커맨드 라인 치트시트를 참고하시기 바랍니다.
1.1 정규식 패턴 읽는 법 (5초 정규식 튜토리얼)
대부분의 패턴은 왼쪽에서 오른쪽으로, 한 토큰씩 읽어 나가는 편이 이해하기 쉽습니다. ^[A-Z]\w+\d{2,4}$을 예로 들어 보겠습니다.
^은 매칭을 문자열의 시작 위치에 고정합니다.[A-Z]는 대문자 한 글자에 정확히 매칭합니다.\w+는 단어 문자 하나 이상에 매칭합니다.\d{2,4}는 숫자 2개에서 4개 사이에 매칭합니다.$은 문자열의 끝에 고정합니다.
요령은 앵커를 먼저 읽고, 그다음 문자 클래스를, 마지막으로 수량자를 읽는 순서입니다.
2. 빠른 참조 표
이 글에서 가장 자주 들춰 보게 될 섹션입니다. 필요한 줄만 복사해서 쓰시면 됩니다.
메타문자
| 패턴 | 매칭 대상 |
|---|---|
. | 줄바꿈을 제외한 모든 문자 (또는 s/dotall 플래그가 있으면 모든 문자) |
\d | 숫자 ([0-9], u 플래그가 있으면 모든 Unicode 숫자) |
\D | 숫자가 아닌 문자 |
\w | 단어 문자 ([A-Za-z0-9_]) |
\W | 단어 문자가 아닌 문자 |
\s | 공백 문자 (스페이스, 탭, 줄바꿈 등) |
\S | 공백 문자가 아닌 문자 |
수량자
| 패턴 | 매칭 대상 |
|---|---|
* | 0번 이상 (greedy) |
+ | 1번 이상 (greedy) |
? | 0번 또는 1번 (greedy) |
{n} | 정확히 n번 |
{n,m} | n번 이상 m번 이하 |
{n,} | n번 이상 |
*?, +?, ??, {n,m}? | 각 수량자의 lazy 버전 |
앵커
| 패턴 | 매칭 대상 |
|---|---|
^ | 문자열의 시작 (m 플래그가 있으면 줄의 시작) |
$ | 문자열의 끝 (m 플래그가 있으면 줄의 끝) |
\b | 단어 경계 |
\B | 단어 경계가 아닌 위치 |
\A | 문자열의 절대 시작 (Python 전용) |
\Z | 문자열의 절대 끝 (Python 전용) |
문자 클래스
| 패턴 | 매칭 대상 |
|---|---|
[abc] | a, b, c 중 하나 |
[^abc] | a, b, c가 아닌 모든 문자 |
[a-z] | 소문자 알파벳 하나 |
[0-9] | 숫자 하나 |
\p{L} | 모든 Unicode 글자 (JS에서는 u 플래그, Python re에서는 기본) |
그룹
| 패턴 | 매칭 대상 |
|---|---|
(...) | 캡처 그룹 |
(?:...) | 비캡처 그룹 |
(?<name>...) | 이름 있는 캡처 (JS ES2018 이상); Python에서는 (?P<name>...) 사용 |
\1, \2 | 1번, 2번 그룹에 대한 역참조 |
Lookaround
| 패턴 | 매칭 대상 |
|---|---|
(?=...) | 긍정형 Lookahead |
(?!...) | 부정형 Lookahead |
(?<=...) | 긍정형 Lookbehind |
(?<!...) | 부정형 Lookbehind |
플래그
| 플래그 | 효과 |
|---|---|
i | 대소문자 구분 없음 |
m | 멀티라인: ^와 $가 줄 단위로 매칭 |
s | Dotall: .이 줄바꿈도 매칭 |
g | 전역 (JS); 모든 매칭을 찾음 |
u | Unicode 모드 |
y | Sticky (JS); lastIndex에 앵커 고정 |
3. 메타문자와 문자 클래스
3.1 리터럴 vs 특수 문자
대부분의 문자는 리터럴입니다. 그대로 쓰려면 이스케이프해야 하는 메타문자는 다음 12개입니다.
. ^ $ * + ? ( ) [ ] { } | \
.을 이스케이프하지 않는 것은 가장 흔한 정규식 버그입니다. \.은 리터럴 점을 매칭합니다. 문자 클래스 안에서는 [.]도 리터럴 점을 매칭합니다. 대부분의 메타문자는 [...] 안에 들어가면 특수한 의미를 잃으며, 예외는 ], \, ^ (맨 앞일 때), - (중간일 때)입니다.
3.2 단축 문자 클래스
단축 클래스는 단순해 보이지만 Unicode가 등장하면 이야기가 달라집니다.
// JavaScript — u 플래그가 없으면 \d는 ASCII만 매칭
/\d/.test('5'); // true
/\d/.test('٥'); // false (아라비아-인도 숫자)
/\d/u.test('٥'); // false — u 플래그가 있어도 JS의 \d는 여전히 ASCII
/\p{N}/u.test('٥'); // true — \p{N}이 Unicode를 인식하는 숫자 클래스
# Python — re 모듈은 기본적으로 \d를 Unicode로 처리
import re
re.match(r'\d', '٥') # <Match span=(0, 1)>
re.match(r'(?a)\d', '٥') # None — (?a)는 ASCII를 강제
영어 ASCII 입력만 다룬다면 \d와 [0-9]는 서로 바꿔 써도 됩니다. 사용자가 악센트가 들어간 이름을 붙여 넣는 순간부터는 \w보다 \p{L}이 필요합니다.
3.3 사용자 정의 문자 클래스
// JavaScript
/[A-Za-z][A-Za-z0-9_-]{2,29}/.test('valid_handle-1'); // true
// 부정과 범위 조합
/[^aeiou\s]/g // 모음도 공백 문자도 아닌 문자
Unicode 카테고리에서 \p{L}은 “모든 글자”, \p{N}은 “모든 숫자”, \p{Script=Han}은 “모든 한자”를 가리킵니다. JavaScript는 u 플래그가 있어야 하고, Python은 표준 라이브러리 re가 아니라 PyPI의 regex 패키지를 통해서만 \p{...}를 사용할 수 있습니다.
커맨드 라인에서 작업하시다 보면 POSIX 문자 클래스도 자주 마주치게 됩니다.
| POSIX 클래스 | 매칭 대상 | ASCII 대응 표기 |
|---|---|---|
[[:alpha:]] | 글자 | [A-Za-z] |
[[:digit:]] | 숫자 | [0-9] (\d) |
[[:alnum:]] | 글자 + 숫자 | [A-Za-z0-9] |
[[:space:]] | 공백 문자 | \s |
[[:upper:]] | 대문자 | [A-Z] |
[[:lower:]] | 소문자 | [a-z] |
POSIX 클래스는 grep -E와 sed -E에서 동작합니다. JavaScript나 Python re에서는 동작하지 않으니, 그쪽에서는 \d, \s, \w를 대신 사용하시면 됩니다.
4. 수량자와 Greedy vs Lazy
4.1 기본 수량자
/a*/.exec('aaab') // ['aaa'] — 0번 이상
/a+/.exec('aaab') // ['aaa'] — 1번 이상
/a?/.exec('aaab') // ['a'] — 0번 또는 1번
/a{2,3}/.exec('aaaab') // ['aaa'] — 2번에서 3번
4.2 Greedy vs Lazy
기본적으로 수량자는 greedy입니다. 가능한 한 많이 가져간 뒤, 전체 패턴이 맞도록 뒤로 한 칸씩 양보합니다. ?을 붙이면 lazy로 바뀝니다.
const html = '<p>one</p><p>two</p>';
html.match(/<p>.*<\/p>/)[0]; // '<p>one</p><p>two</p>' (greedy가 둘 다 먹어버림)
html.match(/<p>.*?<\/p>/)[0]; // '<p>one</p>' (lazy는 첫 매칭에서 멈춤)
태그나 따옴표 안의 문자열을 추출할 때는 거의 항상 lazy 버전이 원하는 동작입니다. 더 좋은 방법은 .을 아예 쓰지 않고 부정 클래스를 사용하는 것입니다. <p>[^<]*</p>는 <p>.*?</p>보다 빠릅니다. 백트래킹할 여지가 없기 때문입니다.
4.3 치명적 백트래킹
정규식 때문에 서버가 멈추는 일은 보통 여기서 일어납니다. 모호하게 겹치는 수량자를 다른 수량자 안에 중첩하면, 엔진이 포기할 때까지 지수적인 수의 경로를 탐색하게 됩니다.
// 이렇게 하면 안 됩니다
/(a+)+b/.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!'); // 몇 초가 걸림
a가 41개에 !이 붙어 있는 입력의 경우, 엔진은 b가 빠졌다고 판단하기까지 약 2^41개의 분할 지점을 시도합니다. 해결책은 세 갈래로 나뉩니다.
- 패턴을 평탄화합니다.
/a+b/는 중첩 없이 같은 일을 합니다. - 원자 그룹을 사용합니다 (Python
regex, PCRE, Java, Ruby).(?>a+)+b처럼 쓰면a+가 매칭되고 난 뒤 엔진이 그 안으로 백트래킹하기를 거부합니다. - 엔진을 바꿉니다. Go의
regexp, RE2, Rust의regex크레이트는 선형 시간의 NFA를 사용하므로 설계상 치명적 백트래킹이 발생하지 않습니다.
JavaScript와 Python re는 모두 백트래킹 엔진이고 표준 라이브러리에 원자 그룹이 없습니다 (Python에서는 PyPI regex 패키지가 그 자리를 채워 줍니다). 입력 길이를 직접 통제할 수 있다면 큰 문제가 안 됩니다. 입력이 사용자로부터 오는 상황이라면 길이부터 검사하거나 RE2 위에서 미리 컴파일하시기 바랍니다.
5. 앵커와 단어 경계
5.1 ^과 $
기본 상태에서 ^은 입력 전체의 시작이고 $은 끝입니다. m (멀티라인) 플래그를 켜야 비로소 각 줄의 시작과 끝으로 의미가 바뀝니다.
const log = 'INFO start\nERROR boom\nINFO done';
log.match(/^ERROR.*/); // null — 단일 행 모드에서는 ^이 인덱스 0만 매칭
log.match(/^ERROR.*/m); // ['ERROR boom']
5.2 \b와 \B
\b는 너비가 0인 어서션으로, 단어 문자(\w)와 단어가 아닌 문자 사이의 위치를 매칭합니다. 단어 단위 검색에 유용합니다.
/\bcat\b/.test('the cat sat'); // true
/\bcat\b/.test('concatenate'); // false
단어 경계는 기본적으로 ASCII로 정의된 \w 위에 올라가 있습니다. 중국어·일본어·한국어 텍스트는 단어 사이에 공백이 없어 \b로 단어 경계를 찾을 수 없습니다. 정규식을 대체하는 도구가 아니라 정규식 앞에 둘 토크나이저(jieba, MeCab)가 필요한 상황입니다.
5.3 멀티라인 모드
import re
text = "INFO ok\nERROR fail\nINFO done\n"
re.findall(r'^ERROR.*$', text) # []
re.findall(r'^ERROR.*$', text, re.MULTILINE) # ['ERROR fail']
JavaScript에서 같은 작업은 text.match(/^ERROR.*$/gm)로 처리합니다. m과 g를 함께 쓰면 매칭되는 모든 줄이 한 번에 잡힙니다.
6. 그룹, 캡처, 역참조
6.1 캡처 그룹
괄호는 두 가지 역할을 동시에 합니다. 수량자가 적용될 서브패턴을 묶고, 나중에 참조할 수 있도록 매칭한 문자열을 캡처합니다.
'2026-05-13'.match(/(\d{4})-(\d{2})-(\d{2})/);
// ['2026-05-13', '2026', '05', '13', index: 0, ...]
그룹은 여는 괄호를 기준으로 왼쪽에서 오른쪽으로, 1부터 번호가 붙습니다.
6.2 비캡처 그룹
캡처는 필요 없고 묶기만 하고 싶다면 (?:...)을 사용하시면 됩니다. 더 빠르게 동작하고 번호 그룹도 깔끔하게 유지됩니다.
/(?:https?):\/\/(\S+)/.exec('see https://go-tools.org');
// ['https://go-tools.org', 'go-tools.org']
// — 프로토콜은 묶기만 하고 캡처는 하지 않음; 그룹 1은 호스트
6.3 이름 있는 그룹
그룹에 이름을 붙이면 패턴을 읽기가 쉬워지고 리팩터링도 안전해집니다.
// JavaScript (ES2018+)
const m = '2026-05-13'.match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
m.groups.year; // '2026'
# Python — (?P<...>) 문법에 주의
import re
m = re.match(r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})', '2026-05-13')
m.group('year') # '2026'
6.4 역참조
역참조는 앞쪽 캡처가 매칭한 내용을 패턴 뒷부분에서 그대로 다시 요구하는 장치입니다.
// 연속해서 반복되는 문자를 찾기
'bookkeeper'.match(/(\w)\1/g); // ['oo', 'kk', 'ee']
// 이름이 같은 HTML 짝 태그 매칭
const tag = /<(\w+)>(.*?)<\/\1>/;
'<b>bold</b>'.match(tag);
// ['<b>bold</b>', 'b', 'bold']
Python에서는 \1이 패턴과 치환 양쪽에서 모두 동작합니다. 이름 있는 참조는 패턴에서 (?P=name), re.sub 치환에서는 \g<name> 형태로 씁니다.
7. Lookaround: Lookahead와 Lookbehind
Lookaround는 너비가 0인 어서션입니다. 문자를 소비하지 않고 조건만 검사하기 때문에 여러 개를 이어 붙여 쓸 수 있습니다.
7.1 Lookahead
// 비밀번호: 최소 8자, 숫자 하나, 대문자 하나, 소문자 하나
const strong = /^(?=.*\d)(?=.*[A-Z])(?=.*[a-z]).{8,}$/;
strong.test('Hunter2!'); // true
strong.test('hunter2!'); // false — 대문자 없음
// 부정형 Lookahead — .tmp가 아닌 파일 이름
/^[\w-]+(?!\.tmp$)\.[a-z]+$/.test('report.csv'); // true
7.2 Lookbehind
Lookbehind는 그 반대로, 현재 위치의 앞에 무엇이 있는지를 어서션합니다.
// 통화 기호 뒤의 가격을 추출 — 숫자만 남기고 $는 버림
'price: $42.50'.match(/(?<=\$)\d+(\.\d+)?/); // ['42.50', '.50']
// 부정형 Lookbehind — Bond는 매칭하되 James Bond는 제외
'Mr. Bond'.match(/(?<!James )Bond/); // ['Bond']
'James Bond'.match(/(?<!James )Bond/); // null
7.3 JavaScript vs Python Lookbehind
JS 패턴을 Python으로 옮기다 보면 Lookbehind에서 자주 막힙니다. 두 엔진의 동작이 다음과 같이 갈라져 있기 때문입니다.
| 엔진 | Lookbehind 길이 |
|---|---|
| JavaScript (V8, SpiderMonkey, JSC 16.4 이상) | ES2018부터 가변 너비 지원. (?<=\d+)은 유효함. |
Python 표준 라이브러리 re | 고정 너비만 가능. (?<=\d+)은 error: look-behind requires fixed-width pattern을 발생시킴. |
Python PyPI regex 패키지 | 가변 너비 지원. import regex; regex.search(r'(?<=\d+)abc', '12abc'). |
Python에서는 Lookbehind를 알려진 반복 횟수((?<=\d{3}))로 다시 쓰거나, 접두사를 캡처한 뒤 매칭 후에 잘라 내는 방식으로 우회합니다.
8. 플래그와 수식자
8.1 i — 대소문자 구분 없음
/error/i.test('FATAL ERROR'); // true
re.search(r'error', 'FATAL ERROR', re.IGNORECASE) # <Match span=(6, 11)>
8.2 m과 s
m은 ^과 $을 줄 단위 앵커로 바꿉니다. s (dotall)는 .이 줄바꿈 문자까지 매칭하게 만듭니다. 두 플래그는 독립적이라 둘 다 필요한 경우에는 함께 사용하시면 됩니다.
/<script>(.*?)<\/script>/s.exec('<script>\nalert(1)\n</script>')[1];
// '\nalert(1)\n' — s가 없으면 .은 줄바꿈을 거부함
8.3 g — 전역
JavaScript에서 g는 매칭 자체가 아니라 API의 반환 방식을 바꿉니다. g가 없으면 String.match가 캡처 그룹을 반환하고, g가 붙으면 매칭된 모든 문자열을 반환합니다. 매 매칭마다 캡처 그룹까지 유지하려면 matchAll을 쓰시면 됩니다.
const text = 'a=1 b=2 c=3';
text.match(/(\w)=(\d)/); // 그룹과 함께 첫 매칭
text.match(/(\w)=(\d)/g); // ['a=1', 'b=2', 'c=3'] — 그룹 없음
[...text.matchAll(/(\w)=(\d)/g)]; // 모든 매칭, 그룹 포함
Python에는 g 플래그가 없습니다. re.findall, re.finditer, re.sub이 전역 버전의 역할을 합니다.
8.4 u — Unicode와 \p{...}
// 모든 한자 매칭 (중국어, 일본 한자)
/\p{Script=Han}+/gu.test('Hello 世界'); // true
// 이모지 매칭 (확장 pictographic)
/\p{Extended_Pictographic}/u.test('👋'); // true
Python에서는 Unicode가 기본으로 켜져 있습니다. 한자 범위는 re.findall(r'[一-鿿]+', text)처럼 직접 범위로 표현합니다. 전체 Unicode 속성 이스케이프가 필요하다면 PyPI의 regex 패키지를 설치한 뒤 regex.findall(r'\p{Script=Han}+', text) 형태로 작성하시면 됩니다.
9. 매일 쓰게 될 흔한 패턴
9.1 이메일 유효성 검사
먼저 어느 수준의 검증이 필요한지부터 정해야 합니다.
// 95% 패턴 — 대부분의 폼 검증기가 사용하는 형태
const email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
email.test('a@b.co'); // true
// "정말로 RFC 5322에 가깝게 가고 싶다" 버전
const rfc = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[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])?)*$/;
순수 정규식으로 RFC 5322 이메일을 끝까지 검증하려고 들면 패턴이 6000자 가까이 부풀어 오르는데도 엣지 케이스에서 여전히 틀립니다. 95% 패턴을 두고 실제 인증 메일을 보내는 편이 낫습니다. 메일이 도착해서 사용자가 링크를 눌렀는지가 결국 유일한 진짜 테스트입니다.
9.2 URL 추출
const urlPattern = /https?:\/\/[^\s<>"]+/g;
const found = 'See https://example.com/a?b=1 and http://x.io'.match(urlPattern);
// ['https://example.com/a?b=1', 'http://x.io']
URL을 뽑아낸 다음에는 보통 쿼리 스트링을 뜯어 보고 싶어집니다. 저희 URL 인코더 디코더에 붙여 넣으시면 퍼센트 인코딩된 파라미터가 사람이 읽을 수 있는 형태로 풀립니다. 언제 인코딩하고 언제 디코딩해야 하는지에 대한 전체 그림은 URL 인코딩/디코딩: 퍼센트 인코딩 가이드와 온라인 도구에서 확인하시면 됩니다.
9.3 전화번호
// E.164 — 국제 표준, 선택적 +와 1~3자리 국가 코드
const e164 = /^\+?[1-9]\d{1,14}$/;
e164.test('+14155551234'); // true
// 구분자가 포함된 북미 번호 체계
const nanp = /^(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/;
nanp.test('(415) 555-1234'); // true
“형태가 그럴듯한가” 수준을 넘어가는 검증이 필요하다면 libphonenumber를 사용하시기 바랍니다. 지역번호가 실제로 존재하는 번호 대역인지까지는 정규식으로 알아내지 못합니다.
9.4 IPv4와 IPv6
// IPv4 — 옥텟마다 엄격하게 0~255
const ipv4 = /^((25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(25[0-5]|2[0-4]\d|1?\d?\d)$/;
ipv4.test('192.168.1.1'); // true
ipv4.test('999.0.0.1'); // false
// IPv6 — 단순화된 형태. 완전한 RFC 4291 패턴은 ~600자.
const ipv6simple = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
ipv6simple.test('2001:0db8:85a3:0000:0000:8a2e:0370:7334'); // true
:: 축약, 내장 IPv4, zone identifier까지 포함한 진짜 IPv6를 다뤄야 한다면 node:net의 isIP()나 Python의 ipaddress.ip_address()를 쓰는 편이 안전합니다. 순수 정규식으로 시도해 보는 것은 한 번쯤 거치는 통과의례이고, 그 뒤로는 유지 보수 부담이 됩니다.
9.5 ISO 8601 날짜와 타임스탬프
// 날짜만 — YYYY-MM-DD
const isoDate = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
isoDate.test('2026-05-13'); // true
// 날짜 + 시간 + 선택적 소수점 초 + Z 또는 오프셋
const iso = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
iso.test('2026-05-13T09:30:00.123Z'); // true
ISO 8601은 단순해 보여도 함정이 적지 않습니다. 윤초, 주 단위 날짜(2026-W19), 일련 날짜(2026-133) 같은 표기가 그렇습니다. 에폭 초와 밀리초, 타임존 이동을 더 깊게 보시려면 유닉스 타임스탬프 완벽 가이드를 참고하시기 바랍니다.
10. 정규식으로 하는 찾기/치환 워크플로
10.1 JavaScript — String.replace와 $1
// 미국식 날짜 재포맷: MM/DD/YYYY -> YYYY-MM-DD
'05/13/2026'.replace(/(\d{2})\/(\d{2})\/(\d{4})/, '$3-$1-$2');
// '2026-05-13'
// 치환이 조건부일 때는 콜백 사용
'price 42 dollars'.replace(/(\d+) dollars/, (_, n) => `$${n}`);
// 'price $42'
$1, $2 등은 번호 그룹을 가리키고, $<name>은 이름 있는 그룹을 가리킵니다. $&는 매칭 전체, $$는 리터럴 $ 문자에 대응합니다.
10.2 Python — re.sub와 \1, 콜백
import re
# 위와 같은 날짜 재포맷
re.sub(r'(\d{2})/(\d{2})/(\d{4})', r'\3-\1-\2', '05/13/2026')
# '2026-05-13'
# 콜백 — 문자열 안의 모든 이메일 주소를 대문자로
def upper_email(m):
return m.group(0).upper()
re.sub(r'[\w.-]+@[\w.-]+', upper_email, 'mail me at hi@go-tools.org')
# 'mail me at HI@GO-TOOLS.ORG'
치환 쪽에서 Python은 \1 또는 \g<name> 표기를 씁니다. 원시 문자열 접두사 r'...'이 중요하니 빠뜨리시면 안 됩니다. 빠지면 \1이 리터럴 문자로 해석돼 버립니다.
10.3 CLI: sed, grep, ripgrep, jq
커맨드 라인에서 대량 리팩터링을 하다 보면 정규식이 스크립트 파일에서 셸 한 줄로 옮겨오게 됩니다.
# ripgrep — 이름이 붙은 모든 TODO를 찾기
rg -n '\bTODO\(([^)]+)\)' --replace 'TODO(\1)'
# 앵커가 들어간 grep -E — auth.log의 로그인 실패 줄
grep -E '^[A-Z][a-z]{2} +[0-9]+ .*Failed password' /var/log/auth.log
# sed — 트리 전체에서 줄 끝 공백을 제자리에서 제거
find . -name '*.md' -print0 | xargs -0 sed -i -E 's/[[:space:]]+$//'
ripgrep은 Rust의 regex 크레이트(RE2 스타일, 선형 시간, Lookbehind 미지원)를 씁니다. grep -E와 sed -E는 POSIX 확장 정규식을 쓰는데 \d가 없어서 [0-9]나 [[:digit:]]로 바꿔 써야 합니다. 데이터가 JSON이라면 정규식을 붙들고 있기보다 jq로 갈아타시는 편이 빠릅니다. 같은 형식의 참고 카드는 jq 치트시트에 정리해 두었습니다.
11. 흔한 함정
11.1 .을 이스케이프하지 않기
실제로 운영에 올라간 적 있는 버그입니다. IP 주소를 가려야 하는 로그 마스킹 도구에서 일어난 일입니다.
// 잘못된 코드 — '192a168b1c1'도 매칭됨
/(\d+).(\d+).(\d+).(\d+)/.test('192a168b1c1'); // true
// 올바른 코드
/(\d+)\.(\d+)\.(\d+)\.(\d+)/.test('192a168b1c1'); // false
문자 클래스 안에서는 .이 이미 리터럴이라 [.]와 \.가 모두 동작합니다. 클래스 밖에서는 반드시 백슬래시로 이스케이프해야 합니다.
11.2 Greedy한 .*이 너무 많이 먹는다
'<a href="x"><b>bold</b></a>'.match(/<(.*)>/)[1];
// 'a href="x"><b>bold</b></a' — 전체가 잡혀버림!
Greedy한 .*은 문자열 끝까지 훑은 다음 >이 매칭될 때까지 뒤로 물러납니다. 결국 입력의 마지막 >이 잡힙니다. Lazy(.*?)로 바꾸거나, 더 빠르고 의도가 분명한 부정 클래스([^>]*)를 사용하시면 됩니다.
11.3 멀티라인 앵커
자주 헷갈리는 지점입니다. ^과 $은 기본 상태에서 줄바꿈 문자에 매칭되지 않고, 전체 입력의 시작과 끝 위치에만 매칭됩니다. m 플래그를 더해야 줄 단위 앵커로 바뀌고, s 플래그를 더해야 .이 줄바꿈을 넘어갑니다. 두 플래그는 직교 관계라 로그 파싱에서는 보통 둘 다 필요합니다.
11.4 ReDoS와 무력화하는 법
ReDoS(정규식 서비스 거부)는 치명적 백트래킹의 프로덕션 버전입니다. 대처 방법은 다음과 같습니다.
- 정적 분석 도구를 사용합니다.
safe-regex,recheck, ESLint의no-misleading-character-class같은 도구가 위험한 패턴을 배포 전에 걸러 줍니다. - 원자 그룹을 활용합니다 (Python
regex, PCRE, Ruby, Java).(?>...)은 백트래킹 시 엔진이 그룹 안으로 다시 들어가지 못하게 막습니다. - 소유적 수량자를 씁니다 (PCRE/Java의
*+,++,?+). 같은 발상을 더 짧은 문법으로 표현합니다. - 백트래킹하지 않는 엔진으로 갈아탑니다. Go의
regexp, RE2, Rust의regex크레이트, Python의re2바인딩이 모두 선형 시간에 실행됩니다. RE2를 가장 널리 사용하는 사례가 ripgrep입니다. - 입력 길이부터 검사합니다. 10 KB짜리 정규식 폭탄은 버그이지만, 입력에 10바이트 상한을 거는 것은 한 줄짜리 코드입니다.
정규식과 짝을 이루는 일상 도구(포매터, 디코더, 변환기 등)의 전체 목록은 인코딩·해싱 필수 개발자 도구 온라인 가이드 (2026)에 정리해 두었습니다.
복잡한 패턴을 배포에 올리기 전에 인터랙티브하게 테스트해 보시기 바랍니다. regex101.com은 PCRE, JavaScript, Python, Go 플레이버를 오갈 수 있고 각 토큰을 설명해 주며 백트래킹까지 시각화해 주기 때문에 치명적인 패턴을 미리 잡아낼 수 있습니다.
12. FAQ
정규식의 *와 +의 차이는 무엇인가요?
*은 0번 이상 출현을 매칭하므로 빈 문자열도 통과합니다. +는 최소 한 번 이상을 요구합니다. 즉 a*는 '', 'a', 'aaaa'를 모두 매칭하고, a+는 'a'와 'aaaa'는 매칭하지만 ''은 매칭하지 않습니다.
정규식으로 여러 줄에 걸친 매칭은 어떻게 하나요?
멀티라인 플래그를 켜시면 됩니다. JavaScript에서는 /.../m, Python에서는 re.MULTILINE입니다. 그러면 ^과 $이 줄 단위 앵커로 동작합니다. .이 줄바꿈을 넘어가게 하려면 dotall 플래그도 추가합니다. JavaScript는 s, Python은 re.DOTALL입니다.
JavaScript와 Python의 정규식은 똑같나요?
수량자, 앵커, 문자 클래스, 기본 그룹 같은 핵심 문법은 90%가량 동일합니다. 실제로 갈리는 지점은 두 가지입니다. JavaScript(ES2018 이상)는 가변 길이 Lookbehind를 지원하고 이름 있는 그룹을 (?<name>...)로 표기합니다. Python 표준 라이브러리 re는 고정 너비 Lookbehind만 허용하고 (?P<name>...) 문법을 씁니다. Python에서 가변 길이 Lookbehind가 필요하다면 PyPI의 regex 패키지를 설치하시면 됩니다.
제 정규식에 치명적 백트래킹이 발생하는 이유는 무엇인가요?
(a+)+나 (a|a)*처럼 매칭이 겹치는 수량자를 중첩했기 때문입니다. 끝부분에서 매칭이 거의 성공하다가 실패하는 입력이 들어오면, 엔진이 안쪽 수량자의 모든 분할 조합을 시도하게 되고 경로 수가 지수적으로 늘어납니다. 원자 그룹 (?>a+)+이나 소유적 수량자 a++을 사용하시거나, RE2·Go의 regexp처럼 백트래킹하지 않는 엔진으로 옮기시면 해소됩니다.
JavaScript에서 Lookbehind를 쓸 수 있나요?
그렇습니다. 긍정형 (?<=...)과 부정형 (?<!...) Lookbehind는 ES2018부터 V8(Chrome, Node.js), SpiderMonkey(Firefox), JavaScriptCore(Safari 16.4 이상)에 모두 들어와 있습니다. 가변 길이 Lookbehind도 사용 가능합니다. 더 오래된 Safari를 지원해야 한다면 Babel로 트랜스파일하시거나, new RegExp 주변을 try/catch로 감싸 기능 감지를 두시면 됩니다.
정규식에서 리터럴 점 .은 어떻게 매칭하나요?
백슬래시로 이스케이프하시면 됩니다. \.이 리터럴 점에 대응합니다. 문자 클래스 안에서는 점이 이미 리터럴이라 [.]와 [\.]가 모두 동작합니다. 클래스 밖에서 이스케이프되지 않은 .은 메타문자이며 “줄바꿈을 제외한 모든 문자”(dotall 플래그가 있으면 모든 문자)를 의미합니다.
정규식에서 \s는 어떤 의미인가요?
\s는 모든 공백 문자(스페이스, 탭, 줄바꿈, 캐리지 리턴)에 매칭합니다. Unicode 모드에서는 NBSP까지 함께 매칭합니다. \S는 그 반대입니다.
정규식은 대소문자를 구분하나요?
기본 상태에서는 구분합니다. JavaScript에서는 i 플래그(/cat/i)를, Python에서는 re.IGNORECASE 또는 (?i)를 사용하시면 됩니다.