후기 1,500건에 뭐가 들어 있나
Article
4교시에 이 코드를 쳤습니다.
r = con.execute("""
SELECT purchase_id, review FROM purchases
WHERE is_holdout = 0 AND review IS NOT NULL AND review != ''
ORDER BY purchase_id
""").fetchall()
targets["review"] = ("purchase_id", "purchases", [x[0] for x in r], [x[1] for x in r])
후기를 그대로 벡터로 만들었습니다. review_vectors 1,200행입니다.
그 후기가 어떻게 생겼는지 봅니다.
1. 실제로 이렇게 생겼습니다
- 가격 생각하면 이 정도는 하는 것 같아요. 중성인데 자극 없이 잘 썼어요.
- 궁금하신 분은 010-7600-2955으로 문자주세요.
- 기대가 컸나 봐요. 산뜻한 제형이라 아침에 쓰기 좋아요.
- 재고 문의는 010 8202 9049 로 부탁드려요.
2. 이번 시간에 만드는 것
지금 (6교시가 끝난 상태)
두 칸
core/— config · dbfeatures/— retrievedomain/— 아직 없다
7교시가 끝나면
세 칸이 된다
domain/masking.py— 새 파일 · 규칙만. DB 를 모른다domain/__init__.py— 새 빈 파일features/privacy.py— 새 파일 · DB 를 읽어 규칙에 넣어 준다core/·features/retrieve.py는 안 건드린다
1교시에 「domain/ 은 넣을 것이 생기는 날 만든다」고 했습니다. 오늘이 그날입니다.
두 파일이 왜 둘인지가 이 시간의 절반입니다.
3. 제일 먼저 할 일은 지우는 게 아닙니다
- 01
① 안 꺼낸다
필요 없는 컬럼은
SELECT에서 뺍니다. 제일 확실하고 제일 쌉니다 ― 코드가 한 글자도 안 늘어납니다 - 02
② 가린다
후기처럼 뜻이 필요해서 통째로 뺄 수 없는 글만 정규식·대조로 처리합니다
- 03
③ 남는 걸 적는다
정규식으로 못 잡는 게 반드시 있습니다. 못 잡는다는 걸 아는 것과 모르는 것은 다릅니다
customers 표에 어떤 컬럼이 있는지 봅니다.
python -c "import sqlite3; print([r[1] for r in sqlite3.connect('cosmetic.db').execute('PRAGMA table_info(customers)')])"
- ['customer_id', 'name', 'age', 'gender', 'skin_type', 'city',
- 'phone', 'email']
phone 과 email 이 있습니다. 그런데 1일차 6교시에 만든 대시보드 쿼리를 봅니다.
profile = dicts("""
SELECT customer_id, name, age, gender, skin_type, city
FROM customers WHERE customer_id = ?
""", (customer_id,))
컬럼 이름을 하나씩 적어 뒀습니다. phone 과 email 은 거기 없습니다.
SELECT * 와 컬럼을 적는 것의 차이SELECT *
안 쓴다
- 전화 · 메일이 자동으로 따라온다
- 나중에 컬럼이 늘면 그것도 자동으로 따라온다
- 가리는 코드를 아무리 잘 짜도 여기서 새면 소용없다
컬럼을 적는다
이렇게 한다
- 꺼낼 것만 꺼낸다
- 컬럼이 늘어도 이 쿼리는 안 바뀐다
- 가릴 코드가 아예 필요 없다
🔴 제일 좋은 마스킹은 마스킹을 안 하는 것입니다충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
가리는 코드는 틀릴 수 있습니다. 정규식이 못 잡는 모양이 있고, 사전에 없는 이름이 있습니다. 오늘 오후 내내 그 얘기를 합니다.
그런데 애초에 안 꺼내면 틀릴 일이 없습니다. 코드도 안 늘고, 시험할 것도 없고, 성능도 좋습니다.
그래서 「어떻게 가릴까」보다 「이걸 꼭 꺼내야 하나」를 먼저 묻습니다. 실무에서 이 질문 하나로 절반이 사라집니다.
그런데 후기는 못 뺍니다
review 컬럼을 통째로 빼면 되지 않나요? 안 됩니다.
| 어디서 | 무엇에 쓰나 |
|---|---|
오늘 4교시 review_vectors | 「비슷한 불만을 쓴 후기」를 찾는 재료다. 1,200행이 통째로 이것이다 |
| 4일차 추천 이유 | 「지난번 진정 세럼에 만족하셨으니」 같은 말을 쓰려면 후기가 필요하다 |
| 6일차 화면 | 관리자가 고객을 이해하는 화면이다. 후기가 없으면 볼 게 없다 |
뜻이 필요해서 뺄 수 없는 글. 여기가 ② 「가린다」가 필요한 자리입니다.
4. 주소부터 재 봅니다
전화 · 메일 · 카톡은 특징이 뚜렷합니다. 010 · @ · 카톡 이라는 글자를 보면 됩니다.
주소는 그렇지 않습니다. 흔히 쓰는 방식은 이렇습니다.
ADDRESS = re.compile(r"[가-힣]+시|[가-힣]+구|[가-힣]+동") # 인터넷에서 가져온 모양
정말 잘 도는지 재 봅니다. try_address.py 를 뿌리에 만듭니다. 다 보고 지울 파일입니다.
import re
from collections import Counter
from app.core.db import query
reviews = [r for (r,) in query(
"SELECT review FROM purchases WHERE review IS NOT NULL AND review != ''")]
# ① 흔히 쓰는 방식
NAIVE = re.compile(r"[가-힣]+시|[가-힣]+구|[가-힣]+동")
# ② 우리 데이터에서 만든 사전 ― 고객이 실제로 사는 도시만
cities = sorted({c for (c,) in query("SELECT DISTINCT city FROM customers")})
DICT = re.compile("|".join(sorted(cities, key=len, reverse=True)))
print(f"고객이 사는 도시 {len(cities)}개: {cities}\n")
a = [t for t in reviews if NAIVE.search(t)]
b = [t for t in reviews if DICT.search(t)]
print(f" 순진한 정규식이 잡은 후기 {len(a):>5}건")
print(f" 도시 사전이 잡은 후기 {len(b):>5}건\n")
# 순진한 쪽만 잡은 것 = 오탐. 무엇을 잡았는지 세어 본다
only_naive = [t for t in a if not DICT.search(t)]
print(f" 순진한 쪽만 잡은 후기(오탐) {len(only_naive):>5}건")
counted = Counter(m for t in only_naive for m in NAIVE.findall(t))
print(f" 그 오탐의 정체: {counted.most_common(5)}\n")
# 반대쪽 ― 사전은 잡는데 순진한 쪽이 놓친 것
only_dict = [t for t in b if not NAIVE.search(t)]
print(f" 사전은 잡고 순진한 쪽은 놓친 후기 {len(only_dict):>3}건")
print(f" 예: {[DICT.search(t).group() for t in only_dict[:6]]}")
python try_address.py
- 고객이 사는 도시 12개: ['고양', '광주', '대구', '대전', '부산', '서울', '성남', '수원', '용인', '인천', '천안', '청주']
- 순진한 정규식이 잡은 후기 546건
- 도시 사전이 잡은 후기 100건
- 순진한 쪽만 잡은 후기(오탐) 478건
- 그 오탐의 정체: [('재구', 245), ('친구', 205), ('공구', 49), ('원하시', 49), ('정시', 3)]
- 사전은 잡고 순진한 쪽은 놓친 후기 32건
- 예: ['고양', '서울', '대전', '성남', '천안', '대전']
try_address.py 는 여기서 지웁니다.
del try_address.py
5. app/domain/masking.py — 규칙만 두는 자리
새 폴더 app/domain/ 을 만들고 __init__.py 를 빈 파일로 하나 넣습니다 (1교시와 같습니다).
mkdir app\domain
type nul > app\domain\__init__.py
"""개인정보 마스킹 ― 규칙만 있는 자리. **DB 를 모른다.**
왜 DB 를 모르게 만드나
이 파일에는 「어떤 글자를 어떻게 가리나」라는 규칙만 둔다. 그 규칙을 시험하려면
후기 한 줄만 있으면 된다 ― 데이터베이스도, 파일도, 인터넷도 필요 없다.
도시 사전은 DB 에서 와야 한다. 그건 이 파일이 아니라
app/features/privacy.py 가 읽어서 **인자로 넣어 준다.**
"""
import re
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))
이 파일에서 기억할 것
import re말고는 아무것도 안 씁니다. DB 도, 설정도 모릅니다. 그래서 이 파일은 후기 한 줄만 있으면 시험할 수 있습니다 — 9교시에 이 성질을 씁니다.- 도시 목록을 인자로 받습니다. 사전을 어디서 읽어 오는지는 이 파일의 관심사가 아닙니다. 1교시에 말한 의존성 주입이 이 모양입니다.
- 긴 것부터 정렬합니다. 정규식의
|는 왼쪽부터 시도하고 처음 맞는 것에서 멈춥니다. 「천안」이 「천안시」보다 먼저 시도되면 「시」 한 글자가 덩그러니 남습니다.
「도시 이름 + 동네」 로 만든 이유충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
(?:성남|천안|서울|...) 다음에 (?:\s?[가-힣]+(?:동|구|읍|면|로|길))? 를 선택적으로 붙였습니다.
「성남」만 있어도 잡고, 「성남 정자동」이면 통째로 잡습니다. 뒤에 ? 가 붙어서 없어도 된다는 뜻입니다.
🔴 사전은 우리 데이터에서 만듭니다. 전국 주소 목록을 가져오는 게 아니라 customers.city 에 실제로 있는 12개만 씁니다. 그래서 오탐이 안 납니다.
6. app/features/privacy.py — 사전을 읽어다 주는 자리
"""마스킹 조립 ― 규칙(domain)에 사전(DB)을 붙여 주는 자리.
이번 시간에는 「센다」까지만 한다. 가리는 일은 다음 시간이다.
혼자 돌려볼 수 있다: 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)
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")
# 🔴 정규식이 아니다. 이건 「찾는 코드」가 아니라 「자」다
checks = (
("전화", lambda t: "010" in t.replace("-", "").replace(" ", "")),
("메일", lambda t: "@" in t),
("카톡", lambda t: "카톡" in t),
("주소", lambda t: _address.search(t) is not None),
)
for label, has in checks:
print(f" {label:6s} {sum(1 for t in reviews if has(t)):>6,}건")
이 파일에서 기억할 것
_load()가 왜 함수인가.cities = query(...)를 파일 맨 위에 그냥 두면 이 파일을import하는 순간 DB 를 읽습니다. 그러면 DB 를 아직 안 만든 상태에서는 import 자체가 터집니다. 9교시에 시험을 붙일 때 바로 문제가 됩니다 — 시험은 DB 없이 돌아야 합니다.- 세는 코드가 정규식이 아닙니다. 이건 「찾는 코드」가 아니라 자입니다. 정규식으로 세고 정규식으로 잡으면 언제나 100% 가 나옵니다 — 자기가 자기를 채점하는 꼴입니다. 그래서 거칠지만 독립적인 자를 씁니다.
- 다음 시간에 이 152 를 기준으로 정규식을 채점합니다.
python -m app.features.privacy
- 후기 1,500건
- 전화 152건
- 메일 46건
- 카톡 49건
- 주소 100건
7. 이름은 어떻게 세나
넷을 셌습니다. 그런데 이름이 빠졌습니다.
이 시간에 한 것
| 주제 | 핵심 |
|---|---|
| 왜 지금인가 | 4교시에 후기를 그대로 벡터로 만들었다 (review_vectors 1,200행). 벡터가 되면 되돌릴 수 없다 |
| 순서 | ① 안 꺼낸다 → ② 가린다 → ③ 남는 걸 적는다 |
| 🔴 제일 좋은 마스킹 | 마스킹을 안 하는 것. SELECT * 대신 컬럼을 적으면 틀릴 일이 없다 |
| 후기는 왜 못 빼나 | review_vectors 의 재료 · 추천 이유 · 화면. 뜻이 필요한 글이다 |
| 파일을 왜 둘로 | domain/masking.py = 규칙(사전을 받는다) · features/privacy.py = 조립(사전을 읽어다 준다) |
| 실측 | 전화 152 · 주소 100 · 카톡 49 · 메일 46 (1,500건 중) |
| 자를 따로 만든다 | 정규식으로 세고 정규식으로 잡으면 언제나 100%. 독립적인 자가 있어야 채점이 된다 |
| 🔴 주소 | [가-힣]+구 는 546건을 잡는데 478건이 오탐(재구·친구·공구) → 데이터에서 사전을 만든다 |
| 긴 것부터 | 정규식 | 는 왼쪽부터 시도하고 멈춘다. 짧은 게 먼저 먹으면 조각이 남는다 |
_load() | import 할 때가 아니라 처음 쓸 때 DB 를 읽는다. 안 그러면 import 만으로 DB 가 있어야 한다 |
| 🔴 이름은 셀 수가 없다 | 「김서연」이 사람인지 제품인지 글자만 봐선 모른다. 물음표로 남긴다 |
다음 시간에 할 것
셌으니 이제 지웁니다. 전화 · 메일 · 카톡 세 가지를 정규식으로 잡습니다.
그런데 전화번호부터 걸립니다.