CASE-004
그렙 앤 코 데이터 탐정 사무소
의뢰서
의뢰인
온라인 쇼핑몰 "마켓 정" 정 사장
증거물
회원가입 신청 내역과, 아이디 규칙을 검사하는 파이썬 함수 한 개.
해결 조건
규칙에 맞지 않는 아이디를 모두 걸러 내고, 검사기가 왜 뚫렸는지 밝힌다.
점심 무렵, 정 사장이 노트북을 끌어안고 들어옵니다. "아이디 규칙을 분명히 정해 놨거든요. 그런데 이상한 아이디가 막 들어와요." 소장이 노트북 화면을 한참 보더니 짧게 말합니다. "규칙은 맞아. 묻는 방법이 틀렸어."
마켓 정의 아이디 규칙과 검사 함수는 이렇습니다.
아이디 규칙: 영문 소문자로 시작하고, 소문자·숫자·밑줄(_)만 써서 4~16자import re
ID = r"[a-z][a-z0-9_]{3,15}"
def is_valid_id(uid):
return re.search(ID, uid) is not None가입 신청 내역입니다.
2026-10-18 14:02:51 signup id=jeong_fan
2026-10-19 03:11:02 signup id=ok_id!!!
2026-10-19 03:11:04 signup id=_admin
2026-10-19 03:11:05 signup id=ABC123
2026-10-19 03:11:07 signup id=a1
2026-10-19 09:40:13 signup id=minsu99먼저 정 사장의 검사기가 무엇을 통과시키는지 봅니다.
print(is_valid_id("ok_id!!!")) # True ← 느낌표가 있는데 통과
print(is_valid_id("_admin")) # True ← 밑줄로 시작하는데 통과
print(is_valid_id("ABC123")) # Falsere.search는 "이 문자열 어딘가에 규칙에 맞는 부분이 있는가"를 묻습니다. ok_id!!! 안에는 ok_id가, _admin 안에는 admin이 있으니 성공입니다.
re.search(ID, "ok_id!!!") # <re.Match object; span=(0, 5), match='ok_id'>
re.search(ID, "!!!ok_id") # <re.Match object; span=(3, 8), match='ok_id'>입력 검증은 다른 질문을 해야 합니다. "이 문자열 전체가 규칙에 맞는가." 그러려면 패턴에 "여기가 시작, 여기가 끝"을 적어야 합니다.
대괄호 밖의 ^는 문자열의 시작, $는 문자열의 끝을 뜻합니다. 이런 기호를 앵커(anchor, 닻)라고 합니다.
re.search(r"^id", "id=kim") # <re.Match object; span=(0, 2), match='id'>
re.search(r"^id", "my id=kim") # None
re.search(r"kim$", "id=kim") # <re.Match object; span=(3, 6), match='kim'>
re.search(r"kim$", "id=kim!") # None^id의 매치는 여전히 id 두 글자입니다.[^...]와 헷갈리지 마세요. ^는 대괄호 맨 앞이면 부정, 대괄호 밖이면 시작입니다.검사기 패턴의 앞뒤에 ^와 $를 붙이면, 규칙에 맞는 부분이 문자열 처음부터 끝까지 이어져야 합니다.
증거 분석실
re.search(r"", text)
돌려준 값
앵커를 직접 쓰지 않아도, 어디서 맞춰 볼지를 함수로 고를 수 있습니다.
| 함수 | 묻는 것 | 앵커로 쓰면 |
|---|---|---|
re.search | 어딘가에 맞는 부분이 있나 | (앵커 없음) |
re.match | 맨 앞에서부터 맞는 부분이 있나 | \A패턴 |
re.fullmatch | 전체가 맞나 | \A패턴\Z |
\A와 \Z는 ^, $와 비슷한 앵커인데, 차이는 4-4에서 봅니다.
re.match(ID, "ok_id!!!") # <re.Match object; span=(0, 5), match='ok_id'>
re.match(ID, "!!!ok_id") # None
re.fullmatch(ID, "ok_id!!!") # None
re.fullmatch(ID, "ok_id") # <re.Match object; span=(0, 5), match='ok_id'>re.match는 이름과 달리 "전체가 맞는지"를 보지 않습니다. 앞부분만 맞으면 성공이라 ok_id!!!를 통과시킵니다.re.fullmatch**입니다. 패턴에 앵커를 붙이는 것을 잊어도 안전하고, 다음 절의 함정도 피합니다.증거 분석실
re.search(r"", text)
돌려준 값
$에는 숨은 규칙이 하나 있습니다. 문자열의 진짜 끝뿐 아니라, 맨 마지막 줄바꿈 바로 앞에서도 매치합니다. 그래서 ^abc$는 끝에 줄바꿈이 붙은 "abc\n"도 통과시킵니다.
re.search(r"^abc$", "abc\n") # <re.Match object; span=(0, 3), match='abc'>
re.fullmatch(r"abc", "abc\n") # None
re.search(r"\Aabc\Z", "abc\n") # None\A는 문자열의 맨 처음에서만, \Z는 문자열의 맨 끝에서만 매치합니다. 줄바꿈 예외가 없습니다.\z도 씁니다. 파이썬에서는 \Z와 똑같고, 다른 언어의 정규식과 표기를 맞추려고 추가됐습니다.re.search(r"\Aabc\z", "abc") # <re.Match object; span=(0, 3), match='abc'>
re.search(r"\Aabc\z", "abc\n") # None도트의 한마디
파일에서 한 줄씩 읽은 글자에는 끝에 줄바꿈이 붙어 있을 때가 많아요. ^...$ 로 검사하면 그 줄바꿈을 못 본 척 통과시켜요. 그러면 아이디 끝에 줄바꿈이 붙은 채로 저장돼요. 검증할 때는 fullmatch, 아니면 \A...\Z 를 쓰세요.
3교시의 숙제가 남아 있습니다. 전화번호 패턴이 1234-5678-9012의 한가운데 234부터 매치를 시작한 사고입니다. 이번에는 문자열 전체가 아니라, 긴 글 속에서 번호 하나의 시작과 끝을 알려 줄 앵커가 필요합니다.
\b는 단어 경계입니다. \w(글자·숫자·밑줄)와 \W(그 밖의 글자) 사이, 또는 문자열의 시작·끝 중 \w 옆인 자리에 매치합니다. \B는 그 반대, 경계가 아닌 자리입니다. 둘 다 글자를 차지하지 않습니다.
re.findall(r"\bid\b", "id=kim userid=lee id_card") # ['id']
re.findall(r"\Bid\b", "id=kim userid=lee id_card") # ['id']
re.findall(r"\bid\B", "id=kim userid=lee id_card") # ['id']세 결과는 모두 ['id']이지만 찾은 위치가 다릅니다.
\bid\b : 앞뒤가 모두 경계인 id=kim의 id\Bid\b : 앞은 경계가 아닌 userid의 id\bid\B : 뒤가 밑줄(\w)이라 경계가 아닌 id_card의 id전화번호 앞뒤에 \b를 붙이면, 숫자 한가운데서는 시작할 수 없습니다. 1과 2 사이는 둘 다 \w라서 경계가 아니기 때문입니다.
re.findall(r"\b\d{2,3}[-. ]?\d{3,4}[-. ]?\d{4}\b", phonebook)
# ['010-0000-1234', '010 0000 9876', '02-0000-5678', '010.0000.4321']한 가지 조심할 점이 있습니다. 파이썬에서는 한글도 \w입니다. 그래서 한글 바로 뒤에 붙은 숫자 앞에는 경계가 없습니다.
re.findall(r"\b\d{2,3}-\d{3,4}-\d{4}\b", "전화010-0000-1234") # []
re.findall(r"\b\d{2,3}-\d{3,4}-\d{4}\b", "전화 010-0000-1234") # ['010-0000-1234']증거 분석실
re.search(r"", text)
돌려준 값
가입 내역처럼 여러 줄이 한 문자열에 들어 있으면, ^는 첫 줄의 시작에서만 매치합니다. 줄마다 맨 앞의 날짜를 뽑고 싶다면 re.MULTILINE(줄여서 re.M) 플래그를 줍니다. 그러면 ^와 $가 줄마다 시작과 끝에 매치합니다.
re.findall(r"^\d{4}-\d\d-\d\d", signups)
# ['2026-10-18']
re.findall(r"^\d{4}-\d\d-\d\d", signups, re.MULTILINE)
# ['2026-10-18', '2026-10-19', '2026-10-19', '2026-10-19', '2026-10-19', '2026-10-19']re.M을 줘도 \A와 \Z는 여전히 문자열 전체의 처음과 끝에서만 매치합니다.
re.findall(r"^\d", "1\n2", re.M) # ['1', '2']
re.findall(r"\A\d", "1\n2", re.M) # ['1']플래그는 11교시에 모두 정식으로 다룹니다. 오늘은 "^와 $를 줄 단위로 바꾸는 스위치가 있다"는 것만 기억하세요.
증거 분석실
re.search(r"", text)
플래그
돌려준 값
검사기의 re.search를 re.fullmatch로 바꿉니다. 패턴은 그대로입니다.
def is_valid_id(uid):
return re.fullmatch(r"[a-z][a-z0-9_]{3,15}", uid) is not None
for line in signups.splitlines():
uid = line.split("id=")[1]
if not is_valid_id(uid):
print(line)
# 2026-10-19 03:11:02 signup id=ok_id!!!
# 2026-10-19 03:11:04 signup id=_admin
# 2026-10-19 03:11:05 signup id=ABC123
# 2026-10-19 03:11:07 signup id=a1걸러진 네 건은 모두 규칙 위반입니다. ok_id!!!는 느낌표, _admin은 밑줄로 시작, ABC123은 대문자, a1은 너무 짧습니다.
사건 종결 보고서 — 아이디 검사기가 re.search로 "일부만 맞아도 통과"시키고 있었음. re.fullmatch로 고쳐 규칙 위반 가입 4건을 차단함.
그런데 소장이 출력된 네 줄의 시각을 손가락으로 짚어 내려갑니다. 03:11:02, 03:11:04, 03:11:05, 03:11:07. "5초 동안 네 번. 새벽 3시에. 사람 손이 아니야." 정 사장의 얼굴이 굳습니다. 가짜 계정들은 같은 날 새벽, 한꺼번에 몰려왔습니다. 누가 보냈는지는 아직 아무도 모릅니다.
여기까지가 1부입니다. 한 글자, 한 자리, 횟수, 경계까지 "무엇을 찾을지"를 적는 법을 다 배웠습니다. 2부에서는 찾은 것에서 필요한 조각을 꺼내는 법을 배우며 이 새벽의 손님을 쫓습니다.
re.match(r"\d{4}", "2026-10-19")와 re.match(r"\d{4}", "#2026")의 결과를 예상해 보고 실행해 보세요.re.findall(r"\bcat\b", "cat concat cats")와 re.findall(r"\Bcat", "cat concat cats")는 각각 어느 cat을 찾을까요?소장의 수첩
^ 는 시작, $ 는 끝. 앵커는 글자가 아니라 위치에 매치한다.
re.search 는 어디든, re.match 는 맨 앞에서만, re.fullmatch 는 전체. 입력 검증은 fullmatch.
$ 는 마지막 줄바꿈 앞에서도 매치한다. 줄바꿈 예외 없이 끝을 보려면 \Z (3.14부터 \z 도 같다).
\b 는 \w 와 \W 사이의 경계, \B 는 경계가 아닌 자리. 한글도 \w 다.
re.M 을 주면 ^ $ 가 줄마다 매치한다. \A \Z 는 그대로 문자열 전체 기준.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.