대부분의 팀은 고객 민원이나 규정 준수 감사 이후에야 PII 유출을 발견합니다. 그때가 되면 데이터는 이미 ETL 파이프라인을 통과해 애플리케이션 로그에 남고, 세 가지 다른 observability 도구에 인덱싱되어 있습니다.
사후에 찾는 것은 고고학과 같습니다. 실제로 필요한 것은 PII가 코드에 들어오는 순간 추적하고, 모든 변환을 통해 그 정보를 전파하며, 잘못된 채널로 나가는 것을 차단하는 시스템입니다. 이는 이미 해결된 문제는 아니지만, 해결 가능한 문제입니다.
”PII 추적”이 실제로 의미하는 것
PII 추적은 종종 데이터 마스킹이나 접근 제어와 혼동됩니다. 이들은 관련된 문제이지만, 실제 문제의 하위 단계입니다. 위치를 알 수 없는 데이터를 삭제하거나 암호화하거나 접근을 제한할 수는 없습니다.
추적이란 파이프라인의 모든 지점에서 어떤 필드가 민감한 데이터를 포함하는지 아는 것을 의미합니다. email이나 ssn 같은 열 이름을 보고 추측하는 것이 아닙니다. 데이터가 변환, 집계, 직렬화를 거쳐도 살아남는 레이블을 가지고 있기 때문에 실제로 아는 것입니다.
이를 위해서는 세 가지가 필요합니다: 필드를 민감한 것으로 태그할 수 있는 타입 시스템이나 메타데이터 레이어, 데이터가 함수와 서비스 사이를 이동할 때 태그를 전달하는 전파 메커니즘, 그리고 태그된 데이터가 승인된 목적지에만 기록되는지 검증하는 enforcement 지점입니다.
아키텍처: 태그, 전파, 강제
제가 본 가장 깔끔한 구현은 언어 수준에서 labeled 데이터 타입을 사용하는 것입니다. Python에서는 민감도 태그를 가진 클래스로 값을 감쌀 수 있습니다.
from dataclasses import dataclass
from enum import Enum, auto
from typing import Generic, TypeVar
T = TypeVar("T")
class Sensitivity(Enum):
PLAIN = auto()
PII = auto()
PCI = auto()
@dataclass(frozen=True)
class Labeled(Generic[T]):
value: T
sensitivity: Sensitivity = Sensitivity.PLAIN
def map(self, fn):
return Labeled(fn(self.value), self.sensitivity)
사용자가 계정을 등록할 때, 경계에서 원시 입력에 레이블을 붙입니다.
from labeled import Labeled, Sensitivity
def create_user(payload: dict) -> dict:
email = Labeled(payload["email"], Sensitivity.PII)
name = Labeled(payload["name"], Sensitivity.PII)
age = Labeled(int(payload["age"]), Sensitivity.PLAIN)
user = {
"id": generate_uuid(),
"email": email,
"name": name,
"age": age,
}
return user
핵심 통찰은 email과 name이 이제 자기 기술적이라는 것입니다. Labeled 값을 받는 모든 함수는 페이로드를 파싱하거나 키 이름을 보고 추측하지 않고도 민감도를 확인할 수 있습니다.
변환을 통해 레이블 전파하기
labeled 데이터는 레이블이 비즈니스 로직을 거쳐도 살아남을 때에만 유용합니다. map, filter, 집계를 할 때 민감도가 어떻게 결합되는지에 대한 규칙이 필요합니다.
가장 간단한 규칙 집합은 join semilattice입니다. 두 값을 병합하면 결과는 더 제한적인 레이블을 가집니다.
class Sensitivity(Enum):
PLAIN = 1
PII = 2
PCI = 3
def join(self, other: "Sensitivity") -> "Sensitivity":
return self if self.value >= other.value else other
하류 서비스를 위해 레코드를 직렬화할 때 메타데이터에 레이블을 포함합니다. 그러면 consumer는 원시 데이터베이스, 마스킹된 분석 웨어하우스, 감사 로그 중 어디에 기록할지 결정할 수 있습니다.
def serialize(record: dict) -> dict:
return {
key: {
"value": serialize_value(val.value),
"sensitivity": val.sensitivity.name.lower(),
}
for key, val in record.items()
if isinstance(val, Labeled)
}
이것이 사람들을 넘어뜨리는 부분입니다. 수집 지점에서만 태그를 붙이고 잘되기를 바랄 수는 없습니다. 레이블은 직렬화 레이어에서 일급 관심사가 되어야 합니다. 내부 RPC 형식이 메타데이터를 떨어뜨리면 데이터가 service boundary를 넘는 순간 파이프라인은 눈이 멀게 됩니다.
Enforcement: 레이블이 실제로 중요한 곳
enforcement 없는 추적은 비싼 관찰 가능성 연극에 불과합니다. 데이터가 저장소에 기록되거나 네트워크를 통해 전송되거나 UI에 렌더링되기 전에 labeled 데이터를 검사하는 choke point가 필요합니다.
흔한 패턴은 목적지를 허용된 최대 민감도에 매핑하는 sink registry입니다.
SINK_POLICIES = {
"raw_postgres": {Sensitivity.PLAIN, Sensitivity.PII, Sensitivity.PCI},
"analytics_clickhouse": {Sensitivity.PLAIN},
"application_logs": {Sensitivity.PLAIN},
"third_party_api": set(),
}
def write(sink_name: str, record: dict):
allowed = SINK_POLICIES[sink_name]
for key, val in record.items():
if isinstance(val, Labeled) and val.sensitivity not in allowed:
raise ValueError(
f"Field {key} with sensitivity {val.sensitivity.name} "
f"is not allowed in sink {sink_name}"
)
# proceed with write
분석 파이프라인이 Sensitivity.PII를 포함한 레코드를 ClickHouse에 수집하려 하면 런타임에 명확한 오류와 함께 쓰기가 실패합니다. 이는 우아하지 않지만 명시적입니다. 명시적인 것이 GDPR 위반보다 낫습니다.
로깅의 경우 특히 logger 수준에서 이를 enforcement할 수 있습니다. Python의 logging 모듈은 로그 레코드가 출력되기 전에 검사할 수 있는 filter를 지원합니다.
import logging
class PiiFilter(logging.Filter):
def filter(self, record):
msg = record.getMessage()
# In practice, use a more sophisticated check or structured logging
if any(label in msg for label in ["email", "ssn", "phone"]):
record.msg = "[REDACTED: PII detected]"
record.args = ()
return True
logger = logging.getLogger(__name__)
logger.addFilter(PiiFilter())
이는 무딘 도구입니다. 더 나은 접근법은 로그 페이로드가 동일한 labeled dictionary인 structured logging이며, 포매터가 정규표현식이 아닌 민감도 태그를 기준으로 삭제하는 것입니다.
백스톱으로서의 정적 분석
런타임 레이블링은 강력하지만 규율이 필요합니다. 누군가는 새 필드를 감싸는 것을 잊거나, 타입 오류를 피하기 위해 Labeled 값을 원시 타입으로 캐스팅할 것입니다.
정적 분석이 이 공백을 메웁니다. Semgrep이나 사용자 정의 린터 같은 도구는 HTTP request body의 원시 문자열이 labeling constructor를 거치지 않고 로거나 데이터베이스 sink에 직접 전달되지 않도록 enforcement할 수 있습니다.
최소한의 Semgrep 규칙은 다음과 같습니다:
rules:
- id: unlabeled-pii-log
patterns:
- pattern: logger.info($X)
- metavariable-pattern:
metavariable: $X
pattern-either:
- pattern: request.json[$FIELD]
- pattern: request.form[$FIELD]
message: "Logging raw request data without sensitivity labeling"
languages: [python]
severity: ERROR
모든 유출을 잡아내지는 못하지만, 명백한 것들은 잡아냅니다. 대부분의 유출은 이런 명백한 곳에서 발생합니다.
아무도 이야기하고 싶어 하지 않는 트레이드오프
이 접근법은 마찰을 더합니다. 이제 모든 데이터 변환에 추가 메타데이터 필드가 포함됩니다. 직렬화 페이로드가 커집니다. 코드 리뷰는 user_agent가 PII인지에 대한 논쟁이 됩니다. (그런데 보통 맞습니다. EU도 그렇게 생각합니다.)
성능은 실제 우려 사항입니다. 초당 수백만 개의 이벤트를 처리할 때 모든 필드에 Labeled 래퍼를 할당하는 것은 측정 가능한 오버헤드입니다. hot path에서는 레이블 검사를 일괄 처리하거나 enforcement를 엣지로 옮길 필요가 있을 수 있습니다.
유지보수 부담도 있습니다. 민감도 레이블은 이를 유지하는 사람만큼만 좋습니다. 개인정보 보호 규정이 변경되거나 비즈니스가 새로운 관할 구역으로 확장될 때 데이터를 다시 태그해야 합니다. 여기에 마법은 없습니다. 이는 일입니다.
우리가 선택하지 않은 것: 저장 데이터의 정규표현식 검색
일부 팀은 사후에 데이터 저장소를 정규표현식으로 검색해 이메일이나 주민등록번호처럼 보이는 패턴을 찾는 방식으로 이를 해결합니다. 이는 아무것도 하지 않는 것보다 낫지만, 근본적으로 사후 대응적입니다.
정규표현식은 난독화된 데이터, 해시된 식별자, 복합 PII를 놓칩니다. 또한 시스템에 대한 신뢰를 침식하는 오탐을 만들어냅니다. 우리는 초기에 이 접근법을 검토했다가 거부했습니다. 왜냐하면 이는 원인(레이블 없이 서비스를 떠나는 데이터)이 아닌 증상(잘못된 곳에 있는 데이터)을 다루기 때문입니다.
choke point부터 시작하라
첫날부터 모든 서비스의 모든 필드에 레이블을 붙일 필요는 없습니다. 경계부터 시작하세요. 사용자, 서드파티 API, 이벤트 스트림에서 시스템으로 들어오는 데이터에 레이블을 붙입니다. 그런 다음 가장 위험이 큰 sink에서 enforcement를 추가하세요: 분석 파이프라인, 로깅 인프라, 외부 통합.
이것들이 자리 잡으면 안쪽으로 확장하세요. 목표는 완벽한 커버리지가 아닙니다. 목표는 PII 유출을 실수로 만들기 어렵게 하고, 코드 리뷰에서 명백하게 잡아낼 수 있게 하는 것입니다.
Python으로 이를 구축한다면 위의 Labeled 클래스만으로 시작하기에 충분합니다. TypeScript에서는 branded type이나 유사한 wrapper가 같은 방식으로 작동합니다. Go에서는 실수로 캐스팅되지 않도록 unexported 필드를 가진 struct를 사용하고 싶을 것입니다.
도구는 간단합니다. 어려운 부분은 추적되지 않는 PII를 버그로 인정하고, 버그처럼 다루는 것입니다.