ETL/ELT 파이프라인 상에서 개인정보(PII)를 분석계 레이크하우스나 DW에 적재되기 직전 단계에서 동적·정적 마스킹(Data Masking)으로 처리하면, 내부 오남용과 컴플라이언스 리스크를 거의 “원천 차단”하는 수준까지 끌어올릴 수 있습니다.
이 글에서는 ETL/ELT 단계별로 주민번호, 이메일, 휴대폰번호 등 민감 필드를 실시간 컴플라이언스 관점에서 어떻게 설계·구현하는지, 코드 수준 예시까지 포함해 구조적으로 정리해 보겠습니다.

개인정보 마스킹의 개념과 목적
개인정보 마스킹(data masking)은 PII(Personally Identifiable Information)를 원 소스 데이터와 논리적으로 연결되도록 유지하면서도, 실제 값은 보호하는 기법을 의미합니다.
대표적으로 주민등록번호, 이메일, 휴대폰번호, 카드번호, 계좌번호, 주소 등이 해당되며, 분석용 데이터베이스나 데이터 레이크하우스에서는 전체 또는 일부만 노출되도록 설계합니다.
- 정적 마스킹(static masking)
배치 ETL/ELT 파이프라인에서 한 번만 실행되며, 원본과 마스킹된 복제본을 분리하는 방식입니다.
예: 개발·테스트 환경에 사용하기 위한 마스킹된 샘플 데이터베이스. - 동적 마스킹(dynamic masking)
조회 시점(쿼리 실행 시)에만 가상으로 마스킹 값을 보여주는 방식으로, 실시간 분석용 데이터 레이크하우스나 DW에 적합합니다.
예: 관리자와 일반 사용자에게 동일한 테이블을 보여주되, PII 컬럼은 역할(role)에 따라 다르게 마스킹합니다.
ETL vs ELT에서의 PII 처리 시점 차이
ETL과 ELT는 “언제 변환과 마스킹을 수행하는지”가 핵심 차이점입니다.
ETL 방식에서의 PII 마스킹
- 순서: Extract → Transform (마스킹 포함) → Load
- 민감 데이터는 소스에서 추출된 후, 스테이징 구간이나 중간 processing 계층에서 이미 마스킹·토크나이징되고,
그 결과만 분석계 레이크하우스나 DW에 적재되기 때문에, 데이터 저장소 자체에는 원본 PII가 거의 남지 않습니다.
이 방식은
- 규제가 엄격한 금융·의료·통신과 같이 “저장소에 PII가 존재하면 안 된다”는 요구가 강한 환경
- 데이터 거버넌스 팀이 강하게 통제하는 전통적 DW/랩스(Lakehouse) 환경
에서 특히 유리합니다.
ELT 방식에서의 PII 마스킹
- 순서: Extract → Load → Transform (마스킹 포함)
- 소스에서 조회된 원시 데이터(raw)를 먼저 레이크하우스나 클라우드 DW에 적재한 후,
이후 SQL 또는 파이프라인 내 UDF/스칼라 함수로 PII 컬럼을 마스킹하거나,
뷰(view) 단에서 동적 마스킹 규칙을 적용합니다.
장점은
- 유연한 스키마 변경, 반정형 데이터 처리,
- 대규모 데이터에 대해 파워풀한 클라우드 SQL 엔진을 활용할 수 있다는 점입니다.
반면,
- 원본 PII가 일정 기간 데이터 저장소에 존재하게 되므로,
- 접근 제어(ICL, RBAC), 감사 로그, 암호화, 동적 마스킹 정책을 강력하게 같이 설계해야 합니다.
실시간 컴플라이언스 관점에서의 설계 원칙
실시간 또는 배치 ETL/ELT 파이프라인을 설계할 때, 컴플라이언스 준수를 위해 기본적으로 “마스킹 지점”이 어디인지 명확히 정의해야 합니다. iri
1. PII 인식 및 분류(PII Discovery)
- 모든 소스 테이블과 JSON 스키마에 대해 자동 또는 메타데이터 기반 PII 태깅을 수행합니다.
- 예:
user_id,name,email,mobile,rrn,account_number필드를
“sensitive: true”, “masking_rule: partial_mask_6digits” 등 메타데이터로 표시합니다.
2. 마스킹 룰 통합 관리
- 별도의 masking policy 테이블이나 IaC 정책 파일을 두어,
동일한 규칙을 여러 파이프라인에서 공유하도록 설계합니다.
예제 테이블 스키마(SQL DDL):
CREATE TABLE masking_policy (
table_name VARCHAR(128),
column_name VARCHAR(128),
data_type VARCHAR(32),
masking_type VARCHAR(20), -- e.g., 'partial', 'hash', 'token'
rule_expression VARCHAR(1024),
enabled BOOLEAN,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
이 테이블을 읽어서,
- ETL 컴포넌트는
SELECT/INSERT구문 내에서 PII 컬럼에 대해CASE나 UDF로 변환 - ELT 파이프라인은 matérialized view 또는 secure view 생성 시
CASE로직을 적용합니다.
ETL 파이프라인: 스테이징 단계에서 정적 마스킹
ETL 파이프라인에서는 Transform 단계(스테이징 테이블 또는 중간 테이블)에서 PII를 마스킹하는 것이 가장 일반적인 패턴입니다.
1. 주민번호 마스킹 예시 (SQL)
대부분의 기준은 주민등록번호 뒷자리 6자리를 마스크하는 방식입니다.
-- 예시: 스테이징 테이블에서 주민번호 마스킹 후 DW 적재
INSERT INTO dw_staging.customer_clean
SELECT
user_id,
name,
CASE
WHEN rrn IS NOT NULL
THEN CONCAT(
SUBSTR(rrn, 1, LENGTH(rrn) - 6),
'******'
)
ELSE NULL
END AS masked_rrn,
CASE
WHEN LENGTH(email) > 0
THEN CONCAT(
SUBSTR(email, 1, 1),
'**',
'@',
SUBSTR(email, POSITION('@' IN email) + 1)
)
ELSE NULL
END AS masked_email,
CASE
WHEN mobile LIKE '01%'
THEN CONCAT(
SUBSTR(mobile, 1, 3),
'-****-',
SUBSTR(mobile, 9)
)
ELSE mobile
END AS masked_mobile,
created_at
FROM source_staging.customer_raw;
이 쿼리는
- 주민번호 뒷자리 6자리를
******로 변환 - 이메일은 ID 앞 1자리와
**를 유지하고, - 휴대폰번호는 가운데 4자리를 마스킹
하는 방식으로, 일반적인 개인정보 마스킹 가이드라인을 반영합니다.
2. 파이프라인 구성 예시 (Python + Airflow)
아래는 간단한 ETL 파이프라인 예시 코드입니다.
(ETL = Extract → Transform → Load)
from typing import List, Dict
import pandas as pd
from sqlalchemy import create_engine
def extract_source_data() -> pd.DataFrame:
"""소스 DB에서 원본 데이터 추출"""
engine = create_engine("postgresql://user:pass@localhost:5432/source_db")
query = "SELECT user_id, name, rrn, email, mobile, created_at FROM customer_raw;"
df = pd.read_sql(query, engine)
return df
def mask_pii_column(df: pd.DataFrame) -> pd.DataFrame:
"""PII 컬럼 마스킹 처리"""
df = df.copy()
# 주민번호 마스킹
if "rrn" in df.columns:
df["masked_rrn"] = df["rrn"].apply(
lambda x: f"{x[:-6]}{'*' * 6}" if pd.notna(x) and len(x) >= 7 else None
)
# 이메일 마스킹
if "email" in df.columns:
def mask_email(email: str) -> str:
if not email or "@" not in email:
return email
local, domain = email.split("@", 1)
if len(local) <= 2:
return "*" * len(local) + "@" + domain
return local[0] + "*" * (len(local) - 2) + local[-1] + "@" + domain
df["masked_email"] = df["email"].apply(mask_email)
# 휴대폰번호 마스킹
if "mobile" in df.columns:
def mask_mobile(phone: str) -> str:
if not phone or len(phone) < 9:
return phone
if len(phone) >= 11:
return phone[:3] + "****" + phone[7:]
return phone
df["masked_mobile"] = df["mobile"].apply(mask_mobile)
return df
def load_to_dw(df: pd.DataFrame):
"""데이터 웨어하우스에 적재 (원본 PII 제외)"""
engine = create_engine("postgresql://user:pass@localhost:5432/dw_db")
df[["user_id", "name", "masked_rrn", "masked_email", "masked_mobile", "created_at"]].to_sql(
"customer_fact", engine, if_exists="append", index=False
)
def main_etl_pipeline():
raw_df = extract_source_data()
masked_df = mask_pii_column(raw_df)
load_to_dw(masked_df)
if __name__ == "__main__":
main_etl_pipeline()
이 방식은
- 소스에서 추출한 직후에 PII 컬럼을 처리하고,
- 데이터 웨어하우스에는 마스킹된 컬럼만 저장하여,
저장소 내부에서 PII 내부 오남용 리스크를 최소화합니다.
ELT 파이프라인: 레이크하우스/DW 내부에서 마스킹
ELT 환경에서는 원본 데이터를 먼저 적재하고, 그 위에 마스킹된 뷰 또는 파생 테이블을 생성하는 방식을 많이 사용합니다.
1. 데이터 레이크하우스에 원본 적재
예를 들어, Deltalake/Spark 또는 Snowflake/Staging Zone을 사용하는 경우:
-- 1. 원본 테이블 생성 (ETL/ELT ingestion)
CREATE TABLE raw.customer (
user_id STRING,
name STRING,
rrn STRING,
email STRING,
mobile STRING,
created_at TIMESTAMP
);
-- 2. 원본 데이터는 ETL/ELT 파이프라인에서 적재
이 단계에서 원본 PII는 저장소에 존재하지만, 접근 권한과 암호화 정책으로만 제어할 수 있습니다.
2. 동적 마스킹 뷰(view) 정의
여러 팀이 분석을 위해 같은 테이블을 공유하되, 역할에 따라 PII 가시성이 다르게 설정됩니다.
Snowflake 기준 동적 마스킹 뷰 예시:
CREATE VIEW secure.customer (
user_id,
name,
masked_rrn,
masked_email,
masked_mobile
) AS
SELECT
user_id,
name,
CASE
WHEN CURRENT_ROLE() IN ('admin_role', 'security_role')
THEN rrn
ELSE
CONCAT(
SUBSTR(rrn, 1, LENGTH(rrn) - 6),
'******'
)
END AS masked_rrn,
CASE
WHEN CURRENT_ROLE() IN ('admin_role', 'security_role')
THEN email
ELSE
CONCAT(
SUBSTR(email, 1, 1),
'**',
'@',
SUBSTR(email, POSITION('@' IN email) + 1)
)
END AS masked_email,
CASE
WHEN CURRENT_ROLE() IN ('admin_role', 'security_role')
THEN mobile
ELSE
CONCAT(
SUBSTR(mobile, 1, 3),
'-****-',
SUBSTR(mobile, 8)
)
END AS masked_mobile
FROM raw.customer;
이 뷰는
- 관리자/보안 담당자에게는 원본 PII를 보여주고,
- 일반 분석가/데이터 사이언티스트에게는 마스킹된 PII만 조회되도록 설계합니다.
정적 마스킹 vs 동적 마스킹 적용 사례
정적 마스킹 사용 시점
- 테스트/개발 환경용 데이터베이스
- 규제 감사용 샘플 데이터
- ETL 파이프라인에서 생성된 중간 테이블
예: 배치 ETL 마지막 단계에서 masked_customer라는 테이블을 생성하고, 이 테이블만 데이터 레이크하우스/BI 툴에 연결합니다.
동적 마스킹 사용 시점
- 실시간 분석 플랫폼
- 여러 역할이 같은 테이블을 공유해야 하는 경우
- 원본 데이터를 보존해야 하면서도, 분석용으로는 마스킹이 필요한 경우
이때는 Row‑Level Security(RLS) + Dynamic Masking View를 함께 사용하여,
하나의 스키마로 컴플라이언스를 만족하는 방식으로 설계합니다.
실시간 ETL 파이프라인에서의 마스킹 처리
최근에는 실시간 스트리밍 ETL/ELT가 늘면서, Kafka‑to‑Deltalake/Spark, Change Data Capture(CDC) 등에서 PII를 스트리밍 단계에서 마스킹하는 패턴이 증가합니다.
예: Kafka Consumer에서 PII 마스킹 (Python)
아래 코드는 Kafka 서버에서 메시지 수신 후,
메시지 내 PII 필드를 마스킹하여 다시 Kafka 토픽 또는 Deltalake로 적재하는 예시입니다.
from kafka import KafkaConsumer, KafkaProducer
import json
def mask_pii_in_stream(record: Dict) -> Dict:
"""스트리밍 레코드 내 PII 마스킹"""
if "rrn" in record:
rrn = record["rrn"]
if rrn and len(rrn) >= 7:
record["masked_rrn"] = rrn[:-6] + "******"
else:
record["masked_rrn"] = None
if "email" in record:
email = record["email"]
if email and "@" in email:
local, domain = email.split("@", 1)
if len(local) <= 2:
record["masked_email"] = "*" * len(local) + "@" + domain
else:
record["masked_email"] = local[0] + "*" * (len(local) - 2) + local[-1] + "@" + domain
else:
record["masked_email"] = None
if "mobile" in record:
mobile = record["mobile"]
if mobile and len(mobile) >= 11:
record["masked_mobile"] = mobile[:3] + "****" + mobile[7:]
else:
record["masked_mobile"] = mobile
# 원본 PII는 제거
record.pop("rrn", None)
record.pop("email", None)
record.pop("mobile", None)
return record
def main_streaming_masker():
consumer = KafkaConsumer(
"source-topic",
bootstrap_servers=["localhost:9092"],
value_deserializer=lambda v: json.loads(v.decode("utf-8")),
auto_offset_reset="earliest"
)
producer = KafkaProducer(
bootstrap_servers=["localhost:9092"],
value_serializer=lambda v: json.dumps(v).encode("utf-8")
)
for message in consumer:
raw_record = message.value
masked_record = mask_pii_in_stream(raw_record)
# 마스킹된 레코드를 새 토픽으로 전달
producer.send("masked-topic", masked_record)
if __name__ == "__main__":
main_streaming_masker()
이 구조는
- 데이터 소스와 분석계 사이에 PII 마스킹 미들웨어를 두고,
- 원본 PII는 레이크하우스/데이터 웨어하우스에 절대 적재되지 않도록 보장합니다.
컴플라이언스 관점에서의 보완 정책
PII 마스킹 기술만으로는 부족하고, 거버넌스·접근 제어·로깅을 반드시 함께 설계해야 합니다.
1. 역할 기반 접근 제어(RBAC)
- 관리자/보안 담당자: 원본 PII(데이터베이스 레벨, 로그, 백업 등) 접근
- 분석가/데이터 사이언티스트: 마스킹 뷰 또는 마스킹된 테이블만 조회
- 외부 분석 파트너: 접근 제한 또는 합성 데이터(Privacy‑Preserving Synthetic Data) 사용
2. 감사 로그 및 접근 이력
- 谁가, 언제, 어떤 PII 컬럼을 조회했는지
- PII 마스킹 로직이 변경된 시점
- 백업/복구 과정에서 PII가 노출될 수 있는 경로
을 모두 기록하여, 규제 감사 시에도 추적 가능하도록 설계합니다.
3. 암호화와 토큰라이징
- 데이터베이스 계층 암호화(TDE)
- PII 필드 토큰라이징(tokenization)
- 마스킹과 토큰라이징을 병행하여,
“디지털 환경에서 PII를 본 사람이 없도록” 하는 Defense‑in‑Depth 구조를 적용합니다.
놓치면 아쉬운 추천 글, 함께 읽어보세요!
- 추천 글을 불러오는 중입니다...