11 분 소요

Summary

PDF 적재 파이프라인 글에서 문서를 인입 → 검증 → 적재 → 사람 확인 으로 흘려보내는 뼈대를 짰어요. 거기서 검증은 “값이 타입에 맞나, 합계가 맞나” 같은 품질 검증이었죠. 이번 글은 그 파이프라인에 하나를 더 얹습니다. 이 문서에 개인정보가 들어있나?

들어오는 문서에 주민등록번호·카드번호·전화번호·이메일이 섞여 있으면, 그걸 모르고 DB 에 넣거나 로그로 흘리는 순간 사고예요. 그래서 적재 전에 문 앞에서 한 번 훑어야 합니다. 그런데 이게 생각보다 까다로워요. 너무 빡빡하게 잡으면 주문번호·송장번호까지 개인정보로 오해해서(오탐) 멀쩡한 문서가 다 막히고, 너무 느슨하게 잡으면 진짜 주민번호가 통과(미탐)해버려요.

이 글에서는 그 균형을 3레이어 탐지 + 3티어 라우팅 으로 잡습니다. 그리고 오탐을 없애려고 애쓰기보다 오탐을 안전하게 흡수하는 구조를 만드는 데 무게를 둬요.

💡 이 글에서 다루는 것

  • 왜 “개인정보 있으면 막아줘” 가 어려운가 — 오탐 vs 미탐의 비대칭 비용
  • 레이어 1 규칙(정규식)으로 후보를 넓게 잡기 (recall 우선)
  • 레이어 2 검증기(주민번호 체크섬·카드 Luhn)로 오탐을 걷어내기 — 여기가 핵심
  • 레이어 3 주변 문맥으로 confidence 점수 매기기
  • 3티어 라우팅 — HIGH 자동차단 / MID 사람확인 큐 / LOW 통과+로그
  • 오탐(false positive) 처리 — validator-first · 문맥 suppress · allowlist 피드백 · 관측
  • Airflow @task 게이트로 얹기

예시로 나오는 개인정보 값은 전부 합성/마스킹 한 것이고, 실제 값이 아니에요.



1. “개인정보 있으면 막아줘” 가 왜 어렵나

주민등록번호는 \d{6}-\d{7} 이니까 정규식 하나면 될 것 같죠. 그런데 이렇게 생긴 것들을 생각해보세요.

  • 송장번호: 2024011500123 — 13자리 숫자. 주민번호처럼 보임
  • 주문번호 4242-4242-4242-4242 — 16자리. 카드번호처럼 보임
  • 문서관리번호 900101-1234567 — 주민번호 정규식에 그대로 걸림

정규식만 쓰면 이 셋이 다 개인정보로 잡혀요(오탐, false positive). 반대로 정규식을 빡빡하게 고치다 보면 9001011234567 처럼 하이픈 없는 진짜 주민번호를 놓치고요(미탐, false negative).

여기서 중요한 건 두 오류의 비용이 다르다 는 거예요.

구분 무슨 일이 생기나 비용
오탐 (아닌데 잡음) 멀쩡한 문서가 검토 큐로 감 사람이 30초 보고 통과시킴
미탐 (진짜인데 놓침) 주민번호가 DB·로그로 유출됨 유출 사고, 규제 위반

오탐은 사람이 잠깐 확인하면 끝이지만, 미탐은 되돌릴 수 없어요. 그래서 이 게이트의 설계 원칙은 하나로 정해집니다.

🚨 넓게 잡아서 미탐을 줄이고, 오탐은 뒷단(검증기·사람 큐)에서 흡수한다.

recall(재현율)을 먼저 올리고, precision(정밀도)은 그 다음에 깎는다는 뜻이에요. 이 순서가 이 글 전체를 관통합니다.



2. 설계 — 넓게 잡고 → 거르고 → 점수

한 방에 판정하지 않고 세 레이어로 나눕니다. 각 레이어는 앞 레이어가 넘긴 후보를 줄이기만 해요.

문서 텍스트
   │
   ▼
[레이어 1] 규칙(정규식)    ── 후보를 넓게 잡음 (recall 우선)
   │  candidates
   ▼
[레이어 2] 검증기          ── 체크섬·Luhn 으로 구조적 오탐 제거
   │  validated hits
   ▼
[레이어 3] 문맥 점수        ── 주변 키워드로 confidence 가감
   │  scored hits
   ▼
[라우팅] confidence → 티어  ── HIGH 차단 / MID 검토 / LOW 통과

핵심은 레이어를 섞지 않는 것 이에요. 정규식은 “그럴듯한 모양” 만 잡고, 진짜/가짜 판정은 검증기와 문맥이 해요. 이렇게 나눠두면 각 단계를 따로 테스트할 수 있고, 새 양식이 들어와서 오탐이 늘어도 어느 레이어를 고쳐야 하는지 가 분명해져요.

먼저 탐지 결과를 담을 자료구조부터.

from dataclasses import dataclass, field

@dataclass
class PiiHit:
    kind: str          # "rrn", "card", "phone", "email" ...
    raw: str           # 매치된 원문 조각
    start: int         # 문서 내 위치 (검토 근거)
    end: int
    score: float = 0.0 # 최종 confidence (0.0 ~ 1.0)

@dataclass
class PiiReport:
    hits: list[PiiHit] = field(default_factory=list)
    tier: str = "pass"   # "block" | "review" | "pass"

start/end 를 같이 들고 다니는 이유는, 나중에 사람 확인 큐에서 “문서 몇 번째 글자에서 뭐가 걸렸는지” 를 근거로 보여주기 위해서예요. 좌표가 없으면 검토자가 수천 자짜리 문서를 처음부터 다시 읽어야 하거든요.



3. 레이어 1 — 규칙으로 후보를 넓게 잡기

정규식은 “판정” 이 아니라 “후보 수집” 이에요. 그러니 일부러 느슨하게 씁니다. 하이픈이 있든 없든, 공백이 섞였든 다 잡히게.

import re

PATTERNS = {
    # 주민등록번호: 6자리-7자리. 구분자 없거나 -, 공백 허용
    "rrn":   re.compile(r"\b(\d{6})[-\s]?([1-4]\d{6})\b"),
    # 신용카드: 4-4-4-4. 구분자 없거나 -, 공백 허용
    "card":  re.compile(r"\b(?:\d[ -]?){15}\d\b"),
    # 휴대폰: 010/011/016/017/018/019
    "phone": re.compile(r"\b01[016789][-\s]?\d{3,4}[-\s]?\d{4}\b"),
    # 이메일
    "email": re.compile(r"\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b"),
}

def find_candidates(text: str) -> list[PiiHit]:
    hits = []
    for kind, pat in PATTERNS.items():
        for m in pat.finditer(text):
            hits.append(PiiHit(kind=kind, raw=m.group(), start=m.start(), end=m.end()))
    return hits

실제로 돌려보면 이렇게 나와요. 일부러 오탐이 섞인 문장을 넣어봅니다.

sample = "고객 900101-1234567, 송장번호 2024011500123456. 연락처는 010-1234-5678 입니다."
for h in find_candidates(sample):
    print(f"{h.kind:6} | {h.raw}")
rrn    | 900101-1234567
card   | 2024011500123456
phone  | 010-1234-5678

보시면 송장번호(2024011500123456)가 카드로 잡혔어요. 이게 바로 우리가 원한 상황이에요 — 이 단계에서는 오탐이 나도 괜찮습니다. 뒤에서 거를 거니까요. 넓게 잡는 게 목적이라 여기서 놓치는 게 더 위험해요.



4. 레이어 2 — 검증기로 오탐을 걷어낸다

이 글에서 제일 중요한 단계예요. 한국의 주요 개인정보 번호들은 체크섬(검증 숫자) 을 갖고 있어요. 랜덤한 13자리·16자리 숫자가 이 체크섬을 우연히 통과할 확률은 매우 낮아서, 송장번호·주문번호 같은 구조적 오탐을 여기서 대부분 떨궈낼 수 있어요.

4-1. 주민등록번호 체크섬

주민번호 13자리 중 마지막 자리는 앞 12자리로 계산되는 검증 숫자예요. 게다가 앞부분은 생년월일 이라 날짜로도 유효성을 볼 수 있고요.

def valid_rrn(digits: str) -> bool:
    d = re.sub(r"\D", "", digits)          # 구분자 제거
    if len(d) != 13:
        return False
    # 1) 생년월일 자리가 실제 날짜인지
    mm, dd = int(d[2:4]), int(d[4:6])
    if not (1 <= mm <= 12 and 1 <= dd <= 31):
        return False
    # 2) 성별 자리(7번째)는 1~4 (내국인 기준 예시)
    if d[6] not in "1234":
        return False
    # 3) 체크섬: 앞 12자리에 가중치를 곱해 합산
    weights = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5]
    total = sum(int(n) * w for n, w in zip(d[:12], weights))
    check = (11 - (total % 11)) % 10
    return check == int(d[12])

앞에서 잡힌 후보를 넣어봅니다.

print(valid_rrn("900101-1234567"))   # 흔히 쓰는 더미 값
False

900101-1234567 은 정규식엔 걸리지만 체크섬이 안 맞아서 탈락 해요. 실제로 이 “유명한 더미 번호” 는 검증 숫자가 8이어야 해서 통과하지 못합니다. 반대로 체크섬까지 맞춘 진짜 주민번호 형태만 True 가 돼요.

✅ 정규식만 썼으면 통과했을 값이 검증기 한 줄에서 걸러졌어요. 이게 오탐을 없애는 가장 값싼 방법이에요 — 사람도, LLM 도 필요 없어요.

4-2. 신용카드 Luhn 체크

카드번호는 Luhn 알고리즘 이라는 체크섬을 써요. 여기서 송장번호가 걸러집니다.

def valid_luhn(digits: str) -> bool:
    d = [int(c) for c in re.sub(r"\D", "", digits)]
    if len(d) < 13:
        return False
    total, parity = 0, len(d) % 2
    for i, n in enumerate(d):
        if i % 2 == parity:
            n *= 2
            if n > 9:
                n -= 9
        total += n
    return total % 10 == 0

아까 카드로 잡혔던 송장번호와, 실제 카드 형식(Luhn 통과하는 테스트 번호)을 비교해봅니다.

print(valid_luhn("4242424242424242"))   # Luhn 통과 (카드 형식)
print(valid_luhn("2024011500123456"))   # 송장번호 예시 — Luhn 실패
True
False

송장번호는 Luhn 을 통과하지 못해서(False) 여기서 탈락해요. 카드 형식만 남습니다.

4-3. 검증기 붙이기

kind 별로 검증기를 매핑해두고, 검증기가 있는 종류는 통과한 것만 남깁니다. 검증기가 없는 종류(이메일 등)는 정규식 자체가 충분히 구체적이라 그대로 통과시켜요.

VALIDATORS = {
    "rrn":  valid_rrn,
    "card": valid_luhn,
    # phone, email 은 정규식만으로 충분 → 검증기 없음
}

def validate(hits: list[PiiHit]) -> list[PiiHit]:
    kept = []
    for h in hits:
        check = VALIDATORS.get(h.kind)
        if check is None or check(h.raw):
            kept.append(h)
    return kept
kept = validate(find_candidates(sample))
for h in kept:
    print(f"{h.kind:6} | {h.raw}")
phone  | 010-****-5678

(출력 예시의 전화번호는 마스킹해서 표기) 아까 3개였는데, 900101-1234567(체크섬 실패)과 송장번호 2024011500123456(Luhn 실패)이 빠지고 진짜 형태만 남았죠. 정규식이 잡은 오탐 두 개가 사람 손도 안 거치고 사라졌어요. 다른 번호들도 같은 방식으로 검증기를 붙일 수 있어요.

종류 검증 방법 오탐 제거 효과
주민등록번호 체크섬 + 생년월일 유효성 랜덤 13자리 대부분 탈락
신용카드 Luhn 송장·주문번호 탈락
사업자등록번호 가중치 체크섬(10자리) 랜덤 10자리 탈락
계좌번호 은행 코드 prefix + 자릿수 임의 숫자열 탈락
전화번호 통신사 prefix 화이트리스트 일반 숫자열 탈락



5. 레이어 3 — 문맥으로 점수를 매긴다

검증기까지 통과한 것도 100% 확신은 아니에요. 특히 이메일·전화처럼 체크섬이 없는 종류는 문맥이 판단을 도와줘요. 매치 위치 주변 창(앞뒤 20자 정도)에 어떤 단어가 있는지로 confidence 를 가감합니다.

# 개인정보임을 강하게 시사하는 주변 단어 → 점수 +
POSITIVE = ["주민", "생년월일", "고객", "성명", "연락처", "계좌", "카드", "본인"]
# 개인정보가 아닐 가능성을 시사하는 단어 → 점수 -
NEGATIVE = ["송장", "주문", "문서번호", "관리번호", "예시", "샘플", "테스트"]

BASE = {"rrn": 0.9, "card": 0.7, "phone": 0.5, "email": 0.5}

def score(text: str, hits: list[PiiHit], window: int = 20) -> list[PiiHit]:
    for h in hits:
        s = BASE.get(h.kind, 0.5)
        ctx = text[max(0, h.start - window): h.end + window]
        if any(w in ctx for w in POSITIVE):
            s += 0.2
        if any(w in ctx for w in NEGATIVE):
            s -= 0.4
        h.score = max(0.0, min(1.0, s))
    return hits

주민번호는 체크섬을 통과한 시점에서 이미 강한 신호라 base 를 0.9 로 높게 두고, 이메일·전화는 0.5 에서 시작해 문맥으로 움직이게 했어요.

scored = score(sample, validate(find_candidates(sample)))
for h in scored:
    print(f"{h.kind:6} | score={h.score:.2f}")
phone  | score=0.70

검증기를 통과한 전화번호는 주변에 “연락처” 가 있어서 0.5 → 0.7 로 올라갔어요. 반대로, 만약 어떤 카드번호가 Luhn 까지 통과했더라도 옆에 “송장”·”주문” 이 있었으면 0.7 - 0.4 = 0.3 으로 내려가 통과 쪽으로 분류됐을 거예요. 문맥은 검증기가 못 거른 오탐을 한 번 더 눌러주는 안전판 이에요.



6. 3티어 라우팅 — pii_scan 완성

이제 점수를 티어로 바꿔요. 문서 안 히트 중 가장 높은 점수 로 문서 전체를 판정합니다(하나라도 위험하면 위험).

HIGH, LOW = 0.85, 0.4   # 임계값

def pii_scan(text: str) -> PiiReport:
    hits = score(text, validate(find_candidates(text)))
    report = PiiReport(hits=hits)
    if not hits:
        report.tier = "pass"
    else:
        top = max(h.score for h in hits)
        if top >= HIGH:
            report.tier = "block"     # 자동 차단·격리
        elif top >= LOW:
            report.tier = "review"    # 사람 확인 큐
        else:
            report.tier = "pass"      # 통과 + 감사 로그
    return report
report = pii_scan(sample)
print("tier:", report.tier)
print("hits:", [(h.kind, round(h.score, 2)) for h in report.hits])
tier: review
hits: [('phone', 0.7)]

세 레이어를 다 지나고 나니 살아남은 건 전화번호(0.7) 하나예요. MID 구간이라 문서는 review — 사람이 한 번 보게 됩니다. 여기에 체크섬까지 맞는 진짜 주민번호 가 섞여 있었다면 점수가 0.9+ 로 올라가 block 이 됐을 거예요. 세 티어의 의미는 이래요.

티어 조건 처리
🚨 block 최고 점수 ≥ 0.85 자동 차단, 격리 저장소로. DB·로그 진입 금지
⚠️ review 0.4 ≤ 최고 점수 < 0.85 사람 확인 큐 로. 통과/차단은 사람이 결정
pass 최고 점수 < 0.4, 또는 히트 없음 통과. 단, 히트가 있었으면 감사 로그 남김

review 티어가 오탐을 흡수하는 완충 지대 예요. 애매한 건 무조건 막지도, 무조건 통과시키지도 않고 사람에게 넘겨요.



7. Airflow 에 얹기 — pii_gate task

pii_scan 은 외부 의존성이 없는 순수 함수라 테스트가 쉬워요. Airflow 쪽은 이걸 호출해서 분기만 시키면 됩니다. PDF 적재 파이프라인의 상태 머신에 티어를 그대로 태워요.

from airflow.decorators import task
from airflow.exceptions import AirflowFailException

@task
def pii_gate(doc: dict) -> dict:
    """추출된 문서 텍스트를 검사해 티어를 붙여 반환.
    block 이면 적재로 못 가게 예외를 던져 격리 경로로 보낸다."""
    report = pii_scan(doc["text"])

    # 근거(종류·좌표·점수)를 문서에 붙여서 다음 태스크로 넘김
    doc["pii_tier"] = report.tier
    doc["pii_hits"] = [
        {"kind": h.kind, "start": h.start, "end": h.end, "score": round(h.score, 2)}
        for h in report.hits
    ]

    if report.tier == "block":
        # 적재 파이프라인 진입 차단 → 격리. 재시도해도 결과 같으니 재시도 금지 대상
        raise AirflowFailException(
            f"PII detected: {[h['kind'] for h in doc['pii_hits']]}"
        )
    return doc   # review / pass 는 doc 을 그대로 흘려보냄

block 을 예외로 처리하는 이유는, PDF 적재 글에서 다룬 일시적 오류 vs 영구적 오류 구분과 맞물려요. 개인정보 검출은 재시도한다고 결과가 바뀌지 않는 영구적 사건이라, 재시도 없이 바로 격리(failed 상태)로 보내야 해요. reviewneeds_review 상태로 사람 큐에 쌓이고, pass 만 적재 태스크로 이어집니다.

분기를 상태로 명시하고 싶으면 BranchPythonOperatorpii_tier 를 읽어 세 갈래로 나눠도 되고요. 어느 쪽이든 원본 근거(pii_hits)를 문서에 붙여 다음 단계로 넘기는 것 이 핵심이에요 — 검토자가 근거 없이 판단하게 두면 안 되니까.



8. 오탐(false positive)을 다루는 4가지

여기가 이 글의 무게중심이에요. 오탐은 없앨 수 없어요. 없애려고 임계값을 올리면 미탐이 늘어요(둘은 시소예요). 그래서 목표를 바꿉니다 — 오탐을 0으로 만드는 게 아니라, 오탐이 나도 싸게 처리되게 하는 거예요.

① validator-first — 점수 매기기 전에 구조적으로 제거

가장 값싼 오탐 제거는 레이어 2 검증기 예요. 체크섬·Luhn 은 사람도 LLM 도 필요 없고, 랜덤 숫자열을 결정론적으로 떨궈내요. 송장번호·주문번호 대부분이 여기서 사라져요. 오탐 대응의 90% 는 좋은 검증기 라고 봐도 돼요.

② 문맥 suppress — “송장번호 옆이면 강등”

검증기를 통과했어도 주변에 송장·주문·예시·테스트 가 있으면 점수를 크게 깎아요(레이어 3의 NEGATIVE). 양식 문서의 샘플/템플릿 값 이 여기서 많이 걸러져요.

③ allowlist — 확정 오탐은 다시 안 묻는다

레터헤드에 늘 박혀 있는 대표 전화번호, 계약서 템플릿의 예시 주민번호 처럼, 사람이 한 번 “이건 오탐” 이라고 확정한 값은 다음부터 자동으로 눌러줘야 해요. 원문을 그대로 저장하면 그 자체가 개인정보 저장이 되니, 해시로 저장합니다.

import hashlib

def fingerprint(raw: str) -> str:
    norm = re.sub(r"\D", "", raw)               # 구분자 무시하고 정규화
    return hashlib.sha256(norm.encode()).hexdigest()

ALLOWLIST = set()   # 사람이 "오탐" 확정한 값들의 해시 (실서비스는 DB/Variable)

def is_allowlisted(h: PiiHit) -> bool:
    return fingerprint(h.raw) in ALLOWLIST

score() 직전에 is_allowlisted 로 걸러주면, 사람 큐에서 한 번 “오탐” 판정한 값이 다시 큐로 오지 않아요. 사람 확인 큐 → allowlist 로 이어지는 이 피드백 루프 가 있어야 검토 부하가 시간이 갈수록 줄어들어요. 루프가 없으면 같은 오탐을 매번 사람이 다시 봐요.

⚠️ 원문이 아니라 정규화 후 해시 를 저장하는 이유는 두 가지예요. 하나는 allowlist 자체가 개인정보 저장고가 되면 안 되니까, 다른 하나는 010-1234-567801012345678 을 같은 값으로 인식하려고.

④ 관측 — 오탐률이 오르면 양식이 바뀐 것

마지막은 재는 거예요. 차단율·검토율·검토 후 오탐 확정 비율 을 매일 기록해요.

  • 검토 큐의 오탐 확정 비율이 갑자기 오르면 → 새 양식이 들어와서 우리 규칙이 헛발질하는 중 (레이어 1·2 손볼 때)
  • 차단율이 0 으로 뚝 떨어지면 → 탐지가 죽었거나 인입이 끊긴 것 (오히려 미탐 위험 신호)

숫자를 안 보면 파이프라인이 조용히 망가져도 몰라요. “조용한 게 정상” 인지 “조용히 고장 난” 건지는 metric 으로만 구분돼요.

💡 정리하면 오탐은 검증기(공짜) → 문맥(공짜) → allowlist(1회 사람) → 관측(감시) 순서로 점점 비싼 수단을 써서 흡수해요. 값싼 층에서 최대한 거르고, 사람은 정말 애매한 것만 보게 만드는 게 핵심이에요.



9. 마무리

개인정보 탐지는 “정규식 하나” 로 끝나지 않아요. 그렇다고 처음부터 무거운 NER 모델이나 LLM 을 붙일 필요도 없고요. 순서를 지키면 대부분 규칙+검증기로 충분히 확실해져요.

  • 넓게 잡는다 — 정규식은 후보 수집. 미탐을 줄이는 게 우선
  • 확실히 거른다 — 체크섬·Luhn 이 오탐의 대부분을 공짜로 떨군다
  • 문맥으로 점수 — 체크섬 없는 종류의 마지막 안전판
  • 3티어로 라우팅 — 애매한 건 막지도 통과시키지도 말고 사람에게
  • 오탐은 흡수한다 — 없애려 하지 말고, allowlist·관측으로 싸게 처리

규칙+검증기로도 오탐이 안 잡히는 비정형 개인정보(문장 속 이름·주소)가 문제가 되기 시작하면, 그때 레이어 2와 3 사이에 NER 이나 LLM 재판정을 한 겹 더 끼우면 돼요. 구조를 레이어로 짜뒀으니 그 확장이 쉬워요.

일단 오늘은 여기까지…..
다음 글에서는 이 게이트가 잡은 걸 어떻게 안전하게 마스킹해서 격리 저장 하는지 정리해볼게요.