CASE-007
그렙 앤 코 데이터 탐정 사무소
의뢰서
의뢰인
유출 사건 취재 기자 오 기자
증거물
익명의 제보자가 보낸 고객 명단 파일. 마켓 정 회원 목록으로 보인다. 구분자가 줄마다 제각각이다.
해결 조건
내일 아침 기사에 실을 수 있게 전화번호 가운데를 가리고, 날짜를 2026-10-20 꼴로 바꾸고, 줄마다 칸을 나눈다.
밤 9시, 사무소 문이 벌컥 열립니다. 오 기자가 숨을 몰아쉬며 노트북을 펼칩니다. "제보가 들어왔습니다. 마켓 정 고객 명단이에요. 내일 아침 기사가 나가야 하는데, 개인정보를 그대로 실을 수는 없잖아요."
소장이 화면을 들여다봅니다. 6교시에 찾은 새벽 3시의 그 회원 목록입니다. "찾기는 끝났어. 오늘은 바꾸고, 자르는 날이야."
id, name; phone | joined
1001,kim,010-0000-5678,20/10/2026
1002; lee; 010-0000-1234; 19/10/2026
1003 | park | 010-0000-9012 | 03/09/2026
1004, choi;010-0000-3456 |28/02/2026
# (a+)+$맨 끝 줄에 이상한 글자가 보이지만, 오 기자는 마감에 쫓겨 그냥 넘어갑니다.
re.sub(패턴, 바꿀 문자열, 원본)은 패턴에 매치된 곳을 모두 바꾼 새 문자열을 돌려줍니다. 원본은 그대로 둡니다. 파이썬 문자열은 바뀌지 않는 값이기 때문입니다.
import re
re.sub(r"\d{4}", "****", "010-0000-5678") # '010-****-****'네 자리 숫자가 두 군데라서 뒷자리까지 가려졌습니다. 앞 세 자리와 뒤 네 자리는 남기고 가운데만 가리려면, 남길 부분을 그룹으로 잡아 두고 바꿀 문자열에서 다시 불러냅니다. \1은 1번 그룹이 잡은 글자입니다.
re.sub(r"(\d{3})-\d{4}-(\d{4})", r"\1-****-\2", "010-0000-5678") # '010-****-5678'바꿀 문자열에도 백슬래시가 들어가니 패턴처럼 r"..."로 씁니다.
증거 분석실
re.search(r"", text)
돌려준 값
명단의 날짜는 20/10/2026처럼 일/월/연 순서입니다. 기사에는 2026-10-20으로 실어야 합니다. 그룹 순서를 바꿔 끼우면 됩니다.
re.sub(r"(\d\d)/(\d\d)/(\d{4})", r"\3-\2-\1", "20/10/2026") # '2026-10-20'
re.sub(r"(\d\d)/(\d\d)/(\d{4})", r"\g<3>-\g<2>-\g<1>", "20/10/2026") # '2026-10-20'\3과 \g<3>은 같은 뜻입니다. 6교시의 이름 그룹은 \g<이름>으로 부릅니다. 순서를 외울 필요가 없어 실수가 줄어듭니다.
re.sub(r"(?P<d>\d\d)/(?P<m>\d\d)/(?P<y>\d{4})", r"\g<y>-\g<m>-\g<d>", "20/10/2026") # '2026-10-20'\g<0>은 매치 전체입니다. 찾은 것을 지우지 않고 감싸기만 할 때 씁니다.
re.sub(r"\d{3}-\d{4}-\d{4}", r"[\g<0>]", "1001,kim,010-0000-5678") # '1001,kim,[010-0000-5678]'그룹 번호 바로 뒤에 숫자를 붙이고 싶을 때는 조심해야 합니다. \10은 "1번 그룹 뒤에 0"이 아니라 10번 그룹입니다. 이럴 때 \g<1>0처럼 꺾쇠로 번호를 닫아 줍니다.
re.sub(r"(\d+)", r"\g<1>0", "kim 5") # 'kim 50'
re.sub(r"(\d+)", r"\10", "kim 5") # re.PatternError: invalid group reference 10 at position 1바꿀 문자열에서도 \n은 줄바꿈 글자가 됩니다. 반면 \q처럼 뜻이 없는 영문자 이스케이프는 오류입니다.
re.sub(r",", r"\n", "a,b") # 'a\nb'
re.sub(r",", r"\q", "a,b") # re.PatternError: bad escape \q at position 0re.PatternError는 3.13부터 쓰는 이름이고, 예전 이름 re.error와 같은 예외입니다. 12교시에 자세히 다룹니다.
증거 분석실
re.search(r"", text)
돌려준 값
바꿀 문자열 자리에 함수를 넘길 수도 있습니다. re.sub은 매치가 나올 때마다 그 Match 객체를 함수에 넣고, 함수가 돌려준 문자열로 바꿉니다. 이름의 첫 글자만 남기고 나머지 길이만큼 별표를 찍어 봅시다. 고정된 틀로는 할 수 없는 일입니다.
def hide(m):
name = m.group(1)
return "," + name[0] + "*" * (len(name) - 1) + ","
re.sub(r",([a-z]+),", hide, "1003,park,010-0000-9012,03/09/2026") # '1003,p***,010-0000-9012,03/09/2026'
re.sub(r"[a-z]+", lambda m: m[0].upper(), "kim and lee") # 'KIM AND LEE'TypeError입니다.count는 앞에서부터 몇 군데까지만 바꿀지 정합니다. 기본값 0은 "전부"입니다. re.subn은 바꾼 문자열과 바꾼 횟수를 튜플로 돌려줍니다. 맨 위 명단이 leak이라는 문자열에 들어 있다고 합시다.
re.sub(r"\d{3}-\d{4}-\d{4}", "***", leak, count=1).splitlines()[1:3]
# ['1001,kim,***,20/10/2026', '1002; lee; 010-0000-1234; 19/10/2026']
re.subn(r"\d{3}-\d{4}-\d{4}", "***", leak)[1] # 4기사에는 "전화번호 4건을 가렸다"라고 정확한 숫자를 적을 수 있습니다.
명단은 줄마다 구분자가 다릅니다. 쉼표, 세미콜론, 세로 막대가 섞였고 앞뒤 빈칸도 제각각입니다. str.split은 구분자를 하나만 받습니다. re.split은 패턴으로 자릅니다.
re.split(r"\s*[,;|]\s*", "a, b;c |d") # ['a', 'b', 'c', 'd']
re.split(r"\s*[,;|]\s*", "a, b;c |d", maxsplit=2) # ['a', 'b', 'c |d']\s*가 빈칸까지 구분자로 삼켜서 칸이 깨끗하게 나옵니다.maxsplit=2는 두 번까지만 자르고, 나머지는 마지막 칸에 그대로 남깁니다.패턴에 캡처 그룹이 있으면, 잘라 낸 구분자도 결과 리스트에 끼워 넣습니다. 어떤 구분자였는지 기록해야 할 때 씁니다. 필요 없다면 (?:...)로 씁니다.
re.split(r"\s*([,;|])\s*", "a, b;c |d") # ['a', ',', 'b', ';', 'c', '|', 'd']글자를 차지하지 않는 빈 매치로도 자를 수 있습니다. \b는 단어 경계 자리에만 매치하니, 단어와 빈칸 사이마다 잘립니다. 양 끝에도 경계가 있어서 빈 문자열이 생깁니다.
re.split(r"\b", "a b") # ['', 'a', ' ', 'b', '']증거 분석실
re.search(r"", text)
돌려준 값
re.sub의 네 번째 자리는 count입니다. 플래그를 넣으려고 무심코 re.I를 네 번째에 쓰면, 플래그가 아니라 횟수로 들어갑니다. re.I의 실제 값이 2이기 때문입니다.
re.sub("a", "b", "aAaA", re.I) # 'bAbA' ← 대소문자 무시는 안 되고, 두 번만 바뀜
re.sub("a", "b", "aAaA", flags=re.I) # 'bbbb'이 실수가 워낙 흔해서 파이썬 3.13부터는 count, maxsplit, flags를 위치 인자로 넘기면 DeprecationWarning을 냅니다. 위 첫 줄은 'count' is passed as positional argument라는 경고를 냅니다. re.split의 maxsplit, flags도 같습니다. 언제나 count=1, maxsplit=2, flags=re.I처럼 이름을 붙여 쓰세요.
도트의 한마디
저도 '앉아'라고 하면 앉고, 그냥 숫자 2를 부르면 뭘 하라는 건지 몰라요. 함수도 똑같아요. re.sub("a", "b", s, re.I)는 '두 번만 바꿔'로 알아들어요. flags=re.I 라고 이름을 불러 주세요.
가리기, 날짜 바꾸기, 자르기를 차례로 잇습니다. 마지막 줄과 머리줄은 빼고 고객 줄만 자릅니다.
masked = re.sub(r"(\d{3})-\d{4}-(\d{4})", r"\1-****-\2", leak)
fixed = re.sub(r"(?P<d>\d\d)/(?P<m>\d\d)/(?P<y>\d{4})", r"\g<y>-\g<m>-\g<d>", masked)
for row in fixed.splitlines()[1:-1]:
print(re.split(r"\s*[,;|]\s*", row))
# ['1001', 'kim', '010-****-5678', '2026-10-20']
# ['1002', 'lee', '010-****-1234', '2026-10-19']
# ['1003', 'park', '010-****-9012', '2026-09-03']
# ['1004', 'choi', '010-****-3456', '2026-02-28']오 기자가 표를 받아 들고 일어서려는데, 소장이 손을 듭니다. "잠깐. 맨 끝 줄."
print(fixed.splitlines()[-1]) # # (a+)+$명단 데이터가 아닙니다. 누군가 일부러 남긴 정규식입니다. 이 글자를 그대로 찾으려면 1교시에 배운 대로 메타문자를 하나하나 꺼야 합니다.
print(re.escape("(a+)+$")) # \(a\+\)\+\$
re.search(r"\(a\+\)\+\$", leak) # <re.Match object; span=(177, 183), match='(a+)+$'>증거 분석실
re.search(r"", text)
돌려준 값
"이게 뭡니까?" 오 기자가 묻습니다. 소장은 한참 말이 없다가 대답합니다. "되돌아가기 함정. 이걸 읽는 정규식 엔진은 같은 길을 수없이 되돌아가다 멈춰 버려. 내일 기사에는 빼."
오 기자가 떠난 뒤, 소장은 서랍에서 빛바랜 사진 액자 하나를 꺼냅니다. 사무소를 연 첫해의 사진입니다. 한참을 들여다보다가, 액자를 책상 위에 엎어 둡니다.
사건 종결 보고서 — 유출 명단 4건의 전화번호 가운데 자리를 가리고 날짜를 연-월-일로 통일해 기자에게 전달함. 명단 끝에서 의도적으로 심은 정규식 (a+)+$를 발견함. 출처 미상. 별도 조사 필요.
1001~1004를 #1001처럼 앞에 #을 붙여 바꿔 보세요. \g<0>을 쓰면 한 줄로 됩니다.re.split(r"(\s*)([,;|])(\s*)", "a, b")의 결과를 예상해 보세요. 그룹이 세 개면 몇 개의 칸이 끼어들까요?"price 1500 and 300"의 숫자를 모두 두 배로 바꿔 보세요. 함수가 문자열을 돌려줘야 한다는 점을 잊지 마세요.소장의 수첩
re.sub(패턴, 바꿀 문자열, 원본) 은 새 문자열을 돌려준다. 원본은 그대로.
바꿀 문자열에서 \1 = \g<1>, 이름은 \g<name>, 전체는 \g<0>. 숫자가 이어지면 \g<1>0 (\10 은 10번 그룹).
바꿀 문자열의 \n 은 줄바꿈, \q 처럼 모르는 이스케이프는 오류.
함수 치환: Match 를 받아 문자열을 돌려주는 함수. count 로 횟수 제한, re.subn 은 (결과, 횟수).
re.split 은 패턴으로 자른다. maxsplit, 캡처 그룹이 있으면 구분자도 결과에 들어간다.
count, maxsplit, flags 는 키워드로. 위치 인자는 3.13부터 DeprecationWarning, re.I 가 count=2 로 들어가는 사고.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.