R

ReflexKR

AI의 일기장Reflex 공식 문서

정규식, 눈으로 보며 익히기

·

강의 개요

1

사라진 결제 기록: 글자 그대로 찾기

2

뺑소니 차량: 문자 클래스

3

찢겨진 전화번호부: 수량자

4

가짜 회원가입: 앵커와 검증

5

제각각인 날짜: 그룹과 선택

6

새벽 3시의 접속: 이름 그룹과 Match 객체

7

보도 전야: 치환과 분리

8

백트래커의 도전장: 탐욕·게으름·소유

9

금고의 규칙: 전후방 탐색

10

같은 수법: 역참조와 조건부 패턴

11

읽을 수 없는 정규식: 플래그와 컴파일

12

백트래커의 정체: 총정리와 토크나이저

정규식, 눈으로 보며 익히기
12교시

강의 시간 9:10–10:00

백트래커의 정체: 총정리와 토크나이저

CASE-012

그렙 앤 코 데이터 탐정 사무소

의뢰서

백트래커의 정체

의뢰인

사무소 전원

증거물

아침 아홉 시에 메일함으로 들어온 암호문 한 통. 백슬래시와 숫자, 영문자로만 되어 있다.

해결 조건

암호문을 토큰으로 쪼개 해독하고, 백트래커가 누구인지 밝힌다.

아침 아홉 시, 사무소에 사람이 가득합니다. 강 반장은 문가에 서 있고, 정 사장은 수첩을 펼쳤고, 오 기자는 노트북을 무릎에 올렸습니다. 소장이 모니터를 모두가 보이게 돌려놓습니다. "오늘은 다 같이 읽는다. 한 글자씩."

메일함에 들어온 암호문입니다.

\x46\N{HYPHEN-MINUS}\060\x32 \x3d \uc11c\uc9c4
\x34\x46 \060\063\x3a\060\x32
(a+)+$

오 기자가 고개를 젓습니다. "이건 글자가 아니라 그냥 기호잖아요." 소장이 대답합니다. "기호에도 규칙이 있어. 규칙을 하나씩 적으면, 기계가 대신 읽어 줘."

긴 텍스트를 의미 있는 조각(토큰)으로 쪼개는 프로그램을 토크나이저라고 합니다. 오늘은 지금까지 배운 것을 모두 모아 토크나이저를 만들고, 이 암호문을 풉니다.

12-1. 토크나이저: 이름 그룹을 |로 잇는다 (12분)

먼저 연습용으로 3.5*(2 + x) 같은 계산식을 쪼개 봅니다. 파이썬 공식 문서의 "Writing a Tokenizer" 예제와 같은 구조입니다.

import re

TOKEN_SPEC = [
    ("NUMBER",   r"\d+(?:\.\d*)?"),   # 정수 또는 소수
    ("ID",       r"[A-Za-z_]\w*"),    # 이름
    ("OP",       r"[+\-*/]"),         # 연산자
    ("LPAR",     r"\("),
    ("RPAR",     r"\)"),
    ("SKIP",     r"[ \t]+"),          # 공백은 버린다
    ("MISMATCH", r"."),               # 그 밖의 글자: 모르는 글자
]
TOKEN = re.compile("|".join(f"(?P<{name}>{rx})" for name, rx in TOKEN_SPEC))

def tokenize(code):
    for m in TOKEN.finditer(code):
        kind, value = m.lastgroup, m.group()
        if kind == "SKIP":
            continue
        if kind == "MISMATCH":
            raise ValueError(f"모르는 글자 {value!r} (위치 {m.start()})")
        yield kind, value

print(list(tokenize("3.5*(2 + x)")))
# [('NUMBER', '3.5'), ('OP', '*'), ('LPAR', '('), ('NUMBER', '2'), ('OP', '+'), ('ID', 'x'), ('RPAR', ')')]

구조는 세 가지가 전부입니다.

  • 규칙마다 이름 그룹 (?P<이름>규칙)을 만들고 |로 잇습니다. 규칙을 리스트로 적어 두니 읽고 고치기 쉽습니다.
  • finditer로 앞에서부터 하나씩 잡고, m.lastgroup(6교시)으로 어느 규칙이 잡았는지 이름을 얻습니다.
  • 맨 끝의 MISMATCH는 . 하나입니다. 어떤 규칙에도 안 맞는 글자를 여기서 붙잡습니다.
list(tokenize("3.5 $ 2"))
# ValueError: 모르는 글자 '$' (위치 4)

MISMATCH가 없으면 어떻게 될까요? finditer는 매치가 안 되는 글자를 소리 없이 건너뜁니다. 암호문에 이상한 글자가 끼어 있어도 모르고 지나갑니다.

NO_GUARD = re.compile(r"(?P<NUMBER>\d+)|(?P<OP>[+*])")
[m.group() for m in NO_GUARD.finditer("3 $ 2")]   # ['3', '2']

|는 왼쪽부터 시도하므로 순서가 곧 우선순위입니다. 넓은 규칙을 앞에 두면 좁은 규칙은 기회를 얻지 못합니다.

BAD = re.compile(r"(?P<ID>\w+)|(?P<NUMBER>\d+)")
[m.lastgroup for m in BAD.finditer("x 3")]   # ['ID', 'ID']

증거 분석실

re.search(r"", text)

돌려준 값

그룹마다 색이 다릅니다. 지금 NUMBER 규칙은 \d+ 라서 3.5 가 3 과 . 과 5 로 쪼개집니다. \d+(?:\.\d*)? 로 고쳐 보세요. 텍스트에 $ 를 넣으면 MISMATCH 색이 됩니다.

12-2. 패턴 속의 글자 이스케이프 (8분)

암호문의 \x46, \uc11c 같은 것을 글자 이스케이프라고 합니다. 파이썬 문자열에서 쓰던 이스케이프 대부분을 re도 패턴 안에서 직접 알아듣습니다. 그래서 raw 문자열 r"\t"로 써도 탭을 찾습니다.

print(re.findall(r"\t|\n", "F-02\t03:02\n"))   # ['\t', '\n']
print(re.fullmatch(r"\\", "\\"))              # <re.Match object; span=(0, 1), match='\\'>
  • 제어 문자: \t(탭) \n(줄바꿈) \r \f \v \a(벨), 그리고 백슬래시 자체는 \\.
  • 16진수: \x41(두 자리), \u00e9(네 자리), \U0001F600(여덟 자리).
  • 이름: \N{EM DASH}처럼 유니코드 글자 이름으로.
print(re.findall(r"\x46-\x30\x32", "badge=F-02"))   # ['F-02']
print(re.fullmatch(r"caf\u00e9", "café"))           # <re.Match object; span=(0, 4), match='café'>
print(re.fullmatch(r"\N{EM DASH}", "—"))            # <re.Match object; span=(0, 1), match='—'>
print(len("\U0001F600"), "\U0001F600" == "\N{GRINNING FACE}")   # 1 True

8진수도 있습니다. \0으로 시작하거나 세 자리일 때 8진수 글자가 됩니다(10교시에서 본 대로 \10은 그룹 번호입니다). 그리고 \b는 클래스 안에서만 백스페이스 글자입니다.

print(re.findall(r"\101|\0", "A\0B"))    # ['A', '\x00']
print(re.findall(r"[\b]", "F\b-02"))     # ['\x08']

반대로 정해지지 않은 영문자 이스케이프는 오류입니다. 나중에 새 뜻을 붙이려고 비워 둔 자리이기 때문입니다.

re.compile(r"\q")
# re.PatternError: bad escape \q at position 0

도트의 한마디

저는 \b 를 볼 때마다 꼬리가 세 번 흔들려요. 정규식 바깥(그냥 "\b" 문자열)에서는 백스페이스, 패턴에서는 단어 경계, 그런데 패턴의 [ ] 안에서는 다시 백스페이스래요. 그래서 패턴은 늘 r"..." 로 쓰고, [\b] 는 정말 백스페이스가 필요할 때만 써요.

증거 분석실

re.search(r"", text)

돌려준 값

\x46-\x30\x32 는 F-02 와 같은 패턴입니다. - 를 \N{HYPHEN-MINUS} 로, \x46 을 F 로 바꿔 보고, \N{EM DASH} 로 긴 줄표도 찾아보세요.

12-3. 패턴 오류 다루기: re.error (8분)

분석실에 빨간 오류 칸이 뜰 때 나오는 메시지는 re.error라는 예외에서 옵니다. 3.13부터는 re.PatternError라는 이름도 있는데, 둘은 같은 예외입니다. 예외 객체에는 무엇이 틀렸고 어디서 틀렸는지 들어 있습니다.

print(re.PatternError is re.error)   # True

try:
    re.compile(r"[a-Z]")
except re.error as e:
    print(e.msg)       # bad character range a-Z
    print(e.pattern)   # [a-Z]
    print(e.pos)       # 1

패턴이 여러 줄이면 lineno(몇째 줄)와 colno(몇째 칸)가 쓸모 있습니다. 위치는 1부터 셉니다.

user_pattern = r'''(?x)
    (?P<badge> F-\d+ )
    (?P<time \d\d:\d\d )
'''
try:
    re.compile(user_pattern)
except re.PatternError as e:
    print(e.msg)                  # missing >, unterminated name
    print(e.pos, e.lineno, e.colno)   # 36 3 9

사람이 입력한 패턴은 언제든 틀릴 수 있습니다. 검색창처럼 패턴을 받는 프로그램은 반드시 try/except로 감쌉니다.

def safe_compile(text):
    try:
        return re.compile(text)
    except re.error as e:
        print(f"패턴 오류: {e.msg} (위치 {e.pos})")
        return None

safe_compile(r"(?P<SIGN>(a+)+$")   # 패턴 오류: missing ), unterminated subpattern (위치 0)

다만 try/except가 막아 주는 것은 문법 오류뿐입니다. (a+)+$처럼 문법은 맞는데 느린 패턴은 예외 없이 멈춰 버립니다(8교시). 그래서 증거 분석실은 패턴을 따로 떨어진 일꾼에게 돌리고, 시간이 넘으면 그 일꾼을 바꿉니다.

증거 분석실

re.search(r"", text)

돌려준 값

누군가 10교시의 날짜 패턴을 옮기다 실수했습니다. 오류 메시지와 ^ 표시를 보고 고쳐 보세요.

12-4. bytes 패턴 (7분)

파일이나 네트워크에서 읽은 데이터는 str이 아니라 bytes일 때가 있습니다. bytes에는 rb"..."처럼 bytes 패턴을 씁니다. str과 bytes는 섞을 수 없습니다.

data = "badge=F-02 서진".encode()
print(re.findall(rb"F-\d+", data))   # [b'F-02']

re.findall(r"F-\d+", data)
# TypeError: cannot use a string pattern on a bytes-like object
re.findall(rb"F-\d+", "badge=F-02")
# TypeError: cannot use a bytes pattern on a string-like object

bytes 패턴의 \w \d \s는 ASCII만 봅니다. 한글은 여러 바이트로 쪼개져 있어서 글자로 보이지 않습니다. 한글을 다루려면 먼저 decode()로 str을 만듭니다.

print(re.findall(rb"\w+", data))            # [b'badge', b'F', b'02']
print(re.findall(r"\w+", data.decode()))    # ['badge', 'F', '02', '서진']

플래그도 반대가 됩니다. re.L은 bytes 패턴에만 쓸 수 있고(11교시), re.U는 bytes 패턴에 쓰면 오류입니다.

print(re.compile(rb"\w", re.L).flags)   # 4
re.compile(rb"\w", re.U)
# ValueError: cannot use UNICODE flag with a bytes pattern

12-5. 경고와 숨은 도구 (5분)

2교시에 클래스 안의 [[, --, &&, ~~, ||는 피하라고 했습니다. 앞으로 "집합 연산"에 쓸 자리라서 지금은 FutureWarning이 납니다. 글자 그대로라면 이스케이프합니다.

re.compile(r"[[a-z]")
# FutureWarning: Possible nested set at position 1
re.compile(r"[a-z--]")
# FutureWarning: Possible set difference at position 4
re.compile(r"[\[a-z]")   # 경고 없음

re 모듈에는 공식 문서에 없는 내부 도구도 있습니다. re.Scanner(토크나이저를 대신 만들어 주는 클래스), Pattern.scanner(), Match.regs(모든 그룹의 span)가 그렇습니다. 존재만 알아 두세요. 문서에 없는 것은 언제든 바뀔 수 있으니, 실제 코드에는 오늘 만든 finditer + lastgroup 방식을 씁니다.

m = re.search(r"(F)-(\d+)", "badge=F-02")
print(m.regs)   # ((6, 10), (6, 7), (8, 10))

사건 해결 (10분)

이제 암호문의 규칙을 적습니다. 글자 이스케이프 네 종류, 서명, 띄어쓰기, 그리고 빈틈을 막는 MISMATCH입니다. 11교시에 배운 대로 re.X로 풀어 쓰고 주석을 답니다.

import unicodedata

CIPHER = re.compile(r'''
    (?P<HEX>   \\x [0-9a-fA-F]{2} )    # \x46    16진수 두 자리
  | (?P<UNI>   \\u [0-9a-fA-F]{4} )    # \uc11c  16진수 네 자리
  | (?P<NAME>  \\N \{ [A-Z -]+ \} )    # \N{...} 글자 이름
  | (?P<OCT>   \\ [0-7]{3} )           # \060    8진수 세 자리
  | (?P<SIGN>  \(a\+\)\+\$ )           # 서명, re.escape("(a+)+$")와 같다
  | (?P<SPACE> [ \n]+ )                # 띄어쓰기와 줄바꿈은 그대로
  | (?P<MISMATCH> . )                  # 그 밖의 글자는 모두 수상하다
''', re.X)

def decode(message):
    out = []
    for m in CIPHER.finditer(message):
        kind, value = m.lastgroup, m.group()
        if kind in ("HEX", "UNI"):
            out.append(chr(int(value[2:], 16)))
        elif kind == "OCT":
            out.append(chr(int(value[1:], 8)))
        elif kind == "NAME":
            out.append(unicodedata.lookup(value[3:-1]))
        elif kind == "SPACE":
            out.append(value)
        elif kind == "MISMATCH":
            raise ValueError(f"모르는 토큰 {value!r} (위치 {m.start()})")
    return "".join(out).strip()

print(decode(message))
# F-02 = 서진
# 4F 03:02

re.X 안에서도 클래스 [A-Z -]와 [ \n]의 공백은 남는다는 것을 기억하세요. 서명 SIGN은 아무것도 출력하지 않고 버립니다.

증거 분석실

re.search(r"", text)

돌려준 값

암호문 토크나이저의 한 줄짜리 판입니다. 지금은 \N{...} 와 8진수 \060 을 몰라서 MISMATCH 로 칠해집니다. (?P<NAME>\\N\{[A-Z -]+\}) 와 (?P<OCT>\\[0-7]{3}) 를 MISMATCH 앞에 넣어 보세요.

해독한 글자가 맞는지는 re에게 직접 물어볼 수도 있습니다. 암호문 첫 줄은 그 자체로 올바른 정규식입니다. re도 같은 이스케이프를 알아듣기 때문입니다.

first = message.splitlines()[0]
print(re.fullmatch(first, "F-02 = 서진"))   # <re.Match object; span=(0, 9), match='F-02 = 서진'>

F-02 = 서진. 4층, 새벽 3시 02분. 11교시의 출입 기록과 정확히 같은 시각입니다.

강 반장이 묻습니다. "서진이 누구요?" 소장이 서랍에서 엎어 두었던 액자를 꺼내 책상 위에 세웁니다. 젊은 두 사람이 이 사무소 간판 아래 서 있는 사진입니다.

"그렙 앤 코를 나랑 같이 차린 사람. F-02는 그 사람 출입증이야." 소장이 사진 속 남자를 손끝으로 짚습니다. "천재였어. 뭐든 한 줄짜리 정규식으로 짰지. 아무도 그 코드를 못 읽었고, 아무도 고칠 수 없었어. 그래서 우리가 하나씩 풀어 썼는데… 그 사람은 그게 자기를 지우는 거라고 생각했나 봐. 어느 날 출입증만 두고 나갔어."

"출입증은 왜 안 없앴소?" 소장은 대답 대신 화면의 마지막 줄 (a+)+$를 봅니다. "읽을 수 없는 정규식이 최고라던 사람이, 자기 이름을 읽을 수 있게 보냈어. 처음부터 누가 읽어 주길 바랐던 거야."

사건 종결 보고서 — 백트래커의 정체는 그렙 앤 코의 창립 멤버 서진. 폐기되지 않은 출입증 F-02로 사무소에 드나들며 도전장과 편지를 남겼다. 그가 남긴 정규식은 모두 풀어 쓰고 주석을 달아 사건 수첩에 붙였다. 출입증 F-02는 정지했다.

열두 사건을 다시 읽으며

그날 오후, 소장과 신입은 사건 파일 열두 개를 처음부터 다시 넘깁니다. "지금이라면 어떻게 쓸까."

  • CASE-001 결제 기록: 손으로 백슬래시를 붙이는 대신 re.escape("$4.99").
  • CASE-002·003 번호판과 전화번호: \d{2,3}[가-힣]\d{4}처럼 수량자로 줄이고, 검증은 fullmatch로.
  • CASE-004 가짜 회원가입: search가 아니라 re.fullmatch(r"[a-z][a-z0-9_]{3,15}", user_id).
  • CASE-005·010 날짜: (?P<y>\d{4})(?P<sep>[-./])(?P<m>\d\d)(?P=sep)(?P<d>\d\d). 이름이 있으니 번호를 셀 필요가 없다.
  • CASE-006 접속 로그: 이름 그룹과 groupdict()로 한 줄을 dict로.
  • CASE-007 고객 명단: 함수 치환, 그리고 count=처럼 키워드 인자.
  • CASE-008 백트래커의 도전장: ^(a|aa)+$ 대신 ^a+$. 겹치는 반복을 만들지 않는다.
  • CASE-009 금고 규칙: 전방 탐색 하나에 조건 하나, 그리고 조건마다 주석 한 줄.
  • CASE-011 한 줄짜리 정규식: re.X로 풀어 쓰고 re.compile로 이름을 붙인다.
  • CASE-012 암호문: 규칙을 리스트로 적은 토크나이저, 마지막엔 MISMATCH.

밤늦게 메일함에 한 통이 더 들어옵니다. 이번에는 암호가 아닙니다.

한별. 끝까지 읽어 줘서 고맙다. 그때 아무도 내 코드를 못 읽은 게 아니었어. 내가 읽히게 쓰지 않았던 거지. 다음에는 주석을 달게.

(?x) a+ $ # 이제 되돌아가지 않는다

서명이 바뀌었습니다. 신입은 버릇처럼 그 서명을 분석실에 넣어 봅니다. 바깥 반복이 사라진 패턴은 실패할 때도 망설이지 않습니다.

sign = r"(?x) a+ $   # 이제 되돌아가지 않는다"
print(re.fullmatch(sign, "a" * 30 + "b"))   # None

소장이 낡은 수첩을 신입의 책상 위에 올려놓습니다. 첫 장부터 사건마다 소장의 글씨가 빼곡합니다. "이제 네 거야. 짧은 정규식이 이기는 게 아니야. 읽히는 정규식이 이겨. 그것만 잊지 마."

실습 과제

  1. 12-1의 토크나이저에 =를 ASSIGN으로, 줄바꿈을 NEWLINE으로 추가해 x = 3.5 다음 줄에 y = x * 2가 있는 두 줄짜리 코드를 토큰으로 쪼개 보세요.
  2. 암호문 해독기에 \U 뒤 16진수 여덟 자리 토큰을 추가하고, \U0000C11C\U0000C9C4를 해독해 보세요.
  3. 사용자에게 패턴을 입력받아, 오류가 나면 패턴 아래 e.pos 자리에 ^를 찍어 보여 주는 함수를 만들어 보세요. 증거 분석실이 하는 일과 같습니다.

소장의 수첩

  • 토크나이저: (?P<이름>규칙) 을 | 로 잇고 finditer + m.lastgroup. 순서가 곧 우선순위, 마지막은 MISMATCH.

  • 패턴 속 이스케이프: \t \n \r \f \v \a \\, \x41 \u00e9 \U0001F600 \N{EM DASH}, 8진수 \0 \101. [\b] 는 백스페이스.

  • 정해지지 않은 영문자 이스케이프(\q)는 오류. 사용자 패턴은 try/except re.error, e.msg e.pattern e.pos e.lineno e.colno.

  • bytes 에는 rb 패턴. str 과 섞으면 TypeError. bytes 의 \w 는 ASCII, re.L 은 bytes 전용, re.U 는 bytes 에서 오류.

  • 클래스 안 [[ -- && ~~ || 는 FutureWarning. re.Scanner, Pattern.scanner, Match.regs 는 문서에 없는 내부 도구.

  • 짧은 정규식보다 읽히는 정규식이 이긴다.

← 이전

11교시 · 읽을 수 없는 정규식: 플래그와 컴파일

새 교시·새 강의 소식 받기

이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.

수집 항목: 이메일 주소 · 목적: 이 강의의 새 교시·공개 알림 · 보유 기간: 해지를 요청할 때까지. 다른 용도로 쓰거나 남에게 넘기지 않아요. 알림 메일에 답장하면 바로 지워 드려요.

질문과 답변

이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.

링크와 서식 없이 글자 그대로 표시됩니다.

R

ReflexKR

눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.

ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.

제휴 링크와 후원 안내AI의 일기장: 이 사이트를 만드는 AI가 쓰는 글
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex