21. 정규표현식

🎯 이 장의 목표
  • 정규표현식이 "패턴으로 문자열을 다루는 언어"임을 이해한다.
  • 메타문자·문자 클래스·수량자·앵커로 패턴을 만든다.
  • re 모듈로 검색·추출·치환·분리를 한다.
  • 그룹으로 원하는 부분을 뽑아낸다.

먼저: 정규표현식이 뭔가요?

"이 문자열에서 전화번호만 뽑아줘", "이메일 형식이 맞는지 검사해줘", "날짜를 모두 찾아줘" — 이런 작업을 일일이 if와 문자열 메서드로 짜면 끔찍하게 복잡해집니다.

정규표현식(regular expression, 정규식)"찾으려는 문자열의 패턴"을 기호로 표현하는 작은 언어입니다. "숫자 3개 다음 하이픈 다음 숫자 4개" 같은 패턴을 짧은 기호로 적고, 그 패턴에 맞는 부분을 찾아냅니다.

PYTHON
import re

text = "연락처: 010-1234-5678"
match = re.search(r"\d{3}-\d{4}-\d{4}", text)
print(match.group())    # 010-1234-5678

\d{3}-\d{4}-\d{4}가 "숫자 3개 - 숫자 4개 - 숫자 4개"라는 패턴입니다. 처음엔 외계어 같지만, 기호 몇 개만 익히면 강력한 도구가 됩니다.

📎 정규식 패턴은 항상 r"..."(raw 문자열)로 적습니다. \d 같은 백슬래시 기호가 그대로 전달되게 하기 위함입니다. 초급편의 이스케이프(\n 등)와 충돌하지 않도록 r을 붙이는 게 표준입니다.
📌 핵심
핵심: 정규식은 문자열의 패턴을 기호로 표현하는 언어다. 검색·검증·추출·치환을 짧은 패턴으로 해낸다. re 모듈로 사용하고, 패턴은 r"..."로 적는다.

핵심 기호: 문자 클래스

먼저 "어떤 종류의 문자"를 나타내는 기호들입니다.

기호의미예시 매칭
\d숫자 하나 (0~9)7
\w문자/숫자/밑줄 하나a, 3, _
\s공백 하나 (스페이스·탭·줄바꿈)
.줄바꿈 빼고 아무 문자 하나x, 9, @
[abc]a, b, c 중 하나a
[a-z]a부터 z 중 하나m
[^abc]a, b, c가 아닌 하나x
PYTHON
import re

print(re.findall(r"\d", "a1b2c3"))         # ['1', '2', '3']
print(re.findall(r"[aeiou]", "hello"))     # ['e', 'o']
print(re.findall(r"[A-Z]", "Hello World")) # ['H', 'W']

re.findall(패턴, 문자열)은 패턴에 맞는 모든 부분을 리스트로 돌려줍니다.

수량자: 몇 번 반복되는가

문자 클래스 뒤에 "몇 개"를 붙입니다.

기호의미
*0개 이상
+1개 이상
?0개 또는 1개 (있어도 되고 없어도 됨)
{n}정확히 n개
{n,}n개 이상
{n,m}n개 이상 m개 이하
PYTHON
import re

print(re.findall(r"\d+", "a1b22c333"))     # ['1', '22', '333']  연속 숫자
print(re.findall(r"a{2,3}", "a aa aaa aaaa"))  # ['aa', 'aaa', 'aaa']
print(re.findall(r"colou?r", "color colour"))  # ['color', 'colour']  u가 있어도 없어도

\d+는 "숫자 1개 이상"이라, 333처럼 연속된 숫자를 한 덩어리로 잡습니다. ?는 "있어도 되고 없어도 되는" 선택적 문자에 씁니다(colou?r → color와 colour 둘 다).

앵커: 위치 지정

문자가 아니라 "위치"를 나타내는 기호입니다.

기호의미
^문자열의 시작
$문자열의 끝
\b단어 경계
PYTHON
import re

print(bool(re.search(r"^Hello", "Hello world")))   # True  (Hello로 시작?)
print(bool(re.search(r"world$", "Hello world")))    # True  (world로 끝?)
print(bool(re.search(r"^Hello", "say Hello")))      # False (시작이 아님)
💡 팁
^는 문자 클래스 안([^abc])에서는 "부정"을 뜻하지만, 밖에서는 "시작"을 뜻합니다. 위치에 따라 의미가 다릅니다.

re 모듈의 주요 함수

re 모듈에는 용도별 함수가 있습니다.

함수하는 일반환
re.search(p, s)s에서 패턴 p를 처음 찾음Match 객체 또는 None
re.match(p, s)s의 시작부터 패턴 매칭Match 또는 None
re.fullmatch(p, s)s 전체가 패턴과 일치Match 또는 None
re.findall(p, s)매칭되는 모든 부분리스트
re.sub(p, repl, s)패턴을 repl로 치환문자열
re.split(p, s)패턴으로 분리리스트

search와 Match 객체

re.search는 찾으면 Match 객체를, 못 찾으면 None을 돌려줍니다. .group()으로 매칭된 문자열을 꺼냅니다.

PYTHON
import re

m = re.search(r"\d+", "가격은 1500원")
if m:                           # 못 찾으면 None이니 확인!
    print(m.group())            # 1500
⚠️ 흔한 실수
흔한 함정 — None 확인: re.search가 못 찾으면 None을 돌려줍니다. 바로 .group()을 부르면 AttributeError가 나니, 항상 if m:으로 확인하세요.

sub: 치환

re.sub로 패턴에 맞는 부분을 다른 문자열로 바꿉니다.

PYTHON
import re

print(re.sub(r"\d", "*", "비번 1234"))         # 비번 ****
print(re.sub(r"\s+", " ", "공백   이   많아"))   # 공백 이 많아  (연속 공백을 하나로)

split: 분리

여러 구분자로 한 번에 쪼갤 때 re.split이 편합니다(초급편 .split()은 한 구분자만).

PYTHON
import re

print(re.split(r"[,;]\s*", "사과, 배; 감,  귤"))   # ['사과', '배', '감', '귤']

⭐ 그룹: 원하는 부분 뽑아내기

괄호 ( )로 패턴의 일부를 그룹으로 묶으면, 그 부분만 따로 꺼낼 수 있습니다. 날짜에서 연·월·일을 분리하는 예를 봅시다.

PYTHON
import re

m = re.search(r"(\d{4})-(\d{2})-(\d{2})", "오늘은 2025-12-31")
print(m.group())     # 2025-12-31   (전체 매칭)
print(m.group(1))    # 2025         (첫 번째 그룹)
print(m.group(2))    # 12           (두 번째 그룹)
print(m.groups())    # ('2025', '12', '31')   (모든 그룹을 튜플로)

group(0)(또는 group())은 전체, group(1)·group(2)...는 각 괄호 그룹입니다.

명명 그룹

번호 대신 이름을 붙이면 더 읽기 좋습니다. (?P<이름>패턴) 형식입니다.

PYTHON
import re

m = re.search(r"(?P<year>\d{4})-(?P<month>\d{2})", "2025-12")
print(m.group("year"))    # 2025
print(m.group("month"))   # 12
📌 핵심
핵심: 괄호 ( )는 그룹을 만든다. group(번호)group("이름")으로 원하는 부분만 추출한다. "전체에서 특정 조각을 뽑아내는" 가장 강력한 기능이다.

컴파일: 패턴 재사용

같은 패턴을 여러 번 쓴다면, re.compile로 미리 컴파일해두면 효율적이고 읽기 좋습니다.

PYTHON
import re

phone_re = re.compile(r"\d{3}-\d{4}-\d{4}")    # 한 번 컴파일
print(phone_re.findall("010-1234-5678 / 010-9999-8888"))
# ['010-1234-5678', '010-9999-8888']
print(phone_re.search("내 번호 010-1111-2222").group())

컴파일한 객체는 .findall()·.search() 등을 메서드로 가집니다.

⚠️ 탐욕 vs 게으름

수량자는 기본적으로 탐욕적(greedy)입니다. 가능한 한 많이 먹으려 합니다. 뒤에 ?를 붙이면 게으르게(lazy) 최소한만 먹습니다.

PYTHON
import re

# 탐욕: 첫 < 부터 마지막 > 까지 한 번에
print(re.findall(r"<.+>", "<a><b>"))      # ['<a><b>']

# 게으름: 각 < > 쌍을 따로
print(re.findall(r"<.+?>", "<a><b>"))     # ['<a>', '<b>']

.+는 탐욕적이라 <a><b> 전체를 삼키지만, .+?는 게을러서 <a>, <b>를 따로 잡습니다. HTML 태그나 따옴표 안 내용을 뽑을 때 자주 마주치는 차이입니다.

💡 팁
정규식은 강력하지만 복잡한 패턴은 읽기 어렵습니다. HTML 파싱처럼 구조가 복잡한 것은 정규식보다 전용 도구(BeautifulSoup 등)가 낫습니다. 정규식은 "단순하고 명확한 패턴"에 쓰는 게 좋습니다.

실용 예: 검증과 추출

PYTHON
import re

# 이메일 형식 검증 (간단 버전)
def is_valid_email(s):
    return bool(re.fullmatch(r"[\w.+-]+@[\w.-]+\.\w+", s))

print(is_valid_email("user.name@example.com"))   # True
print(is_valid_email("nope@"))                     # False

# 텍스트에서 모든 해시태그 추출
text = "오늘 #python 공부 #coding 재밌다 #개발"
print(re.findall(r"#\w+", text))    # ['#python', '#coding', '#개발']
⚠️ 흔한 실수
흔한 함정 — 완벽한 검증의 함정: 이메일·URL을 정규식으로 "완벽히" 검증하려 하면 패턴이 끔찍하게 복잡해집니다. 실무에서는 "대략 형식 검사"만 정규식으로 하고, 진짜 검증은 다른 방법(실제 발송 등)을 씁니다. 정규식으로 모든 것을 하려 들지 마세요.

자주 쓰는 패턴 모음

용도패턴
숫자만\d+
단어\w+
공백 정리\s+" " 로 sub
전화번호(한국)\d{2,3}-\d{3,4}-\d{4}
날짜\d{4}-\d{2}-\d{2}
해시태그#\w+
이메일(간단)[\w.+-]+@[\w.-]+\.\w+

이 장에서 배운 것

  • 정규식은 문자열 패턴을 기호로 표현하는 언어다. re 모듈로 쓰고, 패턴은 r"..."로 적는다.
  • 문자 클래스(\d·\w·\s·[...])와 수량자(*·+·?·{n,m})와 앵커(^·$)로 패턴을 만든다.
  • 주요 함수: search(찾기)·findall(모두)·sub(치환)·split(분리). search 결과는 None일 수 있으니 확인한다.
  • 괄호 ( )그룹을 만들어 group(번호)·group("이름")으로 원하는 부분을 추출한다.
  • 수량자는 기본 탐욕적, ?를 붙이면 게으름. 복잡한 검증은 정규식만으로 하려 들지 않는다.

🧪 실습 문제

문제 1. re.findall로 문자열 "방 301호, 502호, 1203호"에서 호수의 숫자만 모두 추출하세요.

문제 2. re.sub로 문자열 "비밀번호는 secret123"의 모든 숫자를 *로 바꾸세요.

문제 3. 다음 코드의 출력은?

PYTHON
import re
m = re.search(r"(\w+)@(\w+)", "id: hong@naver")
print(m.group(1), m.group(2))

문제 4. 정규식으로 문자열에 전화번호 형식(숫자3-숫자4-숫자4)이 있는지 검사하는 함수 has_phone(text)를 작성하세요. (bool 반환)

문제 5. 다음 두 패턴의 결과 차이를 설명하세요.

PYTHON
import re
re.findall(r'".+"', '"a" "b"')
re.findall(r'".+?"', '"a" "b"')

<details>

<summary>✅ 정답·해설 보기</summary>

1.

PYTHON
import re
print(re.findall(r"\d+", "방 301호, 502호, 1203호"))   # ['301', '502', '1203']

2.

PYTHON
import re
print(re.sub(r"\d", "*", "비밀번호는 secret123"))   # 비밀번호는 secret***

3. hong naver. 첫 그룹 (\w+)hong, 둘째가 naver를 잡습니다.

4.

PYTHON
import re
def has_phone(text):
    return bool(re.search(r"\d{3}-\d{4}-\d{4}", text))

print(has_phone("연락처 010-1234-5678"))   # True
print(has_phone("전화 없음"))               # False

5.

  • ".+" (탐욕): "a" "b" 전체를 한 덩어리로 잡아 ['"a" "b"']
  • ".+?" (게으름): 각 따옴표 쌍을 따로 잡아 ['"a"', '"b"']

탐욕적 수량자는 최대한 많이, 게으른 수량자(?)는 최소한만 매칭합니다.

</details>

◀️ 목차 | ▶️ 다음 장: 22. 로깅