9 분 소요

🔐 문서 속 개인정보, 탐지부터 평가까지 3부작 — 들어오는 문서에서 개인정보를 넓게 잡고 검증기로 확실히 거르는 탐지 게이트(1편), 잡은 값을 마스킹·가역 토큰화·봉투 암호화로 안전하게 다루는 뒷단(2편), 그리고 사람 확인 큐를 정답지 삼아 지표·임계값을 재고 되먹이는 평가 루프(3편)까지, "개인정보는 문 앞에서 거른다"는 한 목표로 이어집니다. 전체 3편.
  1. 들어오는 문서에서 개인정보를 걸러내는 Airflow util — 넓게 잡고, 확실히 거른다
  2. 탐지한 개인정보를 안전하게 다루기 — 마스킹, 가역 토큰화, 격리 저장
  3. 개인정보 게이트를 스스로 재게 하기 — 라벨·지표·임계값 스윕과 안 보이는 절반지금 글

Summary

이 3부작에서 1편은 문서에서 개인정보를 잡는 게이트를, 2편은 잡은 걸 다루는(마스킹·토큰화·격리) 뒷단을 만들었어요. 그런데 게이트를 한 번 만들어두면 끝일까요? 아니에요. 들어오는 문서 양식은 계속 바뀌고, 어제 잘 맞던 임계값이 오늘은 오탐을 쏟아내거나 진짜를 놓쳐요. 게이트는 스스로 좋아지지 않아요.

그래서 마지막 편은 게이트가 얼마나 잘 맞는지 스스로 재고, 그 결과로 자기를 고치는 평가 루프를 만듭니다. 다행히 정답지는 이미 있어요 — 1편에서 만든 사람 확인 큐 예요. 사람이 “이건 진짜(정탐), 이건 오탐” 하고 판정한 게 그대로 라벨이 되거든요.

💡 이 글에서 다루는 것

  • 라벨 수집 — 사람 확인 큐가 곧 정답지 (정탐/오탐)
  • 지표 — precision·recall·Fβ, 그리고 왜 β=2 (1편의 비대칭 비용)
  • 임계값 스윕 — recall 목표를 만족하는 가장 싼(검토 부하 최소) 지점 찾기
  • 안 보이는 절반pass 는 아무도 안 봐서 미탐이 안 잡힘 → 감사 샘플링 으로 누출률 추정
  • 종류별 드리프트 — 어느 탐지기가 새기 시작했나
  • 루프 닫기 — 확정 오탐을 allowlist·검증기로 되먹이고, 임계값은 사람 승인 후 반영
  • Airflow 스케줄 — 매일 재고 Slack 으로 리포트

예시 숫자·값은 전부 합성이에요.



1. 한 번 정한 임계값은 썩는다

1편에서 HIGH=0.85, LOW=0.4 를 손으로 박았어요. 그때는 맞았을지 몰라도, 그 숫자가 영원히 맞을 리가 없어요. 이유는 하나예요 — 들어오는 데이터가 변하거든요.

  • 거래처가 송장 양식을 바꿔서 16자리 관리번호가 새로 등장 → 카드 오탐 급증
  • 새 계약서 템플릿에 예시 주민번호가 박혀 들어옴 → 주민번호 오탐 급증
  • 반대로, 새로운 형식의 진짜 개인정보가 들어오는데 우리 규칙이 못 잡음 → 미탐

이런 변화(데이터 드리프트)를 재지 않으면 모릅니다. 게이트는 조용히 틀리기 시작하고, 조용해서 아무도 몰라요. 그래서 “잘 맞나” 를 숫자로 계속 재고, 그 숫자로 임계값을 다시 잡는 루프가 필요해요.



2. 라벨은 사람 확인 큐에서 공짜로 나온다

평가에는 정답지(라벨)가 필요해요. 새로 만들 필요 없어요 — 1편의 review 티어 가 매일 정답지를 찍어내고 있거든요. 검토자가 큐에 뜬 문서를 보고 판정하잖아요. 그 판정이 그대로 라벨이에요.

각 검토 항목은 이렇게 라벨링돼요.

from dataclasses import dataclass

@dataclass
class Label:
    kind: str      # "rrn", "card", ...
    score: float   # 게이트가 매겼던 confidence
    is_real: bool  # 사람 판정: 진짜 개인정보(True) / 오탐(False)

검토자가 “통과시킴”(오탐이었다) 하면 is_real=False, “차단 유지”(진짜였다) 하면 is_real=True. 게이트가 매겼던 score 를 같이 저장해두는 게 핵심이에요. 그래야 “점수 얼마짜리를 사람이 어떻게 판정했나” 를 나중에 분석 할 수 있거든요.

이렇게 며칠 모으면 (score, is_real) 쌍이 쌓여요. 이게 우리 평가셋입니다.



3. 지표 — precision, recall, 그리고 왜 Fβ(β=2)

라벨셋이 있으면 게이트를 채점할 수 있어요. 어떤 임계값 low 에서 점수가 low 이상이면 “플래그”(검토·차단), 미만이면 “통과” 예요. 네 칸으로 갈려요.

  • TP 진짜인데 플래그함 (잘 잡음)
  • FP 오탐인데 플래그함 (헛수고 — 검토 부하)
  • FN 진짜인데 통과시킴 (누출 — 제일 위험)
  • TN 오탐인데 통과시킴 (잘 흘려보냄)
def evaluate(labeled: list, low: float) -> dict:
    tp = sum(1 for s, real in labeled if real and s >= low)
    fn = sum(1 for s, real in labeled if real and s <  low)   # 놓친 진짜 = 누출
    fp = sum(1 for s, real in labeled if not real and s >= low)
    tn = sum(1 for s, real in labeled if not real and s <  low)
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall    = tp / (tp + fn) if tp + fn else 0.0
    beta = 2                                    # recall 을 precision 보다 무겁게
    denom = beta * beta * precision + recall
    fbeta = (1 + beta * beta) * precision * recall / denom if denom else 0.0
    return dict(low=low, tp=tp, fp=fp, fn=fn, tn=tn,
                precision=round(precision, 3), recall=round(recall, 3),
                fbeta=round(fbeta, 3), review_load=tp + fp)

며칠 치 검토 라벨을 넣어봅니다(합성 예시).

labeled = [(1.00, True), (0.95, True), (0.90, True), (0.70, True), (0.55, True), (0.45, True),
           (0.70, False), (0.50, False), (0.45, False), (0.40, False), (0.30, False), (0.20, False)]

for k, v in evaluate(labeled, 0.40).items():
    print(f"{k}: {v}")
low: 0.4
tp: 6
fp: 4
fn: 0
tn: 2
precision: 0.6
recall: 1.0
fbeta: 0.882
review_load: 10

여기서 왜 F1 이 아니라 Fβ(β=2) 냐가 중요해요. F1 은 precision 과 recall 을 똑같이 취급해요. 근데 1편에서 정했듯이 우리 세계에선 미탐(누출)이 오탐보다 훨씬 비싸요. β=2 는 recall 을 precision 보다 네 배 무겁게(β²) 쳐요. 그래서 “누출을 줄이는 쪽” 으로 임계값이 움직이게 돼요. 비용이 비대칭이면 지표도 비대칭이어야 해요.



4. 임계값 스윕 — 목표를 만족하는 가장 싼 지점

이제 low 를 여러 값으로 바꿔가며 재봐요. 목표는 명확해요 — recall 을 목표치(예: 1.0)로 지키면서, 검토 부하(review_load)는 최소로.

print(f"{'LOW':>5} {'recall':>7} {'leaks':>6} {'load':>5} {'prec':>6} {'Fβ':>6}")
for low in [0.30, 0.40, 0.50, 0.60]:
    r = evaluate(labeled, low)
    print(f"{low:>5} {r['recall']:>7} {r['fn']:>6} {r['review_load']:>5} {r['precision']:>6} {r['fbeta']:>6}")
  LOW  recall  leaks  load   prec     Fβ
  0.3     1.0      0    11  0.545  0.857
  0.4     1.0      0    10    0.6  0.882
  0.5   0.833      1     7  0.714  0.806
  0.6   0.667      2     5    0.8   0.69

이 표를 읽는 법이 이 글의 핵심이에요.

  • 0.5, 0.6 은 검토 부하는 낮지만 leaks(누출)가 생겨요. 0.5 는 점수 0.45짜리 진짜 개인정보 하나를 통과시켜요. 비대칭 비용 세계에선 이건 논외 예요. 부하가 낮다고 절대 여기로 가면 안 돼요.
  • 0.30.4 는 둘 다 recall 1.0(누출 0). 이 중에선 검토 부하가 더 낮은 0.4 를 골라요(load 10 < 11). Fβ 도 0.4 가 제일 높고요.

그래서 LOW=0.40. “recall 목표를 만족하는 것들 중 가장 싼 것” 이라는 규칙이 표에서 그대로 읽혀요.

HIGH(자동 차단 경계)는 다른 기준으로 잡아요. 자동 차단은 사람을 안 거치니까, 거기 걸린 게 오탐이면 멀쩡한 문서가 격리 돼요. 그래서 HIGH 는 “그 위로는 오탐이 거의 없는” 지점이어야 해요.

def block_precision(labeled, high):
    real = sum(1 for s, r in labeled if r and s >= high)
    tot  = sum(1 for s, r in labeled if s >= high)
    return real, tot, (round(real / tot, 3) if tot else None)

print(block_precision(labeled, 0.85))
(3, 3, 1.0)

HIGH=0.85 위쪽은 3개 전부 진짜(정밀도 1.0). 오탐을 자동 차단할 위험이 없어요. LOW 는 recall(누출)로, HIGH 는 자동차단 precision 으로 — 두 경계를 다른 잣대로 잡는 게 포인트예요.



5. 안 보이는 절반 — pass 는 아무도 안 본다

여기가 가장 많이 놓치는 함정이에요. 방금 잰 지표는 전부 사람이 본 것(review·block) 에서 나온 라벨이에요. 그런데 pass 로 통과한 문서는? 아무도 안 봐요. 그 안에 우리가 놓친 진짜 개인정보가 있어도 라벨이 안 생겨요.

즉 위에서 잰 recall=1.0“플래그된 것들 중에서” 의 recall 이지, 전체 recall 이 아니에요. 진짜 미탐(놓쳐서 그냥 통과한 것)은 이 방식으로 영영 안 보여요. 이걸 검증 편향(verification bias) 이라고 해요 — 우리가 본 것만 채점하면, 안 본 실수는 없는 셈 쳐지는 거죠.

해결은 하나예요 — 통과시킨 문서도 일부는 일부러 본다. pass 문서를 무작위로 소량 샘플링해서 사람이 검토하고, 거기서 놓친 개인정보가 몇 건 나오는지로 전체 누출률을 추정 해요.

def leak_estimate(sampled: int, missed: int) -> dict:
    rate = missed / sampled
    # 0건 나와도 "누출 0" 이 아님 — 표본이 작을 뿐. rule of three 로 상한을 같이 봄
    upper = 3 / sampled if missed == 0 else None
    return dict(sampled=sampled, missed=missed,
                miss_rate=round(rate, 4), upper_if_zero=upper)

print(leak_estimate(200, 1))
{'sampled': 200, 'missed': 1, 'miss_rate': 0.005, 'upper_if_zero': None}

통과 문서 200장을 감사했더니 1장에서 놓친 개인정보가 나왔다면, 추정 누출률은 0.5% 예요. 만약 0건이 나왔더라도 “누출 0” 이라고 단정하면 안 돼요 — 표본이 200이면 rule of three 로 상한이 대략 3/200 = 1.5% 라, “많아야 1.5%” 정도로만 말할 수 있어요.

🚨 감사 샘플링을 안 하면, 게이트가 조용히 새고 있어도 지표는 계속 recall=1.0 을 찍어요. 플래그된 것만 채점하는 평가는 자기가 놓친 걸 절대 못 봐요. 통과 표본을 조금이라도 사람이 보는 게, 이 루프에서 제일 값진 한 삽이에요.



6. 어느 탐지기가 새기 시작했나

전체 지표 하나로는 “무엇이 나빠졌는지” 를 몰라요. 종류별로 오탐률을 쪼개서, 시간에 따라 지켜봐야 어느 탐지기가 문제인지 짚여요.

종류 지난주 FP율 이번주 FP율 신호
rrn 4% 5% 안정
phone 6% 7% 안정
email 3% 3% 안정
card 8% 28% 🚨 급증 — 새 양식 의심

card 오탐률이 8% → 28% 로 튀었어요. 십중팔구 새 송장/관리번호 양식이 들어와서 Luhn 을 우연히 통과하거나 문맥이 안 걸리는 거예요. 이건 임계값으로 누를 문제가 아니라 1편의 레이어 1·2(정규식·검증기·문맥어)를 손볼 신호 예요. 종류별로 안 쪼갰으면 전체 오탐률이 조금 오른 걸로만 보여서 원인을 못 찾았을 거예요.



7. 루프를 닫는다 — 라벨이 개선으로 흘러가게

재기만 하고 안 고치면 루프가 아니에요. 모은 라벨과 지표는 세 갈래로 되먹여 요.

  • 확정 오탐 → allowlist — 사람이 “오탐” 판정한 값은 2편의 fingerprint() 로 해시해서 1편의 allowlist 에 넣어요. 같은 오탐이 다시 큐로 안 와요.
  • 반복 패턴 → 새 검증기/문맥어 — 6장에서 잡은 card 급증 같은 건, 그 양식의 관리번호를 걸러낼 문맥어(관리번호)나 검증 규칙을 추가해요.
  • 임계값 → 사람 승인 후 반영 — 4장 스윕이 “LOW 를 0.4 로 두자” 를 제안 하면, 사람이 확인하고 반영해요.

마지막 것이 중요해요. 임계값을 자동으로 바꾸지 않아요. 스윕은 후보를 제시할 뿐이고, 최종 반영은 사람이 승인합니다. 왜냐하면 게이트의 동작이 조용히 바뀌면(어느 날 갑자기 recall 이 떨어지면) 그게 곧 누출이거든요. 보안 경계의 임계값은 사람이 눈으로 보고 바꾸는 게 원칙 이에요.

✅ 이렇게 닫힌 루프예요 — 게이트가 플래그 → 사람이 판정(라벨) → 지표·스윕 → allowlist·검증기·임계값 개선 → 다시 게이트. 라벨이 공짜로 나오니, 루프를 돌릴수록 게이트가 그 데이터에 맞게 좋아져요.



8. Airflow 스케줄 — 매일 재고 리포트한다

이 루프를 매일 도는 DAG 로 만들어요. Airflow 운영 시리즈에서 쓴 Slack 자동 리포트 패턴을 그대로 얹습니다.

from airflow.decorators import dag, task

@dag(schedule="0 9 * * *", catchup=False)   # 매일 09:00
def pii_gate_eval():

    @task
    def collect_labels() -> list:
        # 어제자 사람 확인 큐 판정을 (score, is_real) 로 수집
        return load_review_labels(since="1d")

    @task
    def audit_pass_docs() -> dict:
        # 통과 문서 무작위 표본을 사람 검토 큐로 보내고, 지난 표본 결과를 회수
        return leak_estimate(*collect_audit_results())

    @task
    def compute_and_report(labeled: list, audit: dict) -> None:
        sweep = [evaluate(labeled, low) for low in [0.3, 0.4, 0.5, 0.6]]
        best  = pick_cheapest_meeting_recall(sweep, target=1.0)
        drift = per_kind_fp_drift(labeled)
        post_to_slack(render_report(best, audit, drift))   # 임계값은 '제안'만

    compute_and_report(collect_labels(), audit_pass_docs())

pii_gate_eval()

리포트는 이런 모양이에요.

📊 PII 게이트 일일 평가 (2026-07-03)
• 검토 라벨 12건 · recall 1.00 · Fβ 0.88 · 검토부하 10
• 감사 샘플: 통과 200장 중 미탐 1건 (추정 누출률 0.5%)
• 종류별 오탐: card 8% → 28% 🚨 (새 양식 의심 — 레이어1/2 점검)
• 임계값 제안: LOW 0.40 유지, HIGH 0.85 유지  → 승인 대기

숫자를 매일 이렇게 눈에 띄게 흘려보내면, 게이트가 조용히 망가지는 걸 조용하지 않게 만들 수 있어요. 6장의 card 급증 같은 신호가 아침에 Slack 으로 뜨면, 그날 바로 손볼 수 있죠.



9. 마무리 — 3부작을 닫으며

개인정보 파이프라인 세 편을 한 줄씩으로 정리하면 이래요.

  • 1편 (탐지) — 넓게 잡고, 검증기로 확실히 거르고, 3티어로 라우팅. 오탐은 흡수한다
  • 2편 (처리) — 마스킹으로 값을 죽이고, 토큰화로 잠그고, 격리로 문서를 잠근다. 다 쓴 건 소각
  • 3편 (평가) — 사람 큐를 정답지 삼아 재고, 안 보이는 절반은 감사로 추정하고, 라벨을 개선으로 되먹인다

관통하는 생각은 하나예요 — 개인정보 게이트는 만들고 끝나는 물건이 아니라, 계속 재고 고치는 살아있는 루프 라는 거예요. 데이터가 변하니 게이트도 변해야 하고, 변화를 재지 않으면 조용히 틀려요. 그래서 “얼마나 잘 잡나” 만큼이나 “얼마나 잘 재고 있나” 가 중요해요.

일단 오늘은 여기까지…..
개인정보 파이프라인 3부작은 이걸로 마무리예요. 다음엔 또 다른 데이터 파이프라인 이야기로 돌아올게요.



← 이전 글: (2/3) 탐지한 개인정보를 안전하게 다루기 — 마스킹, 가역 토큰화, 격리 저장