Showing posts with label Regex. Show all posts
Showing posts with label Regex. Show all posts

Tuesday, May 30, 2023

정규 표현식의 원리부터 실전 활용까지: 텍스트 처리의 핵심

디지털 시대의 데이터는 대부분 텍스트 형태로 존재합니다. 로그 파일, 사용자 입력, 소스 코드, 웹 페이지 등 방대한 양의 문자열 데이터 속에서 특정 패턴을 찾아내고, 검증하며, 원하는 형태로 가공하는 능력은 개발자, 데이터 과학자, 시스템 관리자 모두에게 필수적인 기술입니다. 바로 이 지점에서 정규 표현식(Regular Expression, 이하 RegEx 또는 정규식)이 그 강력한 힘을 발휘합니다. 정규 표현식은 복잡한 문자열 처리 규칙을 간결한 하나의 '패턴'으로 정의할 수 있게 해주는, 텍스트 처리 분야의 스위스 군용 칼과 같은 존재입니다.

단순한 문자열 검색을 넘어, 이메일 주소의 유효성 검사, URL에서 특정 정보 추출, 코드 리팩토링 시 변수 이름 일괄 변경, 방대한 로그 데이터에서 오류 메시지만 필터링하는 등 정규 표현식의 활용 범위는 상상을 초월합니다. 처음 접할 때는 외계어처럼 보이는 기호들의 나열에 당황할 수 있지만, 그 구성 원리와 핵심 개념을 차근차근 이해하고 나면 이전에는 수십, 수백 줄의 코드로 해결해야 했던 문제들을 단 한 줄의 표현식으로 우아하게 해결하는 자신을 발견하게 될 것입니다. 이 글에서는 정규 표현식의 가장 기본적인 구성 요소부터 시작하여, 엔진의 작동 방식에 대한 깊이 있는 이해를 바탕으로 실무에서 마주할 수 있는 다양한 문제들을 해결하는 구체적인 예제까지 폭넓게 다룰 것입니다.

1. 정규 표현식의 심장: 기본 구성 요소 파헤치기

정규 표현식은 여러 특수 기호와 일반 문자의 조합으로 만들어집니다. 이 조합을 '패턴'이라고 부르며, 정규식 엔진은 이 패턴을 기준으로 대상 문자열을 탐색합니다. 패턴을 구성하는 가장 기본적인 요소들을 이해하는 것이 정규식 학습의 첫걸음입니다.

1.1. 리터럴 (Literals)

가장 단순한 형태의 정규 표현식 요소는 '리터럴(literal)' 즉, 일반 문자입니다. 특별한 의미를 가지지 않는 모든 알파벳(a-z, A-Z), 숫자(0-9), 그리고 대부분의 기호들은 보이는 그대로의 문자와 일치합니다. 예를 들어, 정규 표현식 cat은 대상 문자열 "The cat sat on the mat."에서 정확히 'cat'이라는 부분과 일치합니다. 이는 가장 직관적인 형태로, 특정 단어나 고정된 문자열을 찾을 때 사용됩니다.

1.2. 메타 문자 (Metacharacters)

정규 표현식의 진정한 힘은 '메타 문자'에서 나옵니다. 메타 문자는 일반적인 문자가 아닌, 패턴 내에서 특별한 의미나 기능을 수행하는 문자들을 말합니다. 이들을 통해 '모든 문자', '문장의 시작', '하나 이상의 숫자' 등 추상적인 조건을 표현할 수 있습니다.

주요 메타 문자는 다음과 같습니다.

  • . (마침표): 줄 바꿈 문자(\n, \r)를 제외한 모든 단일 문자와 일치합니다. 예를 들어, c.t는 "cat", "cot", "c@t" 등 가운데에 어떤 한 문자가 오는 세 글자 단어와 모두 일치합니다.
  • | (수직선): 'OR' 연산자와 같습니다. cat|dog 패턴은 'cat' 또는 'dog'라는 문자열과 일치합니다.
  • ^ (캐럿): 문자열의 시작 부분을 의미하는 '앵커(anchor)'입니다. ^The는 "The"로 시작하는 문자열에서만 "The"와 일치합니다. "Hello, The world"에서는 일치하지 않습니다. (문자 세트 [] 안에서는 '부정'의 의미로 사용되므로 위치에 따라 의미가 달라짐을 유의해야 합니다.)
  • $ (달러 기호): 문자열의 끝 부분을 의미하는 앵커입니다. world$는 "world"로 끝나는 문자열에서만 "world"와 일치합니다.
  • \ (역슬래시): 이스케이프(escape) 문자입니다. 메타 문자의 특수 기능을 없애고 리터럴 문자로 취급하도록 만듭니다. 예를 들어, 실제 마침표(.) 문자를 찾고 싶다면 \.와 같이 사용해야 합니다. 또한, \d, \s와 같이 미리 정의된 문자 클래스를 나타내는 데도 사용됩니다.

1.3. 문자 세트 (Character Sets or Character Classes)

대괄호 []는 내부에 포함된 문자 중 '하나'와 일치함을 의미하는 문자 세트를 정의합니다. 이는 특정 범위의 문자들을 유연하게 표현할 때 매우 유용합니다.

  • 기본 사용법: [aeiou] 패턴은 모든 모음 중 하나와 일치합니다. "chat"에서는 'a'와 일치하고, "shot"에서는 'o'와 일치합니다.
  • 범위 지정 (Ranges): 하이픈(-)을 사용하여 문자 범위를 지정할 수 있습니다. [a-z]는 모든 영어 소문자 중 하나, [0-9]는 모든 숫자 중 하나, [A-Za-z0-9]는 모든 영문 대소문자와 숫자 중 하나와 일치합니다.
  • 부정 (Negation): 대괄호 안에서 캐럿(^)을 첫 문자로 사용하면, 해당 문자 세트에 포함되지 '않는' 모든 문자와 일치합니다. 예를 들어, [^0-9]는 숫자가 아닌 모든 문자와 일치합니다.

미리 정의된 문자 클래스 (Predefined Character Classes)

자주 사용되는 문자 세트는 편리한 약어로 미리 정의되어 있습니다. 가독성과 편의성을 크게 향상시켜 줍니다.

  • \d: 모든 숫자와 일치합니다. [0-9]와 동일합니다.
  • \D: 숫자가 아닌 모든 문자와 일치합니다. [^0-9]와 동일합니다.
  • \w: 영문 대소문자, 숫자, 그리고 언더스코어(_)를 포함하는 '단어' 문자와 일치합니다. [A-Za-z0-9_]와 동일합니다.
  • \W: '단어' 문자가 아닌 모든 문자와 일치합니다. [^A-Za-z0-9_]와 동일합니다. (공백, 특수문자 등)
  • \s: 공백, 탭, 줄 바꿈 등 모든 공백 문자와 일치합니다. [ \t\n\r\f\v]와 유사합니다.
  • \S: 공백 문자가 아닌 모든 문자와 일치합니다. [^ \t\n\r\f\v]와 유사합니다.

1.4. 수량자 (Quantifiers)

수량자는 바로 앞의 문자, 문자 세트, 또는 그룹이 몇 번 반복될 수 있는지를 지정합니다. 수량자를 통해 패턴의 유연성을 극대화할 수 있습니다.

  • * (별표): 바로 앞의 요소가 0번 이상 반복되는 경우와 일치합니다. (zero or more) ca*t는 "ct", "cat", "caaat" 등과 모두 일치합니다.
  • + (더하기): 바로 앞의 요소가 1번 이상 반복되는 경우와 일치합니다. (one or more) ca+t는 "cat", "caaat"과는 일치하지만, "ct"와는 일치하지 않습니다.
  • ? (물음표): 바로 앞의 요소가 0번 또는 1번 나타나는 경우와 일치합니다. (zero or one) colou?r는 "color"와 "colour" 모두와 일치합니다. 선택적인 문자를 표현할 때 유용합니다.
  • {n}: 바로 앞의 요소가 정확히 n번 반복되는 경우와 일치합니다. \d{3}은 세 자리 숫자("123", "987")와 일치합니다.
  • {n,}: 바로 앞의 요소가 최소 n번 이상 반복되는 경우와 일치합니다. \d{2,}는 두 자리 이상의 모든 숫자("12", "123", "1234")와 일치합니다.
  • {n,m}: 바로 앞의 요소가 최소 n번, 최대 m번 반복되는 경우와 일치합니다. \d{2,4}는 두 자리, 세 자리, 네 자리 숫자와 일치합니다.

1.5. 그룹화와 캡처링 (Grouping and Capturing)

소괄호 ()는 정규 표현식에서 두 가지 중요한 역할을 수행합니다.

  1. 그룹화: 패턴의 일부를 하나의 단위로 묶어줍니다. 그룹화된 부분에는 수량자를 적용할 수 있습니다. 예를 들어, (ha)+ 패턴은 "ha", "haha", "hahaha" 등 'ha'라는 문자열이 한 번 이상 반복되는 경우와 일치합니다. 만약 ha+라고 썼다면, 'a'만 반복되어 "ha", "haa", "haaa"와 일치하게 되므로 의미가 완전히 달라집니다.
  2. 캡처링: 소괄호로 묶인 부분과 일치하는 문자열은 '캡처'되어 나중에 다시 참조하거나 결과에서 추출할 수 있습니다. 캡처된 그룹은 왼쪽 소괄호가 나타나는 순서대로 1번, 2번... 번호가 매겨집니다. 예를 들어, (\w+)-(\d+) 패턴을 "product-1234" 문자열에 적용하면, 그룹 1에는 "product"가, 그룹 2에는 "1234"가 캡처됩니다. 이는 문자열 치환이나 데이터 추출에 매우 강력한 기능입니다.

때로는 그룹화만 필요하고 캡처는 원하지 않을 수 있습니다. 캡처링은 약간의 성능 오버헤드를 유발하기 때문입니다. 이 경우 비-캡처링 그룹(non-capturing group) (?:...)을 사용할 수 있습니다. 예를 들어, (?:http|https):// 패턴은 "http://" 또는 "https://"와 일치하지만, "http"나 "https" 부분을 별도로 캡처하지는 않습니다.

1.6. 경계 (Boundaries)

앵커(^, $)가 문자열 전체의 시작과 끝을 다룬다면, 단어 경계는 단어 수준에서의 위치를 지정합니다.

  • \b (단어 경계): 단어 문자와 단어가 아닌 문자 사이의 위치와 일치합니다. 단어 문자는 \w(영문, 숫자, _)이고, 그 외는 모두 단어가 아닌 문자입니다. 예를 들어, \bcat\b는 "The cat sat"에서 'cat'과 일치하지만, "concatenate"의 'cat'과는 일치하지 않습니다. 정확히 'cat'이라는 단어만 찾고 싶을 때 매우 유용합니다.
  • \B (단어 경계가 아님): \b의 반대입니다. 단어 문자와 단어 문자 사이, 또는 단어가 아닌 문자와 단어가 아닌 문자 사이의 위치와 일치합니다. \Bcat\B는 "concatenate"의 'cat'과 일치합니다.

2. 보이지 않는 조건: 전후방 탐색 (Lookarounds)

전후방 탐색은 정규 표현식의 고급 기능 중 하나로, '제로 너비 단언(zero-width assertion)'이라고도 불립니다. 이는 특정 위치의 앞이나 뒤에 특정 패턴이 오는지 '확인'만 할 뿐, 해당 패턴 자체를 일치(match) 결과에 포함시키지는 않는다는 의미입니다. 즉, 조건을 검사하기 위해 눈으로 보기만 하고(look around), 실제로는 한 발자국도 움직이지(zero-width) 않는 것과 같습니다.

  • (?=...) (긍정형 전방 탐색, Positive Lookahead): 현재 위치 바로 뒤에 ... 패턴이 따라오는 경우에만 일치를 허용합니다. 예를 들어, A(?=B) 패턴은 뒤에 'B'가 오는 'A'와 일치합니다. "ABC" 문자열에서 'A'만 일치하고 'B'는 결과에 포함되지 않습니다.
  • (?!...) (부정형 전방 탐색, Negative Lookahead): 현재 위치 바로 뒤에 ... 패턴이 따라오지 않는 경우에만 일치를 허용합니다. A(?!B) 패턴은 뒤에 'B'가 오지 않는 'A'와 일치합니다. "ACD"에서는 'A'와 일치하지만, "ABC"에서는 일치하지 않습니다. (비밀번호 규칙: "문자와 숫자를 모두 포함하지만, 특정 특수문자는 포함 금지"와 같은 복잡한 조건을 만드는 데 매우 유용합니다.)
  • (?<=...) (긍정형 후방 탐색, Positive Lookbehind): 현재 위치 바로 앞에 ... 패턴이 있는 경우에만 일치를 허용합니다. (?<=A)B 패턴은 앞에 'A'가 있는 'B'와 일치합니다. "ABC" 문자열에서 'B'만 일치하고 'A'는 결과에 포함되지 않습니다.
  • (?<!...) (부정형 후방 탐색, Negative Lookbehind): 현재 위치 바로 앞에 ... 패턴이 없는 경우에만 일치를 허용합니다. (?<!A)B 패턴은 앞에 'A'가 없는 'B'와 일치합니다. "XBC"에서는 'B'와 일치하지만, "ABC"에서는 일치하지 않습니다.

주의: 일부 오래된 정규식 엔진에서는 후방 탐색, 특히 가변 길이 후방 탐색을 지원하지 않을 수 있습니다.

3. 정규 표현식 엔진의 두 얼굴: Greedy vs. Lazy

수량자(*, +, {})는 기본적으로 '탐욕스러운(Greedy)' 방식으로 동작합니다. 이는 가능한 한 가장 긴 문자열을 찾아 일치시키려고 시도한다는 의미입니다.

예를 들어, 문자열 "<p>first</p> and <p>second</p>"에서 HTML 태그를 제거하기 위해 <.*> 패턴을 사용했다고 가정해 봅시다. 우리의 의도는 <p></p>를 각각 찾는 것이지만, Greedy 수량자인 *는 가능한 한 길게 일치하려고 하기 때문에, 첫 <에서 시작하여 마지막 >까지, 즉 "<p>first</p> and <p>second</p>" 전체를 하나의 일치 항목으로 간주합니다.

이 문제를 해결하기 위해 '게으른(Lazy)' 또는 '최소 일치(Minimal Match)' 수량자를 사용합니다. 수량자 뒤에 물음표(?)를 붙여주면 됩니다.

  • *?: 0번 이상, 최소한으로 일치
  • +?: 1번 이상, 최소한으로 일치
  • ??: 0번 또는 1번, 최소한으로 일치 (기본 ?와 동일하게 동작)
  • {n,}?: n번 이상, 최소한으로 일치

위의 예시에 Lazy 수량자를 적용한 <.*?> 패턴을 사용하면, 정규식 엔진은 첫 <를 만난 후, 가장 가까운 다음 >를 찾아 일치를 멈춥니다. 따라서 "<p>", "</p>", "<p>", "</p>"를 각각 정확하게 찾아냅니다. 이는 HTML/XML 파싱, 따옴표 안의 문자열 추출 등에서 매우 중요한 개념입니다.

(참고: 일부 엔진은 '소유적인(Possessive)' 수량자 *+, ++ 등을 지원하며, 이는 Greedy하게 일치하되 엔진의 역추적(Backtracking)을 허용하지 않아 성능 최적화에 사용됩니다.)

4. 실전! 정규 표현식 활용 예제

이제 이론을 바탕으로 실제 문제 해결에 정규 표현식이 어떻게 사용되는지 구체적인 예제를 통해 살펴보겠습니다.

예제 1: 이메일 주소 유효성 검사

가장 흔한 예제 중 하나는 이메일 주소의 형식을 검사하는 것입니다. 완벽한 이메일 정규식(RFC 5322 표준을 따르는)은 극도로 복잡하지만, 일반적인 수준에서는 아래와 같은 패턴이 널리 사용됩니다.

^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$

이 패턴을 단계별로 분석해 보겠습니다.

  • ^: 문자열의 시작.
  • [a-zA-Z0-9._%+-]+: 이메일의 로컬 파트(@ 앞부분).
    • [a-zA-Z0-9._%+-]: 영문 대소문자, 숫자, 또는 특수문자 ., _, %, +, - 중 하나.
    • +: 이 문자들 중 하나 이상이 반복됨.
  • @: 리터럴 '@' 문자.
  • [a-zA-Z0-9.-]+: 도메인 이름 파트.
    • [a-zA-Z0-9.-]: 영문 대소문자, 숫자, 또는 ., - 중 하나.
    • +: 이 문자들 중 하나 이상이 반복됨.
  • \.: 리터럴 '.' 문자. 최상위 도메인(TLD)을 구분합니다.
  • [a-zA-Z]{2,}: 최상위 도메인(TLD) 파트.
    • [a-zA-Z]: 영문 대소문자 중 하나.
    • {2,}: 이 문자가 최소 2번 이상 반복됨 (e.g., 'com', 'kr', 'io').
  • $: 문자열의 끝.

예제 2: URL에서 정보 추출하기

주어진 URL에서 프로토콜, 호스트, 경로를 분리하고 싶다고 가정해 봅시다.

대상 문자열: https://www.example.com/products/search?q=regex

정규 표현식:

^(https?):\/\/([^\/]+)(\/.*)?$

분석 및 캡처 결과:

  • ^: 문자열 시작.
  • (https?): 캡처 그룹 1. 'http'로 시작하고, 's'가 있을 수도 있고 없을 수도 있습니다(s?).
    • 결과: https
  • :\/\/: 리터럴 '://' 문자열.
  • ([^\/]+): 캡처 그룹 2. 슬래시(/)가 아닌 모든 문자([^\/])가 하나 이상(+) 반복되는 부분. 즉, 호스트 부분입니다.
    • 결과: www.example.com
  • (\/.*)?: 캡처 그룹 3. 슬래시(/)로 시작하고 그 뒤에 어떤 문자(.)가 0번 이상(*) 오는, 즉 경로 전체를 의미합니다. 이 전체 그룹이 있을 수도 있고 없을 수도 있습니다(?).
    • 결과: /products/search?q=regex
  • $: 문자열 끝.

예제 3: 전화번호 형식 통일하기

다양한 형식의 전화번호(010-1234-5678, 010 1234 5678, 01012345678)를 010-XXXX-XXXX 형식으로 통일하고 싶을 때, 캡처 그룹과 치환 기능을 활용할 수 있습니다.

정규 표현식 (검색용):

(010)[-\s]?(\d{4})[-\s]?(\d{4})

치환 문자열: $1-$2-$3 (많은 프로그래밍 언어와 도구에서 $n 또는 \n 형태로 캡처 그룹을 참조합니다.)

분석:

  • (010): 캡처 그룹 1. 리터럴 '010'을 캡처합니다.
  • [-\s]?: 하이픈(-) 또는 공백(\s)이 있을 수도 있고 없을 수도 있습니다.
  • (\d{4}): 캡처 그룹 2. 숫자 4개를 캡처합니다.
  • [-\s]?: 다시 구분자가 있을 수도 있고 없을 수도 있습니다.
  • (\d{4}): 캡처 그룹 3. 마지막 숫자 4개를 캡처합니다.

이 패턴으로 검색한 후, $1-$2-$3 형식으로 치환하면 모든 형식이 010-1234-5678로 깔끔하게 통일됩니다.

5. 성능과 가독성: 현명한 정규 표현식 사용법

정규 표현식은 매우 강력하지만, 복잡하고 비효율적으로 작성될 경우 심각한 성능 저하를 일으키거나 동료 개발자가 해독 불가능한 코드를 만들 수 있습니다.

5.1. 가독성 우선

몇 달 뒤 자기 자신도 이해하지 못할 정규식은 좋은 코드가 아닙니다.

  • 주석 활용: 복잡한 정규식은 반드시 주석을 통해 각 부분이 어떤 역할을 하는지 설명해야 합니다. 일부 정규식 플레이버는 '자유 간격 모드(free-spacing mode)' 또는 '주석 모드(comment mode)' (e.g., a /x flag)를 지원하여 정규식 내부에 공백과 주석을 허용하기도 합니다.
  • 분해하기: 하나의 거대한 정규식으로 모든 것을 해결하려 하기보다, 여러 개의 더 작고 단순한 정규식을 조합하거나, 정규식과 프로그래밍 언어의 문자열 처리 함수를 함께 사용하는 것이 더 명확할 수 있습니다.

5.2. 파멸적인 역추적(Catastrophic Backtracking)을 피하라

정규식 엔진(특히 NFA 엔진)의 작동 방식 때문에, 특정 패턴은 입력 문자열의 길이에 따라 처리 시간이 기하급수적으로 증가하여 사실상 프로그램이 멈추는 현상을 유발할 수 있습니다. 이를 '파멸적인 역추적'이라 부릅니다.

주로 중첩된 수량자모호한 패턴의 조합에서 발생합니다. 예를 들어, (a+)+, (a|aa)+, (a*)* 같은 패턴이 대표적입니다. (a+)+ 패턴을 "aaaaaaaaaaaaaaaaaaaaaaab"와 같은 문자열에 적용하면, 엔진은 일치하는 경우를 찾기 위해 수많은 경우의 수를 시도하고 되돌아가는(역추적) 과정을 반복하다가 과부하에 걸리게 됩니다.

해결책:

  • 패턴을 구체화하라: .* 대신 [a-z]* 와 같이 가능한 한 명확하고 좁은 범위의 패턴을 사용합니다.
  • 중첩된 수량자를 피하라: (a+)+는 사실상 a+와 같으므로, 불필요한 중첩을 제거합니다.
  • 소유적 수량자(Possessive Quantifiers) 또는 원자적 그룹(Atomic Groups) 사용: 역추적을 방지하는 고급 기능을 활용하여 엔진이 한번 일치시킨 부분에 대해 다시 탐색하지 않도록 강제할 수 있습니다.

5.3. 정규 표현식이 만능은 아니다

"어떤 사람들은 문제가 생기면 '정규 표현식을 써야지'라고 생각한다. 그럼 이제 그 사람에겐 두 개의 문제가 생긴 것이다." 라는 유명한 농담이 있습니다. 정규 표현식은 강력하지만 모든 문제에 대한 최적의 해결책은 아닙니다.

  • 단순한 작업: 특정 문자의 위치를 찾거나(indexOf), 문자열을 특정 구분자로 자르는(split) 등의 단순한 작업은 언어에서 제공하는 내장 함수가 훨씬 빠르고 가독성이 좋습니다.
  • 구조화된 데이터 파싱: HTML, XML, JSON과 같은 구조화된 텍스트를 파싱하는 데 정규식을 사용하는 것은 매우 위험하고 오류가 발생하기 쉽습니다. 이러한 데이터는 중첩 구조, 속성 순서의 변화, 주석 등 정규식으로 안정적으로 처리하기 어려운 복잡성을 내포하고 있습니다. 반드시 해당 데이터 형식을 위한 전용 파서(Parser)를 사용해야 합니다.

결론: 꾸준한 연습으로 익히는 강력한 도구

정규 표현식은 배우기 위한 초기 진입 장벽이 분명히 존재합니다. 수많은 메타 문자와 기호들의 조합은 암기 과목처럼 느껴질 수도 있습니다. 하지만 그 핵심 원리를 이해하고, 온라인 정규식 테스터(Regex101, RegExr 등)와 같은 훌륭한 도구를 활용하여 직접 패턴을 만들고 테스트하는 과정을 반복하다 보면, 텍스트를 다루는 시야가 완전히 달라지는 경험을 하게 될 것입니다.

정규 표현식은 단순히 문자열을 찾는 기술이 아니라, 데이터의 구조를 꿰뚫어 보고 원하는 형태로 자유자재로 제어하는 논리적 사고의 훈련 과정입니다. 오늘 배운 개념들을 바탕으로 여러분의 코드와 데이터 속에서 반복되는 패턴을 찾아보고, 그것을 정규 표현식으로 표현하는 연습을 시작해 보십시오. 그 작은 시도가 여러분을 한 단계 더 높은 수준의 개발자와 데이터 분석가로 이끌어 줄 것입니다.

The Fabric of Text: A Comprehensive Exploration of Regular Expressions

In the vast universe of data that defines our digital world, text remains the most fundamental and ubiquitous form. From simple log files to complex codebases, from user-generated content to scientific research papers, the ability to parse, search, and manipulate text with precision and efficiency is an indispensable skill. At the heart of this capability lies a powerful and elegant tool: the Regular Expression, often abbreviated as RegEx or Regex. A regular expression is not a programming language in itself, but rather a specialized, highly concise language for defining search patterns.

Mastering regular expressions is akin to learning a new grammar—the grammar of text patterns. It allows a developer, data scientist, or system administrator to ask sophisticated questions of their data: "Does this string look like a valid email address?", "Find all lines in this file that start with a date and end with an error code," or "Replace all American-style dates with the British format." The power of RegEx lies in its ability to express complex rules in a compact sequence of characters, turning what could be dozens of lines of procedural code into a single, declarative pattern. This exploration will delve into the foundational components and advanced mechanisms of regular expressions, providing a robust framework for understanding and applying them to real-world challenges.

The Foundational Syntax: Atoms of the RegEx Language

Every language is built upon a set of fundamental rules and symbols. For regular expressions, this foundation is composed of literal characters and a special class of characters known as metacharacters. Understanding the distinction and interplay between these two is the first step toward proficiency.

Literals: The Simplest Match

The most basic component of a regular expression is a literal character. A literal is a character that matches itself, with no special meaning. For example, the regular expression cat is composed of three literal characters: 'c', 'a', and 't'. When applied to a string, this pattern will successfully match the exact sequence of characters "cat". In the string "The cat sat on the mat," the pattern cat would find a match. This is the simplest form of text searching, equivalent to what one might do with a "Find" function in a standard text editor.

Metacharacters: The Symbols of Power

While literals provide exact matching, the true power of regular expressions is unlocked through metacharacters. These are special characters that do not represent themselves but instead act as instructions for the RegEx engine, defining the rules and logic of the pattern. The set of metacharacters forms the core syntax of the RegEx language.

To match a character that has a special meaning in RegEx, you must "escape" it using a backslash (\). For instance, to find a literal dot character (`.`) in a string, you would use the pattern \.. The backslash tells the engine to treat the following character as a literal, stripping it of its special powers.

Character Classes and Sets: Defining "What" to Match

Often, you don't want to match a specific character, but rather any character from a specific group. This is where character sets, or character classes, come into play. They provide a concise way to define a set of allowed characters for a single position in the pattern.

Custom Character Sets with Square Brackets []

Square brackets [] are used to create a custom character set. Any single character inside the brackets will be matched. For example, the pattern gr[ae]y will match either "gray" or "grey". It specifies that the third character can be either 'a' or 'e'.

  • Ranges: To avoid listing every character, you can specify a range using a hyphen (-). For instance, [a-z] matches any single lowercase letter, [A-Z] matches any single uppercase letter, and [0-9] matches any single digit. These can be combined: [a-zA-Z0-9] matches any single alphanumeric character.
  • Negation: A caret (^) as the first character inside a character set inverts its meaning. It matches any character that is not in the set. For example, [^0-9] matches any single character that is not a digit. The pattern q[^u] would match a 'q' followed by any character except 'u', useful for finding words in English that break the "q is followed by u" rule.

Predefined (Shorthand) Character Classes

For convenience, RegEx provides several shorthand notations for common character sets. These are represented by a backslash followed by a letter.

  • \d: Matches any digit. Equivalent to [0-9].
  • \D: Matches any non-digit character. Equivalent to [^0-9].
  • \w: Matches any "word" character. This typically includes uppercase letters, lowercase letters, digits, and the underscore. Equivalent to [a-zA-Z0-9_].
  • \W: Matches any "non-word" character. Equivalent to [^a-zA-Z0-9_].
  • \s: Matches any whitespace character. This includes spaces, tabs (\t), newlines (\n), carriage returns (\r), and other whitespace symbols.
  • \S: Matches any non-whitespace character.
  • . (The Dot or Wildcard): The dot is a particularly powerful metacharacter that matches any single character except for a newline. Some RegEx engines have a "dotall" or "single-line" mode (often activated by an `s` flag) that allows the dot to match newlines as well.

Using these shorthands makes patterns more readable and portable across different systems that adhere to these common conventions.

Quantifiers: Defining "How Many" to Match

Quantifiers specify how many times the preceding element (a literal, character set, or group) must occur to be considered a match. They transform a pattern from a fixed-length template into a flexible, variable-length one.

  • * (Asterisk): Matches the preceding element zero or more times. For example, ab*c matches "ac", "abc", "abbc", "abbbc", and so on.
  • + (Plus Sign): Matches the preceding element one or more times. ab+c will match "abc" and "abbc", but not "ac".
  • ? (Question Mark): Matches the preceding element zero or one time. This makes the element optional. For example, the pattern colou?r will match both "color" and "colour".
  • {n} (Curly Braces): Matches the preceding element exactly n times. \d{4} matches exactly four digits, like "2024".
  • {n,}: Matches the preceding element at least n times. \d{2,} matches any sequence of two or more digits.
  • {n,m}: Matches the preceding element at least n times but no more than m times. \w{3,5} matches any word character sequence that is 3, 4, or 5 characters long.

The Concept of Greediness and Laziness

By default, quantifiers are greedy. This means they will try to match as much of the string as possible while still allowing the rest of the pattern to match. Consider the string <h1>Title</h1> and the greedy pattern <.*>. One might expect it to match <h1>. However, because the * is greedy, it will match the . (any character) as many times as possible. The match will start at the first < and extend all the way to the final > in the string, resulting in the entire string <h1>Title</h1> being matched.

To change this behavior, you can make a quantifier lazy (or non-greedy) by appending a question mark (?) to it. A lazy quantifier will match as little of the string as possible. Using the lazy pattern <.*?> on the same string, the *? will match as few characters as possible until it finds the first closing >. This results in two separate matches: <h1> and </h1>. Understanding the difference between greedy and lazy matching is critical for accurately extracting data from structured text like HTML or XML.


// Example in JavaScript
const html = '<p>First paragraph.</p><p>Second paragraph.</p>';

// Greedy quantifier: matches from the first <p> to the last </p>
const greedyRegex = /<p>.*<\/p>/;
console.log(html.match(greedyRegex)[0]);
// Output: "<p>First paragraph.</p><p>Second paragraph.</p>"

// Lazy quantifier: matches each paragraph tag separately
const lazyRegex = /<p>.*?<\/p>/g; // 'g' flag for global search
console.log(html.match(lazyRegex));
// Output: ["<p>First paragraph.</p>", "<p>Second paragraph.</p>"]

Grouping, Capturing, and Alternation

Parentheses () are one of the most versatile constructs in regular expressions. They serve multiple purposes: grouping parts of a pattern together, capturing the matched text for later use, and defining the scope of alternation.

Grouping for Quantification

Parentheses allow you to apply a quantifier to an entire sequence of characters, not just a single one. For example, if you want to match the sequence "ha" repeated one or more times, you would write (ha)+. This pattern would match "ha", "haha", "hahaha", and so on. Without the parentheses, the pattern ha+ would match "ha", "haa", "haaa", as the quantifier would only apply to the character 'a'.

Capturing for Extraction and Backreferences

By default, any text matched by a pattern inside parentheses is "captured" into a numbered group. These captured groups can be accessed from your programming language after a match is found, allowing you to easily extract specific parts of the matched string. For example, in a pattern to match a date like (\d{4})-(\d{2})-(\d{2}), a successful match on "2024-07-26" would capture "2024" into group 1, "07" into group 2, and "26" into group 3.

These captured groups can also be referenced from within the pattern itself, a feature known as backreferences. \1 refers to the text matched by the first capturing group, \2 to the second, and so on. This is extremely useful for finding repeated words. The pattern \b(\w+)\s+\1\b finds a word boundary, captures one or more word characters into group 1, matches one or more whitespace characters, and then looks for the exact same text that was captured in group 1. It would find a match in "the the" but not in "the then".

Non-Capturing Groups (?:...)

Sometimes you need to group parts of a pattern for quantification but have no intention of extracting the matched text. In these cases, using a standard capturing group is slightly inefficient and can clutter your list of captured results. A non-capturing group, denoted by (?:...), provides the grouping behavior without the capturing overhead. For example, (?:http|https):// uses a non-capturing group to match either "http" or "https" without creating a capture group for it.

Alternation with the Pipe |

The pipe character | acts as an "OR" operator, allowing you to specify a set of alternatives. The pattern cat|dog|fish will match "cat" or "dog" or "fish". The scope of the alternation can be controlled with parentheses. For instance, I love (cats|dogs) will match "I love cats" or "I love dogs". Without the parentheses, the pattern I love cats|dogs would mean "I love cats" or "dogs", which is a completely different logic.

Anchors and Boundaries: Defining "Where" to Match

Anchors and boundaries are special metacharacters that do not match any characters themselves. Instead, they assert that the match must occur at a specific position within the string, such as the beginning, the end, or next to a word.

  • ^ (Caret): When used outside of a character set, the caret anchors the pattern to the start of the string. The pattern ^Hello will only match "Hello" if it appears at the very beginning of the string.
  • $ (Dollar Sign): This anchors the pattern to the end of the string. world$ will only match "world" if it is at the very end of the string. Combining these, ^Start to Finish$ will only match the exact string "Start to Finish" and nothing else.
  • Multiline Mode: In many RegEx engines, a "multiline" flag (often `m`) can be enabled. In this mode, `^` and `$` match the start and end of each line within the string, not just the absolute start and end of the entire string. This is invaluable for processing text files line by line.
  • \b (Word Boundary): This is a zero-width assertion that matches the position between a word character (\w) and a non-word character (\W) or the start/end of the string. It is used to match whole words. The pattern \bcat\b will find "cat" in "The cat sat" but will not match the "cat" in "caterpillar" or "concatenate".
  • \B (Non-Word Boundary): The opposite of \b. It matches any position that is not a word boundary. For example, \Bcat\B would match the "cat" in "concatenate" but not in "the cat".

Advanced Assertions: Lookarounds

Lookarounds are powerful, advanced features that allow you to create patterns that depend on the context surrounding the match, without including that context in the match itself. Like anchors, they are "zero-width assertions"—they check a condition but do not "consume" any characters from the string.

  • Positive Lookahead (?=...): This asserts that the text immediately following the current position must match the pattern inside the lookahead, but this text is not part of the overall match. For example, to match a password that must contain a digit, you could use ^(?=.*\d).{8,}$. This pattern breaks down as:
    • ^: Start of the string.
    • (?=.*\d): A positive lookahead that asserts "from this position, there must be zero or more characters followed by a digit somewhere ahead". This check is performed, but the engine's position doesn't move.
    • .{8,}: After the check succeeds, this part of the pattern matches any character (except newline) 8 or more times.
    • This ensures the string is at least 8 characters long AND contains at least one digit. The digit itself is not specifically part of the match returned by `.{8,}`.
  • Negative Lookahead (?!...): This asserts that the text immediately following the current position must not match the pattern inside the lookahead. For example, q(?!u) matches any 'q' that is not followed by a 'u'.
  • Positive Lookbehind (?<=...): This asserts that the text immediately preceding the current position must match the pattern inside the lookbehind. For example, to extract the numbers from prices like "$100" or "€50" without including the currency symbol, you could use (?<=[\$€])\d+. This matches one or more digits only if they are preceded by a '$' or '€' symbol. The symbol itself is not captured.
  • Negative Lookbehind (?<!...): This asserts that the text immediately preceding the current position must not match the pattern inside the lookbehind. For example, (?<!un)defined would match "defined" but not "undefined".

Note: Lookbehind support, especially variable-length lookbehind, can vary between RegEx engines. Historically, JavaScript had limited or no support for lookbehind, though it has been added in modern versions.

Deconstructing a Practical Example: Email Validation

Let's revisit the common task of email validation to synthesize these concepts. A frequently seen pattern for this is:

^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$

This pattern, while not perfectly compliant with the official RFC 5322 standard (which is monstrously complex), serves as an excellent practical example. Let's break it down:

  • ^: Anchors the match to the beginning of the string.
  • [a-zA-Z0-9._%+-]+: This is the "local part" of the email address (before the @).
    • [a-zA-Z0-9._%+-]: A character set allowing lowercase letters, uppercase letters, digits, and the special characters dot, underscore, percent, plus, or hyphen.
    • +: A quantifier meaning the preceding character set must appear one or more times.
  • @: Matches the literal "@" symbol.
  • [a-zA-Z0-9.-]+: This is the domain name (and subdomains).
    • [a-zA-Z0-9.-]: A character set allowing letters, digits, dots, and hyphens.
    • +: Quantifier for one or more occurrences.
  • \.: Matches the literal dot separating the domain from the top-level domain (TLD). It must be escaped with a backslash because `.` is a metacharacter.
  • [a-zA-Z]{2,}: This is the TLD.
    • [a-zA-Z]: A character set allowing only letters.
    • {2,}: A quantifier specifying that there must be at least two letters.
  • $: Anchors the match to the end of the string, ensuring there are no trailing characters.

This pattern is a pragmatic compromise. It validates many common email formats while being relatively simple. However, it fails on newer TLDs with more than letters (e.g., in Punycode for international domains) and doesn't permit all legal characters in the local part as defined by the standards. For critical applications, using a library specifically designed for email validation is often safer than relying on a custom RegEx.

Performance, Pitfalls, and Best Practices

While powerful, regular expressions can be a source of performance bottlenecks and security vulnerabilities if not crafted carefully. A poorly written pattern can lead to a condition known as Catastrophic Backtracking. This occurs when the RegEx engine gets stuck in a recursive loop of trying countless permutations to find a match, leading to exponential increases in execution time and potential denial-of-service attacks.

This often happens with nested quantifiers combined with alternation, such as in the pattern (a|aa)*b. When trying to match a long string of 'a's that does not end in 'b', the engine has to try every possible combination of matching 'a' and 'aa', leading to a catastrophic slowdown.

Writing Efficient and Readable Patterns

  • Be Specific: If you know you're matching digits, use \d instead of .. The more specific your pattern, the faster the engine can fail on non-matching strings.
  • Use Non-Capturing Groups: If you only need to group for quantification or alternation, use non-capturing groups (?:...) to avoid the overhead of capturing.
  • Avoid Nested Quantifiers: Be wary of patterns like (a*)*. Re-evaluate if there's a simpler, more direct way to express your logic.
  • Anchor Your Patterns: If you know your match should be at the start or end of a string, use ^ and $. This allows the engine to fail very quickly.
  • Add Comments and Formatting: Many RegEx engines support a "free-spacing" mode (often flag `x`) that ignores whitespace and allows for line comments within the pattern itself. This can make complex patterns dramatically easier to read and maintain.

Conclusion: The Enduring Relevance of Regular Expressions

Regular expressions represent a timeless and fundamental concept in computer science. They are a testament to the power of declarative programming, allowing users to define what they are looking for, rather than detailing how to find it. From simple text substitutions in an editor to complex data-wrangling pipelines in a server-side application, the language of RegEx is a universal tool for text manipulation.

While the initial learning curve can seem steep due to its terse and symbolic nature, the rewards are immense. A solid understanding of literals, metacharacters, quantifiers, groups, and anchors provides a robust foundation. Layering on advanced concepts like lookarounds and performance-conscious design elevates this skill from a simple tool to a powerful problem-solving paradigm. In a world increasingly driven by data, the ability to fluently speak the language of text patterns is more valuable than ever.

正規表現の探求:文字列操作の核心技術を解き明かす

正規表現(Regular Expression、略してRegEx)は、単なる文字列検索ツールではありません。それは、現代のコンピューティングにおけるテキスト処理の根幹をなす、一種のミニ言語です。開発者が書くコード、システム管理者が解析するログ、データサイエンティストが整理する非構造化データ、そのあらゆる場面で正規表現は静かに、しかし強力にその役割を果たしています。この技術を理解することは、膨大なテキストデータの中から特定のパターンを瞬時に抽出し、置換し、検証する能力を手にいれることを意味します。本稿では、正規表現の基本的な構成要素から、その応用、さらにはパフォーマンスに関する高度なトピックまで、その深遠な世界を段階的に探求していきます。

正規表現を構成する基本要素:パターンの組み立て方

正規表現の力は、単純な文字(リテラル)と、特別な意味を持つ記号(メタ文字)の組み合わせによって生まれます。これらの要素を理解することが、効果的なパターンを作成するための第一歩です。

1. リテラル (Literals)

最も基本的な要素はリテラル文字です。これは、あなたが探したいと考える「そのままの」文字を指します。例えば、正規表現 apple は、文字列 "I have an apple." の中の "apple" という部分に正確に一致します。ここには何の特殊な機能もありません。見たままの文字が、見たままの順序で出現する箇所を探します。

2. メタ文字 (Metacharacters)

正規表現の真価を発揮させるのがメタ文字です。これらは単なる文字としてではなく、特別な指示や条件として機能します。主要なメタ文字には以下のようなものがあります。

  • . (ドット): 改行文字(\n)を除く、任意の1文字に一致します。例えば、h.t は "hat", "hot", "h8t" などに一致しますが、"ht" や "heat" には一致しません。
  • ^ (キャレット): 文字列の先頭を表します。^start というパターンは、"start of the line" には一致しますが、"This is the start" には一致しません。
  • $ (ドル記号): 文字列の末尾を表します。end$ というパターンは、"This is the end" には一致しますが、"end of the line" には一致しません。^$ を組み合わせることで、文字列全体がパターンに一致するかどうかを検証できます。例:^exact$ は "exact" という文字列にのみ一致します。
  • | (パイプ): 「または」を意味する選択 (OR) を表します。cat|dog は "cat" または "dog" のいずれかに一致します。
  • \ (バックスラッシュ): エスケープ文字として機能します。メタ文字の特別な意味を無効化し、リテラル文字として扱いたい場合に使用します。例えば、文字列中のドット . そのものを探したい場合は \. と記述します。同様に、\*, \+, \\ などもリテラルな文字として扱われます。

3. 文字セット (Character Sets) と文字クラス

特定の文字グループの中からいずれか1文字に一致させたい場合、角括弧 [] を用いた文字セットが非常に便利です。

  • 基本の文字セット: [abc] は 'a', 'b', 'c' のいずれか1文字に一致します。gr[ae]y は "gray" と "grey" の両方に一致します。
  • 範囲指定: ハイフン - を使うことで、連続した文字の範囲を指定できます。[a-z] は任意の小文字アルファベット1文字に、[0-9] は任意の数字1文字に、[A-Za-z0-9] は任意の英数字1文字に一致します。
  • 否定文字セット: キャレット ^ を角括弧の先頭に置くと、そのセットに含まれない任意の1文字に一致します。[^0-9] は数字以外の任意の1文字に一致します。

さらに、頻繁に使用される文字セットには、便利なショートカット(文字クラス)が用意されています。

  • \d: 任意の数字1文字に一致します。[0-9] と等価です。
  • \D: 数字以外の任意の1文字に一致します。[^0-9] と等価です。
  • \w: 任意の英数字またはアンダースコア1文字に一致します。[a-zA-Z0-9_] と等価です。("word character"の意)
  • \W: 英数字とアンダースコア以外の任意の1文字に一致します。[^a-zA-Z0-9_] と等価です。
  • \s: スペース、タブ、改行などの任意の空白文字1文字に一致します。[ \t\r\n\f] と等価です。("whitespace"の意)
  • \S: 空白文字以外の任意の1文字に一致します。[^ \t\r\n\f] と等価です。

これらの文字クラスを使うことで、正規表現はより簡潔で読みやすくなります。例えば、郵便番号(7桁の数字)を表現する場合、[0-9][0-9][0-9]-[0-9][0-9][0-9][0-9] と書く代わりに、後述する量指定子と組み合わせて \d{3}-\d{4} と書くことができます。

4. 量指定子 (Quantifiers)

量指定子は、直前の文字、グループ、または文字セットが何回繰り返されるかを指定します。これにより、パターンの長さを柔軟に定義できます。

  • *: 直前の要素が0回以上繰り返される場合に一致します。("zero or more")例: ab*c は "ac", "abc", "abbc", "abbbc" などに一致します。
  • +: 直前の要素が1回以上繰り返される場合に一致します。("one or more")例: ab+c は "abc", "abbc" には一致しますが、"ac" には一致しません。
  • ?: 直前の要素が0回または1回出現する場合に一致します。("zero or one")例: colou?r は "color" と "colour" の両方に一致します。これはオプションの文字を表現するのに便利です。
  • {n}: 直前の要素がちょうどn回繰り返される場合に一致します。例: \d{3} は3桁の数字に一致します。
  • {n,}: 直前の要素が少なくともn回以上繰り返される場合に一致します。例: \d{2,} は2桁以上の数字に一致します。
  • {n,m}: 直前の要素がn回以上m回以下繰り返される場合に一致します。例: \w{3,5} は3文字から5文字の単語文字に一致します。

貪欲(Greedy)、怠惰(Lazy)、独占的(Possessive)な量指定子

デフォルトでは、量指定子(*, +, {})は貪欲(Greedy)に振る舞います。これは、可能な限り最も長い文字列に一致しようとすることを意味します。例えば、文字列 "<p>first</p> and <p>second</p>" に対して、正規表現 <p>.*</p> を適用すると、意図した "<p>first</p>" ではなく、"<p>first</p> and <p>second</p>" 全体に一致してしまいます。これは、.* が最初の <p> から最後の </p> まで、できる限り長くマッチしようとするためです。

この問題を解決するのが怠惰(Lazy)な量指定子です。量指定子の後に ? を追加することで、可能な限り最も短い文字列に一致するようになります。同じ例で <p>.*?</p> を使うと、まず "<p>first</p>" に一致し、次に "<p>second</p>" に一致します。.*? が最初の </p> を見つけた時点で一致を完了させるためです。

さらに高度な概念として、独占的(Possessive)な量指定子があります。これは量指定子の後に + を追加します(例: *+, ++)。これは貪欲に一致しますが、一度一致した部分を後続のパターンのために「譲る(バックトラックする)」ことをしません。これはパフォーマンスの最適化や、特定の種類のバックトラックによる意図しない一致を防ぐために使用されます。

5. グループ化 (Grouping) とキャプチャ (Capturing)

丸括弧 () は、正規表現の一部をグループ化するために使用されます。グループ化には主に2つの目的があります。

  1. 量指定子の適用範囲を広げる: 例えば、(ab)+ は "ab", "abab", "ababab" など、"ab" というシーケンスが1回以上繰り返されるものに一致します。もし ab+ と書くと、これは "abb", "abbb" などに一致し、意味が全く異なります。
  2. 部分文字列のキャプチャ: 括弧で囲まれた部分に一致した文字列は、後で参照するためにメモリに保存されます。これをキャプチャグループと呼びます。例えば、(\d{4})-(\d{2})-(\d{2}) というパターンを "2023-10-26" に適用すると、全体の一致の他に、"2023", "10", "26" という3つの部分文字列がキャプチャされます。これらは後方参照(\1, \2など)や、プログラミング言語の正規表現APIを通じて取得できます。

キャプチャが不要で、単にグループ化だけを行いたい場合は、非キャプチャグループ `(?:...)` を使用します。これにより、メモリを消費せず、パフォーマンスがわずかに向上します。例: (?:https?|ftp)://...

6. アンカー (Anchors) と境界 (Boundaries)

アンカーは、文字列内の特定の位置に「錨(いかり)」を下ろします。文字そのものではなく、位置に一致するゼロ幅のアサーションです。

  • ^$: 前述の通り、それぞれ文字列の先頭と末尾に一致します。
  • \b: 単語の境界に一致します。単語の境界とは、単語文字(\w)と非単語文字(\W)の間、または単語文字と文字列の先頭/末尾の間です。例えば、\bcat\b は "the cat sat" の "cat" には一致しますが、"concatenate" の中の "cat" には一致しません。「単語全体」を検索する際に極めて重要です。
  • \B: 単語の非境界に一致します。\b の逆で、単語の途中に一致します。\Bcat\B は "concatenate" の "cat" には一致しますが、"the cat sat" の "cat" には一致しません。

7. 先読み (Lookahead) と後読み (Lookbehind)

これは正規表現の最も強力な機能の一つで、あるパターンの前後を「覗き見る」ことで、マッチの条件をより厳密に指定できます。これらもゼロ幅のアサーションであり、マッチ結果自体には含まれません。

  • 肯定的先読み `(?=...)`: 指定したパターンが直後に続く場合にのみ、現在位置に一致します。例: Windows(?=NT|XP|10) は、"Windows2000" には一致せず、"WindowsNT" や "Windows10" の "Windows" の部分に一致します。
  • 否定的先読み `(?!...)`: 指定したパターンが直後に続かない場合にのみ、現在位置に一致します。例: q(?!u) は、"Iraq" の 'q' には一致しますが、"queen" の 'q' には一致しません。('q' の後に 'u' が来ないものを探す)
  • 肯定的後読み `(?<=...)`: 指定したパターンが直前にある場合にのみ、現在位置に一致します。例: (?<=\$)\d+ は、"$100" の "100" には一致しますが、"EUR100" の "100" には一致しません。(直前に '$' がある数字列を探す)
  • 否定的後読み `(?<!...)`: 指定したパターンが直前にない場合にのみ、現在位置に一致します。例: (?<!un)happy は "happy" や "very happy" の "happy" には一致しますが、"unhappy" の "happy" には一致しません。

Lookaroundは、パスワードの強度検証(例:「数字とアルファベットの両方を含む8文字以上」を ^(?=.*\d)(?=.*[a-zA-Z]).{8,}$ のように表現する)など、複雑な条件を簡潔に記述するのに非常に役立ちます。


実践例:電子メールアドレスの検証

これまで学んだ要素を組み合わせて、実用的な正規表現を作成してみましょう。最も一般的な例の一つが、電子メールアドレスの検証です。

単純な正規表現は以下のようになります。

^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$

この正規表現を分解してみましょう。

  • ^: 文字列の先頭から一致を開始します。
  • [a-zA-Z0-9._%+-]+: ローカルパート(@の前の部分)です。
    • [a-zA-Z0-9._%+-]: 英数字、ドット、アンダースコア、パーセント、プラス、ハイフンの中からいずれか1文字。
    • +: 上記の文字が1回以上繰り返されることを示します。
  • @: リテラルな '@' 記号に一致します。
  • [a-zA-Z0-9.-]+: ドメイン名(サブドメインを含む)の部分です。
    • [a-zA-Z0-9.-]: 英数字、ドット、ハイフンの中からいずれか1文字。
    • +: 上記の文字が1回以上繰り返されることを示します。
  • \.: リテラルなドット '.' に一致します。トップレベルドメインの前に必ず必要です。
  • [a-zA-Z]{2,}: トップレベルドメイン(TLD)の部分です。
    • [a-zA-Z]: アルファベット1文字。
    • {2,}: 上記の文字が2回以上繰り返されることを示します(例: .com, .net, .info)。
  • $: 文字列の末尾で一致を終了します。

注意点とより厳密なパターン

上記の正規表現は多くの一般的なメールアドレスを検証できますが、完璧ではありません。例えば、RFC 5322という公式な仕様に準拠したメールアドレスの中には、このパターンではじかれてしまうものがあります(例: "very.(),:;<>[]\".VERY.\"very@\\ \"very\".unusual"@strange.example.com)。また、ドメイン名のハイフンが先頭や末尾に来るケース(例: test@-example.com)を許可してしまいます。

より現実に即した、少し改良されたバージョンは以下のようになります。

^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$

さらに、ドメイン名のルールを厳密に適用するなら、以下のような形が考えられます。

/^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/i

このレベルになると、もはや人間が直感的に理解するのは困難です。実際には、100%の正確性を求めるよりも、「一般的によく使われる形式」をカバーするバランスの取れた正規表現を使用することが多いです。完璧なバリデーションは、正規表現だけではなく、実際に確認メールを送信するなどの手法と組み合わせるのが最善です。


正規表現の利点と注意すべき落とし穴

利点

  • 強力性と柔軟性: 複雑なテキストパターンを非常に簡潔な式で表現できます。
  • 効率性: 多くの正規表現エンジンは高度に最適化されており、手作業や他のプログラミング手法よりも高速に文字列処理を実行できます。
  • 普遍性: Perl、Python、Java、JavaScript、Ruby、.NET、Goといった主要なプログラミング言語、grepやsedといったUNIXコマンド、VS CodeやSublime Textのようなテキストエディタなど、幅広い環境でサポートされています。

注意点とベストプラクティス

  1. 可読性の低下: 複雑な正規表現は、書いた本人でさえ後から解読するのが困難になることがあります。「正規表現は書き込み専用言語だ」と揶揄されることもあるほどです。
    • 対策: コメントを活用しましょう。正規表現エンジンによっては、パターン内にコメントを記述できます(例: PCREの (?#this is a comment))。また、パターンを組み立てるロジックをコードのコメントとして残すことも重要です。
    • 対策: フリースペースモード(多くの言語で /x フラグ)を使いましょう。このモードでは、パターン内の無視される空白や改行、#以降の行コメントが利用可能になり、複雑な正規表現を論理的なブロックに分けて記述できます。
      
      /
        ^                    # 行の先頭
        (?=.*\d)            # 少なくとも1つの数字を含む(先読み)
        (?=.*[a-z])         # 少なくとも1つの小文字を含む(先読み)
        (?=.*[A-Z])         # 少なくとも1つの大文字を含む(先読み)
        [a-zA-Z\d]{8,}      # 8文字以上の英数字
        $                    # 行の末尾
      /x
      
  2. 破滅的なバックトラッキング (Catastrophic Backtracking): 特定の「悪い」正規表現は、特定の文字列に対して指数関数的に計算時間が増加し、アプリケーションをフリーズさせる(ReDoS - Regular Expression Denial of Service)可能性があります。これは、入れ子になった量指定子と、それらの間で重複する可能性のあるパターンが組み合わさったときに発生します。
    • 例: (a+)+(a|aa)+ のようなパターンを、"aaaaaaaaaaaaaaaaaaaaaaaaaaaaab" のような「ほぼマッチするが最終的に失敗する」文字列に対して実行すると、エンジンは膨大な数の組み合わせを試すことになります。
    • 対策:
      • 可能な限り具体的なパターンを使用し、安易に .*.+ を使わない。
      • 入れ子になった量指定子を避ける。
      • 怠惰な量指定子 *?, +? を検討する。
      • 独占的な量指定子 *+, ++ やアトミックグループ (?>...) を使用して、バックトラックを意図的に禁止する。
  3. フレーバー(方言)の違い: 正規表現の標準は一つではありません。PCRE (Perl Compatible Regular Expressions)、POSIX、JavaScript、Pythonなど、環境によってサポートされる構文や機能に微妙な差異があります(例: 後読みはJavaScriptでは比較的最近までサポートされていませんでした)。開発時には、使用する環境のドキュメントを確認することが不可欠です。

正規表現は、習得には時間と実践を要しますが、一度身につければ強力な武器となります。単純な検索置換から、データクレンジング、ログ解析、セキュリティチェックまで、その応用範囲は無限大です。小さなパターンから始め、オンラインの正規表現テスターなどを活用しながら、少しずつ複雑なパターンに挑戦していくことが、この深遠な技術をマスターするための確実な道筋となるでしょう。

Sunday, September 15, 2019

실무에서 바로 쓰는 정규표현식: 연속된 문자, 숫자 완벽하게 찾아내기

개발을 하다 보면 문자열 데이터 속에서 특정 패턴을 찾아야 하는 경우가 비일비재합니다. 사용자 아이디의 유효성을 검사하거나, 비밀번호 정책을 강제하거나, 혹은 잘못 입력된 데이터를 정리해야 할 때가 대표적입니다. 예를 들어, 'test0000'처럼 동일한 숫자가 과도하게 반복되는 아이디를 막거나, 'password111'과 같이 취약한 비밀번호를 걸러내고 싶을 수 있습니다. 또한, 텍스트 편집기에서 사용자가 실수로 '안녕하세요오오'라고 입력한 오타를 '안녕하세요'로 교정해주는 기능도 필요할 수 있습니다.

이러한 문제들을 해결하기 위해 매번 반복문(for, while)과 조건문(if)을 조합하여 코드를 작성하는 것은 매우 번거롭고 비효율적입니다. 코드가 길어지고, 가독성이 떨어지며, 다양한 예외 케이스를 처리하기 위해 로직은 점점 더 복잡해질 것입니다. 바로 이럴 때, '정규표현식(Regular Expression, 줄여서 Regex)'이 강력한 해결사로 등장합니다.

정규표현식은 문자열의 특정 패턴을 표현하는 언어입니다. 단 몇 줄의 코드로 복잡한 문자열 검색, 치환, 추출 작업을 놀랍도록 간결하고 우아하게 처리할 수 있습니다. 이번 글에서는 그중에서도 매우 실용적이면서도 강력한 기능인 '연속으로 반복되는 문자나 숫자'를 찾아내는 정규표현식 패턴에 대해 아주 깊이 있게 파헤쳐 보겠습니다. 이 글을 끝까지 읽으시면, 단순히 패턴을 복사해서 붙여넣는 수준을 넘어, 그 원리를 완벽하게 이해하고 여러분의 코드에 자유자재로 응용할 수 있는 능력을 갖추게 될 것입니다.


1. 문제 정의: 우리는 왜 '반복'을 찾아야 하는가?

본격적인 정규표현식 탐구에 앞서, 우리가 해결하려는 문제가 무엇인지 구체적인 시나리오를 통해 명확히 해봅시다. '연속된 문자 반복'이라는 패턴은 다양한 애플리케이션에서 중요한 검사 항목이 됩니다.

  • 사용자 입력 데이터 유효성 검사 (Input Validation)
    • 아이디/닉네임 생성 규칙: 'aaaaa', 'user0000' 등 성의 없거나 의미 없는 아이디 생성을 방지하여 서비스 품질을 유지합니다.
    • 게시글 및 댓글 작성: 'ㅋㅋㅋㅋㅋㅋㅋ', '!!!!!!!' 와 같이 특정 문자를 무의미하게 반복하여 도배하는 행위를 제한할 수 있습니다.
  • 비밀번호 보안 강화 (Password Security)
    • 가장 대표적인 사용 사례입니다. 'pass1111'이나 'qwertyzzz'처럼 동일한 문자나 숫자가 연속으로 3번 이상 나타나는 비밀번호는 추측하기 쉬워 매우 취약합니다. 대부분의 서비스에서는 이러한 패턴을 금지하는 정책을 가지고 있습니다.
  • 데이터 정제 및 클렌징 (Data Cleansing)
    • 사용자의 오타나 기계적인 오류로 인해 데이터가 잘못 입력되는 경우가 있습니다. 예를 들어, 'Hellooo World'를 'Hello World'로, '미팅 장소는 강남역 1번 출구구구'를 '강남역 1번 출구'로 교정하는 작업에 활용될 수 있습니다.
  • 로그 분석 및 시스템 모니터링 (Log Analysis)
    • 시스템 로그에서 비정상적으로 반복되는 특정 에러 코드나 메시지 시퀀스를 찾아내어 시스템의 이상 징후를 조기에 발견할 수 있습니다. 예를 들어, 'FAIL FAIL FAIL FAIL'과 같은 패턴을 감지할 수 있습니다.
  • 자연어 처리 및 텍스트 마이닝 (NLP & Text Mining)
    • 텍스트 데이터에서 강조나 감정 표현(예: '정말 대박이다ㅏㅏㅏ')을 분석하거나, 반대로 정규화(Normalization) 과정에서 이러한 반복을 제거하여 분석의 정확도를 높이는 데 사용됩니다.

이처럼 반복되는 문자를 찾는 기술은 단순히 재미있는 코딩 트릭이 아니라, 안정적이고 신뢰성 높은 소프트웨어를 구축하기 위한 핵심적인 요소 중 하나입니다. 이제, 이 모든 문제를 해결해 줄 마법 같은 정규표현식을 만나보겠습니다.


2. 핵심 원리: 마법의 패턴 /(\w)\1+/g 완전 해부

연속된 문자나 숫자를 찾는 가장 기본적인 정규표현식은 바로 /(\w)\1+/g 입니다. 처음 보면 암호처럼 보일 수 있지만, 각 구성 요소의 의미를 하나씩 뜯어보면 놀랍도록 논리적이고 간단합니다. 이 패턴을 완벽히 이해하는 것이 이번 여정의 핵심입니다.

/ ( \w ) \1 + / g

이 패턴을 6개의 조각으로 나누어 세밀하게 분석해 보겠습니다.

2.1. 슬래시 (/): 패턴의 시작과 끝

JavaScript에서 정규표현식 리터럴은 슬래시(/)로 시작하고 슬래시로 끝납니다. 즉, /.../는 "이 안에 있는 내용이 정규표현식 패턴이다"라고 인터프리터에게 알려주는 약속입니다.

2.2. 괄호 ( ... ): '기억'을 위한 캡처 그룹(Capturing Group)

정규표현식에서 괄호는 매우 특별하고 강력한 역할을 합니다. 괄호로 감싸인 부분은 '캡처 그룹'이 됩니다. 캡처 그룹의 핵심 기능은 '그룹 안의 패턴과 일치하는 문자열을 기억(캡처)하는 것'입니다.

예를 들어, (abc)라는 패턴은 'abc'라는 문자열과 일치하며, 일치하는 순간 'abc'를 메모리에 저장해 둡니다. 이 '기억' 기능은 나중에 다시 참조하기 위해 사용되며, 이것이 바로 우리가 살펴볼 '역참조'의 기반이 됩니다.

2.3. 역슬래시 w (\w): 단어 문자(Word Character)

\w는 '단어 문자'를 의미하는 정규표현식의 약칭(shorthand)입니다. 이것은 다음 문자의 집합과 동일한 의미를 가집니다.

  • 알파벳 대문자 (A-Z)
  • 알파벳 소문자 (a-z)
  • 숫자 (0-9)
  • 언더스코어 (_)

즉, \w[A-Za-z0-9_]와 정확히 같습니다. 우리의 패턴 (\w)는 "알파벳, 숫자, 언더스코어 중 하나의 문자와 일치하고, 그 문자를 첫 번째 캡처 그룹으로 기억하라"는 의미가 됩니다.

만약 문자열 'apple'을 이 패턴으로 검사한다면, 엔진은 다음과 같이 동작합니다.

  1. 'a'를 만납니다. \w와 일치합니다. 'a'를 첫 번째 캡처 그룹(\1)에 저장합니다.
  2. 'p'를 만납니다. \w와 일치합니다. 'p'를 첫 번째 캡처 그룹에 저장합니다. (이전 값 'a'는 덮어쓰여집니다.)
  3. ... 이런 식으로 계속 진행됩니다.

아직은 각각의 문자와 한 번씩만 매칭되고 있습니다. 이제 '반복'을 찾아내는 마법이 등장할 차례입니다.

2.4. 역슬래시 1 (\1): 마법의 열쇠, 역참조(Backreference)

\1은 '역참조(Backreference)'라고 불리며, 정규표현식의 가장 강력한 기능 중 하나입니다. 이것의 의미는 "첫 번째(1) 캡처 그룹이 기억하고 있는 바로 그 내용과 정확히 일치하는 문자"를 찾으라는 명령입니다.

앞서 (\w)가 문자를 찾아 기억한다고 했습니다. \1은 그 기억된 문자를 다시 불러와 사용하는 것입니다.

이제 (\w)\1 이라는 패턴을 'apple'과 'aa'에 적용해 봅시다.

  • 'apple'의 경우:
    1. 첫 번째 문자 'a'를 만납니다. (\w)가 'a'와 일치하고, 'a'를 \1에 기억합니다.
    2. 다음으로 \1을 검사할 차례입니다. \1은 'a'를 의미합니다. 하지만 문자열의 다음 문자는 'p'입니다. 'a'와 'p'는 다르므로 일치하지 않습니다. 패턴 매칭이 실패하고 처음부터 다시 시작합니다.
    3. 두 번째 문자 'p'부터 다시 시작합니다. (\w)가 'p'와 일치하고, 'p'를 \1에 기억합니다.
    4. 다음으로 \1('p'를 의미)을 검사합니다. 문자열의 다음 문자는 'p'입니다. 일치합니다! 하지만 그 다음이 없으므로 'pp'는 일치하지 않습니다. (만약 'apple'이 아니라 'appple'이었다면 'pp'가 일치했을 것입니다)
  • 'aa'의 경우:
    1. 첫 번째 문자 'a'를 만납니다. (\w)가 'a'와 일치하고, 'a'를 \1에 기억합니다.
    2. 다음으로 \1('a'를 의미)을 검사합니다. 문자열의 다음 문자는 'a'입니다. 'a'와 'a'는 일치합니다!
    3. 결과적으로, (\w)\1 패턴은 'aa'라는 문자열과 성공적으로 일치합니다.

이것이 바로 '연속된 동일한 문자'를 찾는 핵심 원리입니다. 첫 번째 문자를 기억하고, 바로 다음 문자가 기억된 문자와 같은지 비교하는 것입니다.

2.5. 플러스 (+): 하나 이상의 반복 (Quantifier)

+는 '수량자(Quantifier)'라고 불리며, 바로 앞에 있는 패턴이 1번 이상 반복되는 경우를 찾습니다. 즉, "as many times as possible, but at least once"의 의미입니다.

우리의 패턴에서 +\1 바로 뒤에 붙어있습니다. 따라서 \1+는 "첫 번째 캡처 그룹에서 기억한 문자가 1번 이상 연속으로 나타나는 부분"을 의미합니다.

이제 전체 패턴 (\w)\1+를 'helooo'라는 문자열에 적용해 보겠습니다.

  1. 'h', 'e', 'l'까지는 일치하는 반복이 없어 넘어갑니다.
  2. 첫 번째 'o'를 만납니다. (\w)가 'o'와 일치하고, 'o'를 \1에 기억합니다.
  3. 이제 \1+를 검사할 차례입니다.
    • 다음 문자는 'o'입니다. \1('o'를 의미)과 일치합니다. (1번 반복)
    • 그 다음 문자도 'o'입니다. \1('o'를 의미)과 다시 일치합니다. (2번 반복)
  4. \1이 총 2번 반복되었습니다. +(1번 이상 반복) 조건을 만족합니다.
  5. 따라서, (\w)에 일치하는 'o'와, \1+에 일치하는 'oo'가 합쳐져, 최종적으로 'ooo'라는 문자열이 이 패턴과 일치하게 됩니다.

2.6. g 플래그 (/g): 전역 검색 (Global Search)

마지막으로 패턴의 끝에 붙는 g는 '플래그(flag)'라고 불리며, 검색 옵션을 지정합니다. g'전역(Global)' 검색을 의미합니다.

만약 g 플래그가 없다면, 정규표현식 엔진은 패턴과 일치하는 첫 번째 결과만 찾고 검색을 종료합니다. 하지만 g 플래그가 있으면, 문자열 전체를 스캔하여 패턴과 일치하는 모든 결과를 찾아냅니다.

예를 들어, 'aaabbc_111'이라는 문자열에 /(\w)\1+/ (g 없음)를 적용하면 'aaa'만 찾아내고 멈춥니다. 하지만 /(\w)\1+/g (g 있음)를 적용하면 'aaa', 'bb', '111'을 모두 찾아냅니다.

이것으로 우리는 /(\w)\1+/g라는 패턴의 모든 구성 요소를 완벽하게 이해했습니다. 다시 한번 정리하면 다음과 같습니다.

/(\w)\1+/g : 문자열 전체(g)에서, 단어 문자(\w)가 나온 뒤, 바로 그 문자(\1)가 1번 이상 연속으로 반복(+)되는 모든 부분을 찾아라. 이때, 첫 번째 단어 문자는 기억(())해 두어야 한다.


3. JavaScript 실전 예제: 다양한 메서드 활용법

원리를 이해했으니 이제 JavaScript에서 이 정규표현식을 실제로 어떻게 활용하는지 다양한 메서드를 통해 알아보겠습니다. 각 메서드는 고유한 특징과 반환 값을 가지므로, 상황에 맞게 적절한 것을 선택하는 것이 중요합니다.


// 테스트에 사용할 정규표현식과 문자열
const pattern = /(\w)\1+/g;
const text = 'Heeelloo, thiiis is a teest string with reepeated characters... and numbers 111, 22, 3333.';

3.1. String.prototype.match(): 모든 일치 항목 배열로 얻기

match() 메서드는 정규표현식과 일치하는 부분을 검색합니다. g 플래그가 있을 때와 없을 때 동작이 다릅니다.

g 플래그 사용 시:

g 플래그가 있으면, 일치하는 모든 문자열을 담은 배열을 반환합니다. 일치하는 것이 없으면 null을 반환합니다. 이는 가장 일반적으로 사용되는 방법입니다.


const pattern = /(\w)\1+/g;
const text = 'Heeelloo, thiiis is a teest string with reepeated characters... and numbers 111, 22, 3333.';

const matches = text.match(pattern);

console.log(matches);
// 결과: [ "eee", "ll", "oo", "iii", "s", "s", "ee", "t", "ee", "111", "22", "3333" ]

보시는 바와 같이, 문자열 내에서 연속으로 반복되는 모든 부분을 정확하게 찾아내어 배열로 만들어 줍니다. 이를 이용해 "반복되는 문자가 3개 이상인 경우"를 필터링하는 등의 추가 작업을 쉽게 할 수 있습니다.


const longRepeats = matches.filter(match => match.length >= 3);
console.log(longRepeats);
// 결과: [ "eee", "iii", "111", "3333" ]

g 플래그 미사용 시:

g 플래그가 없으면, 첫 번째로 일치하는 부분에 대한 상세 정보를 담은 배열을 반환합니다. 이 배열에는 전체 일치 문자열 외에, 각 캡처 그룹의 내용, 인덱스 등의 추가 정보가 포함됩니다.


const patternWithoutG = /(\w)\1+/; // g 플래그 제거
const firstMatchInfo = text.match(patternWithoutG);

console.log(firstMatchInfo);
// 결과:
// [
//   "eee",      // 0: 전체 일치 문자열 (전체 패턴과 매칭된 부분)
//   "e",        // 1: 첫 번째 캡처 그룹 (\w)가 캡처한 내용
//   index: 1,      // 일치하는 부분의 시작 인덱스
//   input: "Heeelloo, thiiis is a teest...", // 원본 문자열
//   groups: undefined
// ]

이처럼 g 플래그 없이 사용하면, 단순히 일치 여부뿐만 아니라 어떤 문자가('e') 반복되었는지, 그리고 어디서(index: 1) 시작되었는지 등의 훨씬 상세한 정보를 얻을 수 있습니다.

3.2. RegExp.prototype.test(): 존재 여부만 빠르게 확인하기

test() 메서드는 문자열이 정규표현식과 일치하는지 여부만 확인하고 싶을 때 사용합니다. true 또는 false를 반환하므로, 조건문에서 간단하게 사용하기 좋습니다.


const pattern = /(\w)\1+/; // g 플래그는 test()와 함께 사용할 때 주의가 필요합니다.
const weakPassword1 = "password111";
const weakPassword2 = "mylovelysun";
const strongPassword = "abc_123_def";

console.log(`"${weakPassword1}"에 반복 문자가 있나요?`, pattern.test(weakPassword1)); // true
console.log(`"${weakPassword2}"에 반복 문자가 있나요?`, pattern.test(weakPassword2)); // true ('l' 반복)
console.log(`"${strongPassword}"에 반복 문자가 있나요?`, pattern.test(strongPassword)); // false

주의: g 플래그가 있는 정규표현식 객체에 test()를 여러 번 호출하면 예상과 다르게 동작할 수 있습니다. 정규표현식 객체는 마지막으로 일치한 위치(lastIndex)를 기억하기 때문에, 다음 검색은 그 위치부터 시작합니다. 따라서 일관된 결과를 원한다면 test()를 사용할 때는 g 플래그를 빼거나, 매번 새로운 정규표현식 객체를 생성하는 것이 안전합니다.

3.3. RegExp.prototype.exec(): 상세 정보와 함께 모든 항목 순회하기

exec() 메서드는 match()(g 없음)와 유사하게 상세 정보를 반환하지만, g 플래그와 함께 사용될 때 진가를 발휘합니다. exec()는 호출될 때마다 다음 일치 항목을 찾아 반환하며, 더 이상 일치하는 것이 없으면 null을 반환합니다. 이를 이용해 while 루프 안에서 모든 일치 항목을 순회하며 상세 정보를 얻을 수 있습니다.


const pattern = /(\w)\1+/g; // g 플래그 필수!
const text = 'aa-bb-cc-111';
let matchInfo;

while ((matchInfo = pattern.exec(text)) !== null) {
  console.log(
    `전체 일치: "${matchInfo[0]}", ` +
    `반복된 문자: "${matchInfo[1]}", ` +
    `시작 위치: ${matchInfo.index}`
  );
}

// 결과:
// 전체 일치: "aa", 반복된 문자: "a", 시작 위치: 0
// 전체 일치: "bb", 반복된 문자: "b", 시작 위치: 3
// 전체 일치: "cc", 반복된 문자: "c", 시작 위치: 6
// 전체 일치: "111", 반복된 문자: "1", 시작 위치: 9

exec()match()보다 더 많은 제어권을 제공하며, 각 일치 항목의 위치와 캡처 그룹 내용을 모두 알아야 할 때 매우 유용합니다.

3.4. String.prototype.replace(): 찾아낸 패턴을 다른 문자열로 바꾸기

replace()는 정규표현식의 활용도를 극대화하는 메서드입니다. 패턴에 일치하는 부분을 다른 문자열로 바꿀 수 있습니다. 이때, 교체될 문자열 안에서 특별한 패턴($&, $1 등)을 사용하여 원본의 일치 정보를 재활용할 수 있습니다.

  • $&: 일치한 전체 문자열
  • $1, $2, ...: 첫 번째, 두 번째, ... 캡처 그룹의 내용

예제 1: 반복된 문자 압축하기 ('helooo' -> 'helo')

이것은 캡처 그룹과 역참조의 개념을 완벽히 보여주는 예제입니다. 우리는 (\w)\1+ 패턴을 사용하여 'ooo'를 찾은 다음, 그것을 캡처된 문자 \1 즉, 'o' 하나로 교체할 것입니다.


const pattern = /(\w)\1+/g;
const text = 'helooo woorld, I am verrry happyyy!';

// $1은 첫 번째 캡처 그룹, 즉 반복되는 문자를 가리킨다.
const compressedText = text.replace(pattern, '$1'); 

console.log(compressedText);
// 결과: "helo world, I am very happy!"

이 코드는 'ooo'를 찾아 $1('o')로 바꾸고, 'oo'를 찾아 $1('o')로, 'rr'을 $1('r')로, 'yyy'를 $1('y')로 바꿔줍니다. 단 한 줄의 코드로 매우 효과적인 데이터 정제가 가능합니다.

예제 2: 반복된 부분 강조하기

이번에는 찾은 부분을 대문자로 바꾸고 괄호로 감싸서 강조해 보겠습니다.


const pattern = /(\w)\1+/g;
const text = 'A mississippi river boat.';

const highlightedText = text.replace(pattern, (match) => `[${match.toUpperCase()}]`);

console.log(highlightedText);
// 결과: "A mi[SS]i[SS]ippi river boat."

replace()의 두 번째 인자로 함수를 전달하면, 더 복잡하고 동적인 치환 로직을 구현할 수 있습니다. 함수의 첫 번째 매개변수(match)는 일치한 전체 문자열($&와 동일)을 받습니다.


4. 패턴 확장 및 고급 응용 기술

기본 패턴 /(\w)\1+/g의 원리를 마스터했다면, 이제 여러분의 필요에 맞게 패턴을 자유자재로 변형하고 확장할 수 있습니다. 정규표현식의 진정한 힘은 이러한 유연성에 있습니다.

4.1. 검색 대상 문자 변경하기

\w(알파벳, 숫자, 언더스코어) 대신 다른 문자 집합을 대상으로 반복을 찾고 싶을 수 있습니다.

모든 문자(줄바꿈 제외)에서 반복 찾기: (.)\1+

.(점) 메타문자는 줄바꿈 문자(\n)를 제외한 모든 문자와 일치합니다. 이를 이용하면 특수문자나 공백의 반복도 찾아낼 수 있습니다.


const pattern = /(.)\1+/g;
const text = 'Wow!!! That is sooooo cool...  Right??';

console.log(text.match(pattern));
// 결과: [ "!!!", "ooooo", "...", "  ", "??" ]

\w를 사용했다면 '!!!', '...', ' ', '??'는 찾지 못했을 것입니다. (.)로 바꾸는 것만으로 검색 범위가 훨씬 넓어졌습니다.

숫자만으로 반복 찾기: (\d)\1+ 또는 ([0-9])\1+

\d는 숫자(digit)를 의미하며, [0-9]와 같습니다. 전화번호나 계좌번호 등에서 연속된 숫자를 찾을 때 유용합니다.


const pattern = /(\d)\1+/g;
const text = 'My phone number is 010-1111-2223';

console.log(text.match(pattern));
// 결과: [ "1111", "222" ]

특정 문자들로만 반복 찾기: ([abc])\1+

대괄호 [] 안에 원하는 문자들을 넣어 '문자 집합(character set)'을 만들 수 있습니다. 아래 예제는 a, b, c 중에서 반복되는 경우만 찾습니다.


const pattern = /([abc])\1+/g;
const text = 'aaabbbcccdddeee';

console.log(text.match(pattern));
// 결과: [ "aaa", "bbb", "ccc" ] 
// 'ddd'와 'eee'는 [abc] 집합에 포함되지 않으므로 무시됩니다.

4.2. 반복 횟수 제어하기: 수량자 {n,m}

+는 '1번 이상'을 의미했지만, 때로는 '정확히 3번' 또는 '2번에서 4번 사이'와 같이 더 정교하게 반복 횟수를 제어해야 합니다. 이때 중괄호 수량자 {}를 사용합니다.

  • {n}: 정확히 n번 반복
  • {n,}: 최소 n번 이상 반복
  • {n,m}: 최소 n번, 최대 m번 반복

3번 이상 연속되는 문자 찾기 (비밀번호 규칙)

이는 비밀번호 유효성 검사에서 매우 흔하게 사용되는 규칙입니다. 'aaa'나 '1111'은 허용하지 않는 경우입니다.


const pattern = /(\w)\1{2,}/; // \1이 2번 이상 반복, 즉 전체 문자는 3번 이상 연속
const password_ok = "pa55word";
const password_fail1 = "passwooorrd";
const password_fail2 = "1234444abc";

console.log(pattern.test(password_ok));    // false
console.log(pattern.test(password_fail1)); // true ('ooo' 때문에)
console.log(pattern.test(password_fail2)); // true ('4444' 때문에)

(\w)\1{2,}를 분석해보면, (\w)가 한 문자를 차지하고, \1{2,}가 그 문자의 2번 이상 반복을 의미하므로, 총 1 + 2 = 3번 이상 연속되는 문자를 찾게 됩니다.

정확히 2번 연속되는 문자만 찾기


const pattern = /(\w)\1{1}/g; // \1이 정확히 1번 반복. 즉, 전체 문자는 2번 연속
// 참고: {1}은 생략할 수 없으므로, (\w)\1 과는 다르게 동작할 수 있습니다. 
// 더 명확하게는 부정형 탐색을 사용해야 하지만, 기본적인 개념은 이렇습니다.
// 정확히 2번만 찾기 위한 더 정교한 패턴: /(\w)\1(?!\1)/g
// (?!\1)은 'lookahead' 문법으로, 뒤에 \1이 오지 않는 경우에만 일치하라는 뜻입니다.

const text = 'aa bbb cccc d ee';
const precisePattern = /(\w)\1(?!\1)/g;

console.log(text.match(precisePattern));
// 결과: [ "aa", "ee" ] 
// 'bbb'나 'cccc'는 뒤에 같은 문자가 또 오기 때문에 (?!\1) 조건에 걸려 제외됩니다.

4.3. 고급 활용 사례: 반복되는 '단어' 찾기

지금까지는 '문자'의 반복을 찾았습니다. 역참조를 응용하면 '단어'의 반복도 쉽게 찾을 수 있습니다. 예를 들어, "I love love this." 와 같은 문장에서 중복된 'love'를 찾는 것입니다.

패턴은 다음과 같습니다: /\b(\w+)\s+\1\b/g

  • \b: 단어 경계(Word Boundary). 단어의 시작이나 끝을 의미합니다. 공백, 구두점, 문자열의 시작/끝과 단어 문자 사이의 위치에 해당합니다. 이것이 없으면 'the theater'에서 'the'와 'theater'의 'the'를 반복으로 오인할 수 있습니다.
  • (\w+): 1개 이상의 단어 문자로 이루어진 '단어'를 캡처합니다.
  • \s+: 1개 이상의 공백 문자(스페이스, 탭, 줄바꿈 등). 단어와 단어 사이의 간격을 의미합니다.
  • \1: 첫 번째 캡처 그룹, 즉 앞에서 찾은 바로 그 '단어'를 의미합니다.
  • \b: 다시 단어 경계로 끝나야 완전한 단어 반복입니다.

const pattern = /\b(\w+)\s+\1\b/gi; // i 플래그로 대소문자 무시
const text = "This is a test test string. Paris in the the spring. Hello hello world!";

console.log(text.match(pattern));
// 결과: [ "test test", "the the", "Hello hello" ]

// 중복 단어 제거하기
const correctedText = text.replace(pattern, '$1'); // $1은 캡처된 단어
console.log(correctedText);
// 결과: "This is a test string. Paris in the spring. Hello world!"

이처럼 기본 원리를 응용하면 훨씬 더 복잡하고 유용한 패턴을 만들어 낼 수 있습니다.


5. 결론: 하나의 패턴, 무한한 가능성

우리는 오늘 /(\w)\1+/g라는 하나의 정규표현식으로 시작하여 그 내부의 동작 원리를 원자 단위까지 깊이 파고들었습니다. 캡처 그룹 ()의 '기억' 능력과 역참조 \1의 '재활용' 능력이 어떻게 결합하여 '반복'이라는 패턴을 찾아내는지 명확히 이해했습니다.

더 나아가, 우리는 이 기본 패턴을 다양한 상황에 맞게 변형하는 방법을 배웠습니다.

  • \w., \d, [] 등으로 바꾸어 검색 대상을 변경했습니다.
  • +{n,m} 수량자로 바꾸어 반복 횟수를 정교하게 제어했습니다.
  • JavaScript의 match, test, exec, replace 메서드를 활용하여 단순히 찾는 것을 넘어, 확인하고, 순회하고, 수정하는 실용적인 코드를 작성했습니다.
  • 마지막으로, 개념을 확장하여 반복되는 '문자'가 아닌 반복되는 '단어'를 찾는 고급 기술까지 살펴보았습니다.

정규표현식은 처음에는 외계어처럼 보일 수 있지만, 그 핵심 원리를 이해하고 나면 코드를 작성하는 방식을 근본적으로 바꾸는 강력한 도구가 됩니다. 복잡한 문자열 처리 로직을 단 한 줄의 표현식으로 압축할 때의 짜릿함은 개발자만이 느낄 수 있는 큰 즐거움 중 하나입니다.

오늘 배운 역참조(Backreference)의 개념을 잊지 마십시오. 이것은 정규표현식의 수많은 고급 기능으로 통하는 문을 열어주는 열쇠입니다. 이제 여러분의 코드에 흩어져 있는 비효율적인 문자열 처리 로직들을 이 우아하고 강력한 정규표현식으로 리팩토링해 보시는 것은 어떨까요? 하나의 패턴을 마스터한 여러분의 가능성은 이제 무한합니다.