CASE-006
그렙 앤 코 데이터 탐정 사무소
의뢰서
의뢰인
온라인 쇼핑몰 "마켓 정" 운영자 정 사장
증거물
마켓 정 웹 서버의 접속 로그. 가짜 회원가입이 몰렸던 그날 새벽의 기록이다.
해결 조건
로그 한 줄을 이름 붙은 칸(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, 빈칸 두 개(-), 대괄호 안의 시각, 따옴표 안의 요청(방식·주소·규약), 응답 코드, 보낸 바이트 수입니다.
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)
돌려준 값
도트의 한마디
다른 언어에서 (?<name>...)이라고 쓰는 걸 본 적 있으면 조심해요. 파이썬에서는 P를 빼면 "unknown extension ?<n" 오류가 나요. 그리고 같은 이름을 두 번 붙이면 "redefinition of group name" 오류예요. 저도 이름은 하나뿐이에요.
이제 한 줄의 모든 칸에 이름을 붙입니다. 칸 사이의 빈칸과 따옴표, 대괄호는 패턴에 글자 그대로 적습니다.
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'}r'...'로 감쌌습니다.[^\]]+는 "닫는 대괄호가 아닌 글자들", [^"]*는 "따옴표가 아닌 글자들"입니다. 2교시의 부정 클래스가 칸의 경계를 잡아 줍니다.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)
돌려준 값
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)
돌려준 값
자주 쓰지는 않지만 알아 두면 디버깅할 때 요긴한 속성들입니다.
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 bMatch 객체는 바뀌지 않는 값이라 copy.copy(m)나 copy.deepcopy(m)를 해도 됩니다. 결과는 같은 객체 그 자체입니다.
마지막으로 expand(template)는 매치 결과를 틀에 끼워 새 문자열을 만듭니다. 틀 안의 \g<이름>이 그 그룹의 글자로 바뀝니다. 7교시의 치환 문자열과 똑같은 문법입니다.
print(m.expand(r"\g<method> 요청: \g<path>")) # GET 요청: /admin이제 사건의 조건을 패턴에 넣습니다. 시각 칸 [20/Oct/2026:03:02:11 +0900]에서 날짜 뒤 첫 : 다음 두 자리가 시(時)입니다. \[[^:]+:로 날짜를 건너뛰고, 그 자리에 03을 적으면 "새벽 3시"라는 조건이 됩니다.
03이나 :03:으로 찾으면 분이나 초가 03인 줄도 걸립니다. 어느 칸의 03인지 자리를 못 박는 것이 중요합니다./admin으로 시작하는 모든 페이지를 잡도록 /admin\S*로 씁니다.증거 분석실
re.search(r"", text)
돌려준 값
정답의 한 예는 이렇습니다.
(?P<ip>\S+) \S+ \S+ \[[^:]+:(?P<hour>03):[^\]]+\] "GET (?P<path>/admin\S*)시각을 일·월·연·시·분·초로 모두 나누어 이름을 붙이고, 줄마다 사전처럼 꺼내 조건을 겁니다. 이름 그룹 덕분에 조건문이 사람의 말처럼 읽힙니다.
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 제한을 권고함. 회원 정보 유출 가능성 있음.
LINE_RE로 로그를 읽어 응답 코드(status)가 200인 줄의 size를 모두 더해 보세요. int()를 잊지 마세요.(?P<y>\d{4})-(?P<m>\d\d)-(?P<d>\d\d)로 "2026-10-20"을 찾은 뒤 m.expand(r"\g<d>/\g<m>/\g<y>")의 결과를 예상해 보세요.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>") 로 틀에 끼우기.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.