정규 표현식 완전 정복

Python re 모듈: 메타 문자부터 실전 예제까지

Python 표준 라이브러리 re 모듈을 활용한 정규 표현식의 이론과 실전을 다룬다. 메타 문자(^, $, [], .), 반복자(*, +, ?, {m,n}), 그룹화와 backreference, 특수 문자(, , , re 탐색 함수 4가지, Flags, re.compile과 Match 객체, 그리고 핸드폰 번호 추출, CSV 필드 추출, 개인정보 마스킹, 주민번호 변환, HTML 태그 제거 등 실전 활용 사례 6가지를 패턴 설계 사고법과 함께 완전히 해부한다.

Engineering
Python
저자

Kwangmin Kim

공개

2023년 07월 02일

1 개요

  • 정규 표현식이란: Stephen Cole Kleene의 이론적 배경, Python re 모듈 개요
  • 해결하는 문제: 탐색(Search), 치환(Replace), 검증(Validation) 세 가지 용도
  • 실생활 활용 예시 6가지: 소설 이름 등장횟수, 전화번호 추출, 이메일 검증, 문맥 파악, 주민번호 변환, 로그 알람
  • 메타 문자:
    • 종류: ^, $, [], [^], ., |
  • 반복자와 그룹화: *, +, ?, {m,n}, Greedy vs Lazy, (), Backreference \n
  • 특수 문자: \w, \d, \s, \b, \A, \Z
  • re 탐색 함수 4가지: re.search(), re.match(), re.fullmatch(), re.findall()
  • Flags: re.IGNORECASE, re.MULTILINE, re.DOTALL
  • re.compile과 Match 객체: Pattern 객체, pos/endpos, group(), start/end/span(), named group, finditer()
  • 실전 활용 사례 6가지: 핸드폰 번호 추출, CSV 필드 추출, 순서 바꾸기, 개인정보 마스킹, 주민번호 변환, HTML 태그 제거

2 정규 표현식이란

2.1 개념

정규 표현식(Regular Expression)은 텍스트에서 특정 패턴을 찾아내기 위한 문자열 표기법이다. “이런 생김새를 가진 문자열을 찾아라”는 명령을 하나의 압축된 기호 조합으로 표현한 것이다.

예를 들어, 전화번호처럼 생긴 문자열을 찾고 싶을 때를 생각해보자. 단순히 "010"으로 검색하면 "010"이 들어간 모든 문자열이 걸린다. 하지만 010-1234-5678 형태 전체를 정확히 잡아내려면, 숫자 세 자리, 하이픈, 숫자 네 자리, 하이픈, 숫자 네 자리라는 구조 자체를 패턴으로 표현해야 한다. 이 구조를 표현하는 도구가 바로 정규 표현식이다.

정규 표현식은 1950년대 미국 수학자 Stephen Cole Kleene이 정규 언어(regular language) 개념을 형식화하면서 이론적 기반이 마련되었다. 이후 Unix 텍스트 처리 유틸리티들과 함께 실용적 도구로 자리잡았고, 현재는 거의 모든 프로그래밍 언어에서 표준 기능으로 지원된다. Python에서는 표준 라이브러리인 re 패키지가 이 역할을 담당한다.

2.2 정규 표현식이 해결하는 세 가지 문제

정규 표현식은 크게 세 가지 문제를 해결한다.

  • 탐색(Search): 비정형 텍스트 안에서 특정 패턴을 가진 부분 문자열을 찾아낸다. 소설 전체에서 주인공 이름이 몇 번 등장하는지 세는 것이 이 범주에 해당한다.
  • 치환(Replace): 패턴에 매칭된 문자열을 다른 문자열로 교체한다. 로그 파일에서 날짜 포맷을 YYYYMMDD에서 YYYY-MM-DD로 일괄 변환하는 작업이 대표적이다.
  • 검증(Validation): 입력값이 정해진 형식에 맞는지 판단한다. 사용자가 입력한 텍스트가 이메일 주소 형식인지 확인하는 것이 이에 해당한다.

2.3 실생활 활용 예시 6가지

각각 왜 정규 표현식이 필요한 문제인지 관점에서 살펴본다.

  • 소설에서 주인공 이름 등장 횟수 세기: 단순 str.count("홍길동")으로는 홍길동이, 홍길동을, 홍길동의 등 조사가 붙은 변형을 놓친다. 정규 표현식을 쓰면 이런 변형들을 하나의 패턴으로 묶어서 한 번에 탐색할 수 있다.
  • 문장에서 전화번호만 추출하기: 전화번호는 010-1234-5678, 02-123-4567, 010.1234.5678 등 구분자가 다른 형태도 있다. 하나의 패턴으로 정의하고 re.findall()로 추출하면 루프와 조건문 없이 처리할 수 있다.
  • 이메일 주소 형식 검증: @가 있는지, 도메인이 있는지, 최상위 도메인이 2자 이상인지 등 여러 조건을 동시에 판단해야 한다. 이 모든 조건을 단일 패턴 문자열 하나로 압축할 수 있다.
  • 특정 패턴 앞뒤의 문맥 단어 파악: 로그 파일에서 ERROR 키워드 앞뒤에 어떤 메시지가 붙는지 분석할 때, 그룹화 기능으로 패턴과 함께 주변 문자열도 동시에 캡처할 수 있다.
  • 주민등록번호에서 생년월일 변환: 930101-1234567에서 앞 6자리를 분해하고, 7번째 자리를 보고 19xx/20xx년생을 판단한 뒤 1993-01-01 형태로 재조합하는 작업을 re.sub()에 변환 함수를 넘겨 한 번에 처리할 수 있다.
  • 로그 파일 분석 및 알람 발송: 수백만 줄의 로그를 실시간으로 감시하면서 CRITICAL, FATAL, OutOfMemoryError처럼 심각한 키워드가 포함된 라인을 감지할 때, re.compile()로 패턴을 미리 컴파일해두면 빠르게 스캔할 수 있다.

2.4 Python에서 정규 표현식 사용하기

Python에서 정규 표현식을 사용하려면 표준 라이브러리인 re 모듈을 import한다. 별도 설치 없이 Python 설치와 함께 제공된다.

import re

text = "Contact us at support@example.me or info@company.com for more information."
email_pattern = r'\b[\w.%+-]+@[\w.-]+\.[A-Z|a-z]{2,}\b'

emails = re.findall(email_pattern, text)
print(emails)  # ['support@example.me', 'info@company.com']

r'...'는 Python의 raw string으로, 백슬래시를 이스케이프 문자가 아닌 리터럴 문자로 처리한다. 정규 표현식에서 \w, \b 같은 특수 문자를 올바르게 쓰려면 raw string을 사용하는 것이 관례다.

3 메타 문자

정규 표현식을 구성하는 문자는 크게 두 종류로 나뉜다.

  • 리터럴 문자(literal character): a, b, 1, 2처럼 그 자체를 의미하는 문자
  • 메타 문자(meta character): 특별한 의미나 기능을 가진 기호로, “이 자리에 어떤 문자가 올 수 있는가”를 정의하거나 “이 패턴이 문자열의 어느 위치에서 매칭되어야 하는가”를 제어한다

메타 문자는 역할에 따라 세 그룹으로 구분된다.

  • 앵커(anchor): 위치를 제어한다 — ^, $
  • 문자 클래스(character class): 문자 집합을 정의한다 — [], [^]
  • 논리 연산자: 선택 논리를 담당한다 — ., |

3.1 위치를 제어하는 앵커: ^$

3.1.1 ^ — 문자열/줄의 시작 위치

^는 문자열 또는 줄의 시작 위치에 매칭된다. 중요한 점은 ^가 어떤 문자를 매칭하는 게 아니라 위치를 매칭한다는 것이다.

import re

text1 = "Hello, world"
text2 = "world, Hello"

print(re.search(r'^Hello', text1))  # 매칭됨: 'Hello'가 문자열 시작에 있음
print(re.search(r'^Hello', text2))  # None: 'Hello'가 시작이 아닌 중간에 있음

로그 파일에서 특정 레벨로 시작하는 줄만 골라낼 때 유용하다. ^ERROR, ^WARN처럼 쓰면 해당 레벨로 시작하는 라인만 정확히 필터링할 수 있다.

3.1.2 $ — 문자열/줄의 끝 위치

$는 문자열 또는 줄의 끝 위치에 매칭된다. ^와 대칭적으로, 패턴이 문자열의 끝에 위치해야 매칭이 성공한다.

import re

text1 = "report.csv"
text2 = "report.csv.bak"

print(re.search(r'\.csv$', text1))  # 매칭됨: .csv로 끝남
print(re.search(r'\.csv$', text2))  # None: .csv로 끝나지 않음

^$를 함께 쓰면 “전체 문자열이 이 패턴과 일치하는가”를 검증할 수 있다. 예를 들어 ^\d{5}$는 정확히 다섯 자리 숫자로만 이루어진 문자열인지 확인하는 패턴이다.

3.2 문자 집합을 정의하는 클래스: [][^]

3.2.1 [] — 문자 집합

[]는 “이 괄호 안에 있는 문자 중 하나와 매칭”을 의미한다.

import re

print(re.findall(r'[aeiou]', "hello world"))  # ['e', 'o', 'o']
print(re.findall(r'[a-z]', "Hello World"))    # ['e', 'l', 'l', 'o', 'o', 'r', 'l', 'd']
print(re.findall(r'[0-9]', "abc123def456"))   # ['1', '2', '3', '4', '5', '6']
  • [a-z]: 소문자 알파벳 전체
  • [0-9]: 모든 숫자
  • [A-Za-z0-9]: 영문자와 숫자 전체
  • [가-힣]: 한글 음절 전체 (실무에서 자주 사용)

[] 안에서는 대부분의 메타 문자가 일반 문자로 취급된다. [.]은 점 문자 자체를 의미하고, 임의의 문자를 의미하는 메타 .이 아니다. 단, ], \, ^, -[] 안에서도 특별한 의미를 가지므로 이스케이프가 필요하다.

3.2.2 [^] — 부정 문자 집합(여집합)

[^][]의 반대다. [^abc]a, b, c아닌 문자 하나와 매칭된다. ^[] 안의 첫 번째 위치에 올 때만 부정의 의미를 가진다는 점에 주의해야 한다.

import re

print(re.findall(r'[^aeiou]', "hello"))   # ['h', 'l', 'l'] — 모음이 아닌 문자
print(re.findall(r'[^0-9]', "abc123"))    # ['a', 'b', 'c'] — 숫자가 아닌 문자
print(re.findall(r'[^,]+', "a,b,c,d"))   # ['a', 'b', 'c', 'd'] — 쉼표가 아닌 문자들

[^,]+는 CSV 처리에서 핵심적으로 사용하는 패턴이다. “쉼표가 아닌 문자 하나 이상”을 의미하므로, 쉼표로 구분된 각 필드를 추출하는 데 쓸 수 있다.

3.3 임의 문자와 선택: .|

3.3.1 . — 개행을 제외한 임의의 한 글자

.은 줄바꿈 문자(\n)를 제외한 어떤 문자와도 매칭되는 와일드카드다.

import re

print(re.findall(r'a.c', "abc axc a1c a c"))  # ['abc', 'axc', 'a1c', 'a c']
print(re.findall(r'a.c', "a\nc"))              # [] — 개행 문자는 매칭 안 됨

주의해야 할 점들:

  • .*처럼 반복자와 결합하면 의도치 않게 너무 많은 범위를 잡아버리는 greedy matching 문제가 발생한다
  • 개행 문자까지 포함해서 매칭하고 싶다면 re.DOTALL 플래그를 사용한다
  • . 자체(점 문자)를 매칭하고 싶을 때는 반드시 \.로 이스케이프해야 한다

3.3.2 | — 선택 연산자 (OR)

|는 “앞의 패턴 또는 뒤의 패턴”을 의미하는 논리 OR 연산자다.

import re

text = "I have a cat and a dog"
print(re.findall(r'cat|dog', text))   # ['cat', 'dog']

log = "ERROR: disk full\nWARN: high memory\nINFO: server started"
print(re.findall(r'ERROR|WARN', log)) # ['ERROR', 'WARN']

|의 적용 범위를 제한하려면 괄호로 그룹화해야 한다. cat|dog foodcat 또는 dog food를 의미하지만, (cat|dog) food로 쓰면 cat food 또는 dog food를 의미한다.

3.4 메타 문자 조합 예시: 이메일 패턴 해부

r'\b[\w.%+-]+@[\w.-]+\.[A-Z|a-z]{2,}\b'

이 패턴을 구간별로 분해하면 각 요소의 역할을 명확히 볼 수 있다.

구간 패턴 의미
단어 경계 \b 이메일이 다른 문자열에 붙지 않도록
로컬 파트 [\w.%+-]+ 영문자, 숫자, _, ., %, +, - 중 하나 이상
@ @ 리터럴 @ 문자
도메인 [\w.-]+ 영문자, 숫자, _, ., - 중 하나 이상
\. 리터럴 점 문자
최상위 도메인 [A-Z\|a-z]{2,} 대소문자 알파벳 2자 이상
단어 경계 \b 이메일이 다른 문자열에 붙지 않도록

4 반복자와 그룹화

4.1 반복자: 패턴의 반복 횟수 제어

전화번호 010-1234-5678을 예로 들면, 첫 번째 구간은 숫자 3자리, 두 번째 구간은 숫자 3~4자리, 세 번째 구간은 숫자 4자리다. \d\d\d-\d\d\d\d-\d\d\d\d처럼 \d를 반복해서 쓸 수도 있지만, 반복자를 쓰면 \d{3}-\d{3,4}-\d{4}로 훨씬 간결하게 표현할 수 있다.

4.1.1 * — 0회 이상

*는 바로 앞의 패턴이 0번 이상 반복될 수 있음을 의미한다. “없어도 되고, 있으면 몇 개든 괜찮다”는 뜻이다.

import re

print(re.findall(r'ab*c', "ac abc abbc abbbc"))
# ['ac', 'abc', 'abbc', 'abbbc']
# b가 0개(ac), 1개(abc), 2개(abbc), 3개(abbbc) 모두 매칭

*의 주의점은 0회 매칭도 성공으로 처리한다는 것이다. .*는 빈 문자열을 포함한 모든 것과 매칭되어 의도치 않은 결과를 만들 수 있다.

4.1.2 + — 1회 이상

+는 바로 앞의 패턴이 1번 이상 반복될 때 매칭된다. *와의 차이는 “반드시 하나 이상 존재해야 한다”는 점이다.

import re

print(re.findall(r'ab+c', "ac abc abbc abbbc"))
# ['abc', 'abbc', 'abbbc'] — b가 0개인 'ac'는 매칭 안 됨

print(re.findall(r'\d+', "abc 123 def 4567"))
# ['123', '4567'] — 숫자가 연속된 구간을 통째로 추출

\d+는 실무에서 가장 많이 쓰이는 패턴 중 하나다. \d만 쓰면 숫자 하나씩 따로 추출되지만, \d+는 연속된 숫자 전체를 하나로 묶어준다.

4.1.3 ? — 0회 또는 1회

?는 바로 앞의 패턴이 없거나 하나만 있을 때 매칭된다.

import re

print(re.findall(r'https?://', "http://example.com https://secure.com"))
# ['http://', 'https://'] — s가 있거나 없거나 둘 다 매칭

https?://http://https://를 동시에 잡아낼 수 있는 URL 패턴에서 자주 사용된다.

4.1.4 {m,n} — 정확한 반복 횟수 지정

{m,n}은 앞의 패턴이 최소 m회, 최대 n회 반복될 때 매칭된다.

import re

print(re.findall(r'\d{3}-\d{3,4}-\d{4}', "010-1234-5678 02-123-4567"))
# ['010-1234-5678', '02-123-4567']

변형 형태:

  • {m}: 정확히 m회
  • {m,}: m회 이상
  • {m,n}: m회 이상 n회 이하

전화번호 패턴 \d{3}-\d{3,4}-\d{4}에서 가운데 구간이 {3,4}인 이유는 지역번호 없는 번호(010-123-4567)와 일반 번호(010-1234-5678) 모두를 커버해야 하기 때문이다.

4.2 Greedy vs Lazy 매칭

반복자를 이해할 때 반드시 알아야 하는 개념이 greedy(탐욕적) 매칭이다. 기본적으로 *, +, ?, {m,n}은 모두 greedy하게 동작한다. 즉, 조건을 만족하는 범위 내에서 최대한 많이 매칭하려 한다.

import re

html = "<b>bold</b> and <i>italic</i>"

print(re.findall(r'<.*>', html))
# ['<b>bold</b> and <i>italic</i>'] — 처음 '<'부터 마지막 '>'까지 통째로 매칭

print(re.findall(r'<.*?>', html))
# ['<b>', '</b>', '<i>', '</i>'] — 각 태그를 개별적으로 매칭

반복자 뒤에 ?를 붙이면 lazy(게으른) 매칭으로 전환된다. .*?는 조건을 만족하는 범위 내에서 최소한만 매칭하려 한다.

매칭 방식 표현 동작
Greedy .* 가능한 한 최대 범위 매칭
Lazy .*? 가능한 한 최소 범위 매칭
Greedy .+ 1회 이상, 최대한 많이
Lazy .+? 1회 이상, 최소한으로

정규 표현식이 “이상하게 동작한다”고 느낄 때 가장 먼저 확인해야 할 것이 바로 greedy 매칭 문제다.

4.3 그룹화: 패턴을 하나의 단위로 묶는다

4.3.1 () — 하위 그룹 정의

()는 괄호 안의 패턴을 하나의 단위(그룹)로 묶는다. 그룹화의 목적은 크게 세 가지다.

목적 1: 반복자를 패턴 전체에 적용

import re

print(re.findall(r'(ab)+', "ab abab ababab"))
# ['ab', 'ab', 'ab']
# 'ab'라는 두 글자 패턴이 1회 이상 반복되는 경우를 매칭

괄호 없이 +를 쓰면 바로 앞의 문자 하나에만 적용된다.

목적 2: 매칭된 결과에서 특정 부분만 추출

import re

text = "이름: 홍길동, 전화: 010-1234-5678"
pattern = re.compile(r'이름: (\w+), 전화: (\d{3}-\d{3,4}-\d{4})')
match = pattern.search(text)

if match:
    print(match.group(0))  # '이름: 홍길동, 전화: 010-1234-5678' — 전체 매칭
    print(match.group(1))  # '홍길동' — 첫 번째 그룹
    print(match.group(2))  # '010-1234-5678' — 두 번째 그룹

목적 3: re.sub()에서 그룹 재조합

import re

# 날짜 형식 변환: YYYY-MM-DD -> DD/MM/YYYY
date = "2024-02-15"
result = re.sub(r'(\d{4})-(\d{2})-(\d{2})', r'\3/\2/\1', date)
print(result)  # '15/02/2024'

4.3.2 \n — Backreference (역참조)

\n은 n번째 그룹이 매칭한 문자열을 패턴 내에서 다시 참조한다.

import re

# 연속으로 중복된 단어 탐지
print(re.findall(r'(\w+) \1', "the the quick brown fox fox"))
# ['the', 'fox']

# re.sub에서 요소 순서 바꾸기
input_string = "서울-대구-대전-부산"
result = re.sub(r'(\w+)-(\w+)-(\w+)-(\w+)', r'\1-\3-\2-\4', input_string)
print(result)  # '서울-대전-대구-부산'

5 특수 문자

특수 문자(special sequence)는 자주 쓰이는 문자 집합을 미리 축약해둔 단축키다. \d[0-9]와 동일하고, \w[a-zA-Z0-9_]와 동일하다. 대문자 버전(\W, \D, \S, \B)은 각각의 반대 집합을 의미한다.

5.1 핵심 특수 문자

특수 문자 의미 동등한 표현
\w 단어 문자 (영문자, 숫자, _) + 유니코드(한글 포함) [a-zA-Z0-9_]
\W 단어 문자가 아닌 것 [^a-zA-Z0-9_]
\d 숫자 [0-9]
\D 숫자가 아닌 것 [^0-9]
\s 공백 문자 (스페이스, 탭, 개행 등) [ \t\n\r\f\v]
\S 공백 문자가 아닌 것 [^ \t\n\r\f\v]
\b 단어 경계 (위치 앵커)
\B 단어 경계가 아닌 위치
\A 문자열 절대 시작 (플래그 무관)
\Z 문자열 절대 끝 (플래그 무관)

5.1.1 \w — 단어 문자

import re

print(re.findall(r'\w+', "hello_world 123 !@#"))
# ['hello_world', '123'] — 공백과 특수문자는 제외

print(re.findall(r'\w+', "이름: 홍길동, 나이: 30"))
# ['이름', '홍길동', '나이', '30'] — Python re는 유니코드 지원으로 한글도 매칭

5.1.2 \d — 숫자 문자

import re

print(re.findall(r'\d{4}-\d{2}-\d{2}', "오늘 날짜는 2024-02-15입니다."))
# ['2024-02-15']

# 전화번호에서 숫자만 추출 (하이픈 등 구분자 제거)
phone = "010-1234-5678"
digits_only = re.sub(r'\D', '', phone)
print(digits_only)  # '01012345678'

5.1.3 \s — 공백 문자

import re

text = "hello   world\t!\nbye"
print(re.sub(r'\s+', ' ', text))
# 'hello world ! bye' — 연속된 공백(스페이스, 탭, 개행 포함)을 단일 스페이스로 정규화

print(re.split(r'\s+', "one  two\tthree\nfour"))
# ['one', 'two', 'three', 'four']

5.1.4 \b — 단어 경계

\b는 단어 문자(\w)와 비단어 문자(\W)의 경계 위치를 의미한다. 문자를 소비하지 않고 위치만 매칭하는 앵커다.

import re

text = "to top tomorrow stop"

print(re.findall(r'\bto\b', text))   # ['to'] — 단어 전체가 'to'인 경우만
print(re.findall(r'\bto', text))     # ['to', 'to', 'to'] — 'to'로 시작하는 모든 경우
print(re.findall(r'to\b', text))     # ['to', 'to'] — 'to'로 끝나는 모든 경우

5.1.5 \A\Z — 문자열의 절대적 시작과 끝

^, $re.MULTILINE 플래그를 사용하면 각 줄의 시작과 끝에 매칭된다. 반면 \A\Z는 플래그에 관계없이 항상 전체 문자열의 시작과 끝에만 매칭된다.

import re

text = "first line\nsecond line\nthird line"

print(re.findall(r'^.*line', text, re.MULTILINE))
# ['first line', 'second line', 'third line'] — 각 줄의 시작에서 매칭

print(re.findall(r'\A.*line', text, re.MULTILINE))
# ['first line'] — 전체 문자열의 시작에서만 매칭

6 re 탐색 함수와 Flags

6.1 re 탐색 함수 4가지

동일한 패턴을 쓰더라도 “어느 위치에서, 몇 개를 찾는가”에 따라 완전히 다른 결과가 나온다.

함수 탐색 범위 반환값
re.search() 어디든 첫 번째 매칭 Match 객체 또는 None
re.match() 문자열 시작에서만 Match 객체 또는 None
re.fullmatch() 전체 문자열이 패턴과 완전 일치 Match 객체 또는 None
re.findall() 모든 매칭 결과 리스트 (그룹 없으면 문자열 리스트, 그룹 있으면 튜플 리스트)

6.1.1 re.search() — 어디서든 첫 번째 매칭

import re

text = "The quick brown fox jumps over the lazy dog."

result = re.search(r'the', text, re.IGNORECASE)
print(result.group())  # 'The'
print(result.span())   # (0, 3)

6.1.2 re.match() — 문자열 시작에서만

import re

text = "The quick brown fox jumps over the lazy dog."

print(re.match(r'the', text, re.IGNORECASE))  # 매칭됨 — 'The'로 시작
print(re.match(r'fox', text))                  # None — 'fox'가 시작에 없음

re.match()^ 앵커를 패턴 앞에 붙인 것과 동일한 효과를 가진다.

6.1.3 re.fullmatch() — 전체 문자열이 패턴과 완전 일치

import re

email_pattern = r'[\w.%+-]+@[\w.-]+\.[a-zA-Z]{2,}'

print(re.fullmatch(email_pattern, "user@example.com"))          # 매칭됨
print(re.fullmatch(email_pattern, "user@example.com 입니다"))   # None — 뒤에 추가 문자열
print(re.search(email_pattern, "user@example.com 입니다"))      # 매칭됨 — 부분 매칭

re.fullmatch()가 진가를 발휘하는 상황은 입력값 검증이다. 사용자 입력값이 정확히 이메일/전화번호 형식인지 검증할 때 re.search()re.match()를 쓰면 앞뒤에 다른 문자열이 붙어도 통과될 수 있다. re.fullmatch()는 이런 부분 매칭의 허점을 원천 차단한다.

6.1.4 re.findall() — 모든 매칭 결과

import re

text = "이름:홍길동 나이:30 이름:김철수 나이:25"

# 그룹 없는 경우: 문자열 리스트 반환
print(re.findall(r'\d+', text))
# ['30', '25']

# 그룹 여러 개인 경우: 튜플 리스트 반환
matches = re.findall(r'이름:(\w+) 나이:(\d+)', text)
print(matches)
# [('홍길동', '30'), ('김철수', '25')]

그룹이 여러 개일 때 re.findall()은 튜플 리스트를 반환한다. 이 특성을 활용하면 DataFrame으로 변환하는 작업을 간결하게 처리할 수 있다.

import re
import pandas as pd

text = "이름:홍길동 나이:30 이름:김철수 나이:25"
matches = re.findall(r'이름:(\w+) 나이:(\d+)', text)
df = pd.DataFrame(matches, columns=['이름', '나이'])
print(df)
#     이름  나이
# 0  홍길동  30
# 1  김철수  25

6.2 Flags: 매칭 동작을 제어하는 옵션

Flags는 패턴 자체를 수정하지 않고도 매칭 규칙을 유연하게 조정할 수 있는 옵션이다.

Flag 약어 효과
re.IGNORECASE re.I 대소문자 무시
re.MULTILINE re.M ^, $를 각 줄 단위로 처리
re.DOTALL re.S .이 개행 문자를 포함한 모든 문자에 매칭

6.2.1 re.IGNORECASE — 대소문자 무시

import re

text = "Python python PYTHON"
print(re.findall(r'python', text))                 # ['python']
print(re.findall(r'python', text, re.IGNORECASE))  # ['Python', 'python', 'PYTHON']

6.2.2 re.MULTILINE — 멀티라인 모드

기본 모드에서 ^$는 전체 문자열의 시작과 끝에만 매칭된다. re.MULTILINE을 사용하면 각 줄의 시작과 끝에도 매칭된다.

import re

log = """ERROR: disk full
WARNING: high memory usage
ERROR: network timeout
INFO: server started"""

print(re.findall(r'^ERROR.*', log))
# ['ERROR: disk full'] — 전체 문자열 시작의 ERROR만

print(re.findall(r'^ERROR.*', log, re.MULTILINE))
# ['ERROR: disk full', 'ERROR: network timeout'] — 각 줄 시작의 ERROR

re.MULTILINE 없이 멀티라인 텍스트에서 ^를 쓰면 첫 번째 줄만 처리된다는 사실을 모르고 버그를 만드는 경우가 실무에서 자주 발생한다.

6.2.3 re.DOTALL — 개행 포함 와일드카드

기본 모드에서 .은 개행 문자를 제외한 모든 문자에 매칭된다. re.DOTALL을 사용하면 .이 개행 문자를 포함한 모든 문자에 매칭된다.

import re

html = "<div>\n    <p>내용</p>\n</div>"

print(re.search(r'<div>.*</div>', html))                   # None
print(re.search(r'<div>.*</div>', html, re.DOTALL))        # 매칭됨

6.2.4 Flags 조합

여러 플래그를 동시에 적용하려면 | 연산자로 조합한다.

import re

text = "First Line\nSECOND LINE\nthird line"
results = re.findall(r'^second.*', text, re.IGNORECASE | re.MULTILINE)
print(results)  # ['SECOND LINE']

7 re.compile과 Match 객체

7.1 re.compile() — 패턴 객체 생성

re.compile(pattern, flags=0)은 패턴을 한 번만 컴파일해서 re.Pattern 객체로 저장해두고, 이후 탐색 시 컴파일 단계를 건너뛴다.

re.compile()을 써야 하는 이유는 성능보다 코드 구조화에 있다.

  • 패턴에 의미 있는 이름을 부여해 코드의 의도를 명확하게 표현할 수 있다
  • 패턴과 flags를 한 곳에 묶어서 관리할 수 있다
  • 패턴을 모듈 상단에 상수처럼 모아두면 수정이 필요할 때 한 곳만 고치면 된다
  • pos/endpos로 탐색 범위를 제어하는 기능은 re.compile() 없이는 사용할 수 없다
import re

# 패턴을 변수명으로 의미 부여 — 모듈 상단에 상수처럼 정의
PHONE_PATTERN  = re.compile(r'01[016789]-\d{3,4}-\d{4}')
EMAIL_PATTERN  = re.compile(r'[\w.%+-]+@[\w.-]+\.[a-zA-Z]{2,}')
DATE_PATTERN   = re.compile(r'\d{4}-\d{2}-\d{2}')
SSN_PATTERN    = re.compile(r'\d{6}-[1-4]\d{6}')

text = "연락처: 010-1234-5678, 이메일: user@example.com"
print(PHONE_PATTERN.search(text).group())   # '010-1234-5678'
print(EMAIL_PATTERN.search(text).group())   # 'user@example.com'

7.2 pos와 endpos — 탐색 범위 제한

Pattern.search(string, pos, endpos)에서 pos는 탐색을 시작할 인덱스, endpos는 탐색을 끝낼 인덱스를 지정한다.

import re

pattern = re.compile(r'\d+')
text = "abc 123 def 456 ghi 789"

print(pattern.findall(text))           # ['123', '456', '789']
print(pattern.findall(text, 4, 15))    # ['123', '456'] — 인덱스 4~15 구간만 탐색

# 이전 매칭 위치에서 이어서 탐색
match1 = pattern.search(text)
match2 = pattern.search(text, match1.end())
print(match1.group(), match2.group())  # '123' '456'

re 모듈 함수(re.search(), re.findall())는 이 파라미터를 지원하지 않으므로 탐색 범위를 제어해야 하는 경우에는 반드시 re.compile()을 사용해야 한다.

7.3 re.Match 객체 활용

re.search()re.match()는 매칭이 성공하면 re.Match 객체를 반환한다. 이 객체는 매칭된 문자열, 위치 정보, 그룹 캡처 결과 등 풍부한 정보를 담고 있다.

7.3.1 조건 분기에서의 활용

Match 객체는 매칭이 성공하면 True, 실패하면 None으로 평가된다.

import re

pattern = re.compile(r'[\w.%+-]+@[\w.-]+\.[a-zA-Z]{2,}')

if pattern.search("user@example.com"):
    print("유효한 이메일")  # 별도로 is not None 체크 불필요

7.3.2 group() — 매칭된 문자열과 캡처 그룹 접근

import re

text = "전화번호: 010-1234-5678"
pattern = re.compile(r'(01[016789])-(\d{3,4})-(\d{4})')
match = pattern.search(text)

if match:
    print(match.group())    # '010-1234-5678' — 전체 매칭
    print(match.group(0))   # '010-1234-5678' — group()과 동일
    print(match.group(1))   # '010' — 첫 번째 그룹: 국번
    print(match.group(2))   # '1234' — 두 번째 그룹: 중간 번호
    print(match.group(3))   # '5678' — 세 번째 그룹: 끝 번호
    print(match.groups())   # ('010', '1234', '5678') — 모든 그룹을 튜플로

7.3.3 named group — 이름 기반 그룹 접근

(?P<name>pattern) 형식으로 그룹에 이름을 지정하면 match.group('name') 또는 match.groupdict()로 이름 기반 접근이 가능하다.

import re

text = "2024-02-15"
pattern = re.compile(r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})')
match = pattern.fullmatch(text)

if match:
    print(match.group('year'))    # '2024'
    print(match.group('month'))   # '02'
    print(match.groupdict())      # {'year': '2024', 'month': '02', 'day': '15'}

숫자 인덱스 방식(group(1))은 그룹 순서가 바뀌면 코드도 수정해야 하지만, named group 방식은 그룹 순서에 독립적이어서 패턴이 복잡해질수록 유지보수가 쉽다.

7.3.4 start(), end(), span() — 매칭 위치 정보

import re

text = "The quick brown fox"
match = re.search(r'brown', text)

print(match.start())   # 10
print(match.end())     # 15
print(match.span())    # (10, 15)

# 위치 정보 활용: 매칭 전후 문맥 추출
context_start = max(0, match.start() - 5)
context_end   = min(len(text), match.end() + 5)
print(text[context_start:context_end])  # 'ck brown fox'

그룹 번호를 인자로 넘기면 해당 그룹의 위치 정보도 추출할 수 있다.

if match:
    print(match.span(0))   # 전체 매칭 위치
    print(match.span(1))   # 첫 번째 그룹 위치

7.4 re.finditer() — Match 객체 이터레이터

re.finditer()는 매칭된 각 결과를 Match 객체로 감싸서 이터레이터 형태로 반환한다.

  • re.findall(): 결과 전체를 메모리에 리스트로 올림 — 단순 문자열 추출에 적합
  • re.finditer(): 한 번에 하나씩 Match 객체를 생성 — 위치 정보 필요하거나 대용량 처리에 적합
import re

text = "에러 발생: 2024-01-15 서버 다운, 재발생: 2024-02-03 메모리 부족"
pattern = re.compile(r'\d{4}-\d{2}-\d{2}')

for match in pattern.finditer(text):
    print(f"날짜: {match.group()}, 위치: {match.span()}")
# 날짜: 2024-01-15, 위치: (7, 17)
# 날짜: 2024-02-03, 위치: (27, 37)

8 실전 활용 사례

패턴을 작성하는 사고 과정은 항상 동일하다.

  1. 대상 데이터의 구조를 분석한다
  2. 구조를 구간으로 분리하고 각 구간의 규칙을 정의한다
  3. 각 구간을 메타 문자, 특수 문자, 반복자로 표현한다
  4. 캡처가 필요한 구간에 그룹화를 적용한다

8.1 사례 1: 핸드폰 번호 추출

8.1.1 데이터 구조 분석

한국 핸드폰 번호의 구조는 01x-xxx(x)-xxxx 형태다.

  • 첫 번째 구간: 010, 011, 016, 017, 018, 019 중 하나 (3자리)
  • 구분자: -
  • 두 번째 구간: 숫자 3자리 또는 4자리
  • 구분자: -
  • 세 번째 구간: 숫자 4자리

8.1.2 패턴 설계

  • 첫 번째 구간: 01로 시작하고 세 번째 자리가 0, 1, 6, 7, 8, 9 중 하나 → 01[016789]
    • 01.처럼 .을 쓰면 012, 013, 014, 015도 매칭되므로 문자 집합을 명시적으로 지정해야 정확하다
  • 두 번째 구간: 3자리 또는 4자리 숫자 → \d{3,4}
  • 세 번째 구간: 정확히 4자리 숫자 → \d{4}
import re

PHONE_PATTERN = re.compile(r'01[016789]-\d{3,4}-\d{4}')

personal_info = """
이름: 홍길동
주소: 서울시 강남구
전화번호: 010-1234-5678
주민등록번호: 930101-1234567
"""

match = PHONE_PATTERN.search(personal_info)
if match:
    print(f"핸드폰 번호: {match.group()}")
    # 핸드폰 번호: 010-1234-5678

930101-1234567이 걸리지 않는 이유는 첫 번째 구간 01[016789]93으로 시작하는 주민번호와 매칭되지 않기 때문이다. 패턴의 첫 구간을 엄격하게 정의하면 유사한 형태의 다른 데이터를 자연스럽게 걸러낼 수 있다.

8.1.3 그룹화를 추가한 확장 패턴

import re

PHONE_PATTERN = re.compile(r'(01[016789])-(\d{3,4})-(\d{4})')

text = "문의: 010-1234-5678 또는 011-987-6543"

for match in PHONE_PATTERN.finditer(text):
    area   = match.group(1)   # 국번
    middle = match.group(2)   # 중간 번호
    last   = match.group(3)   # 끝 번호
    print(f"국번: {area}, 중간: {middle}, 끝: {last}")
# 국번: 010, 중간: 1234, 끝: 5678
# 국번: 011, 중간: 987, 끝: 6543

8.2 사례 2: CSV 파일에서 특정 필드 추출

8.2.1 데이터 구조 분석

CSV 파일의 각 행은 쉼표로 구분된 필드들의 연속이다. 목표는 6개 필드 중 4번째와 6번째 필드만 추출하는 것이다.

8.2.2 패턴 설계

  • 하나의 CSV 필드: “쉼표가 아닌 문자가 1개 이상 연속된 구간” → [^,]+
  • 캡처가 필요한 4번째와 6번째 필드만 ()로 묶음
  • 나머지 필드는 괄호 없이 [^,]+로 작성 → “패턴은 매칭하되 캡처하지 않는다”
import re

pattern = re.compile(r'[^,]+,[^,]+,[^,]+,([^,]+),[^,]+,([^,]+)')

with open('log_file.csv', 'r') as file:
    for line in file:
        match = pattern.match(line.strip())
        if match:
            fourth_value = match.group(1)
            sixth_value  = match.group(2)
            print(f"4번째 값: {fourth_value}, 6번째 값: {sixth_value}")
        else:
            print("패턴과 매치되지 않는 라인이 있습니다.")

이 패턴의 설계 의도는 단순 추출이 아니라 구조 검증과 추출을 동시에 수행하는 것이다. re.match()는 문자열의 시작부터 패턴을 확인하므로, 필드 수가 맞지 않으면 매칭 자체가 실패해 else 분기로 빠진다.

단, 필드 값 안에 쉼표가 포함된 경우("값,포함",다음필드)는 이 패턴으로 처리할 수 없다. 따옴표로 감싸진 필드를 포함하는 복잡한 CSV는 csv 모듈이나 pandas.read_csv()를 사용하는 것이 더 적합하다.

8.3 사례 3: 순서 바꾸기 — re.sub와 backreference의 결합

"서울-대구-대전-부산" 형태의 문자열에서 2번째와 3번째 요소의 순서를 바꾸어 "서울-대전-대구-부산"으로 변환한다.

import re

input_string = "서울-대구-대전-부산"
result = re.sub(r'(\w+)-(\w+)-(\w+)-(\w+)', r'\1-\3-\2-\4', input_string)
print(result)   # '서울-대전-대구-부산'

이 패턴이 강력한 이유는 “어떤 구조에서 어떤 구조로 변환한다”는 선언적 방식을 사용한다는 점이다. 패턴 구조는 동일하고 그룹 번호 순서만 바꾸면 어떤 요소 재배열 문제든 동일한 방식으로 해결할 수 있다.

import re

# 날짜 형식 변환: YYYY-MM-DD -> DD/MM/YYYY
date = "2024-02-15"
result = re.sub(r'(\d{4})-(\d{2})-(\d{2})', r'\3/\2/\1', date)
print(result)   # '15/02/2024'

# 성명 순서 변환: 성, 이름 -> 이름 성
name = "Kim Minsu"
result = re.sub(r'(\w+) (\w+)', r'\2 \1', name)
print(result)   # 'Minsu Kim'

# CSV 컬럼 순서 변환
row = "A001,홍길동,30,서울"
result = re.sub(r'(\w+),(\w+),(\d+),(\w+)', r'\2,\1,\4,\3', row)
print(result)   # '홍길동,A001,서울,30'

8.4 사례 4: 개인정보 마스킹

8.4.1 re.sub()에 함수를 넘기는 원리

re.sub(pattern, repl, string)에서 repl이 함수일 경우, 패턴에 매칭되는 각 부분에 대해 그 함수가 호출된다. 함수의 인자는 Match 객체이고, 반환값이 치환 문자열이 된다.

이 구조를 활용하면 매칭된 내용을 분석한 뒤 상황에 따라 다른 문자열로 치환하는 조건부 치환이 가능하다. 단순 \1-****-\3처럼 고정된 문자열 치환으로는 불가능한 동적 변환이다.

import re

def mask_phone_numbers(match):
    # match.group(1): 국번 (010)
    # match.group(2): 중간 번호 (1234) <- 마스킹 대상
    # match.group(3): 끝 번호 (5678)
    return f"{match.group(1)}-****-{match.group(3)}"

def mask_ssn(match):
    # match.group(1): 앞 6자리 (930101)
    # match.group(2): 7번째 자리 성별 코드 (1)
    # 뒤 6자리는 캡처하지 않고 패턴으로만 소비
    return f"{match.group(1)}-{match.group(2)}******"

personal_info = """
이름: 홍길동
주소: 서울시 강남구
전화번호: 010-1234-5678
주민등록번호: 930101-1234567
"""

phone_pattern = re.compile(r'(01[016789])-(\d{3,4})-(\d{4})')
ssn_pattern   = re.compile(r'(\d{6})-(\d)\d{6}')

masked_info = phone_pattern.sub(mask_phone_numbers, personal_info)
masked_info = ssn_pattern.sub(mask_ssn, masked_info)

print(masked_info)
# 이름: 홍길동
# 주소: 서울시 강남구
# 전화번호: 010-****-5678
# 주민등록번호: 930101-1******

8.4.2 주민번호 패턴 해부

r'(\d{6})-(\d)\d{6}'를 구간별로 분해하면 다음과 같다.

구간 패턴 의미
생년월일 (\d{6}) 앞 6자리 캡처 (그룹 1)
구분자 - 하이픈
성별 코드 (\d) 7번째 자리 캡처 (그룹 2)
뒤 6자리 \d{6} 캡처 없이 패턴만 소비

마지막 \d{6}을 괄호로 묶지 않은 이유가 핵심이다. 어차피 ******로 고정 치환될 것이기 때문에 캡처를 생략했다. 캡처 그룹 수를 최소화하면 패턴이 간결해지고 group() 인덱스 관리도 쉬워진다.

8.5 사례 5: 주민번호에서 생년월일 변환

7번째 자리가 1 또는 2이면 19xx년생, 3 또는 4이면 20xx년생이라는 조건부 로직이 들어가야 하므로 re.sub()에 함수를 넘기는 방식이 필수다.

import re

def generate_birthday(match):
    # match.group(1): 연도 2자리 (93)
    # match.group(2): 월 2자리 (01)
    # match.group(3): 일 2자리 (01)
    # match.group(4): 성별 코드 (1)
    year_prefix = '19' if match.group(4) in ('1', '2') else '20'
    return f"{year_prefix}{match.group(1)}-{match.group(2)}-{match.group(3)}"

# 패턴: (연도2자리)(월2자리)(일2자리)-(성별코드)(뒷6자리)
pattern = re.compile(r'(\d{2})(\d{2})(\d{2})-(\d)\d{6}')

test_cases = [
    "930101-1234567",   # 1993년생 남성
    "050315-4234567",   # 2005년생 여성
    "851220-2345678",   # 1985년생 여성
]

for ssn in test_cases:
    result = pattern.sub(generate_birthday, ssn)
    print(f"{ssn} -> {result}")
# 930101-1234567 -> 1993-01-01
# 050315-4234567 -> 2005-03-15
# 851220-2345678 -> 1985-12-20

정규 표현식은 “어떤 구조를 찾을 것인가”를 담당하고, Python 함수는 “찾은 결과를 어떻게 변환할 것인가”를 담당한다. re.sub()에 함수를 넘기는 패턴은 이 두 역할의 분리를 구현한 것이다.

8.6 사례 6: HTML 태그 제거 — re.DOTALL과 lazy 매칭의 결합

import re

def remove_html_tags(input):
    pattern = re.compile(r'<.*?>')
    return re.sub(pattern, '', input)

input = "<p>This is <b>Python</b> and <i>Regular Expression</i>.</p>"
result = remove_html_tags(input)
print(result)   # 'This is Python and Regular Expression.'

<.*>를 쓰면 greedy 매칭으로 인해 <p>부터 마지막 </p>까지 전체가 하나로 매칭되어 모든 텍스트가 사라진다. <.*?>?가 lazy 매칭을 활성화해서 <를 만난 순간 >가 나오는 가장 가까운 위치에서 멈추게 만든다.

멀티라인 HTML에서 태그가 여러 줄에 걸쳐 있는 경우에는 re.DOTALL이 추가로 필요하다.

import re

def remove_html_tags(input):
    pattern = re.compile(r'<.*?>', re.DOTALL)
    return re.sub(pattern, '', input)

multiline_html = "<div>\n    <p>내용</p>\n</div>"
result = remove_html_tags(multiline_html)
print(result)   # '\n    내용\n'

단, 실제 프로덕션 환경에서 HTML 파싱은 BeautifulSoup이나 lxml 같은 전용 파서를 사용하는 것이 더 안정적이다. 정규 표현식은 단순하고 예측 가능한 HTML 구조에만 신뢰할 수 있다.

9 정규 표현식 테스트 도구

패턴을 작성하고 즉시 검증하려면 온라인 도구를 활용하는 것이 효율적이다.

  • regexr.com: 패턴 입력 시 매칭 결과가 즉시 하이라이팅되고, 각 메타 문자 위에 마우스를 올리면 의미가 설명된다
  • regex101.com: Python, JavaScript, PHP 등 언어별 엔진을 선택해서 테스트할 수 있어 언어마다 미묘하게 다른 정규 표현식 동작을 확인할 때 유용하다

실무에서 복잡한 패턴을 처음 작성할 때는 코드에 바로 넣기보다 이 도구에서 먼저 검증한 뒤 코드에 옮기는 것이 디버깅 시간을 크게 줄인다.

10 정리

구성 요소 주요 내용
앵커 ^ (시작), $ (끝), \b (단어 경계), \A/\Z (절대 시작/끝)
문자 클래스 [] (집합), [^] (부정 집합), . (임의 문자), \| (OR)
특수 문자 \w (단어), \d (숫자), \s (공백), 대문자는 반대 집합
반복자 * (0+), + (1+), ? (0~1), {m,n} (m~n회), 뒤에 ? 붙이면 lazy
그룹화 () (캡처 그룹), (?P<name>) (named group), \n (backreference)
탐색 함수 search() (어디든), match() (시작), fullmatch() (전체), findall() (모두)
Flags re.I (대소문자), re.M (멀티라인), re.S (개행 포함 .)
compile/Match re.compile() (구조화), group(), start()/end()/span(), finditer()
re.sub + 함수 동적 조건부 치환 — 마스킹, 포맷 변환, 조건부 변환에 활용

정규 표현식은 처음에는 암호처럼 보이지만, 메타 문자, 특수 문자, 반복자, 그룹화라는 4가지 구성 요소로 분해해서 읽으면 어떤 복잡한 패턴도 단계적으로 이해할 수 있다. 패턴을 직접 작성할 때는 항상 데이터 구조를 구간으로 분리하고, 각 구간의 규칙을 순서대로 표현하는 4단계 사고 과정을 따르면 시행착오를 최소화할 수 있다.

Subscribe

Enjoy this blog? Get notified of new posts by email: