21. 정규표현식
- 정규표현식이 "패턴으로 문자열을 다루는 언어"임을 이해한다.
- 메타문자·문자 클래스·수량자·앵커로 패턴을 만든다.
re모듈로 검색·추출·치환·분리를 한다.- 그룹으로 원하는 부분을 뽑아낸다.
먼저: 정규표현식이 뭔가요?
"이 문자열에서 전화번호만 뽑아줘", "이메일 형식이 맞는지 검사해줘", "날짜를 모두 찾아줘" — 이런 작업을 일일이 if와 문자열 메서드로 짜면 끔찍하게 복잡해집니다.
정규표현식(regular expression, 정규식)은 "찾으려는 문자열의 패턴"을 기호로 표현하는 작은 언어입니다. "숫자 3개 다음 하이픈 다음 숫자 4개" 같은 패턴을 짧은 기호로 적고, 그 패턴에 맞는 부분을 찾아냅니다.
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 |
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개 이하 |
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 | 단어 경계 |
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()으로 매칭된 문자열을 꺼냅니다.
import re m = re.search(r"\d+", "가격은 1500원") if m: # 못 찾으면 None이니 확인! print(m.group()) # 1500
re.search가 못 찾으면 None을 돌려줍니다. 바로 .group()을 부르면 AttributeError가 나니, 항상 if m:으로 확인하세요.sub: 치환
re.sub로 패턴에 맞는 부분을 다른 문자열로 바꿉니다.
import re print(re.sub(r"\d", "*", "비번 1234")) # 비번 **** print(re.sub(r"\s+", " ", "공백 이 많아")) # 공백 이 많아 (연속 공백을 하나로)
split: 분리
여러 구분자로 한 번에 쪼갤 때 re.split이 편합니다(초급편 .split()은 한 구분자만).
import re print(re.split(r"[,;]\s*", "사과, 배; 감, 귤")) # ['사과', '배', '감', '귤']
⭐ 그룹: 원하는 부분 뽑아내기
괄호 ( )로 패턴의 일부를 그룹으로 묶으면, 그 부분만 따로 꺼낼 수 있습니다. 날짜에서 연·월·일을 분리하는 예를 봅시다.
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<이름>패턴) 형식입니다.
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로 미리 컴파일해두면 효율적이고 읽기 좋습니다.
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) 최소한만 먹습니다.
import re # 탐욕: 첫 < 부터 마지막 > 까지 한 번에 print(re.findall(r"<.+>", "<a><b>")) # ['<a><b>'] # 게으름: 각 < > 쌍을 따로 print(re.findall(r"<.+?>", "<a><b>")) # ['<a>', '<b>']
.+는 탐욕적이라 <a><b> 전체를 삼키지만, .+?는 게을러서 <a>, <b>를 따로 잡습니다. HTML 태그나 따옴표 안 내용을 뽑을 때 자주 마주치는 차이입니다.
실용 예: 검증과 추출
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', '#개발']
자주 쓰는 패턴 모음
| 용도 | 패턴 |
|---|---|
| 숫자만 | \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. 다음 코드의 출력은?
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. 다음 두 패턴의 결과 차이를 설명하세요.
import re re.findall(r'".+"', '"a" "b"') re.findall(r'".+?"', '"a" "b"')
<details>
<summary>✅ 정답·해설 보기</summary>
1.
import re print(re.findall(r"\d+", "방 301호, 502호, 1203호")) # ['301', '502', '1203']
2.
import re print(re.sub(r"\d", "*", "비밀번호는 secret123")) # 비밀번호는 secret***
3. hong naver. 첫 그룹 (\w+)이 hong, 둘째가 naver를 잡습니다.
4.
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. 로깅