Devin.KR
로그인

파이썬 정규표현식 - re 모듈 사용법과 백트래킹 성능 함정 (파이썬 중급 15단원)

개발자 조회 1

이 단원에서 배우는 것

11단원에서 주문 로그를 raw.split(",")로 잘라 OrderLine을 만들었다. 로그 형식이 CSV로 딱 맞아떨어질 때만 통하는 방법이다. 실제 서버 로그는 대개 이렇게 생겼다.

2026-08-25T09:14:33+09:00 INFO  [order] ORD-1001 sku=BOOK-01 qty=2 price=12000

여기서 주문 번호와 수량을 뽑으려면 split을 여러 번 겹쳐야 하고, 필드 순서가 조금만 달라져도 깨진다. 이번 단원은 이런 형식을 다루는 도구를 배운다. 동시에 정규식을 쓰면 안 되는 자리도 같이 짚는다. 실무에서 정규식으로 낸 사고의 절반은 애초에 쓰지 말았어야 할 곳에 쓴 것이다.

  • re 모듈의 함수들을 상황에 맞게 고르고 명명 그룹으로 결과를 읽을 수 있다
  • 탐욕 매칭과 백트래킹이 무엇인지 알고, 서버를 멈추게 하는 패턴을 피할 수 있다
  • 정규식이 맞는 문제와 파서를 써야 하는 문제를 구분할 수 있다

왜 필요한가

위 로그 한 줄에서 주문 번호, SKU, 수량, 금액을 뽑는다고 하자. 문자열 메서드로 하면 이렇게 된다.

parts = raw.split()
order_id = parts[3]
sku = parts[4].split("=")[1]
qty = int(parts[5].split("=")[1])

인덱스 3, 4, 5가 코드에 박혀 있다. 로그 포맷에 필드가 하나 추가되면 전부 밀린다. 게다가 parts[4]sku=로 시작하는지 확인하지 않으므로, 형식이 다른 줄이 들어오면 엉뚱한 값을 조용히 뽑아낸다. 위치가 아니라 모양으로 찾아야 한다. 정규식은 "모양"을 코드로 적는 언어다.

반대로 정규식이 답이 아닌 경우도 분명하다. JSON, HTML, CSV처럼 중첩되거나 인용부호 안에 구분자가 들어갈 수 있는 형식은 정규식으로 완전히 처리할 수 없다. json.loadscsv.reader가 있는데 정규식을 쓰는 것은 스스로 버그를 만드는 일이다.

문법과 예제

raw 문자열은 선택이 아니다

import re

print(re.findall("\d+", "ORD-1001"))    # 동작하지만 경고 대상
print(re.findall(r"\d+", "ORD-1001"))   # ['1001']

파이썬 문자열에서 백슬래시는 이스케이프 문자다. "\d"는 파이썬이 모르는 조합이라 지금은 그냥 통과되지만, 3.12부터 SyntaxWarning이 나고 앞으로 오류가 될 예정이다. "\b"는 아예 백스페이스 문자로 바뀌어 정규식이 전혀 다른 뜻이 된다. 정규식 패턴은 항상 r""로 쓴다. 예외 없이 지키는 편이 낫다.

다섯 개의 검색 함수

import re

text = "ORD-1001 sku=BOOK-01 qty=2"

print(re.match(r"ORD-\d+", text))      # 문자열 '처음'에서만 찾는다
print(re.search(r"qty=\d+", text))     # 어디서든 첫 번째를 찾는다
print(re.fullmatch(r"ORD-\d+", "ORD-1001"))   # 전체가 정확히 일치하는지
print(re.findall(r"\d+", text))        # 모두 찾아 리스트로
print([m.group() for m in re.finditer(r"\w+=\w+", text)])   # 모두 찾되 매치 객체로
<re.Match object; span=(0, 8), match='ORD-1001'>
<re.Match object; span=(21, 26), match='qty=2'>
<re.Match object; span=(0, 8), match='ORD-1001'>
['1001', '01', '2']
['sku=BOOK', 'qty=2']

matchsearch를 헷갈리는 것이 첫 번째 관문이다. re.match(r"qty=\d+", text)None이다. 문자열이 qty=로 시작하지 않기 때문이다. 값 검증에는 fullmatch가 가장 안전하다. match로 검증하면 "ORD-1001; DROP TABLE" 같은 값도 앞부분만 맞아서 통과한다.

명명 그룹으로 읽을 수 있게 만들기

인덱스로 그룹을 꺼내면 정규식을 고칠 때마다 번호가 밀린다. 이름을 붙인다.

import re

LOG_PATTERN = re.compile(
    r"""
    ^(?P<ts>\S+)\s+          # 2026-08-25T09:14:33+09:00
    (?P<level>[A-Z]+)\s+     # INFO
    \[(?P<logger>\w+)\]\s+   # [order]
    (?P<order_id>ORD-\d+)\s+ # ORD-1001
    sku=(?P<sku>[\w-]+)\s+   # sku=BOOK-01
    qty=(?P<qty>\d+)\s+      # qty=2
    price=(?P<price>\d+)$    # price=12000
    """,
    re.VERBOSE,
)

line = "2026-08-25T09:14:33+09:00 INFO  [order] ORD-1001 sku=BOOK-01 qty=2 price=12000"
m = LOG_PATTERN.match(line)
print(m.group("order_id"))    # ORD-1001
print(m.groupdict())
# {'ts': '2026-08-25T09:14:33+09:00', 'level': 'INFO', 'logger': 'order',
#  'order_id': 'ORD-1001', 'sku': 'BOOK-01', 'qty': '2', 'price': '12000'}

re.VERBOSE를 켜면 패턴 안의 공백과 # 뒤 주석이 무시된다. 정규식은 쓴 사람도 두 달 뒤에 못 읽으므로, 조금이라도 긴 패턴은 이 형식으로 쓴다. 대신 진짜 공백을 매치하려면 \s\ 로 써야 한다. 그냥 스페이스를 넣으면 무시된다.

re.compile로 미리 컴파일하고 모듈 수준 상수로 두는 것도 습관을 들일 만하다. re 모듈이 내부 캐시를 갖고 있어서 성능 차이가 크지는 않지만, 패턴에 이름이 붙고 re.VERBOSE 같은 플래그를 한곳에서 관리하게 된다.

11단원 파이프라인에 얹기

import re
from dataclasses import dataclass


@dataclass(frozen=True)
class OrderLine:
    order_id: str
    sku: str
    qty: int
    unit_price: int


def parse_log_lines(raw_lines):
    """형식에 맞는 줄만 OrderLine 으로 바꾸고, 나머지는 건너뛴다."""
    for lineno, raw in enumerate(raw_lines, start=1):
        m = LOG_PATTERN.match(raw)
        if m is None:
            print(f"{lineno}행 형식 불일치: {raw[:40]!r}")
            continue
        yield OrderLine(
            order_id=m["order_id"],
            sku=m["sku"],
            qty=int(m["qty"]),
            unit_price=int(m["price"]),
        )


raw_lines = [
    "2026-08-25T09:14:33+09:00 INFO  [order] ORD-1001 sku=BOOK-01 qty=2 price=12000",
    "2026-08-25T09:15:02+09:00 WARN  [cache] miss",
    "2026-08-25T09:16:41+09:00 INFO  [order] ORD-1002 sku=PEN-07 qty=5 price=3000",
]
lines = list(parse_log_lines(raw_lines))
# 2행 형식 불일치: '2026-08-25T09:15:02+09:00 WARN  [cache] '
print(sum(l.qty * l.unit_price for l in lines))   # 39000

m["order_id"]m.group("order_id")의 축약이다. 그리고 if m is None 검사를 반드시 넣는다. 매치 실패는 예외가 아니라 None이므로, 바로 m["sku"]를 쓰면 TypeError: 'NoneType' object is not subscriptable이 난다.

치환과 분리

import re

# 개인정보 마스킹: 치환 대상에 함수를 넘길 수 있다
def mask(m):
    num = m.group()
    return num[:3] + "-****-" + num[-4:]

text = "연락처 010-1234-5678 로 발송"
print(re.sub(r"\d{3}-\d{4}-\d{4}", mask, text))
# 연락처 010-****-5678 로 발송

# 그룹 참조로 순서 바꾸기
print(re.sub(r"(?P<y>\d{4})/(?P<m>\d{2})", r"\g<m>월 \g<y>년", "2026/08"))
# 08월 2026년

# 여러 구분자로 자르기
print(re.split(r"[,;|]\s*", "BOOK-01, PEN-07; BOOK-02|PEN-08"))
# ['BOOK-01', 'PEN-07', 'BOOK-02', 'PEN-08']

# 몇 개를 바꿨는지 알아야 할 때
result, count = re.subn(r"ORD-\d+", "ORD-****", "ORD-1 과 ORD-2")
print(result, count)   # ORD-**** 과 ORD-**** 2

탐욕과 게으름

import re

text = "[order] ORD-1001 [payment] PAY-77"

print(re.findall(r"\[.*\]", text))    # ['[order] ORD-1001 [payment]']  <- 최대한 길게
print(re.findall(r"\[.*?\]", text))   # ['[order]', '[payment]']        <- 최소한으로
print(re.findall(r"\[[^\]]*\]", text))  # ['[order]', '[payment]']      <- 가장 빠르고 명확

*, +, {n,m}은 기본이 탐욕적이다. 가능한 한 길게 먹고 나서, 뒤가 안 맞으면 한 글자씩 뱉으며 되돌아간다. 이 되돌아가는 동작이 백트래킹이고, 다음 절의 성능 함정의 원인이다. ?를 붙이면 반대로 최소한만 먹는다. 세 번째 방식([^\]]*, "닫는 괄호가 아닌 문자들")은 되돌아갈 일이 아예 없어서 가장 빠르다. 가능하면 . 대신 "무엇이 아닌 것"으로 범위를 좁힌다.

자주 쓰는 조각

패턴주의
\d숫자기본값에서 아라비아 숫자 외 유니코드 숫자도 포함된다. ASCII만 원하면 [0-9]re.ASCII
\w글자, 숫자, 밑줄한글도 포함된다
\s공백류탭과 줄바꿈 포함
^ $줄 시작과 끝re.MULTILINE 없으면 문자열 전체 기준. $는 끝의 개행 하나를 허용한다
.개행 제외 아무 글자개행까지 포함하려면 re.DOTALL
(?:...)묶기만, 캡처 안 함그룹 번호가 밀리는 것을 막는다
(?=...)뒤가 이러해야 함위치만 확인하고 소비하지 않는다

실무에서 자주 틀리는 것

1. 백트래킹 폭발로 프로세스가 멈춘다

가장 위험한 함정이다. 파이썬 re는 백트래킹 방식이라, 어떤 패턴은 입력 길이에 대해 지수적으로 느려진다.

import re, time

pattern = re.compile(r"^(\w+\s?)+$")     # 위험한 형태
text = "a" * 24 + "!"                     # 끝에 ! 하나 때문에 실패한다

start = time.perf_counter()
print(pattern.match(text))                # None
print(f"{time.perf_counter() - start:.2f}초")

글자 수를 하나 늘릴 때마다 시간이 대략 두 배가 된다. 손에 있는 장비에서 재 보면 a 24개에 1.3초, 26개에 5.5초, 28개에 21.5초가 나온다. 입력 길이는 4글자 늘었을 뿐인데 시간은 16배다. 입력 하나가 CPU를 100% 잡아먹고 서버 응답이 멈추므로, 사용자 입력을 검증하는 자리에서 이 패턴을 쓰면 그대로 서비스 장애가 된다.

원인은 (\w+\s?)+처럼 반복 안에 또 반복이 있고, 두 반복이 같은 글자를 나눠 가질 수 있는 구조다. 실패했을 때 나눠 갖는 경우의 수를 전부 시도한다. 고치는 방법은 그 애매함을 없애는 것이다.

pattern = re.compile(r"^\w+(?:\s\w+)*$")   # 나눠 가질 여지가 없다

정규식을 쓸 때는 실패하는 입력으로 시간을 재 보는 습관이 중요하다. 성공하는 입력은 대부분 빠르다. 느려지는 것은 실패할 때다.

2. findall 의 반환형이 그룹 수에 따라 바뀐다

import re

text = "qty=2 qty=5"
print(re.findall(r"qty=\d+", text))       # ['qty=2', 'qty=5']         그룹 0개
print(re.findall(r"qty=(\d+)", text))     # ['2', '5']                 그룹 1개
print(re.findall(r"(\w+)=(\d+)", text))   # [('qty', '2'), ('qty', '5')]  그룹 2개

패턴에 그룹을 하나 추가했을 뿐인데 리스트 원소의 타입이 문자열에서 튜플로 바뀐다. 뒤에서 for item in result: item.strip() 하던 코드가 AttributeError로 죽는다. 그룹이 필요하지만 캡처는 원치 않으면 (?:...)를 쓰고, 결과 구조가 헷갈리는 상황이면 finditer와 명명 그룹으로 바꾸는 편이 안전하다.

3. 사용자 입력을 패턴에 그대로 넣는다

import re

text = "SKU BOOK-01 (재고) 확인"

# (1) 에러로 죽는 경우
re.search("*특가", text)
# re.error: nothing to repeat at position 0

# (2) 더 나쁜 경우: 에러 없이 못 찾는다
print(re.search("BOOK-01 (재고)", text))
# None    <- 괄호가 그룹으로 해석돼 '(재고)' 가 아니라 '재고' 를 찾는다

# 해결
print(re.search(re.escape("BOOK-01 (재고)"), text))
# <re.Match object; span=(4, 16), match='BOOK-01 (재고)'>

괄호, 대괄호, *, +, ?는 정규식의 문법 문자다. (1)은 예외가 나므로 그나마 금방 발견되지만, (2)는 화면에 "검색 결과 없음"만 뜨고 아무도 버그라고 생각하지 않는다. 여기에 더해, 사용자가 앞의 백트래킹 폭발 패턴을 직접 입력해 서버를 멈추게 할 수도 있다. 검색어처럼 글자 그대로 찾아야 하는 값은 re.escape()로 감싼다. 애초에 부분 문자열 검색이면 keyword in text가 더 빠르고 안전하다.

4. 구조가 있는 형식을 정규식으로 파싱한다

import csv, io

row = 'ORD-1001,"BOOK-01, 개정판",2,12000'

print(row.split(","))
# ['ORD-1001', '"BOOK-01', ' 개정판"', '2', '12000']   <- 필드가 5개로 쪼개졌다

print(next(csv.reader(io.StringIO(row))))
# ['ORD-1001', 'BOOK-01, 개정판', '2', '12000']

인용부호 안의 쉼표를 처리하려고 정규식을 점점 키우다 보면, 이스케이프된 따옴표와 여러 줄 필드에서 결국 무너진다. CSV는 csv 모듈, JSON은 json, HTML은 파서, URL은 urllib.parse, 로그 시각은 datetime.fromisoformat을 쓴다. 정규식은 한 줄 안에서 평평한 패턴을 찾는 데 쓰는 도구다. 패턴 문법을 더 파고들 생각이면 정규표현식 HOWTOre 레퍼런스보다 읽기 좋다.

스스로 확인하기

  1. 주문 번호 형식 ORD- 뒤 숫자 4자리를 검증하려 한다. re.match(r"ORD-\d{4}", value)가 통과시키면 안 되는 값을 통과시키는 예를 들고 고쳐라.
  2. 아래 두 패턴은 같은 것을 찾지만 하나는 입력이 길어질수록 위험하다. 어느 쪽이고 왜 그런가?
    A = re.compile(r"^(\w+,?)+$")
    B = re.compile(r"^\w+(?:,\w+)*$")
    
  3. re.findall(r"(\w+)=(\d+)", text)의 결과를 for token in result: print(token.upper())로 처리하면 무슨 일이 생기며, 왜 패턴만 봐서는 알기 어려운가?

정답

  1. "ORD-12345""ORD-1234abc"가 통과한다. match는 문자열 앞부분만 검사하므로 뒤에 무엇이 붙어도 상관하지 않는다. re.fullmatch(r"ORD-\d{4}", value)를 쓰거나 패턴을 r"^ORD-\d{4}$"로 고정한다.
  2. A가 위험하다. 반복(+) 안에 또 반복(\w+)이 있고 쉼표가 선택적이라, 같은 글자들을 어떻게 나눌지에 여러 경우가 생긴다. 매치에 실패하는 입력에서 그 경우의 수를 전부 시도하며 시간이 지수적으로 늘어난다. B는 각 글자가 어느 부분에 속하는지가 하나로 정해져 백트래킹이 일어나지 않는다.
  3. 그룹이 2개라서 findall은 튜플 리스트를 돌려주고, 튜플에는 upper()가 없어 AttributeError가 난다. findall의 반환형이 패턴의 캡처 그룹 개수에 따라 문자열, 문자열, 튜플로 달라지기 때문에 호출부만 봐서는 타입을 알 수 없다. finditerm.group("name")을 쓰면 이 모호함이 사라진다.