학습 자료

후기 1,500건에 뭐가 들어 있나


Article

4교시에 이 코드를 쳤습니다.

발췌 · pipeline/03_embed.py
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. 실제로 이렇게 생겼습니다

purchases.review 중 몇 건
  • 가격 생각하면 이 정도는 하는 것 같아요. 중성인데 자극 없이 잘 썼어요.
  • 궁금하신 분은 010-7600-2955으로 문자주세요.
  • 기대가 컸나 봐요. 산뜻한 제형이라 아침에 쓰기 좋아요.
  • 재고 문의는 010 8202 9049 로 부탁드려요.

2. 이번 시간에 만드는 것

app/

지금 (6교시가 끝난 상태)

두 칸

  • core/ — config · db
  • features/ — retrieve
  • domain/ — 아직 없다

7교시가 끝나면

세 칸이 된다

  • domain/masking.py — 새 파일 · 규칙만. DB 를 모른다
  • domain/__init__.py — 새 빈 파일
  • features/privacy.py — 새 파일 · DB 를 읽어 규칙에 넣어 준다
  • core/ · features/retrieve.py 는 안 건드린다

1교시에 「domain/ 은 넣을 것이 생기는 날 만든다」고 했습니다. 오늘이 그날입니다.

두 파일이 왜 둘인지가 이 시간의 절반입니다.


3. 제일 먼저 할 일은 지우는 게 아닙니다

순서를 이렇게 잡는다
  1. 01

    ① 안 꺼낸다

    필요 없는 컬럼은 SELECT 에서 뺍니다. 제일 확실하고 제일 쌉니다 ― 코드가 한 글자도 안 늘어납니다

  2. 02

    ② 가린다

    후기처럼 뜻이 필요해서 통째로 뺄 수 없는 글만 정규식·대조로 처리합니다

  3. 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교시에 만든 대시보드 쿼리를 봅니다.

발췌 · app/features/retrieve.py
    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 를 뿌리에 만듭니다. 다 보고 지울 파일입니다.

실습용 · 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
app/domain/masking.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 — 사전을 읽어다 주는 자리

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건
후기 1,500건 중 개인정보가 든 것
전화번호152건
주소(도시)100건
카톡 아이디49건
이메일46건

7. 이름은 어떻게 세나

넷을 셌습니다. 그런데 이름이 빠졌습니다.


이 시간에 한 것

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 가 있어야 한다
🔴 이름은 셀 수가 없다「김서연」이 사람인지 제품인지 글자만 봐선 모른다. 물음표로 남긴다

다음 시간에 할 것

셌으니 이제 지웁니다. 전화 · 메일 · 카톡 세 가지를 정규식으로 잡습니다.

그런데 전화번호부터 걸립니다.

Share
  • 파이썬
  • 개인정보
  • 정규식
  • SQL
후기 1,500건에 뭐가 들어 있나 — 디코드랩(DCODELAB)