개인정보보호법 · GDPR · 비식별화 · IRB · LLM 입력 위험
박수현 · aSSIST University · 2026
🎨 이미지 프롬프트(배경): "A digital lock superimposed over flowing data streams that gradually become anonymized — names becoming asterisks, faces blurring — soft teal and red tones, editorial illustration, 16:9, no text"
📖 도서 매핑: Ch.7 §3~4 + 신규 보강
"이름·전화번호 만이 아니다"
키워드: 식별성 · 결합 가능성 · 민감정보
그 자체로 개인을 짚는 정보
- 고유식별정보 (PIPA §24, 시행령 §19) — 현행 시행령 기준 4종
주민등록번호 · 여권번호 · 운전면허번호 · 외국인등록번호
- 일반 식별정보: 이름, 전화번호, 이메일
결합하면 식별 가능
- 생년월일 + 성별 + 우편번호
→ 미국 인구의 87% 식별 가능
- 직장 + 직책 + 부서
- 자주 이용하는 카페·동선
특별 보호 대상 (PIPA §23, GDPR §9)
- 사상·신념, 정치 성향, 노조 가입, 종교
- 건강·성생활, 유전정보, 형사처벌·전과
- 생체인식정보 (PIPA 시행령 §18 — 민감정보 규정)
지문 · 홍채 · 얼굴 사진 · 정맥 · 음성
- 인종·민족
⚠️ 준식별자 결합 이 실무에서 가장 위험. 이름 지웠다고 끝 아님.
| 항목 | 한국 PIPA | EU GDPR |
|---|---|---|
| 시행 | 2011, 2020 개정, 2023 개정 | 2018 |
| 적용 대상 | 한국 내 처리 | EU 거주자 데이터 (전세계 적용) |
| 개인정보 정의 | 살아있는 개인 식별 가능 정보 | 동일 + IP·쿠키 |
| 민감정보 | §23 (사상·건강·성생활 등) | §9 (특별 카테고리) |
| 수집 동의 | 명시적 동의 + 목적 고지 | 6가지 적법 근거 중 1 |
| 위반 과징금 | 전체 매출액의 3% 이내 (2023 개정) | 2천만 € 또는 전세계 매출 4% 중 큰 금액 |
| DPO (Data Protection Officer, 개인정보 보호책임자) | 일정 규모 이상 의무 | 광범위 의무 |
| DPIA (Data Protection Impact Assessment, 개인정보 영향평가) | 공공기관 등 의무 | 일정 조건에서 의무 |
| 국외 이전 | 동의 + 별도 절차 | SCC (Standard Contractual Clauses, 표준계약조항) / 적정성 결정 |
🎯 EU 사이트 스크래핑 또는 EU 시민 데이터 분석 시 GDPR 직접 적용.
| # | 시나리오 | 위험도 | 처리 |
|---|---|---|---|
| 1 | 공공데이터포털 공개 CSV/API 분석 | 🟢 낮음 | 공개 가능 |
| 2 | 쇼핑몰 가격 비교 (제품·가격만) | 🟢 낮음 | 공개 가능 |
| 3 | 영화·도서 댓글 분석 (작성자 ID 포함) | 🟡 중간 | ID 마스킹 필수 |
| 4 | 직무 만족도 설문 (직장명·연봉 등) | 🟠 높음 | IRB + 비식별화 |
| 5 | 환자/학생 데이터 분석 | 🔴 높음 | IRB + 익명화 + 보안 |
🎯 1·2 외엔 모두 IRB 권장.
정확도와 보호 사이의 균형
| # | 기법 | 예 | 분석 가치 | 보호 |
|---|---|---|---|---|
| 1 | 삭제 | 컬럼 통째 제거 | ❌ | ⭐⭐⭐ |
| 2 | 마스킹 | 홍*동, 010-****-1234 |
🟡 | ⭐⭐ |
| 3 | 가명화 | 홍길동 → User_1023 |
✅ (재식별 키) | ⭐⭐ |
| 4 | 범주화 | 25세 → 20대 |
✅ | ⭐⭐⭐ |
| 5 | 잡음 추가 | 25.0 → 25.0±2.0 |
✅ | ⭐⭐⭐ |
⚠
원본 데이터
PII 포함
1
삭제
컬럼 통째
2
마스킹
홍동, ***-1234
3
가명화
User_1023
4
범주화
25세 → 20대
5
잡음 추가
25.0 ± 2.0
🎯 권장 조합
직접 식별자 → 삭제 또는 가명화 / 준식별자 → 범주화.
import re
def mask_phone(s: str) -> str:
return re.sub(r"(\d{3})-?(\d{3,4})-?(\d{4})",
r"\1-****-\3", s)
def mask_email(s: str) -> str:
return re.sub(r"([^@]{2})[^@]*(@.+)",
r"\1***\2", s)
def mask_name(s: str) -> str:
if len(s) <= 2:
return s[0] + "*"
return s[0] + "*" * (len(s)-2) + s[-1]
print(mask_phone("010-1234-5678"))
# 010-****-5678
print(mask_email("hyun.love@gmail.com"))
# hy***@gmail.com
print(mask_name("홍길동"))
# 홍*동
Faker로 원본 이름을 현실적인 가명으로 일관되게 바꿔, 신원은 숨기면서 사용자 단위 분석은 유지하는 방법입니다.
from faker import Faker
import pandas as pd
import hashlib
fake = Faker("ko_KR") # 한국어
df = pd.read_csv("review.csv")
# 같은 user_id 는 항상 같은 가명 (해시 + 시드)
def pseudonym(user_id: str) -> str:
seed = int(hashlib.md5(user_id.encode()).hexdigest(), 16) % (10**9)
Faker.seed(seed)
return Faker("ko_KR").name()
df["author_alias"] = df["author"].map(pseudonym)
df = df.drop(columns=["author"])
print(df.head())
# author_alias rating review
# 김지연 5 "배송 빨라요"
# 박서준 3 "보통이에요"
🎯 가명화의 강점
같은 user_id → 같은 가명 → 사용자 단위 분석 가능, 그러나 직접 이름은 사라짐.
💬 ChatGPT / Claude 입력 anonymize.py
📋 prompt prompts/ch07_prompts.md 📄 example examples/ch07/analyze_json.py
한국어 텍스트의 PII 4종을 가짜값/마스킹으로 치환하는
헬퍼 anonymize.py 를 만들어 줘. LLM 외부 호출 직전에
1줄로 적용해 외부 유출을 막는 용도.
요구사항:
1. 라이브러리: faker(locale=ko_KR), 표준 re
2. 시그니처:
anonymize(text: str, seed: int|None=None) -> str
3. 치환 대상 (정규식 → 처리):
- 휴대폰 010-?\\d{4}-?\\d{4} → fake.phone_number()
- 이메일 → fake.email()
- 한국 이름 + 호칭 (\\s?(님|씨|선생님))
→ fake.name() + 호칭 보존
- 주민번호 앞7자리 \\d{6}-?[1-4]\\d
→ "<REDACTED-RRN>" (마스킹)
4. 같은 입력 → 같은 출력 보장 (seed 고정 시 재현성)
5. faker 인스턴스는 모듈 전역 1회 생성
추가 함수:
- anonymize_with_map(text, seed) -> (text, map)
· map 에 원본↔가짜 매핑 dict 반환
· 후속 단계에서 LLM 결과의 가짜값을 원본으로 역치환
테스트 (if __name__=="__main__"):
- 샘플 5건으로 print
환경: Python 3.11, venv. 코드만, 한국어 주석 최소.
<REDACTED-RRN>)님/씨) 보존 — 비식별 후에도 자연스러운 한국어 → LLM 분석 정확도 보존anonymize_with_map — LLM 결과를 원본 PII 로 역치환 가능 (분석 후 보고서 작성)💡 다음 슬라이드의 안전한 LLM 호출 은 이 헬퍼를 호출 직전 1줄로 적용해 외부 노출 차단.
💬 Antigravity / ChatGPT 입력 analyze_json.py
📋 prompt prompts/ch07_prompts.md 📄 example examples/ch07/analyze_json.py
Python 함수 analyze_json(review: str) -> dict 를
만들어 줘.
목적: 한국어 리뷰 한 건을 OpenAI LLM 에 보내
JSON 객체로 결과를 받아 dict 로 파싱한다.
전제: 같은 파일 상단에서 python-dotenv 로
OPENAI_API_KEY 로드, OpenAI 클라이언트 client
생성됨.
요구사항:
1. 라이브러리: openai, 표준 json
2. 모델: gpt-4o-mini
3. chat.completions.create 호출 시
response_format={"type": "json_object"}
옵션으로 JSON 강제
4. 메시지:
- system: "너는 한국어 리뷰 분석가다.
JSON 으로만 답한다."
- user: 리뷰 본문 + 다음 형식의 JSON 객체로만
응답하라는 지시:
{
"sentiment": "positive|negative|neutral",
"praise": ["키워드1","키워드2"],
"complaint": ["키워드1","키워드2"],
"stars": 1~5 정수
}
5. 반환:
json.loads(resp.choices[0].message.content)
6. 하단에서 예시 리뷰로 호출 후
sentiment, praise 키를 print
에러 처리:
- json.loads 실패 시
{"sentiment":"neutral","praise":[],
"complaint":[],"stars":3}
기본값 반환 + 오류 메시지 출력
- API 호출 실패 시 동일한 기본값 반환
환경: Python 3.11, venv. 코드만, 한국어 주석 최소.
response_format={"type": "json_object"} → 응답 파싱 안정try/except + 기본 dict 반환 → 1만 건 배치 중 하나가 깨져도 전체 멈추지 않음idx 또는 원본 ID 로 다시 합치되, 매핑 테이블은 분리 보관·정해진 일자에 폐기⚠️ 데이터 외부 송신 위험:
OpenAI / Anthropic 으로 보내는 모든 텍스트는 외부 서버에 전송. 본인 책임 하에:
- 가능하면 마스킹 후 송신
- 의료·법률·기밀 데이터는 로컬 LLM (Ollama)
- 회사 데이터는 엔터프라이즈 계약 (학습 미사용 옵션)
📖 출처:
ch07_prompts.md§6 — LLM 으로 한국어 텍스트 다루기.
텍스트에서 이름·전화번호·이메일 같은 개인정보(PII)를 자동 탐지하고 마스킹해 주는 오픈소스 도구입니다.
Presidio 자체는 영어 NER 모델 위주. 한국어는 spaCy 한국어 모델 + transformers NER 모델 + 정규식을 별도 등록해야 인식.
# pip install presidio-analyzer presidio-anonymizer
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
text = (
"안녕하세요. 저는 홍길동이고 이메일은 hong@example.com 입니다. "
"전화번호는 010-1234-5678 이에요."
)
analyzer = AnalyzerEngine()
results = analyzer.analyze(text=text, language="en")
# 영어 위주 + 일부 한국어 패턴
anonymizer = AnonymizerEngine()
anonymized = anonymizer.anonymize(text=text, analyzer_results=results)
print(anonymized.text)
# → "안녕하세요. 저는 <PERSON> 이고 이메일은 <EMAIL_ADDRESS> 입니다. 전화번호는 <PHONE_NUMBER> 이에요."
📌 한국어 PII 정확도는 제한적 → 정규식 + Faker 보강 권장.
import pandas as pd
# 1) 나이를 10살 단위
df["age_group"] = pd.cut(df["age"],
bins=[0, 19, 29, 39, 49, 59, 100],
labels=["~10대","20대","30대",
"40대","50대","60+"])
# 2) 우편번호 앞 3자리만
df["zip3"] = df["zip"].astype(str).str[:3]
# 3) 시점을 월 단위
df["ym"] = pd.to_datetime(df["date"]).dt.to_period("M")
# 원본 컬럼 삭제
df = df.drop(columns=["age", "zip", "date"])
📌 너무 좁은 범주 (1살 단위) → 사실상 식별. 5~10 단위가 실무 균형점.
"같은 준식별자 조합을 가진 사람이 k명 이상"
k-익명성은 같은 준식별자 조합으로 묶인 최소 인원 수를 뜻합니다.
즉 k=3이면, 어떤 한 사람을 골라도 그 사람과 구분되지 않는 후보가 최소 3명은 되도록 만든 상태입니다.
| 나이 | 우편번호 | 직업 | 질병 |
|---|---|---|---|
| 23 | 02100 | 학생 | A |
| 25 | 02105 | 회사원 | B |
| 24 | 02103 | 학생 | A |
| 35 | 03200 | 회사원 | C |
| 36 | 03201 | 회사원 | A |
준식별자 조합이 모두 다름 → k=1 (위험).
| 나이 | 우편번호 | 직업 | 질병 |
|---|---|---|---|
| 20대 | 021** | 학생/회사원 | A |
| 20대 | 021** | 학생/회사원 | B |
| 20대 | 021** | 학생/회사원 | A |
| 30대 | 032** | 회사원 | C |
| 30대 | 032** | 회사원 | A |
상위 3명이 같은 준식별자 → k=3.
📌 「학생/회사원」 처럼 두 직업을 묶은 것은 일반화 (generalization) — 직업 컬럼을 상위 카테고리로 묶어 식별 위험을 낮추는 기법.
🎯 k=5 이상이 학계 권장. k=3은 최소.
import pandas as pd
def check_k_anonymity(df: pd.DataFrame,
quasi_cols: list[str],
k: int = 5) -> dict:
"""준식별자 조합으로 그룹화 → 가장 작은 그룹 크기 = k 값."""
grouped = df.groupby(quasi_cols, dropna=False).size()
min_k = grouped.min()
bad_groups = grouped[grouped < k]
return {
"k": int(min_k),
"n_groups": len(grouped),
"violating_groups": len(bad_groups),
"violating_records": int(bad_groups.sum()),
"ok": int(min_k) >= k,
}
# 사용
report = check_k_anonymity(
df, quasi_cols=["age_group", "zip3", "gender"], k=5,
)
print(report)
# {"k": 3, "n_groups": 124, "violating_groups": 12,
# "violating_records": 28, "ok": False}
📌 위반 그룹이 보이면 → 더 굵은 범주화 (10 → 20세 단위) 또는 해당 행 제거.
같은 준식별자 그룹 안에서 민감속성 값이 l 가지 이상 있어야 한다
❗ k 만 만족하면 부족합니다 — 5명이 같은 그룹인데 5명 모두 「당뇨」 면, 누가 누군지 몰라도 「이 그룹 사람은 당뇨」 가 그대로 노출됩니다.
| 나이 | 지역 | 직업 | 질병 |
|---|---|---|---|
| 20대 | 서울 | 학생/회사원 | 암 |
| 20대 | 서울 | 학생/회사원 | 감기 |
| 20대 | 서울 | 학생/회사원 | 당뇨 |
| 나이 | 지역 | 직업 | 질병 |
|---|---|---|---|
| 20대 | 서울 | 학생/회사원 | 당뇨 |
| 20대 | 서울 | 학생/회사원 | 당뇨 |
| 20대 | 서울 | 학생/회사원 | 당뇨 |
📌 학위논문 권장: k=5 + l=3 (분야·IRB 기준에 따라 조정)
l-다양성은 「민감속성 종류 수」만 봅니다. 그런데 비율 까지 보면 위험한 그룹이 여전히 있습니다.
| 질병 | 비율 |
|---|---|
| 감기 | 70% |
| 당뇨 | 20% |
| 암 | 10% |
전체 인구에서 암은 10% 정도
| 질병 | 비율 |
|---|---|
| 감기 | 0% |
| 당뇨 | 33% |
| 암 | 67% |
그룹 안 암 비율 67% — 전체의 7배
l 은 만족하지만 (암·당뇨 두 종류 → l=2) 위험합니다
🎯 공격자 추론: 「누군지 몰라도 이 그룹 사람은 암일 확률이 67% 네?」 — 이게 그룹 편향 추론 입니다.
그룹 내 민감속성 분포가 전체 분포와 t 이내 로 비슷해야 한다 (Li et al. 2007)
💡 EMD 비유 — 두 막대그래프 위에 모래더미가 쌓여 있다고 상상. 한쪽 모양을 다른 쪽으로 옮기는 데 드는 작업량이 거리.
전체: 감기 70 / 당뇨 20 / 암 10
| 질병 | 비율 |
|---|---|
| 감기 | 60% |
| 당뇨 | 25% |
| 암 | 15% |
→ 전체 분포에 충분히 가까움
→ t 작음 ✓
⚠️ t-근접성이 막는 것 — 「이 그룹 사람은 암일 가능성 67%」 같은 그룹 편향 추론
| 기법 | 막는 것 | 한 줄 정의 |
|---|---|---|
| k-익명성 | 개인 식별 | 같은 QI 조합인 사람이 k명 이상 |
| l-다양성 | 민감정보 단순 노출 | 그룹 안 민감속성이 l가지 이상 |
| t-근접성 | 특정 그룹 편향 추론 | 그룹 분포가 전체 분포와 t 이내 |
| 단계 | k | l | t |
|---|---|---|---|
| 학위논문 | 5 | 3 | — |
| 의학 출판 | 10 | 3 | 0.2 |
| 일반 마케팅 | 3~5 | — | — |
분야·기관·IRB 기준에 따라 다름 — 본인 환경 우선
💡 주요 메시지 — 「k → l → t」 순으로 보호 강도가 올라가고 분석 가치는 낮아집니다. 데이터 성격에 맞는 최소 기준을 선택하는 것이 원칙입니다.
회차 5 의 LLM 분석 — 여기에 함정

LLM 은 할루시네이션 도 한다.
그러나 더 큰 위험은 데이터 외부 송신.
| # | 시나리오 | 위험 | 권장 |
|---|---|---|---|
| 1 | 본인 댓글 분석 → 송신 | 🟢 본인 데이터 | OK (본인 동의) |
| 2 | 타인 공개 댓글 → 송신 | 🟡 ID 포함 시 위험 | 마스킹 후 송신 |
| 3 | 회사 내부 데이터 → 송신 | 🔴 비밀유지 위반 | 금지 (내부 LLM) |
| 4 | 환자/학생 데이터 → 송신 | ⚫ 형사 위험 | 금지 (오프라인 LLM) |
회차 5 에서 배운 클라우드 LLM 호출이 PII 누출 경로 가 됩니다.
PII 가 들어간 텍스트는 LLM 에 보내기 전에 마스킹, 또는 로컬 LLM (Ollama, llama.cpp) 사용.
import re
from anthropic import Anthropic
PII_PATTERNS = [
(re.compile(r"\d{3}-?\d{3,4}-?\d{4}"), "<PHONE>"),
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), "<EMAIL>"),
(re.compile(r"\d{6}-?\d{7}"), "<RESIDENT_NO>"),
]
def mask_pii(text: str) -> str:
for pat, repl in PII_PATTERNS:
text = pat.sub(repl, text)
return text
client = Anthropic()
review = "안녕하세요 010-1234-5678 로 연락주세요. test@example.com"
clean = mask_pii(review) # <PHONE>, <EMAIL> 등으로 치환
# 고정 placeholder 토큰을 사용해야 모델이 형식을 혼동하지 않음
resp = client.messages.create(
model="claude-haiku-4-5",
max_tokens=200,
messages=[{"role":"user", "content": f"감성 분류: {clean}"}],
)
🎯 권장 패턴
1) 마스킹 → 2) 분석 → 3) 결과를 원본 행에 다시 붙임 (마스킹 매핑 보관).
# 1) 설치
curl -fsSL https://ollama.com/install.sh | sh
# 2) 모델 다운
ollama pull llama3.2
ollama pull qwen2.5:7b
# 3) 호출
ollama run llama3.2 "이 리뷰를 분석해..."
import requests
r = requests.post(
"http://localhost:11434/api/generate",
json={"model": "qwen2.5:7b",
"prompt": "이 리뷰 감성: ...",
"stream": False},
)
print(r.json()["response"])
| 항목 | 클라우드 LLM | 로컬 LLM |
|---|---|---|
| 정확도 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 속도 | 빠름 (1초) | 느림 (5~30초/요청) |
| 비용 | 토큰당 | 0 (전기) |
| 데이터 외부 | ⚠️ 송신 | ✅ 머무름 |
🎯 민감 데이터 = 로컬 LLM 우선.
사람·민감 데이터는 반드시 거쳐야 합니다
IRB (Institutional Review Board, 기관생명윤리위원회) — 사람·생체·민감 데이터를 다루는 연구의 윤리적·법적 적합성을 사전 심의하는 대학·기관 위원회
🎯 모르겠으면 신청 — 5~10페이지 양식, 보통 2~4주.
🎯 6번 (비식별화) 이 자주 미흡
"이름은 지웠습니다" 만으론 부족. k-익명성, 마스킹 도구, 보관 위치 까지 적어야.
회차 3·5 데이터에 적용
"데이터 보호는 옵션이 아니라 시작 조건."