들어오는 문서에서 개인정보를 걸러내는 Airflow util — 넓게 잡고, 확실히 거른다
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 상태)로 보내야 해요. review 는 needs_review 상태로 사람 큐에 쌓이고, pass 만 적재 태스크로 이어집니다.
분기를 상태로 명시하고 싶으면 BranchPythonOperator 로 pii_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-5678과01012345678을 같은 값으로 인식하려고.
④ 관측 — 오탐률이 오르면 양식이 바뀐 것
마지막은 재는 거예요. 차단율·검토율·검토 후 오탐 확정 비율 을 매일 기록해요.
- 검토 큐의 오탐 확정 비율이 갑자기 오르면 → 새 양식이 들어와서 우리 규칙이 헛발질하는 중 (레이어 1·2 손볼 때)
- 차단율이 0 으로 뚝 떨어지면 → 탐지가 죽었거나 인입이 끊긴 것 (오히려 미탐 위험 신호)
숫자를 안 보면 파이프라인이 조용히 망가져도 몰라요. “조용한 게 정상” 인지 “조용히 고장 난” 건지는 metric 으로만 구분돼요.
💡 정리하면 오탐은 검증기(공짜) → 문맥(공짜) → allowlist(1회 사람) → 관측(감시) 순서로 점점 비싼 수단을 써서 흡수해요. 값싼 층에서 최대한 거르고, 사람은 정말 애매한 것만 보게 만드는 게 핵심이에요.
9. 마무리
개인정보 탐지는 “정규식 하나” 로 끝나지 않아요. 그렇다고 처음부터 무거운 NER 모델이나 LLM 을 붙일 필요도 없고요. 순서를 지키면 대부분 규칙+검증기로 충분히 확실해져요.
- 넓게 잡는다 — 정규식은 후보 수집. 미탐을 줄이는 게 우선
- 확실히 거른다 — 체크섬·Luhn 이 오탐의 대부분을 공짜로 떨군다
- 문맥으로 점수 — 체크섬 없는 종류의 마지막 안전판
- 3티어로 라우팅 — 애매한 건 막지도 통과시키지도 말고 사람에게
- 오탐은 흡수한다 — 없애려 하지 말고, allowlist·관측으로 싸게 처리
규칙+검증기로도 오탐이 안 잡히는 비정형 개인정보(문장 속 이름·주소)가 문제가 되기 시작하면, 그때 레이어 2와 3 사이에 NER 이나 LLM 재판정을 한 겹 더 끼우면 돼요. 구조를 레이어로 짜뒀으니 그 확장이 쉬워요.
일단 오늘은 여기까지…..
다음 글에서는 이 게이트가 잡은 걸 어떻게 안전하게 마스킹해서 격리 저장 하는지 정리해볼게요.