CASE-012
그렙 앤 코 데이터 탐정 사무소
의뢰서
의뢰인
사무소 전원
증거물
아침 아홉 시에 메일함으로 들어온 암호문 한 통. 백슬래시와 숫자, 영문자로만 되어 있다.
해결 조건
암호문을 토큰으로 쪼개 해독하고, 백트래커가 누구인지 밝힌다.
아침 아홉 시, 사무소에 사람이 가득합니다. 강 반장은 문가에 서 있고, 정 사장은 수첩을 펼쳤고, 오 기자는 노트북을 무릎에 올렸습니다. 소장이 모니터를 모두가 보이게 돌려놓습니다. "오늘은 다 같이 읽는다. 한 글자씩."
메일함에 들어온 암호문입니다.
\x46\N{HYPHEN-MINUS}\060\x32 \x3d \uc11c\uc9c4
\x34\x46 \060\063\x3a\060\x32
(a+)+$오 기자가 고개를 젓습니다. "이건 글자가 아니라 그냥 기호잖아요." 소장이 대답합니다. "기호에도 규칙이 있어. 규칙을 하나씩 적으면, 기계가 대신 읽어 줘."
긴 텍스트를 의미 있는 조각(토큰)으로 쪼개는 프로그램을 토크나이저라고 합니다. 오늘은 지금까지 배운 것을 모두 모아 토크나이저를 만들고, 이 암호문을 풉니다.
먼저 연습용으로 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)
돌려준 값
암호문의 \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(벨), 그리고 백슬래시 자체는 \\.\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 True8진수도 있습니다. \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)
돌려준 값
분석실에 빨간 오류 칸이 뜰 때 나오는 메시지는 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)
돌려준 값
파일이나 네트워크에서 읽은 데이터는 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 objectbytes 패턴의 \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 pattern2교시에 클래스 안의 [[, --, &&, ~~, ||는 피하라고 했습니다. 앞으로 "집합 연산"에 쓸 자리라서 지금은 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))이제 암호문의 규칙을 적습니다. 글자 이스케이프 네 종류, 서명, 띄어쓰기, 그리고 빈틈을 막는 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:02re.X 안에서도 클래스 [A-Z -]와 [ \n]의 공백은 남는다는 것을 기억하세요. 서명 SIGN은 아무것도 출력하지 않고 버립니다.
증거 분석실
re.search(r"", text)
돌려준 값
해독한 글자가 맞는지는 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는 정지했다.
그날 오후, 소장과 신입은 사건 파일 열두 개를 처음부터 다시 넘깁니다. "지금이라면 어떻게 쓸까."
re.escape("$4.99").\d{2,3}[가-힣]\d{4}처럼 수량자로 줄이고, 검증은 fullmatch로.search가 아니라 re.fullmatch(r"[a-z][a-z0-9_]{3,15}", user_id).(?P<y>\d{4})(?P<sep>[-./])(?P<m>\d\d)(?P=sep)(?P<d>\d\d). 이름이 있으니 번호를 셀 필요가 없다.groupdict()로 한 줄을 dict로.count=처럼 키워드 인자.^(a|aa)+$ 대신 ^a+$. 겹치는 반복을 만들지 않는다.re.X로 풀어 쓰고 re.compile로 이름을 붙인다.MISMATCH.밤늦게 메일함에 한 통이 더 들어옵니다. 이번에는 암호가 아닙니다.
한별. 끝까지 읽어 줘서 고맙다. 그때 아무도 내 코드를 못 읽은 게 아니었어. 내가 읽히게 쓰지 않았던 거지. 다음에는 주석을 달게.
(?x) a+ $ # 이제 되돌아가지 않는다
서명이 바뀌었습니다. 신입은 버릇처럼 그 서명을 분석실에 넣어 봅니다. 바깥 반복이 사라진 패턴은 실패할 때도 망설이지 않습니다.
sign = r"(?x) a+ $ # 이제 되돌아가지 않는다"
print(re.fullmatch(sign, "a" * 30 + "b")) # None소장이 낡은 수첩을 신입의 책상 위에 올려놓습니다. 첫 장부터 사건마다 소장의 글씨가 빼곡합니다. "이제 네 거야. 짧은 정규식이 이기는 게 아니야. 읽히는 정규식이 이겨. 그것만 잊지 마."
=를 ASSIGN으로, 줄바꿈을 NEWLINE으로 추가해 x = 3.5 다음 줄에 y = x * 2가 있는 두 줄짜리 코드를 토큰으로 쪼개 보세요.\U 뒤 16진수 여덟 자리 토큰을 추가하고, \U0000C11C\U0000C9C4를 해독해 보세요.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 는 문서에 없는 내부 도구.
짧은 정규식보다 읽히는 정규식이 이긴다.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.