학습 자료

정규식으로 지운다, 그리고 이름 앞에서 무너진다


Article

세는 건 끝났습니다. 전화 152 · 주소 100 · 카톡 49 · 메일 46.

이제 지웁니다. 그리고 지우고 나서 다시 세서 채점합니다.


1. 이번 시간에 손대는 것

새 파일은 없습니다. 7교시에 만든 둘이 자랍니다.

app/

지금 (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 를 뿌리에 만듭니다. 다 보고 지울 파일입니다.

실습용 · 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건의 구분자
공백61건
붙여 씀49건
하이픈42건

하이픈이 오히려 소수입니다. 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() 은 그대로이고, 정규식 넷과 함수 둘이 늘었습니다.

app/domain/masking.py
"""개인정보 마스킹 ― 규칙만 있는 자리. **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() 와 채점표가 늘었습니다.

app/features/privacy.py
"""마스킹 조립 ― 규칙(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」가 숨기고 있던 것

전화번호가 [연락처] 로 안 바뀌었습니다. 문장이 통째로 사라졌습니다.

왜 그런가 — 순서 때문이다
  1. 「...문자주세요.」「문자」가 들어 있다
  2. CONTACT 가 먼저 돈다문장을 통째로 지운다
  3. PHONE 이 볼 게 없다전화번호는 이미 사라졌다

어떤 후기는 [연락처] 로 바뀝니다. 「문자·연락·공구·카톡」이 안 든 문장이면 그렇습니다.

이쪽은 치환된다
  • 전: 기대가 컸나 봐요. 산뜻한 제형이라 아침에 쓰기 좋아요. 재고 문의는 010 8202 9049 로 부탁드려요.
  • 후: 기대가 컸나 봐요. 산뜻한 제형이라 아침에 쓰기 좋아요. 재고 문의는 [연락처] 로 부탁드려요.

몇 건씩인지 셉니다. try_split.py 를 만듭니다.

실습용 · 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건
전화번호 152건이 실제로 처리되는 경로
문장째 사라짐 (CONTACT·KAKAO)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명」이라는 유한한 문제로 바꿨습니다.

그런데 이 방식이 얼마나 잡고 얼마나 놓치는지 먼저 잽니다. 코드를 고치기 전에요.

실습용 · try_names.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 != ''")]
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() 한 함수만 바뀝니다. 인자 하나와 반복문 셋이 늘고, 맨 위 독스트링에 한계를 적습니다.

app/domain/masking.py
"""개인정보 마스킹 ― 규칙만 있는 자리. **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 — 이름 사전을 읽어 넘깁니다

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건을 어떻게 다룰까

안 적는다

제일 흔한 선택

  • 「이름 마스킹 구현 완료」라고 적는다
  • 다음 사람은 다 가려진다고 믿는다
  • 🔴 사고가 나면 「된다고 했잖아요」가 된다

적는다

이걸 한다

  • 「사전 방식 · 최소 31건 잔존」이라고 적는다
  • 다음 사람이 그걸 알고 결정한다
  • 9교시에서 시험으로도 적는다

9절에서 붙여넣은 masking.py 맨 위 독스트링에 그 문장이 이미 들어 있습니다. 파일을 여는 사람이 제일 먼저 보는 자리라서 거기 적었습니다.

🔴 그럼 31건은 그냥 두나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

지금은 둡니다. 그리고 그게 무책임한 게 아닌 이유는, 어디까지 새는지 알고 두는 것이기 때문입니다.

더 막고 싶으면 방법이 있습니다 — 한국인 이름 사전을 사 오거나, 개체명 인식(NER) 모델을 붙이거나, 호칭 앞 2~4글자를 무조건 가립니다. 전부 비용과 오탐이 늡니다.

그 결정은 「31건이 얼마나 위험한가」에 달렸습니다. 그건 코드가 아니라 사업이 정합니다. 우리가 할 일은 31 이라는 숫자를 손에 쥐여 주는 것입니다.

숫자 없이 「좀 새요」라고 말하면 아무도 결정할 수 없습니다.


이 시간에 한 것

8교시 요점 ― 전반 (전화 · 메일 · 카톡 · 주소)
주제핵심
🔴 하이픈만 잡으면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건만 `[연락처]`. 표에는 안 보인다
읽는 법숫자가 맞는다고 이해한 게 아니다. 안을 열어 봐야 안다
8교시 요점 ― 후반 (이름)
주제핵심
🔴 이름에는 모양이 없다「최고예요」와 「최유진」을 글자만 보고 가르는 규칙은 없다
정규식으로 안 되는 이유정보가 글자 안에 없다. 더 잘 짜서 풀리는 문제가 아니다
문제를 바꾼다「모든 한국인 이름」(무한) → 「우리 고객 213명」(유한)
사전을 DB 에서하드코딩하면 회원이 늘 때 오류 없이 샌다
213 vs 300DISTINCT ― 동명이인이 있다. 가리는 데는 문제없다
긴 것부터짧은 게 먼저 먹으면 「수」 한 글자가 남는다. 사전 방식의 단골 문제
실측184건 잡음 · 오탐 후보 0개 · 겹치는 이름 0쌍
🔴 못 잡는 것호칭이 붙은 것만 세어 최소 31건. 호칭 없는 건 세지도 못한다
🔴 31 은 하한선「0건」이 아니라 「최소 31, 상한 모름」이라고 적는다
어디에 적나파일 맨 위 독스트링. 여는 사람이 제일 먼저 보는 자리다
왜 적나숫자 없이는 아무도 결정할 수 없다. 더 막을지는 코드가 아니라 사업이 정한다

다음 시간에 할 것

규칙 다섯을 만들었습니다 — 전화 · 메일 · 카톡 · 주소 · 이름. 그리고 전부 실측으로 확인했습니다.

그런데 지금까지 확인한 방법은 터미널에 찍어서 눈으로 보는 것이었습니다. 규칙을 한 줄 고칠 때마다 1,500건을 다시 눈으로 볼 수는 없습니다.

다음 시간에 그걸 기계에 맡깁니다.

Share
  • 파이썬
  • 정규식
  • 개인정보