Skip to content

필사 모드: 정규식 제로부터 자신감까지: 문자 클래스·수량자·앵커·그룹·룩어라운드·ReDoS 완전정복

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — 정규식은 작은 언어다

정규식(regular expression, regex)을 처음 보면 마치 고양이가 키보드 위를 걸어간 결과물처럼 보입니다. 하지만 정규식은 무작위가 아니라, 문자열의 패턴을 기술하기 위한 아주 작고 정교한 언어입니다. 몇 가지 구성 요소만 이해하면, 겁나던 그 기호들이 문장처럼 읽히기 시작합니다.

이 글은 정규식을 바닥부터 쌓아 올립니다. 구성 요소를 하나씩 익히고, 실무에서 진짜 발목을 잡는 성능 함정까지 다룹니다. 규칙을 하나 미리 말해 두면, 이 글의 모든 정규식 패턴은 인라인 코드나 코드 블록 안에 넣었습니다. 정규식에는 중괄호나 부등호 같은 특수문자가 많아서, 그대로 문장에 두면 렌더링이 깨질 수 있기 때문입니다. 여러분이 코드에 정규식을 쓸 때도 이 습관은 그대로 유용합니다.

패턴을 직접 만들어 보며 익히고 싶다면, 글을 읽는 내내 이 사이트의 정규식 테스터에 패턴을 붙여 넣어 실시간으로 확인해 보세요.

리터럴과 메타문자

가장 단순한 정규식은 그냥 찾을 글자 그대로입니다. 패턴 cat 은 문자열 안에서 "cat"이라는 연속된 세 글자를 찾습니다. 이런 평범한 글자를 **리터럴(literal)**이라고 합니다.

정규식이 강력해지는 건 메타문자(metacharacter) 덕분입니다. 이들은 글자 그대로가 아니라 특별한 의미를 가집니다. 대표적인 메타문자는 다음과 같습니다.

  . ^ $ * + ? ( ) [ ] { } | \

이 문자들을 "글자 그대로" 찾고 싶을 때는 앞에 백슬래시를 붙여 이스케이프합니다. 예를 들어 진짜 마침표를 찾으려면 점 하나를 뜻하는 메타문자 대신 \. 라고 써야 합니다. 도메인에서 마침표를 찾는 패턴은 example\.com 처럼 됩니다.

문자 클래스 — "이 중 아무거나"

**문자 클래스(character class)**는 대괄호로 감싸며, "이 안에 있는 문자 중 하나"에 매칭됩니다.

  • [aeiou] 는 모음 한 글자에 매칭됩니다.
  • [a-z] 는 소문자 한 글자에 매칭됩니다. 하이픈은 범위를 뜻합니다.
  • [a-zA-Z0-9] 는 영문자와 숫자 한 글자에 매칭됩니다.
  • [^0-9] 처럼 대괄호 안 맨 앞에 캐럿을 넣으면 부정이 되어, 숫자가 아닌 한 글자에 매칭됩니다.

자주 쓰는 문자 클래스에는 짧은 축약형이 있습니다.

  • \d 는 숫자, \D 는 숫자가 아닌 문자.
  • \w 는 단어 문자(영문자, 숫자, 밑줄), \W 는 그 반대.
  • \s 는 공백 문자(스페이스, 탭, 줄바꿈 등), \S 는 그 반대.
  • . 은 (기본 설정에서) 줄바꿈을 제외한 아무 문자 하나.

예를 들어 숫자 세 개는 \d\d\d 로 표현할 수 있는데, 곧 배울 수량자를 쓰면 더 짧아집니다.

수량자 — "몇 번 반복?"

**수량자(quantifier)**는 바로 앞의 요소가 몇 번 반복되는지를 나타냅니다.

  • * 는 0번 이상. (있어도 되고 없어도 됨)
  • + 는 1번 이상. (최소 한 번)
  • ? 는 0번 또는 1번. (선택적)
  • {n} 은 정확히 n번.
  • {n,} 은 n번 이상.
  • {n,m} 은 n번 이상 m번 이하.

앞서 나온 숫자 세 개는 \d{3} 으로 간결해집니다. 전화번호의 국번 부분처럼 3에서 4자리 숫자를 원하면 \d{3,4} 라고 씁니다. 한 개 이상의 단어 문자는 \w+, 있어도 되고 없어도 되는 프로토콜의 s는 https? (즉 http 또는 https)로 표현합니다.

앵커와 경계 — "어디에서?"

지금까지의 요소들이 "무엇을" 찾을지였다면, **앵커(anchor)**는 "어디에서" 찾을지를 고정합니다. 앵커는 문자에 매칭되는 것이 아니라 위치에 매칭됩니다.

  • ^ 는 문자열(또는 줄)의 시작.
  • $ 는 문자열(또는 줄)의 끝.
  • \b 는 단어 경계. 단어 문자와 비단어 문자 사이의 틈입니다.
  • \B 는 단어 경계가 아닌 위치.

예를 들어 ^\d+$ 는 "처음부터 끝까지 오직 숫자로만 이루어진 문자열"에 매칭됩니다. 앵커가 없다면 \d+ 는 "abc123def" 안의 "123"에도 매칭되지만, 앞뒤에 시작 앵커 ^ 와 끝 앵커를 붙이면 문자열 전체가 숫자여야만 매칭됩니다. 입력 검증에서 이 차이는 결정적입니다.

단어 경계 \b 도 유용합니다. \bcat\b 는 독립된 단어 "cat"에만 매칭되고, "category"나 "concatenate" 안의 "cat"에는 매칭되지 않습니다.

그룹과 캡처 — 묶고 기억하기

소괄호는 여러 요소를 하나로 묶어 **그룹(group)**을 만듭니다. 그룹은 두 가지 일을 합니다. 수량자를 여러 문자에 한꺼번에 적용하고, 매칭된 부분을 나중에 꺼내 쓸 수 있게 **캡처(capture)**합니다.

  • (ab)+ 는 "ab"가 한 번 이상 반복되는 것("ababab" 등)에 매칭됩니다. 그룹이 없었다면 ab+ 는 "abbbb"에 매칭되어 의미가 완전히 달라집니다.
  • 날짜를 뜯어내는 패턴 (\d{4})-(\d{2})-(\d{2}) 는 연·월·일을 각각 그룹 1, 2, 3으로 캡처합니다. 프로그램에서 이 캡처 그룹을 꺼내 쓸 수 있습니다.

캡처가 필요 없이 묶기만 하고 싶을 때는 비캡처 그룹을 씁니다. (?:...) 형태입니다. 예를 들어 (?:https?://)? 는 프로토콜 부분을 선택적으로 묶되 캡처는 하지 않습니다. 불필요한 캡처를 줄이면 패턴의 의도가 분명해지고 약간의 성능 이득도 있습니다.

많은 언어는 이름 있는 그룹도 지원합니다. (?<year>\d{4}) 처럼 쓰면 숫자 인덱스 대신 이름으로 꺼낼 수 있어 가독성이 좋아집니다.

선택 — "이것 또는 저것"

파이프 기호는 **선택(alternation)**을 뜻합니다. "왼쪽 또는 오른쪽"입니다.

  • cat|dog 는 "cat" 또는 "dog"에 매칭됩니다.
  • 선택의 범위를 제한하려면 그룹으로 감쌉니다. 아래 두 패턴을 비교해 보세요.
^(cat|dog)$    → 문자열 전체가 "cat" 또는 "dog"
^cat|dog$      → "^cat" 또는 "dog$" 로 해석됨 (의도와 다름)

그룹으로 감싼 첫 번째만 "문자열 전체가 cat이거나 dog"를 뜻합니다. 그룹이 없으면 선택 기호의 범위가 패턴 전체로 퍼져 전혀 다른 결과가 나옵니다.

선택지가 여럿이면 나열합니다. (jpg|jpeg|png|gif) 처럼요. 이때 순서와 앵커에 주의해야 원하는 부분만 정확히 잡을 수 있습니다.

룩어라운드 — 소비하지 않고 엿보기

**룩어라운드(lookaround)**는 조금 더 고급 도구입니다. 특정 패턴이 앞이나 뒤에 있는지 확인하되, 그 부분을 매칭 결과에 포함(소비)하지는 않습니다. 네 가지가 있습니다.

  • 긍정 전방 탐색(lookahead) (?=...): 뒤에 이것이 온다면.
  • 부정 전방 탐색 (?!...): 뒤에 이것이 오지 않는다면.
  • 긍정 후방 탐색(lookbehind) (?<=...): 앞에 이것이 있었다면.
  • 부정 후방 탐색 (?<!...): 앞에 이것이 없었다면.

실용적인 예를 봅시다. 숫자에 천 단위 콤마를 넣을 자리를 찾을 때, "뒤에 세 자리씩 숫자가 남아 있는 위치"를 룩어헤드로 찾습니다. 또 비밀번호 규칙 검증에서 "숫자를 최소 하나 포함"을 요구할 때 (?=.*\d) 를 씁니다. 이 조각은 실제로 아무 문자도 소비하지 않고, "어딘가에 숫자가 있다"는 조건만 검사합니다. 여러 조건을 겹쳐 ^(?=.*[a-z])(?=.*\d).{8,}$ 처럼 쓰면 "소문자 포함, 숫자 포함, 8자 이상"을 한 번에 검증할 수 있습니다.

탐욕적 vs 게으른 — 매칭의 식탐

수량자에는 숨은 성격이 있습니다. 기본적으로 수량자는 **탐욕적(greedy)**입니다. 즉 가능한 한 많이 먹으려 합니다. 이 성질은 종종 함정이 됩니다.

HTML 태그를 잡으려고 <.+> 라는 패턴을 썼다고 합시다. "<b>bold</b>" 라는 문자열에서 여러분은 아마 <b> 만 잡히길 기대하겠지만, 탐욕적인 .+ 는 최대한 많이 삼켜서 <b>bold</b> 전체를 잡아 버립니다. 첫 번째 부등호부터 마지막 부등호까지 통째로 먹은 것입니다.

해결책은 수량자를 게으르게(lazy) 만드는 것입니다. 수량자 뒤에 ? 를 붙이면 됩니다. <.+?> 는 "가능한 한 적게" 먹으므로 <b> 만 잡습니다. 정리하면 이렇습니다.

  • *, +, ?, {n,m} 은 탐욕적. 최대한 많이.
  • *?, +?, ??, {n,m}? 은 게으름. 최소한만.

참고로 이 예시에서 더 나은 방법은 부정 문자 클래스를 쓰는 것입니다. <[^>]+> 는 "부등호가 아닌 문자들"만 먹으므로 애초에 닫는 부등호를 넘어가지 않습니다. 이런 식으로 백트래킹 자체를 줄이는 설계가, 다음에 볼 성능 문제를 예방하는 핵심입니다.

파국적 백트래킹과 ReDoS

정규식 엔진의 상당수는 매칭에 실패하면 뒤로 돌아가 다른 경우의 수를 시도하는 백트래킹(backtracking) 방식으로 동작합니다. 대부분은 문제가 없지만, 패턴을 잘못 짜면 시도해야 할 경우의 수가 입력 길이에 따라 지수적으로 폭발합니다. 이것이 **파국적 백트래킹(catastrophic backtracking)**입니다.

전형적인 위험 패턴은 반복 안에 반복이 겹치고, 그 경계가 모호할 때 생깁니다. 예를 들어 (a+)+$ 같은 패턴에 "aaaaaaaaaaX"처럼 끝에서 매칭이 실패하는 입력을 주면, 엔진은 a들을 안쪽 그룹과 바깥쪽 그룹에 나눠 담는 무수한 조합을 전부 시도하다가 사실상 멈춰 버립니다. 입력이 몇 글자만 길어져도 시간이 폭발적으로 늘어납니다.

이 취약점을 악용해 서비스를 마비시키는 공격이 **ReDoS(Regular expression Denial of Service)**입니다. 공격자가 악의적으로 설계한 입력 하나로 서버 CPU를 100%로 묶어 버릴 수 있습니다. 실제로 유명 라이브러리들에서 ReDoS 취약점이 반복적으로 발견되어 왔습니다.

방어법은 다음과 같습니다.

  • 중첩 수량자를 피하기: (a+)+(a*)* 처럼 반복 안의 반복이 모호하게 겹치는 구조를 경계합니다.
  • 가능한 한 구체적으로: 광범위한 . 대신 [^>] 처럼 좁은 문자 클래스를 쓰면 백트래킹 여지가 줄어듭니다.
  • 앵커로 고정: ^$ 로 매칭 범위를 묶으면 엔진이 헤맬 여지가 줄어듭니다.
  • 선형 시간 엔진 고려: RE2(구글)나 Rust의 regex 크레이트처럼 백트래킹을 쓰지 않고 항상 선형 시간을 보장하는 엔진을 쓰면 ReDoS 자체가 불가능합니다.
  • 신뢰할 수 없는 입력에 타임아웃: 언어나 라이브러리가 제공한다면 정규식 실행에 타임아웃을 겁니다.

정규식을 쓰면 안 되는 순간

정규식은 강력하지만 만능이 아닙니다. 가장 유명한 반례는 HTML(또는 XML) 파싱입니다. HTML은 중첩되고 재귀적인 구조인데, 전통적인 정규식은 이런 임의 깊이의 중첩을 근본적으로 표현할 수 없습니다. 정규식으로 HTML을 억지로 파싱하려는 시도는 스택오버플로의 전설적인 답변에서 격렬히 경고된 바 있고, 실무에서도 온갖 엣지 케이스에 무너집니다. HTML은 전용 파서(DOM 파서)로 다뤄야 합니다.

정규식이 부적합한 다른 신호들도 있습니다.

  • 중첩된 괄호나 구조의 균형을 맞춰야 할 때: 여는 괄호와 닫는 괄호의 짝을 임의 깊이로 맞추는 것은 정규식의 영역이 아닙니다.
  • 패턴이 코드보다 이해하기 어려워질 때: 읽는 데 5분이 걸리는 100자짜리 정규식보다, 몇 줄의 명시적인 문자열 처리 코드가 나을 때가 많습니다.
  • 이미 파서가 있는 형식: JSON, CSV, URL, 날짜 등은 대부분 검증된 전용 파서 라이브러리가 있습니다. 직접 정규식을 짜기 전에 그것부터 찾아보세요.

정규식은 "토큰 단위의 국소적 패턴 매칭"에 가장 빛납니다. 이메일 형식의 대략적 검증, 로그 라인에서 필드 추출, 찾아 바꾸기 같은 작업이 정규식의 홈그라운드입니다.

실무 팁 몇 가지

마지막으로 정규식을 실제로 쓸 때 도움이 되는 습관들입니다.

  • 주석과 확장 모드: 많은 언어가 x 플래그(확장 모드)를 지원해, 정규식 안에 공백과 주석을 넣어 여러 줄로 풀어 쓸 수 있게 해 줍니다. 복잡한 패턴일수록 이렇게 풀어 쓰면 유지보수가 쉬워집니다.
  • 플래그를 이해하기: 대소문자 무시(i), 여러 줄 모드(m, ^$ 가 각 줄에 적용), 점이 줄바꿈도 포함(s) 같은 플래그는 결과를 크게 바꿉니다.
  • 미리 컴파일하기: 같은 패턴을 반복 사용한다면 루프 밖에서 한 번 컴파일해 재사용하는 것이 성능에 좋습니다.
  • 테스트를 곁들이기: 정규식은 미묘합니다. 대표 입력과 엣지 케이스로 테스트를 만들어 두면 나중에 패턴을 고칠 때 안전합니다.

배운 것을 굳히는 가장 좋은 방법은 문제를 풀어 보는 것입니다. 이 사이트의 정규식 퀴즈로 개념을 하나씩 점검하고, 정규식 테스터로 자신만의 패턴을 실험해 보세요.

마치며

정규식은 겉보기엔 암호 같지만, 결국 몇 가지 구성 요소의 조합입니다. 리터럴과 문자 클래스로 "무엇을", 수량자로 "몇 번", 앵커로 "어디에서"를 정하고, 그룹과 선택과 룩어라운드로 구조를 세밀하게 다듬습니다. 여기에 탐욕과 게으름의 차이, 그리고 파국적 백트래킹이라는 함정을 알고 나면, 여러분은 이미 대부분의 실무 상황을 자신 있게 다룰 수 있습니다.

가장 중요한 교훈은 절제입니다. 정규식은 국소적 패턴 매칭에 쓰고, 중첩된 구조 파싱에는 쓰지 마세요. 그 선을 지키면 정규식은 위험한 주문이 아니라 믿음직한 도구가 됩니다.

참고 자료

현재 단락 (1/85)

정규식(regular expression, regex)을 처음 보면 마치 고양이가 키보드 위를 걸어간 결과물처럼 보입니다. 하지만 정규식은 무작위가 아니라, 문자열의 패턴을 기술하...

작성 글자: 0원문 글자: 5,803작성 단락: 0/85