CASE-011
그렙 앤 코 데이터 탐정 사무소
의뢰서
의뢰인
한별 소장
증거물
협박 편지 세 통의 마지막 줄, 그리고 사무소 4층 출입문 기록.
해결 조건
세 줄을 이어 정규식 한 줄로 만들고, 그것을 읽을 수 있게 풀어 써서 무엇을 찾는 정규식인지 밝힌다.
밤 열한 시, 사무소에 불이 하나만 켜져 있습니다. 소장이 낡은 기계식 키보드 앞에 앉아 편지 세 통의 마지막 줄을 옮겨 치고 있습니다. "한 줄로 짠 정규식은 짠 사람만 읽어. 석 달 지나면 그 사람도 못 읽고."
편지 세 통의 꼬리를 차례로 옮긴 파일과, 소장이 말없이 뽑아 온 사무소 4층 출입문 기록입니다. 정규식 조각에 door, 4f, badge가 보였기 때문입니다.
<<(?mi)^(\d{4})([-./])(\d\d)\2(\d\d)[ T]
(0[0-4]):(\d\d)\s+(?:door|gate)=4f\s+
badge=(f-0[12])\b(?!.*denied)>>2026-10-19 22:41 door=4F badge=F-01 result=ok
2026-10-20 02:13 door=4F badge=S-07 result=ok
2026-10-20 02:58 door=4F badge=F-02 result=denied
2026-10-20 03:02 door=4F badge=F-02 result=ok
2026.10.20 03:05 gate=4F badge=f-02 result=ok
2026-10-20 09:00 door=4F badge=F-01 result=ok정규식 맨 앞의 (?mi)가 첫 단서입니다. 패턴 바깥에서 패턴이 읽히는 방식을 바꾸는 스위치, 플래그입니다. 오늘은 플래그를 하나씩 켜 보며 이 한 줄을 풉니다.
출입 기록을 보면 뒷문(gate) 기계만 출입증 번호를 소문자 f-02로 적었습니다. re.I(re.IGNORECASE)를 주면 대소문자를 가리지 않습니다.
import re
re.findall(r"badge=F-02", door_log) # ['badge=F-02', 'badge=F-02']
re.findall(r"badge=F-02", door_log, re.I) # ['badge=F-02', 'badge=F-02', 'badge=f-02']re.M(re.MULTILINE)은 4교시에 맛만 봤습니다. 원래 ^는 문자열의 맨 앞, $는 맨 끝에서만 맞습니다. re.M을 켜면 줄마다 앞과 끝에서 맞습니다. 여기서 door_log는 위의 출입 기록 여섯 줄입니다.
re.findall(r"^\S+ 0[0-4]:\d\d", door_log) # []
re.findall(r"^\S+ 0[0-4]:\d\d", door_log, re.M)
# ['2026-10-20 02:13', '2026-10-20 02:58', '2026-10-20 03:02', '2026.10.20 03:05']
re.findall(r"\w+$", door_log) # ['ok']
re.findall(r"\w+$", door_log, re.M) # ['ok', 'ok', 'denied', 'ok', 'ok', 'ok']re.M 없이는 첫 줄이 밤 10시라서 새벽 기록이 하나도 안 나옵니다. 줄 단위 로그를 다룰 때 re.M은 거의 기본입니다.
증거 분석실
re.search(r"", text)
플래그
돌려준 값
이제 편지 꼬리 세 줄을 하나로 이어야 합니다. <<에서 >>까지를 잡으면 될 것 같지만, 아무것도 나오지 않습니다.
m = re.search(r"<<(.*)>>", tails)
print(m) # None1교시부터 도트가 말해 왔습니다. .은 줄바꿈을 넘지 못합니다. << 다음의 .*는 첫 줄 끝에서 멈추고, 거기에는 >>가 없습니다.
소장이 의자를 돌려 창문을 엽니다. "도트, 나가 봐." re.S(re.DOTALL)를 켜면 .이 줄바꿈까지 포함한 정말 아무 글자가 됩니다.
m = re.search(r"<<(.*)>>", tails, re.S)
one_line = m.group(1).replace("\n", "")
print(one_line)
# (?mi)^(\d{4})([-./])(\d\d)\2(\d\d)[ T](0[0-4]):(\d\d)\s+(?:door|gate)=4f\s+badge=(f-0[12])\b(?!.*denied)도트가 창틀을 딛고 밖으로 뛰어나갔다가, 옆 창문으로 들어와 책상에 앉습니다. 세 줄이 한 줄이 되었습니다.
도트의 한마디
창문이 열렸어요! re.S를 켜면 저는 줄바꿈도 넘어서 다음 줄, 그다음 줄까지 갈 수 있어요. 대신 조심하세요. .* 는 원래 욕심쟁이라서, 창문이 열리면 텍스트 끝까지 한달음에 달려가요. 여러 줄에서 짧게 잡고 싶으면 .*? 를 쓰세요.
증거 분석실
re.search(r"", text)
플래그
돌려준 값
re.M과 re.S는 이름이 비슷해 자주 헷갈립니다. 하는 일이 전혀 다릅니다.
re.M은 ^와 $가 맞는 자리를 바꿉니다(줄마다).re.S는 .이 먹을 수 있는 글자를 바꿉니다(줄바꿈 포함).re.findall(r"^.+$", "a\nb", re.M) # ['a', 'b']
re.findall(r"^.+$", "a\nb", re.S) # ['a\nb']백트래커의 정규식이 (?mi)만 켜고 s는 켜지 않은 데도 이유가 있습니다. 끝의 (?!.*denied)는 "이 줄 뒤쪽에 denied가 없어야 한다"는 뜻입니다. s를 켜면 .*가 다음 줄까지 넘어가서, 아래 어느 줄에 denied가 있어도 걸러 버립니다.
한 줄이 되었지만 여전히 읽히지 않습니다. re.X(re.VERBOSE)를 켜면 패턴 안의 공백과 줄바꿈은 무시되고, #부터 줄 끝까지는 주석이 됩니다. 그래서 패턴을 여러 줄에 나눠 쓰고 설명을 달 수 있습니다.
소장이 한 조각씩 끊어서 다시 칩니다. 번호 그룹에는 이름을 붙이고, \2는 10교시에 배운 (?P=sep)로 바꿉니다.
DOOR = re.compile(r'''
^
(?P<year>\d{4}) (?P<sep>[-./]) (?P<month>\d\d) (?P=sep) (?P<day>\d\d) # 날짜, 구분자는 한 가지
[ T] # 날짜와 시각 사이: 공백 또는 T
(?P<hour>0[0-4]) : (?P<minute>\d\d) # 새벽 0시부터 4시대까지
\s+ (?:door|gate) = 4f # 4층 앞문 또는 뒷문
\s+ badge = (?P<badge>f-0[12]) \b # 출입증 F-01 또는 F-02
(?! .* denied ) # 거부된 기록은 뺀다
''', re.I | re.M | re.X)
for m in DOOR.finditer(door_log):
print(m["hour"], m["minute"], m["badge"])
# 03 02 F-02
# 03 05 f-02같은 정규식인데 이제 한 줄씩 읽힙니다. 새벽 0시~4시대에, 4층 문으로, F-01이나 F-02 출입증이 실제로 들어온 기록을 찾는 정규식이었습니다.
re.X에도 예외가 있습니다. 진짜 공백이나 #을 찾으려면 이스케이프하거나 클래스에 넣습니다. 클래스 안의 공백은 지워지지 않습니다. 위 패턴의 [ T]가 그렇습니다.
re.fullmatch(r"\d{4} - \d\d", "2026-10", re.X) # <re.Match object; span=(0, 7), match='2026-10'>
re.fullmatch(r"a b", "a b", re.X) # None
re.fullmatch(r"a\ b", "a b", re.X) # <re.Match object; span=(0, 3), match='a b'>
re.fullmatch(r"a[ ]b", "a b", re.X) # <re.Match object; span=(0, 3), match='a b'>
re.fullmatch(r"a\#b", "a#b", re.X) # <re.Match object; span=(0, 3), match='a#b'>re.X 없이 패턴 중간에 짧은 주석만 넣고 싶을 때는 (?#...)를 씁니다. 괄호 안은 통째로 무시됩니다.
re.findall(r"F-0[12](?#창립 멤버 출입증)", door_log, re.I)
# ['F-01', 'F-02', 'F-02', 'f-02', 'F-01']증거 분석실
re.search(r"", text)
플래그
돌려준 값
소장이 풀어 쓴 패턴을 오래 들여다봅니다. "변수 이름 하나 없이, 전부 한 줄. 이 주석 다는 버릇… 아니, 주석을 안 다는 버릇. 그 녀석도 늘…" 말을 멈춥니다. 책상 위에 엎어 둔 옛 사진 액자를 서랍에 넣고, 다시 키보드에 손을 올립니다. "계속하지."
2교시에 str 패턴의 \w는 한글도, \d는 아랍 숫자 ٣도 포함한다고 했습니다. 유니코드가 기본이기 때문입니다. re.A(re.ASCII)를 켜면 \w \W \d \D \s \S \b \B가 ASCII 글자만 봅니다.
re.findall(r"\w+", "café 한글 F_02") # ['café', '한글', 'F_02']
re.findall(r"\w+", "café 한글 F_02", re.A) # ['caf', 'F_02']
re.findall(r"\d", "٣3") # ['٣', '3']
re.findall(r"\d", "٣3", re.A) # ['3']re.U(re.UNICODE)는 str 패턴의 기본값이라 따로 쓸 필요가 없습니다. 예전 코드와의 호환을 위해 남아 있습니다.re.L(re.LOCALE)은 bytes 패턴에서만 쓸 수 있고, 컴퓨터의 지역 설정에 따라 결과가 달라져 권장하지 않습니다. str 패턴에 주면 오류입니다.re.compile(r"\w", re.L)
# ValueError: cannot use LOCALE flag with a str pattern증거 분석실
re.search(r"", text)
플래그
돌려준 값
플래그는 |로 섞습니다. 값은 re.RegexFlag라는 열거형이고, 정수처럼 다룰 수 있습니다. 아무 플래그도 없다는 뜻의 re.NOFLAG(3.11부터, 값 0)도 있어서 함수의 기본값으로 쓰기 좋습니다.
flags = re.I | re.M
print(flags) # re.IGNORECASE|re.MULTILINE
print(type(flags)) # <flag 'RegexFlag'>
print(int(flags)) # 10
def find_all(pattern, text, flags=re.NOFLAG):
return re.findall(pattern, text, flags)백트래커처럼 패턴 맨 앞에 (?aiLmsux) 중 필요한 글자를 써도 됩니다. (?im)은 re.I | re.M과 같습니다. 맨 앞이 아니면 오류입니다.
re.findall(r"(?im)^\S+ 0\d:\d\d(?=.*f-02)", door_log)
# ['2026-10-20 02:58', '2026-10-20 03:02', '2026.10.20 03:05']
re.compile(r"badge=(?i)f-02")
# re.PatternError: global flags not at the start of the expression at position 6패턴 일부에만 플래그를 걸려면 (?i:...)처럼 괄호 범위를 씁니다. (?-i:...)는 그 범위에서만 끄고, (?i-s:...)는 i는 켜고 s는 끕니다.
re.findall(r"badge=(?i:f)-02", door_log) # ['badge=F-02', 'badge=F-02', 'badge=f-02']
re.findall(r"(?i)BADGE=(?-i:F)-02", door_log) # ['badge=F-02', 'badge=F-02']
re.fullmatch(r"(?s)a.(?i-s:b.)", "a\nB\n") # None
re.fullmatch(r"(?s)a.(?i-s:b.)", "a\nBc") # <re.Match object; span=(0, 4), match='a\nBc'>a, L, u는 글자의 범위를 정하는 플래그라 범위 안에서 켤 수는 있어도 끌 수는 없습니다.
re.findall(r"(?a:\w+)", "café") # ['caf']
re.compile(r"(?-a:\w+)")
# re.PatternError: bad inline flags: cannot turn off flags 'a', 'u' and 'L' at position 4마지막으로 re.DEBUG는 re가 패턴을 어떻게 이해했는지 그대로 찍어 줍니다. 백트래커의 서명과 그 서명에서 바깥 반복을 뺀 패턴을 비교해 봅니다. 아래는 출력의 첫 부분이고, 그 아래로 실행 명령 목록이 이어집니다.
re.compile(r"(a+)+$", re.DEBUG)
# MAX_REPEAT 1 MAXREPEAT
# SUBPATTERN 1 0 0
# MAX_REPEAT 1 MAXREPEAT
# LITERAL 97
# AT AT_END
re.compile(r"a+$", re.DEBUG)
# MAX_REPEAT 1 MAXREPEAT
# LITERAL 97
# AT AT_END반복(MAX_REPEAT) 안에 반복이 또 들어 있습니다. 8교시 도전장의 느린 패턴이 바로 이 모양입니다.
소장이 풀어 쓴 패턴을 DOOR라는 이름에 담은 것처럼, re.compile은 패턴을 Pattern 객체로 만듭니다. 모듈 함수와 같은 메서드 search match fullmatch findall finditer sub split를 가지고, 검색 범위 pos와 endpos를 더 받습니다.
p = re.compile(r"\d\d:\d\d")
line = "2026-10-20 03:02 door=4F"
print(p.search(line)) # <re.Match object; span=(11, 16), match='03:02'>
print(p.match(line)) # None
print(p.match(line, 11)) # <re.Match object; span=(11, 16), match='03:02'>
print(p.fullmatch(line, 11, 16)) # <re.Match object; span=(11, 16), match='03:02'>
print(p.findall(line, 0, 15)) # []
print(len(list(p.finditer(line, 11)))) # 1pos는 문자열을 자른 것과 다릅니다. ^는 pos 자리가 아니라 문자열의 진짜 시작(re.M이면 줄의 시작)에서만 맞습니다.
q = re.compile(r"^\d\d")
print(q.match(line, 11)) # None
print(q.match(line[11:])) # <re.Match object; span=(0, 2), match='03'>Pattern 객체에는 자기 정보도 들어 있습니다. str 패턴의 flags에는 re.UNICODE(32)가 자동으로 더해집니다.
print(DOOR.groups) # 7
print(DOOR.groupindex)
# {'year': 1, 'sep': 2, 'month': 3, 'day': 4, 'hour': 5, 'minute': 6, 'badge': 7}
print(DOOR.flags) # 106
print(re.RegexFlag(DOOR.flags)) # re.IGNORECASE|re.UNICODE|re.MULTILINE|re.VERBOSE
print(re.compile("x").flags) # 32
print(re.compile(one_line).pattern == one_line) # Truere.search(패턴, ...)도 안에서 컴파일합니다. 최근에 쓴 패턴은 내부 캐시에 남아서 다시 컴파일하지 않습니다. re.purge()는 이 캐시를 비웁니다(쓸 일은 드뭅니다).re.compile의 진짜 이점은 속도보다 이름입니다. DOOR라고 이름 붙인 패턴은 코드에서 읽힙니다.re.Pattern[str], re.Match[str]로 씁니다(bytes 패턴이면 [bytes]).def night_entries(p: re.Pattern[str], log: str) -> list[re.Match[str]]:
return list(p.finditer(log))
print(len(night_entries(DOOR, door_log))) # 2풀어 쓴 패턴으로 출입 기록을 다시 읽습니다.
for m in DOOR.finditer(door_log):
print(f"{m['year']}-{m['month']}-{m['day']} {m['hour']}:{m['minute']} {m['badge'].upper()}")
# 2026-10-20 03:02 F-02
# 2026-10-20 03:05 F-02편지에서 꺼낸 원래의 한 줄도 똑같은 두 줄을 찾습니다. 뜻은 같고, 읽히는지만 다릅니다.
[m.group(7) for m in re.finditer(one_line, door_log)] # ['F-02', 'f-02']10월 20일 새벽, 02:58에 앞문에서 한 번 거부된 F-02 출입증이 4분 뒤 다시 찍혀 들어왔고, 3분 뒤에는 뒷문을 지났습니다. 백트래커는 "내가 언제 왔는지 읽을 수 있으면 읽어 보라"고 이 정규식을 보낸 것입니다.
소장이 출입증 명부를 펼칩니다. "F-01은 내 거야. F-02는…" 명부의 그 칸에는 이름 대신 두 줄이 그어져 있습니다. "10년 전에 없앴어야 했는데. 못 없앴지."
사건 종결 보고서 — 편지 세 통의 꼬리는 하나의 정규식이었다. 풀어 쓴 결과, 2026-10-20 03:02~03:05에 출입증 F-02가 사무소 4층에 출입했음이 확인되었다. F-02는 창립 당시 발급된 출입증으로, 오늘 자로 정지했다.
소장이 마지막으로 원래의 한 줄을 화면에 띄워 놓고 말합니다. "같은 정규식이야. 하나는 짠 사람만 읽고, 하나는 누구나 읽어. 우리는 누구나 읽는 쪽으로 쓴다."
[a-z][a-z0-9_]{3,15}를 re.X로 풀어 쓰고, 조각마다 주석을 달아 보세요.re.compile(r"\d+")의 pos를 써서 "no.17 at 03:02"에서 at 뒤에 나오는 숫자만 찾아보세요.re.compile(r"badge=(?i)f-02")의 오류를 확인한 뒤, 범위 플래그 (?i:...)로 같은 뜻이 되게 고쳐 보세요.백트래커의 흔적
(a+)+$
풀어 썼군. 다음 것은 풀어 쓸 수도 없을 거다. 내일 아침 아홉 시, 메일함을 열어라.
소장의 수첩
re.I 대소문자 무시, re.M 은 ^ $ 가 줄마다, re.S 는 . 이 줄바꿈까지. M 과 S 는 하는 일이 다르다.
re.X: 공백과 # 주석을 무시한다. 클래스 안 공백과 \ (백슬래시+공백)은 남는다. (?#...) 는 어디서나 주석.
re.A: \w \d \s \b 를 ASCII 로. str 은 re.U 가 기본, re.L 은 bytes 전용이고 비권장.
섞기 re.I | re.M (re.RegexFlag), 없으면 re.NOFLAG. 인라인 (?im) 은 맨 앞에만.
범위 플래그 (?i:...) (?-i:...) (?i-s:...). a L u 는 끌 수 없다. re.DEBUG 로 패턴의 속을 본다.
re.compile 은 이름을 붙이는 도구. pos endpos 를 받지만 ^ 는 진짜 시작에서만. 속성 flags groups groupindex pattern.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.