CASE-005
그렙 앤 코 데이터 탐정 사무소
의뢰서
의뢰인
온라인 쇼핑몰 "마켓 정" 정 사장
증거물
마켓 정 주문 기록. 아르바이트생마다 날짜를 다른 모양으로 적었다.
해결 조건
모든 주문에서 연, 월, 일을 따로 꺼내 같은 모양으로 정리한다.
가짜 회원가입 사건 다음 날, 정 사장이 다시 찾아옵니다. 이번에는 한숨부터 쉽니다. "가입은 막았어요. 그런데 주문 기록을 날짜별로 정리하려니, 아르바이트생 셋이 날짜를 다 다르게 적어 놨더라고요." 소장이 커피를 한 모금 마십니다. "찾는 법은 1부에서 배웠지. 이제 찾은 것 안에서 꺼내는 법을 배울 차례야."
주문 기록은 이렇습니다.
#1001 2026-10-20 ship=done
#1002 2026.10.20 ship=wait
#1003 2026/10/19 ship=done
#1004 20261019 ship=done
#1005 2026-10/20 ship=wait날짜를 찾기만 해서는 부족합니다. "2026", "10", "20"을 따로따로 꺼내야 정리할 수 있습니다. 오늘의 도구는 괄호, 즉 그룹입니다.
3교시에서 수량자는 바로 앞 한 덩어리에만 붙는다고 했습니다. 괄호 ( )로 묶으면 여러 글자가 한 덩어리가 됩니다.
re.fullmatch(r"ha+", "hahaha") # None
re.fullmatch(r"(ha)+", "hahaha") # <re.Match object; span=(0, 6), match='hahaha'>IP 주소처럼 "숫자와 마침표"가 세 번 되풀이되는 모양도 그룹에 수량자를 붙여 씁니다.
re.search(r"(\d{1,3}\.){3}\d{1,3}", "ip=203.0.113.66")
# <re.Match object; span=(3, 15), match='203.0.113.66'>(\d{1,3}\.){3}는 "숫자 1~3자리와 마침표"를 세 번입니다. 마지막 숫자 뒤에는 마침표가 없으니 \d{1,3}을 따로 붙였습니다.
|는 "왼쪽 또는 오른쪽"입니다. 그런데 |는 정규식에서 우선순위가 가장 낮습니다. 괄호로 막지 않으면 패턴 전체를 둘로 가릅니다.
re.fullmatch(r"ship=done|wait", "wait") # <re.Match object; span=(0, 4), match='wait'>
re.fullmatch(r"ship=(done|wait)", "wait") # None
re.fullmatch(r"ship=(done|wait)", "ship=wait") # <re.Match object; span=(0, 9), match='ship=wait'>ship=done|wait는 "ship=done 또는 wait"입니다. "ship= 다음에 done 또는 wait"를 원하면 선택할 부분만 괄호로 감쌉니다.
앵커와 섞을 때 특히 조심합니다. ^a|b$는 "a 또는 b 하나만 있는 문자열"이 아니라 "^a 또는 b$"입니다.
re.findall(r"^a|b$", "a.b") # ['a', 'b']
re.search(r"^ok|no$", "say no") # <re.Match object; span=(4, 6), match='no'>say no는 ok로 시작하지 않는데도 매치했습니다. 원래 뜻대로라면 ^(ok|no)$로 씁니다.
선택지는 왼쪽부터 차례로 시도하고, 먼저 성공한 것을 씁니다. 가장 긴 것을 고르지 않습니다.
re.search(r"10|100", "100") # <re.Match object; span=(0, 2), match='10'>
re.search(r"100|10", "100") # <re.Match object; span=(0, 3), match='100'>-|\.|/는 클래스 [-./]와 같습니다. 이럴 때는 클래스가 짧고 빠릅니다.증거 분석실
re.search(r"", text)
돌려준 값
괄호에는 묶는 것 말고 두 번째 역할이 있습니다. 괄호 안에 매치한 글자를 따로 기억합니다. 이것을 캡처 그룹이라고 하고, 여는 괄호 순서대로 1, 2, 3 … 번호가 붙습니다.
위의 주문 기록이 orders라는 문자열에 들어 있다고 합시다.
m = re.search(r"(\d{4})-(\d\d)-(\d\d)", orders)
print(m.group(0)) # 2026-10-20
print(m.group(1)) # 2026
print(m.group(2), m.group(3)) # 10 20
print(m.groups()) # ('2026', '10', '20')
print(m.group(1, 2)) # ('2026', '10')
print(m[1], m[0]) # 2026 2026-10-20group(0)(또는 그냥 group())은 매치 전체입니다. 1번부터가 그룹입니다.groups()는 1번부터 끝까지를 튜플로 줍니다.group(1, 2)처럼 번호를 여러 개 주면 튜플로 돌려줍니다.m[1]은 m.group(1)의 줄임입니다.괄호가 겹쳐 있어도 규칙은 같습니다. 여는 괄호 (가 나오는 순서대로 번호를 셉니다.
((\d{4})-(\d\d))-(\d\d)
12 3 4m = re.search(r"((\d{4})-(\d\d))-(\d\d)", "2026-10-20")
print(m.groups()) # ('2026-10', '2026', '10', '20')증거 분석실
re.search(r"", text)
돌려준 값
묶기만 하고 기억할 필요는 없을 때가 많습니다. |의 범위를 정하거나 수량자를 붙이려고 괄호를 쓸 때입니다. 이럴 때는 (?:...)를 씁니다. 묶기는 하지만 번호를 받지 않습니다.
m = re.search(r"(?:\d{4})-(\d\d)", "2026-10")
print(m.group(1)) # 10
print(re.fullmatch(r"(?:ab)+", "abab").groups()) # ()첫 괄호가 번호를 받지 않아서 (\d\d)가 1번이 됐습니다. 필요한 것만 캡처하면 번호가 단순해지고, 다음 절의 findall 함정도 피할 수 있습니다.
findall은 패턴에 캡처 그룹이 있으면 동작이 바뀝니다.
| 캡처 그룹 수 | findall이 돌려주는 것 |
|---|---|
| 0개 | 매치 전체 문자열의 리스트 |
| 1개 | 그 그룹의 문자열 리스트 |
| 2개 이상 | 그룹들의 튜플 리스트 |
re.findall(r"\d{4}[-./]\d\d[-./]\d\d", orders)
# ['2026-10-20', '2026.10.20', '2026/10/19', '2026-10/20']
re.findall(r"(\d{4})[-./]\d\d[-./]\d\d", orders)
# ['2026', '2026', '2026', '2026']
re.findall(r"(\d{4})[-./](\d\d)[-./](\d\d)", orders)
# [('2026', '10', '20'), ('2026', '10', '20'), ('2026', '10', '19'), ('2026', '10', '20')]연·월·일이 필요할 때는 튜플 리스트가 편리합니다. 문제는 묶으려고만 쓴 괄호도 그룹이 된다는 점입니다.
re.findall(r"\d{4}(-|\.|/)\d\d(-|\.|/)\d\d", orders)
# [('-', '-'), ('.', '.'), ('/', '/'), ('-', '/')]날짜를 원했는데 구분자만 돌아왔습니다. 매치 전체가 필요하면 괄호를 (?:...)로 바꾸거나, finditer로 Match를 받아 group()을 씁니다.
re.findall(r"\d{4}(?:-|\.|/)\d\d(?:-|\.|/)\d\d", orders)
# ['2026-10-20', '2026.10.20', '2026/10/19', '2026-10/20']
[m.group() for m in re.finditer(r"\d{4}(-|\.|/)\d\d(-|\.|/)\d\d", orders)]
# ['2026-10-20', '2026.10.20', '2026/10/19', '2026-10/20']도트의 한마디
findall 결과가 이상하게 짧거나 튜플이 나오면, 먼저 패턴의 괄호를 세어 보세요. 묶으려고만 쓴 괄호가 몰래 그룹이 돼 있을 때가 많아요. 3교시에서 (ha)+ 를 findall로 돌렸을 때 'ha'만 나온 것도 이것 때문이에요.
증거 분석실
re.search(r"", text)
돌려준 값
그룹이 매치에 참여하지 않으면 그 자리는 None입니다. ?가 붙은 그룹이 0번 매치했거나, |의 다른 쪽이 선택된 경우입니다.
m = re.fullmatch(r"(\d{4})-(\d\d)(-(\d\d))?", "2026-10")
print(m.groups()) # ('2026', '10', None, None)
print(m.group(3)) # None
print(re.search(r"(a)|(b)", "b").groups()) # (None, 'b')그룹에 수량자를 붙여 여러 번 매치하면, 그룹에는 마지막 한 번만 남습니다.
m = re.fullmatch(r"(\d)+", "2026")
print(m.group(0), m.group(1)) # 2026 6
re.findall(r"(\d{1,3}\.){3}\d{1,3}", "a=203.0.113.66 b=198.51.100.7")
# ['113.', '100.']
re.findall(r"(?:\d{1,3}\.){3}\d{1,3}", "a=203.0.113.66 b=198.51.100.7")
# ['203.0.113.66', '198.51.100.7'](\d)+는 네 번 매치했지만 그룹 1에는 마지막 6만 남았습니다. IP 주소도 그룹에는 세 번째 반복인 113.만 남았고, findall은 그 그룹을 돌려줬습니다. 5-1의 IP 패턴을 findall에 쓸 때는 (?:...)가 정답입니다.
세 가지 구분자 -, ., /는 [-./]로, 구분자 없는 20261019는 ?로 받습니다. 연·월·일은 각각 캡처합니다.
DATE = r"(\d{4})[-./]?(\d\d)[-./]?(\d\d)"
for m in re.finditer(DATE, orders):
y, mo, d = m.groups()
print(f"{y}년 {int(mo)}월 {int(d)}일")
# 2026년 10월 20일
# 2026년 10월 20일
# 2026년 10월 19일
# 2026년 10월 19일
# 2026년 10월 20일주문 번호까지 함께 꺼내려면 그룹을 하나 더 둡니다. 그룹이 넷이니 findall은 네 칸짜리 튜플을 돌려줍니다.
re.findall(r"#(\d+) " + DATE, orders)
# [('1001', '2026', '10', '20'), ('1002', '2026', '10', '20'), ('1003', '2026', '10', '19'), ('1004', '2026', '10', '19'), ('1005', '2026', '10', '20')]증거 분석실
re.search(r"", text)
돌려준 값
사건 종결 보고서 — 주문 5건의 날짜를 연·월·일로 나눠 정리함. 단, #1005는 구분자가 섞인 채 입력되어 있어 직원 확인이 필요함.
정리된 표를 넘겨받은 정 사장이 마지막 줄을 가리킵니다. "#1005는 2026-10/20인데요. 이것도 그냥 통과했네요?"
re.fullmatch(DATE, "2026-10/20")
# <re.Match object; span=(0, 10), match='2026-10/20'>맞습니다. [-./]? 두 개는 서로를 모릅니다. 앞에서 -를 썼으면 뒤에도 -를 쓰라고 말할 방법이 지금 패턴에는 없습니다. 소장이 수첩에 한 줄을 적습니다. "앞에서 잡은 걸 뒤에서 다시 부르는 방법이 있어. 그건 10교시에." 오늘은 이 한 건을 사람 눈으로 확인하는 것으로 마칩니다.
re.findall(r"ship=(done|wait)", orders)의 결과를 예상해 보세요. (?:done|wait)로 바꾸면 어떻게 달라질까요?re.search(r"(\d{4})-(\d\d)(-(\d\d))?", "2026-10 배송")에서 groups()는 무엇일까요?03:11:02에서 시, 분, 초를 그룹으로 꺼내는 패턴을 쓰고, m.group(1, 3)의 결과를 확인해 보세요.소장의 수첩
( ) 는 묶어서 한 덩어리로 만들고, 매치한 글자를 기억한다. (ab)+ 처럼 수량자를 붙인다.
| 는 우선순위가 가장 낮다. ^a|b$ 는 '^a 또는 b$'. 선택지는 왼쪽부터 시도한다.
그룹 번호는 여는 괄호 순서. group(0) 은 전체, groups() 는 튜플, m[1] 은 group(1).
(?:...) 는 묶기만 하고 번호를 받지 않는다.
findall: 그룹 0개면 전체, 1개면 그 그룹, 2개 이상이면 튜플. 전체가 필요하면 (?:...) 나 finditer.
매치되지 않은 그룹은 None, (\d)+ 처럼 반복된 그룹은 마지막 한 번만 남는다.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.