정규식으로 지운다, 그리고 이름 앞에서 무너진다
Article
세는 건 끝났습니다. 전화 152 · 주소 100 · 카톡 49 · 메일 46.
이제 지웁니다. 그리고 지우고 나서 다시 세서 채점합니다.
1. 이번 시간에 손대는 것
새 파일은 없습니다. 7교시에 만든 둘이 자랍니다.
지금 (7교시가 끝난 상태)
세는 것까지
domain/masking.py—build_address_pattern()하나features/privacy.py—_load()와 세는 코드
8교시가 끝나면
지우고 채점한다
domain/masking.py— 정규식 넷 +mask_text()features/privacy.py— 앱이 부를mask_text()+ 채점표- 🔴 후반에 이름이 붙는다 (두 파일 다 한 번 더 바뀐다)
- 다른 파일은 하나도 안 건드린다
이 시간은 둘로 나뉩니다. 전반은 정규식이 되는 이야기(전화·메일·카톡·주소), 후반은 정규식이 안 되는 이야기(이름)입니다.
2. 전화번호 — 순진한 판부터 재 봅니다
먼저 제일 흔한 정규식이 실제로 몇 건을 잡는지 봅니다.
try_phone.py 를 뿌리에 만듭니다. 다 보고 지울 파일입니다.
import re
from app.core.db import query
reviews = [r for (r,) in query(
"SELECT review FROM purchases WHERE review IS NOT NULL AND review != ''")]
# 7교시에서 만든 자 ― 정규식과 무관하게 센다
rough = [t for t in reviews if "010" in t.replace("-", "").replace(" ", "")]
FIRST = re.compile(r"01[016-9]-\d{3,4}-\d{4}") # 첫 판 ― 하이픈만 잡는다
print(f" 들어 있는 후기 {len(rough):>4}건")
print(f" 첫 판이 잡은 것 {sum(1 for t in rough if FIRST.search(t)):>4}건\n")
print(" --- 첫 판이 놓친 것 둘 ---")
for t in [t for t in rough if not FIRST.search(t)][:2]:
print(f" {t}")
print("\n --- 구분자별로 세면 ---")
SHAPES = (
("하이픈", re.compile(r"01[016-9]-\d{3,4}-\d{4}")),
("공백", re.compile(r"01[016-9]\s\d{3,4}\s\d{4}")),
("붙여 씀", re.compile(r"01[016-9]\d{7,8}")),
)
for label, pattern in SHAPES:
print(f" {label:8s} {sum(1 for t in rough if pattern.search(t)):>4}건")
python try_phone.py
- 들어 있는 후기 152건
- 첫 판이 잡은 것 42건
- --- 첫 판이 놓친 것 둘 ---
- 기대가 컸나 봐요. 산뜻한 제형이라 아침에 쓰기 좋아요. 재고 문의는 010 8202 9049 로 부탁드려요.
- 광고를 너무 믿었나 봐요. 중성이라 그런지 발랐더니 조금 따가웠어요. 재고 문의는 010 8202 9049 로 부탁드려요.
- --- 구분자별로 세면 ---
- 하이픈 42건
- 공백 61건
- 붙여 씀 49건
152 중 42. 110건을 놓쳤습니다.
하이픈이 오히려 소수입니다. 152건 중 42건, 28% 뿐입니다.
del try_phone.py
3. 카톡 아이디 — 모양을 정할 수가 없습니다
- ... 카톡 아이디 seoyeon24 입니다.
- ... 카톡 skinlover22 로 문의주세요
- ... 톡 주세요 hj_beauty
[a-z]+\d+
영문+숫자
seoyeon24는 잡는다- 🔴 그런데
hj_beauty는 못 잡는다 - 🔴 그리고 제품명
C50·AHA30도 같이 잡는다
문장을 통째로 버린다
이걸 쓴다
- 「카톡」이 든 문장 전체를 지운다
- 아이디가 어떻게 생겼든 같이 사라진다
- 잃는 게 거의 없다 ― 아래 설명
4. app/domain/masking.py — 규칙 넷과 지우는 함수
7교시에 만든 파일을 통째로 덮어씁니다. build_address_pattern() 은 그대로이고,
정규식 넷과 함수 둘이 늘었습니다.
"""개인정보 마스킹 ― 규칙만 있는 자리. **DB 를 모른다.**
도시 사전 · 이름 사전은 app/features/privacy.py 가 읽어서 **인자로 넣어 준다.**
여기서는 **지운다.** 지우면 못 되돌린다 ― 그래서 나가는 글에만 쓰고,
화면에 보여 줄 원문은 따로 들고 있는다.
"""
import re
# 🔴 010-1234-5678 · 010 1234 5678 · 01012345678 을 다 잡는다.
# 하이픈만 잡는 정규식은 152건 중 42건만 잡았다 (실측)
PHONE = re.compile(r"01[016-9][-.\s]?\d{3,4}[-.\s]?\d{4}")
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]{2,}")
# 🔴 카톡 아이디는 모양이 정해져 있지 않다. 아이디를 잡는 대신 그 문장을 통째로 버린다
KAKAO = re.compile(r"[^.!?]*카톡[^.!?]*[.!?]?")
CONTACT = re.compile(r"[^.!?]*(문자|연락|공구|디엠|DM)[^.!?]*[.!?]?")
def build_address_pattern(cities):
"""도시 목록으로 주소 정규식을 만든다. 목록이 비면 None 을 준다.
'천안 일산동' 처럼 도시 + 동네로 나온다.
"""
if not cities:
return None
# 🔴 긴 도시 이름부터 시도한다. 짧은 게 먼저 먹으면 '천안'이 '천안시'를 조각낸다
ordered = sorted(set(cities), key=len, reverse=True)
return re.compile(r"(?:%s)(?:\s?[가-힣]+(?:동|구|읍|면|로|길))?" % "|".join(ordered))
def _tidy(text):
"""공백 정리 ― 가리고 나면 빈칸이 여러 개 생긴다."""
return re.sub(r"\s{2,}", " ", text).strip()
def mask_text(text, *, address=None):
"""지운다. 되돌릴 수 없다 ― 나가는 글에만 쓴다.
address build_address_pattern() 이 만든 정규식. 안 주면 주소는 안 가린다
"""
if not text:
return text
text = KAKAO.sub(" ", text) # 먼저 문장을 버리고
text = CONTACT.sub(" ", text)
text = PHONE.sub("[연락처]", text) # 남은 것에서 조각을 가린다
text = EMAIL.sub("[메일]", text)
if address is not None:
text = address.sub("[주소]", text)
return _tidy(text)
이 파일에서 기억할 것
[-.\s]?한 조각이 42 를 152 로 만듭니다. 하이픈·점·공백 중 하나가 있어도 되고 없어도 된다(?)는 뜻입니다. 참고로 점(.)은 우리 데이터에 0건인데도 남겨 뒀습니다 — 「지금 안 쓰인다」와 「앞으로도 안 쓰인다」는 다르고, 비용이 0 일 때만 통하는 판단입니다.[^.!?]*가 「문장 하나」를 뜻합니다. 마침표·느낌표·물음표가 아닌 글자의 연속입니다. 그래서[^.!?]*카톡[^.!?]*[.!?]?는 「카톡」을 품은 문장을 통째로 가리킵니다.*뒤의 인자는 이름을 적어서 넘겨야 합니다.mask_text(t, ADDRESS)는 오류입니다. 귀찮아 보이지만 순서를 헷갈릴 자리를 없애는 것이고, 후반에 인자가 하나 더 늘 때 값을 합니다.
5. app/features/privacy.py — 사전을 끼워서 부릅니다
7교시 파일을 통째로 덮어씁니다. _load() 는 그대로이고, 앱이 부를 mask_text() 와
채점표가 늘었습니다.
"""마스킹 조립 ― 규칙(domain)에 사전(DB)을 붙여 주는 자리.
앱의 다른 코드는 여기 mask_text() 하나만 부른다.
혼자 돌려볼 수 있다: python -m app.features.privacy
"""
from app.core.db import query
from app.domain import masking
_address = None # None = 아직 안 읽었다는 표시
def _load():
"""사전을 한 번만 읽어 둔다.
🔴 파일 맨 위에서 읽지 않는다. 그러면 이 파일을 import 하는 것만으로
DB 가 있어야 한다 ― 9교시에 시험을 붙일 때 그게 문제가 된다.
"""
global _address
if _address is not None:
return
cities = [city for (city,) in query("SELECT DISTINCT city FROM customers")]
_address = masking.build_address_pattern(cities)
def mask_text(text):
"""지운다 ― 앱이 부르는 것은 이 함수다."""
_load()
return masking.mask_text(text, address=_address)
__all__ = ["mask_text"]
if __name__ == "__main__":
import sys
sys.stdout.reconfigure(errors="replace")
_load()
reviews = [r for (r,) in query(
"SELECT review FROM purchases WHERE review IS NOT NULL AND review != ''")]
print(f"후기 {len(reviews):,}건\n")
# 왼쪽은 '자'(정규식과 무관하게 센 것), 오른쪽은 우리 정규식이 잡은 것
print(f" {'무엇':10s} {'들어 있는 후기':>12s} {'정규식이 잡은 것':>14s}")
checks = (
("전화", lambda t: "010" in t.replace("-", "").replace(" ", ""), masking.PHONE),
("메일", lambda t: "@" in t, masking.EMAIL),
("카톡", lambda t: "카톡" in t, masking.KAKAO),
("주소", lambda t: _address.search(t) is not None, _address),
)
for label, has, pattern in checks:
rough = sum(1 for t in reviews if has(t))
caught = sum(1 for t in reviews if pattern.search(t))
print(f" {label:10s} {rough:>12,} {caught:>14,}")
# 🔴 두 번째 채점 ― 마스킹한 결과를 다시 검사한다
leaked = [t for t in reviews if "010" in mask_text(t).replace("-", "").replace(" ", "")]
print(f"\n 마스킹 뒤에도 010 이 남은 후기: {len(leaked)}건")
sample = next(t for t in reviews if masking.PHONE.search(t))
print(f"\n 전: {sample}")
print(f" 후: {mask_text(sample)}")
이 파일에서 기억할 것
- 같은 이름의 함수가 둘입니다.
masking.mask_text(text, *, address=…)는 규칙이고 사전을 인자로 받습니다.privacy.mask_text(text)는 조립이고 사전을 DB 에서 읽어 끼워 줍니다. 앱의 다른 코드는 뒤엣것만 부릅니다 —__all__이 그걸 못박아 둔 표시입니다. - 두 번째 채점이 진짜 채점입니다. 「정규식이 152건을 잡는다」와 「마스킹한 결과에
010이 0건이다」는 다른 말입니다. 앞엣것은PHONE.search(t)가 참이었다는 뜻이고, 뒤엣것은 실제 출력물을 다시 검사한 것입니다. sub()부르는 걸 깜빡했거나, 반환값을 안 받았거나, 순서가 꼬였으면 앞은 통과하고 뒤가 잡습니다.
python -m app.features.privacy
- 후기 1,500건
- 무엇 들어 있는 후기 정규식이 잡은 것
- 전화 152 152
- 메일 46 46
- 카톡 49 49
- 주소 100 100
- 마스킹 뒤에도 010 이 남은 후기: 0건
- 전: 가격 생각하면 이 정도는 하는 것 같아요. 중성인데 자극 없이 잘 썼어요. 궁금하신 분은 010-7600-2955으로 문자주세요.
- 후: 가격 생각하면 이 정도는 하는 것 같아요. 중성인데 자극 없이 잘 썼어요.
전부 잡았습니다. 그런데 마지막 두 줄이 이상합니다.
6. 「152/152」가 숨기고 있던 것
전화번호가 [연락처] 로 안 바뀌었습니다. 문장이 통째로 사라졌습니다.
- 「...문자주세요.」「문자」가 들어 있다
- CONTACT 가 먼저 돈다문장을 통째로 지운다
- PHONE 이 볼 게 없다전화번호는 이미 사라졌다
어떤 후기는 [연락처] 로 바뀝니다. 「문자·연락·공구·카톡」이 안 든 문장이면 그렇습니다.
- 전: 기대가 컸나 봐요. 산뜻한 제형이라 아침에 쓰기 좋아요. 재고 문의는 010 8202 9049 로 부탁드려요.
- 후: 기대가 컸나 봐요. 산뜻한 제형이라 아침에 쓰기 좋아요. 재고 문의는 [연락처] 로 부탁드려요.
몇 건씩인지 셉니다. try_split.py 를 만듭니다.
from app.core.db import query
from app.domain import masking
reviews = [r for (r,) in query(
"SELECT review FROM purchases WHERE review IS NOT NULL AND review != ''")]
has_phone = [t for t in reviews if masking.PHONE.search(t)]
eaten = [t for t in has_phone
if masking.KAKAO.search(t) or masking.CONTACT.search(t)]
print(f" 전화번호가 든 후기 {len(has_phone):>4}건")
print(f" 그중 카톡/연락 문장에 먹힌 것 {len(eaten):>4}건")
print(f" [연락처] 로 바뀌는 것 {len(has_phone) - len(eaten):>4}건")
python try_split.py
- 전화번호가 든 후기 152건
- 그중 카톡/연락 문장에 먹힌 것 91건
- [연락처] 로 바뀌는 것 61건
순서를 바꾸면 되지 않나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
PHONE 을 먼저 돌리면 [연락처] 로 바뀌고, 그다음 CONTACT 가 그 문장을 지웁니다 — 결과가 똑같습니다.
진짜로 바꾸려면 CONTACT 규칙 자체를 포기해야 합니다. 그러면 「문자주세요」류 문장이 남고, 거기 있던 다른 개인정보(아이디 · 오픈채팅 링크)도 같이 남습니다.
🔴 더 안전한 쪽과 더 많이 남기는 쪽의 거래입니다. 우리는 안전한 쪽을 골랐고, 그 대가가 「61건만 되돌릴 수 있다」입니다. 되돌릴 수 없다는 것을 아는 것까지가 이 시간의 몫입니다.
del try_split.py
여기부터 후반 — 이름
전화 · 메일 · 카톡 · 주소는 잡았습니다. 이름이 남았습니다.
7교시에서 「이름은 셀 수가 없다」고 물음표를 남겨 뒀습니다. 그 물음표를 풉니다. 그리고 이 후반부에서 정규식을 포기합니다.
7. 정규식으로 해 보면
NAME = re.compile(r"[가-힣]{3}") # 한글 세 글자
- 김서연 박은수 최지은 <- 잡고 싶은 것
- 수분크림 진정세럼 각질제거 <- 제품 얘기
- 좋아요 괜찮아 재구매 <- 그냥 말
한국어 문장에서 연속한 세 글자는 거의 전부 걸립니다. 쓸 수 없습니다. 성씨로 좁혀도 마찬가지입니다.
NAME = re.compile(r"[김이박최정강조윤장임][가-힣]{2}")
잡히는 것
의도한 것
- 김서연 · 박은수 · 최지은
- 성씨가 맞으면 대체로 잡는다
같이 잡히는 것
오탐
- 최고예요 · 정말로 · 조금씩
- 박하향 · 임신중
- 후기에서 제일 흔한 말들이다
8. 밖에서 가져옵니다 — 우리는 이미 갖고 있습니다
고객 이름은 DB 에 있습니다충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
customers 표에 name 컬럼이 있습니다. 우리 고객이 300명이고, 그 사람들 이름을 우리가 압니다.
후기를 쓴 사람도 그 300명 중 하나입니다. 그러니 「후기에 우리 고객 이름이 있으면 가린다」로 문제를 바꿀 수 있습니다.
🔴 「모든 한국인 이름」이라는 무한한 문제를, 「우리 고객 213명」이라는 유한한 문제로 바꿨습니다.
그런데 이 방식이 얼마나 잡고 얼마나 놓치는지 먼저 잽니다. 코드를 고치기 전에요.
import re
from app.core.db import query
reviews = [r for (r,) in query(
"SELECT review FROM purchases WHERE review IS NOT NULL AND review != ''")]
names = [n for (n,) in query("SELECT DISTINCT name FROM customers")]
# ① 사전이 잡은 것
hit = sum(1 for t in reviews if any(n in t for n in names))
print(f" 사전({len(names)}개)이 잡은 후기 {hit:>4}건")
# ② 자 ― 이름은 보통 호칭과 같이 나온다. 그걸로 거칠게 센다
NAMELIKE = re.compile(r"([가-힣]{2,4})\s*(?:고객님|님|씨)")
outside, examples = 0, []
for t in reviews:
for m in NAMELIKE.finditer(t):
if m.group(1) not in names: # 사전에 없는 이름이 호칭과 함께 나왔다
outside += 1
examples.append(m.group(0))
break
print(f" '...님/씨' 인데 사전에 없는 후기 {outside:>4}건")
print(f" 예: {examples[:6]}")
# ③ 오탐 ― 고객 이름이 상품명에 섞여 있나
prods = [n for (n,) in query("SELECT name FROM products")]
false_hits = [n for n in names if any(n in p for p in prods)]
print(f"\n 상품명에 섞인 고객 이름(오탐 후보) {len(false_hits)}개")
# ④ 정렬이 실제로 일하나 ― 다른 이름의 앞부분인 이름이 있나
pairs = [(a, b) for a in names for b in names if a != b and b.startswith(a)]
print(f" 다른 이름의 앞부분인 이름 {len(pairs)}쌍")
python try_names.py
- 사전(213개)이 잡은 후기 184건
- '...님/씨' 인데 사전에 없는 후기 31건
- 예: ['황유진님', '최지은님', '신재민님', '안은수님', '장태윤님', '송재민님']
- 상품명에 섞인 고객 이름(오탐 후보) 0개
- 다른 이름의 앞부분인 이름 0쌍
del try_names.py
9. app/domain/masking.py — 이름을 더합니다
mask_text() 한 함수만 바뀝니다. 인자 하나와 반복문 셋이 늘고, 맨 위 독스트링에
한계를 적습니다.
"""개인정보 마스킹 ― 규칙만 있는 자리. **DB 를 모른다.**
순서를 이렇게 잡는다.
1) 안 꺼낸다 전화 · 메일 컬럼은 SELECT 에서 뺀다. 제일 확실하고 제일 싸다
2) 가린다 후기처럼 뜻이 필요해서 뺄 수 없는 글만 정규식 · 대조로 처리
3) 남는 걸 적는다 못 잡는 게 있다. 못 잡는다는 걸 아는 것과 모르는 것은 다르다
🔴 이름 사전 방식의 한계 ― 우리 고객이 아닌 사람 이름은 못 잡는다.
호칭이 붙은 것만 세어 **최소 31건**이 남는다 (호칭 없는 건 세지도 못했다).
여기서는 **지운다.** 지우면 못 되돌린다 ― 그래서 나가는 글에만 쓰고,
화면에 보여 줄 원문은 따로 들고 있는다.
"""
import re
# 🔴 010-1234-5678 · 010 1234 5678 · 01012345678 을 다 잡는다.
# 하이픈만 잡는 정규식은 152건 중 42건만 잡았다 (실측)
PHONE = re.compile(r"01[016-9][-.\s]?\d{3,4}[-.\s]?\d{4}")
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]{2,}")
# 🔴 카톡 아이디는 모양이 정해져 있지 않다. 아이디를 잡는 대신 그 문장을 통째로 버린다
KAKAO = re.compile(r"[^.!?]*카톡[^.!?]*[.!?]?")
CONTACT = re.compile(r"[^.!?]*(문자|연락|공구|디엠|DM)[^.!?]*[.!?]?")
def build_address_pattern(cities):
"""도시 목록으로 주소 정규식을 만든다. 목록이 비면 None 을 준다.
'천안 일산동' 처럼 도시 + 동네로 나온다.
"""
if not cities:
return None
# 🔴 긴 도시 이름부터 시도한다. 짧은 게 먼저 먹으면 '천안'이 '천안시'를 조각낸다
ordered = sorted(set(cities), key=len, reverse=True)
return re.compile(r"(?:%s)(?:\s?[가-힣]+(?:동|구|읍|면|로|길))?" % "|".join(ordered))
def _tidy(text):
"""공백 정리 ― 가리고 나면 빈칸이 여러 개 생긴다."""
return re.sub(r"\s{2,}", " ", text).strip()
def mask_text(text, *, names=(), address=None):
"""지운다. 되돌릴 수 없다 ― 나가는 글에만 쓴다.
names 가릴 이름 목록. 안 주면 이름은 안 가린다
address build_address_pattern() 이 만든 정규식. 안 주면 주소는 안 가린다
"""
if not text:
return text
text = KAKAO.sub(" ", text) # 먼저 문장을 버리고
text = CONTACT.sub(" ", text)
text = PHONE.sub("[연락처]", text) # 남은 것에서 조각을 가린다
text = EMAIL.sub("[메일]", text)
if address is not None:
text = address.sub("[주소]", text)
# 🔴 긴 이름부터 지운다 (짧은 게 먼저 먹으면 조각이 남는다)
for name in sorted(set(names), key=len, reverse=True):
if name in text:
text = text.replace(name, "[이름]")
return _tidy(text)
이 파일에서 기억할 것
names=()의 기본값이 빈 튜플입니다. 안 주면 이름은 안 가리니masking.mask_text("...")만 불러도 안 터집니다. 9교시에 시험을 쓸 때 이 성질이 일합니다. 그리고 기본값으로[](빈 리스트) 대신()(빈 튜플)을 쓴 이유 — 리스트는 바꿀 수 있어서 기본값으로 쓰면 파이썬의 유명한 함정에 걸립니다.- 긴 이름부터 지웁니다. 사전에 있는 한 이름이 다른 이름의 앞부분일 때 문제가 됩니다. 7교시의 도시 사전에서도 같은 정렬을 했습니다 — 부분이 전체를 먹는 문제는 사전 방식의 단골입니다.
- 한계를 독스트링에 적었습니다. 파일을 여는 사람이 제일 먼저 보는 자리입니다. 이게 오늘의 진짜 주제이고, 11절에서 다시 봅니다.
정렬이 왜 필요한지는 두 줄이면 보입니다.
>>> t = "박은수 고객님"
>>> for name in ["박은", "박은수"]: # 짧은 것부터
... t = t.replace(name, "[이름]") if name in t else t
>>> t
'[이름]수 고객님' # 🔴 「수」 한 글자가 남는다
>>> t = "박은수 고객님"
>>> for name in sorted(["박은", "박은수"], key=len, reverse=True):
... t = t.replace(name, "[이름]") if name in t else t
>>> t
'[이름] 고객님'
10. app/features/privacy.py — 이름 사전을 읽어 넘깁니다
"""마스킹 조립 ― 규칙(domain)에 사전(DB)을 붙여 주는 자리.
앱의 다른 코드는 여기 mask_text() 하나만 부른다.
혼자 돌려볼 수 있다: python -m app.features.privacy
"""
from app.core.db import query
from app.domain import masking
_names = None # None = 아직 안 읽었다는 표시. 빈 목록([])과 구분해야 한다
_address = None
def _load():
"""사전을 한 번만 읽어 둔다.
🔴 파일 맨 위에서 읽지 않는다. 그러면 이 파일을 import 하는 것만으로
DB 가 있어야 한다 ― 9교시에 시험을 붙일 때 그게 문제가 된다.
"""
global _names, _address
if _names is not None:
return
# 🔴 이름 사전은 고객 표에서 그때그때 만든다. 하드코딩하면 고객이 늘 때 조용히 새어 나간다
_names = [name for (name,) in query("SELECT DISTINCT name FROM customers")]
cities = [city for (city,) in query("SELECT DISTINCT city FROM customers")]
_address = masking.build_address_pattern(cities)
def mask_text(text):
"""지운다 ― 앱이 부르는 것은 이 함수다."""
_load()
return masking.mask_text(text, names=_names, address=_address)
__all__ = ["mask_text"]
if __name__ == "__main__":
import sys
sys.stdout.reconfigure(errors="replace")
_load()
reviews = [r for (r,) in query(
"SELECT review FROM purchases WHERE review IS NOT NULL AND review != ''")]
print(f"후기 {len(reviews):,}건 · 이름 사전 {len(_names)}개\n")
# 왼쪽은 '자'(정규식과 무관하게 센 것), 오른쪽은 우리 정규식이 잡은 것
print(f" {'무엇':10s} {'들어 있는 후기':>12s} {'정규식이 잡은 것':>14s}")
checks = (
("전화", lambda t: "010" in t.replace("-", "").replace(" ", ""), masking.PHONE),
("메일", lambda t: "@" in t, masking.EMAIL),
("카톡", lambda t: "카톡" in t, masking.KAKAO),
("주소", lambda t: _address.search(t) is not None, _address),
)
for label, has, pattern in checks:
rough = sum(1 for t in reviews if has(t))
caught = sum(1 for t in reviews if pattern.search(t))
print(f" {label:10s} {rough:>12,} {caught:>14,}")
# 🔴 이름은 왼쪽 칸이 물음표다 ― 「원래 몇 건인지」를 셀 자가 없다
hit_name = sum(1 for t in reviews if any(n in t for n in _names))
print(f" {'이름':10s} {'?':>12s} {hit_name:>14,} <- 사전에 있는 이름만 잡힌다")
# 🔴 두 번째 채점 ― 마스킹한 결과를 다시 검사한다
leaked = [t for t in reviews if "010" in mask_text(t).replace("-", "").replace(" ", "")]
print(f"\n 마스킹 뒤에도 010 이 남은 후기: {len(leaked)}건")
sample = next(t for t in reviews if masking.PHONE.search(t))
print(f"\n 전: {sample}")
print(f" 후: {mask_text(sample)}")
이 파일에서 기억할 것
- 사전을 하드코딩하면 조용히 샙니다.
NAMES = ["김서연", ...]라고 적어 두면 오늘은 잘 돕니다. 그런데 다음 주에 회원이 50명 늘면 그 50명 이름은 안 가려집니다. 오류도 안 나고 시험도 통과합니다 — 1교시에 본 그 종류의 고장입니다. _names로 검사합니다. 둘은 항상 같이 읽히니 하나만 봐도 됩니다.- 채점표의 이름 줄은 왼쪽이 물음표입니다. 「원래 몇 건인지」를 셀 자가 없다는 것을 표에 그대로 남깁니다.
python -m app.features.privacy
- 후기 1,500건 · 이름 사전 213개
- 무엇 들어 있는 후기 정규식이 잡은 것
- 전화 152 152
- 메일 46 46
- 카톡 49 49
- 주소 100 100
- 이름 ? 184 <- 사전에 있는 이름만 잡힌다
- 마스킹 뒤에도 010 이 남은 후기: 0건
11. 못 잡는 것을 어떻게 적을 것인가
여기가 오늘의 진짜 주제입니다.
안 적는다
제일 흔한 선택
- 「이름 마스킹 구현 완료」라고 적는다
- 다음 사람은 다 가려진다고 믿는다
- 🔴 사고가 나면 「된다고 했잖아요」가 된다
적는다
이걸 한다
- 「사전 방식 · 최소 31건 잔존」이라고 적는다
- 다음 사람이 그걸 알고 결정한다
- 9교시에서 시험으로도 적는다
9절에서 붙여넣은 masking.py 맨 위 독스트링에 그 문장이 이미 들어 있습니다.
파일을 여는 사람이 제일 먼저 보는 자리라서 거기 적었습니다.
🔴 그럼 31건은 그냥 두나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
지금은 둡니다. 그리고 그게 무책임한 게 아닌 이유는, 어디까지 새는지 알고 두는 것이기 때문입니다.
더 막고 싶으면 방법이 있습니다 — 한국인 이름 사전을 사 오거나, 개체명 인식(NER) 모델을 붙이거나, 호칭 앞 2~4글자를 무조건 가립니다. 전부 비용과 오탐이 늡니다.
그 결정은 「31건이 얼마나 위험한가」에 달렸습니다. 그건 코드가 아니라 사업이 정합니다. 우리가 할 일은 31 이라는 숫자를 손에 쥐여 주는 것입니다.
숫자 없이 「좀 새요」라고 말하면 아무도 결정할 수 없습니다.
이 시간에 한 것
| 주제 | 핵심 |
|---|---|
| 🔴 하이픈만 잡으면 | 152건 중 42건. 사람은 전화번호를 한 가지 모양으로 안 쓴다 (공백 61 · 붙여 씀 49) |
| 고친 자리 | [-.\s]? ― 구분자를 선택으로. 이 한 조각이 42 를 152 로 만들었다 |
| 메일 | RFC 규격을 다 맞추지 않는다. 정확도를 어디까지 올릴지는 목적이 정한다 |
| 🔴 카톡 아이디 | 모양을 못 정한다 → 문장을 통째로 버린다. 못 잡으면 더 큰 단위로 올라간다 |
[^.!?]* | 「문장 하나」를 뜻하는 관용구. 마침표가 아닌 글자의 연속 |
| 같은 이름 둘 | masking.mask_text = 규칙(사전을 받는다) · privacy.mask_text = 조립(사전을 끼워 준다) |
| 채점 | 전화 152/152 · 메일 46/46 · 카톡 49/49 · 주소 100/100 |
| 두 번째 채점 | 최종 결과물을 다시 검사한다 — 마스킹 뒤 010 잔존 0건 |
| 🔴 152 가 두 갈래 | 91건은 문장째 사라지고 61건만 `[연락처]`. 표에는 안 보인다 |
| 읽는 법 | 숫자가 맞는다고 이해한 게 아니다. 안을 열어 봐야 안다 |
| 주제 | 핵심 |
|---|---|
| 🔴 이름에는 모양이 없다 | 「최고예요」와 「최유진」을 글자만 보고 가르는 규칙은 없다 |
| 정규식으로 안 되는 이유 | 정보가 글자 안에 없다. 더 잘 짜서 풀리는 문제가 아니다 |
| 문제를 바꾼다 | 「모든 한국인 이름」(무한) → 「우리 고객 213명」(유한) |
| 사전을 DB 에서 | 하드코딩하면 회원이 늘 때 오류 없이 샌다 |
| 213 vs 300 | DISTINCT ― 동명이인이 있다. 가리는 데는 문제없다 |
| 긴 것부터 | 짧은 게 먼저 먹으면 「수」 한 글자가 남는다. 사전 방식의 단골 문제 |
| 실측 | 184건 잡음 · 오탐 후보 0개 · 겹치는 이름 0쌍 |
| 🔴 못 잡는 것 | 호칭이 붙은 것만 세어 최소 31건. 호칭 없는 건 세지도 못한다 |
| 🔴 31 은 하한선 | 「0건」이 아니라 「최소 31, 상한 모름」이라고 적는다 |
| 어디에 적나 | 파일 맨 위 독스트링. 여는 사람이 제일 먼저 보는 자리다 |
| 왜 적나 | 숫자 없이는 아무도 결정할 수 없다. 더 막을지는 코드가 아니라 사업이 정한다 |
다음 시간에 할 것
규칙 다섯을 만들었습니다 — 전화 · 메일 · 카톡 · 주소 · 이름. 그리고 전부 실측으로 확인했습니다.
그런데 지금까지 확인한 방법은 터미널에 찍어서 눈으로 보는 것이었습니다. 규칙을 한 줄 고칠 때마다 1,500건을 다시 눈으로 볼 수는 없습니다.
다음 시간에 그걸 기계에 맡깁니다.