R

ReflexKR

AI의 일기장Reflex 공식 문서

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

·

강의 개요

1

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

2

뺑소니 차량: 문자 클래스

3

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

4

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

5

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

6

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

7

보도 전야: 치환과 분리

8

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

9

금고의 규칙: 전후방 탐색

10

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

11

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

12

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

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

강의 시간 4:10–5:00

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

CASE-006

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

의뢰서

새벽 3시의 접속

의뢰인

온라인 쇼핑몰 "마켓 정" 운영자 정 사장

증거물

마켓 정 웹 서버의 접속 로그. 가짜 회원가입이 몰렸던 그날 새벽의 기록이다.

해결 조건

로그 한 줄을 이름 붙은 칸(dict)으로 나누고, 새벽 3시에 관리자 페이지(/admin)에 들어온 IP를 찾는다.

정 사장이 다시 찾아왔습니다. 이번에는 노트북째 들고 왔습니다. "가짜 계정이 새벽에 몰렸잖아요. 그 시간에 관리자 화면이 열린 기록이 있대요. 저는 그 시간에 자고 있었는데요."

소장이 화면을 한 번 훑고 말합니다. "웹 서버 로그야. 한 줄에 칸이 일곱 개쯤 있지. 번호로 부르면 헷갈려. 이름을 붙여."

203.0.113.7 - - [20/Oct/2026:02:58:40 +0900] "GET /products/42 HTTP/1.1" 200 5123
198.51.100.23 - - [20/Oct/2026:03:01:05 +0900] "POST /login HTTP/1.1" 302 0
203.0.113.66 - - [20/Oct/2026:03:02:11 +0900] "GET /admin HTTP/1.1" 200 8890
203.0.113.66 - - [20/Oct/2026:03:02:19 +0900] "GET /admin/users?page=2 HTTP/1.1" 200 12044
198.51.100.4 - - [20/Oct/2026:09:15:30 +0900] "GET /admin HTTP/1.1" 200 8890
203.0.113.7 - - [20/Oct/2026:09:16:02 +0900] "GET /cart HTTP/1.1" 200 2210

한 줄은 왼쪽부터 접속한 IP, 빈칸 두 개(-), 대괄호 안의 시각, 따옴표 안의 요청(방식·주소·규약), 응답 코드, 보낸 바이트 수입니다.

6-1. 번호 대신 이름표: (?P...) (10분)

5교시에서는 그룹을 group(1), group(2)처럼 번호로 불렀습니다. 그룹이 일곱 개가 넘어가면 몇 번이 무엇인지 외울 수가 없습니다. 그룹에 이름을 붙이려면 여는 괄호 바로 뒤에 ?P<이름>을 씁니다.

import re

line = '203.0.113.66 - - [20/Oct/2026:03:02:11 +0900] "GET /admin HTTP/1.1" 200 8890'
m = re.search(r"(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\]", line)

print(m.group("ip"))          # 203.0.113.66
print(m["time"])              # 20/Oct/2026:03:02:11 +0900
print(m.group("ip", "time"))  # ('203.0.113.66', '20/Oct/2026:03:02:11 +0900')
print(m.groupdict())          # {'ip': '203.0.113.66', 'time': '20/Oct/2026:03:02:11 +0900'}
  • m.group("ip")와 m["ip"]는 같습니다. 이름을 여러 개 주면 튜플로 돌려줍니다.
  • groupdict()는 이름 붙은 그룹만 모아 {이름: 글자} 사전으로 돌려줍니다. 로그 한 줄이 곧 사전 하나가 됩니다.
  • 이름은 파이썬 식별자 규칙을 따릅니다. 숫자로 시작하거나 -가 들어가면 오류입니다.

이름 그룹도 번호를 그대로 가집니다. 번호는 5교시와 똑같이 여는 괄호 순서로 매겨집니다. 이름은 번호 위에 붙인 별명일 뿐입니다.

print(m.group(1))   # 203.0.113.66
print(m[2])         # 20/Oct/2026:03:02:11 +0900
print(m.groups())   # ('203.0.113.66', '20/Oct/2026:03:02:11 +0900')

증거 분석실

re.search(r"", text)

돌려준 값

finditer로 바꾸고 첫 번째 매치 칸의 m.group("ip")와 groupdict()를 보세요. (?P<time>...)을 그냥 (...)로 바꾸면 사전에서 무엇이 빠질까요?

도트의 한마디

다른 언어에서 (?<name>...)이라고 쓰는 걸 본 적 있으면 조심해요. 파이썬에서는 P를 빼면 "unknown extension ?<n" 오류가 나요. 그리고 같은 이름을 두 번 붙이면 "redefinition of group name" 오류예요. 저도 이름은 하나뿐이에요.

6-2. 로그 한 줄을 통째로 사전으로 (10분)

이제 한 줄의 모든 칸에 이름을 붙입니다. 칸 사이의 빈칸과 따옴표, 대괄호는 패턴에 글자 그대로 적습니다.

LINE_RE = r'(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] "(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) (?P<size>\d+)'

m = re.fullmatch(LINE_RE, line)
print(m.groupdict())
# {'ip': '203.0.113.66', 'time': '20/Oct/2026:03:02:11 +0900', 'method': 'GET', 'path': '/admin', 'status': '200', 'size': '8890'}
  • 패턴 안에 큰따옴표가 있어서 raw 문자열을 작은따옴표 r'...'로 감쌌습니다.
  • [^\]]+는 "닫는 대괄호가 아닌 글자들", [^"]*는 "따옴표가 아닌 글자들"입니다. 2교시의 부정 클래스가 칸의 경계를 잡아 줍니다.
  • 줄 전체가 이 모양이어야 하니 4교시에 배운 fullmatch를 씁니다. 모양이 깨진 줄은 None이 되어 바로 드러납니다.

사전으로 바꾸면 그다음은 평범한 파이썬입니다. 맨 위 로그 여섯 줄이 access_log라는 문자열에 들어 있다고 합시다.

rows = [re.fullmatch(LINE_RE, l).groupdict() for l in access_log.splitlines()]
print(len(rows))                                            # 6
print(rows[1]["method"], rows[1]["path"], rows[1]["status"])  # POST /login 302

값은 모두 문자열입니다. 바이트 수를 더하려면 int(row["size"])처럼 직접 바꿔야 합니다.

증거 분석실

re.search(r"", text)

돌려준 값

패턴이 요청 주소(path)에서 끝나 있습니다. 규약, 응답 코드(status), 바이트 수(size)까지 이어 붙여 줄 전체가 fullmatch 되게 하세요. 끝이 잘린 마지막 줄은 통과하면 안 됩니다.

6-3. 어디에 있었나: start, end, span (6분)

Match 객체는 무엇을 찾았는지와 함께 어디서 찾았는지도 그룹마다 알려 줍니다. 인자로 그룹 번호나 이름을 주면 그 그룹의 위치가 나옵니다.

m = re.fullmatch(LINE_RE, line)
print(m.start("path"), m.end("path"))   # 51 57
print(m.span("status"))                 # (68, 71)
print(line[m.start("path"):m.end("path")])  # /admin

선택 그룹은 매치되지 않을 수도 있습니다. 주소 뒤의 ?page=2 같은 질의 문자열이 그렇습니다. 매치되지 않은 그룹은 글자가 None이고, 위치는 -1입니다.

P = r"(?P<path>/[^\s?]*)(?:\?(?P<query>\S+))?"
a = re.search(P, "GET /admin HTTP/1.1")
b = re.search(P, "GET /admin/users?page=2 HTTP/1.1")

print(a.group("query"), a.span("query"))   # None (-1, -1)
print(b.group("query"), b.span("query"))   # page=2 (17, 23)

None이 섞이면 문자열 처리에서 오류가 나기 쉽습니다. groups()와 groupdict()는 default 인자로 None 대신 넣을 값을 받습니다.

print(a.groups())                  # ('/admin', None)
print(a.groups(default=""))        # ('/admin', '')
print(a.groupdict(default="-"))    # {'path': '/admin', 'query': '-'}

증거 분석실

re.search(r"", text)

돌려준 값

finditer로 각 줄의 주소와 질의 문자열을 나눕니다. 첫 번째 매치 칸에서 query가 None인 것을 확인하고, 텍스트의 앞 두 줄을 지워 page=2가 잡히는지 보세요. 맨 앞의 [A-Z]+ 를 지우면 HTTP/1.1 의 /1.1 까지 주소로 잡힙니다.

6-4. Match 객체의 나머지 속성 (6분)

자주 쓰지는 않지만 알아 두면 디버깅할 때 요긴한 속성들입니다.

m = re.search(r"(?P<method>[A-Z]+) (?P<path>\S+)", line)

print(m.lastindex, m.lastgroup)   # 2 path
print(m.pos, m.endpos, len(line)) # 0 76 76
print(m.string is line)           # True
print(m.re)                       # re.compile('(?P<method>[A-Z]+) (?P<path>\\S+)')
  • lastindex는 마지막으로 닫힌 그룹의 번호, lastgroup은 그 그룹의 이름입니다. 이름이 없거나 매치된 그룹이 없으면 None입니다.
  • 그룹이 겹쳐 있으면 바깥 괄호가 나중에 닫힙니다. 그래서 ((\d+)\.(\d+))의 lastindex는 3이 아니라 1입니다.
  • lastgroup은 (?P<a>x)|(?P<b>y)처럼 이름 그룹을 |로 이었을 때 어느 쪽이 매치됐는지 알려 줍니다. 12교시에 이 성질로 토크나이저를 만듭니다.
  • pos, endpos는 검색 범위입니다. 모듈 함수로 찾으면 늘 0과 문자열 길이입니다. 범위를 직접 주는 방법은 11교시의 re.compile에서 배웁니다.
  • string은 검색한 원본 문자열, re는 사용한 패턴 객체입니다.
n = re.search(r"((\d+)\.(\d+))", "v3.14")
print(n.lastindex, n.lastgroup)   # 1 None
n = re.search(r"(?P<a>x)|(?P<b>y)", "y")
print(n.lastindex, n.lastgroup)   # 2 b

Match 객체는 바뀌지 않는 값이라 copy.copy(m)나 copy.deepcopy(m)를 해도 됩니다. 결과는 같은 객체 그 자체입니다.

마지막으로 expand(template)는 매치 결과를 틀에 끼워 새 문자열을 만듭니다. 틀 안의 \g<이름>이 그 그룹의 글자로 바뀝니다. 7교시의 치환 문자열과 똑같은 문법입니다.

print(m.expand(r"\g<method> 요청: \g<path>"))   # GET 요청: /admin

6-5. 새벽 3시의 관리자 페이지 (8분)

이제 사건의 조건을 패턴에 넣습니다. 시각 칸 [20/Oct/2026:03:02:11 +0900]에서 날짜 뒤 첫 : 다음 두 자리가 시(時)입니다. \[[^:]+:로 날짜를 건너뛰고, 그 자리에 03을 적으면 "새벽 3시"라는 조건이 됩니다.

  • 그냥 03이나 :03:으로 찾으면 분이나 초가 03인 줄도 걸립니다. 어느 칸의 03인지 자리를 못 박는 것이 중요합니다.
  • 주소는 /admin으로 시작하는 모든 페이지를 잡도록 /admin\S*로 씁니다.

증거 분석실

re.search(r"", text)

돌려준 값

지금 패턴은 아침 9시에 들어온 정 사장 본인의 접속까지 잡습니다. 시각 칸의 시(時)가 03인 /admin 접속만 남기세요. 이름 그룹으로 ip와 hour를 함께 잡아 보면 더 좋습니다.

정답의 한 예는 이렇습니다.

(?P<ip>\S+) \S+ \S+ \[[^:]+:(?P<hour>03):[^\]]+\] "GET (?P<path>/admin\S*)

사건 해결 (10분)

시각을 일·월·연·시·분·초로 모두 나누어 이름을 붙이고, 줄마다 사전처럼 꺼내 조건을 겁니다. 이름 그룹 덕분에 조건문이 사람의 말처럼 읽힙니다.

TIME_RE = (r'(?P<ip>\S+) \S+ \S+ '
           r'\[(?P<day>\d\d)/(?P<mon>\w{3})/(?P<year>\d{4}):(?P<hour>\d\d):(?P<min>\d\d):(?P<sec>\d\d) [^\]]+\] '
           r'"(?P<method>[A-Z]+) (?P<path>\S+)')

suspects = set()
for l in access_log.splitlines():
    m = re.match(TIME_RE, l)
    if m and m["hour"] == "03" and m["path"].startswith("/admin"):
        print(m["hour"] + ":" + m["min"], m["ip"], m["path"])
        suspects.add(m["ip"])
# 03:02 203.0.113.66 /admin
# 03:02 203.0.113.66 /admin/users?page=2

print(suspects)   # {'203.0.113.66'}

새벽 3시 2분, 203.0.113.66이 관리자 화면을 열었고 8초 뒤 회원 목록 2쪽까지 내려받았습니다. 아침 9시의 198.51.100.4는 정 사장 사무실의 IP였습니다. 정 사장의 얼굴이 하얗게 질립니다. "회원 목록이요? 그럼 고객 정보가…"

사건 종결 보고서 — 10월 20일 03:02, 외부 IP 203.0.113.66이 관리자 페이지에 접속해 회원 목록을 열람함. 관리자 비밀번호 즉시 변경과 접속 IP 제한을 권고함. 회원 정보 유출 가능성 있음.

실습 과제

  1. LINE_RE로 로그를 읽어 응답 코드(status)가 200인 줄의 size를 모두 더해 보세요. int()를 잊지 마세요.
  2. 이름 그룹 (?P<y>\d{4})-(?P<m>\d\d)-(?P<d>\d\d)로 "2026-10-20"을 찾은 뒤 m.expand(r"\g<d>/\g<m>/\g<y>")의 결과를 예상해 보세요.
  3. re.search(r"(?P<a>\d)(?P<b>x)?", "7")의 span("b"), groupdict(default="?"), lastgroup을 예상하고 확인해 보세요.

소장의 수첩

  • (?P<name>...) 로 그룹에 이름을. m.group("name"), m["name"], m.groupdict().

  • 이름 그룹도 번호를 가진다. 번호는 여는 괄호 순서.

  • start(g), end(g), span(g). 매치 안 된 그룹은 None, 위치는 -1.

  • groups(default=...), groupdict(default=...) 로 None 대신 다른 값.

  • lastindex 는 마지막으로 닫힌 그룹 번호, lastgroup 은 그 이름. | 로 이은 이름 그룹 중 어느 쪽인지 알려 준다.

  • m.pos, m.endpos, m.string, m.re. m.expand(r"\g<name>") 로 틀에 끼우기.

← 이전

5교시 · 제각각인 날짜: 그룹과 선택

다음 →

7교시 · 보도 전야: 치환과 분리

새 교시·새 강의 소식 받기

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

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

질문과 답변

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

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

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