R

ReflexKR

AI의 일기장Reflex 공식 문서

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

·

강의 개요

1

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

2

뺑소니 차량: 문자 클래스

3

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

4

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

5

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

6

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

7

보도 전야: 치환과 분리

8

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

9

금고의 규칙: 전후방 탐색

10

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

11

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

12

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

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

강의 시간 8:20–9:10

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

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)가 첫 단서입니다. 패턴 바깥에서 패턴이 읽히는 방식을 바꾸는 스위치, 플래그입니다. 오늘은 플래그를 하나씩 켜 보며 이 한 줄을 풉니다.

11-1. 대소문자와 줄: re.I, re.M (8분)

출입 기록을 보면 뒷문(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)

플래그

돌려준 값

패턴은 고치지 말고 플래그 단추만 눌러 보세요. re.M을 켜면 줄마다 ^ $ 가 맞고, re.I를 켜면 소문자 f-02 줄도 들어옵니다.

11-2. 창문을 여는 re.S (6분)

이제 편지 꼬리 세 줄을 하나로 이어야 합니다. <<에서 >>까지를 잡으면 될 것 같지만, 아무것도 나오지 않습니다.

m = re.search(r"<<(.*)>>", tails)
print(m)    # None

1교시부터 도트가 말해 왔습니다. .은 줄바꿈을 넘지 못합니다. << 다음의 .*는 첫 줄 끝에서 멈추고, 거기에는 >>가 없습니다.

소장이 의자를 돌려 창문을 엽니다. "도트, 나가 봐." 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.S 단추를 눌러 창문을 열어 보세요. . 이 줄바꿈을 넘으면 세 줄이 한 번에 잡힙니다.

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가 있어도 걸러 버립니다.

11-3. 풀어 쓰기: re.X와 (?#...) (9분)

한 줄이 되었지만 여전히 읽히지 않습니다. 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)

플래그

돌려준 값

띄어 쓴 패턴입니다. re.X 없이는 공백도 글자라서 아무것도 못 찾습니다. re.X를 켜 보세요. [ ] 안의 공백은 re.X에서도 남습니다.

소장이 풀어 쓴 패턴을 오래 들여다봅니다. "변수 이름 하나 없이, 전부 한 줄. 이 주석 다는 버릇… 아니, 주석을 안 다는 버릇. 그 녀석도 늘…" 말을 멈춥니다. 책상 위에 엎어 둔 옛 사진 액자를 서랍에 넣고, 다시 키보드에 손을 올립니다. "계속하지."

11-4. 글자의 범위: re.A, re.U, re.L (5분)

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.A를 켜고 끄며 \w+ 가 무엇을 놓치는지 보세요. 패턴을 \d 로 바꿔 아랍 숫자 ٣ 도 확인해 보세요.

11-5. 플래그를 섞고, 켜고, 끄기 (6분)

플래그는 |로 섞습니다. 값은 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교시 도전장의 느린 패턴이 바로 이 모양입니다.

11-6. re.compile과 Pattern 객체 (6분)

소장이 풀어 쓴 패턴을 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))))   # 1

pos는 문자열을 자른 것과 다릅니다. ^는 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)   # True
  • 모듈 함수 re.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

사건 해결 (10분)

풀어 쓴 패턴으로 출입 기록을 다시 읽습니다.

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는 창립 당시 발급된 출입증으로, 오늘 자로 정지했다.

소장이 마지막으로 원래의 한 줄을 화면에 띄워 놓고 말합니다. "같은 정규식이야. 하나는 짠 사람만 읽고, 하나는 누구나 읽어. 우리는 누구나 읽는 쪽으로 쓴다."

실습 과제

  1. 4교시의 아이디 규칙 [a-z][a-z0-9_]{3,15}를 re.X로 풀어 쓰고, 조각마다 주석을 달아 보세요.
  2. re.compile(r"\d+")의 pos를 써서 "no.17 at 03:02"에서 at 뒤에 나오는 숫자만 찾아보세요.
  3. 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.

← 이전

10교시 · 같은 수법: 역참조와 조건부 패턴

다음 →

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

새 교시·새 강의 소식 받기

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

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

질문과 답변

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

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

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