Blog
pysparksparkmllibfeature-engineeringdata-leakagemachine-learning

PySpark ML 피처 엔지니어링 — 대규모 파이프라인과 데이터 누수 방지

수억 행에서 ML 피처를 만들 때의 난제. VectorAssembler·Pipeline 구성, 범주형 인코딩, train/test 누수(data leakage) 방지, 그리고 학습-서빙 일관성을 지키는 패턴을 PySpark MLlib 코드와 함께 정리합니다.

Data Dynamics2026年6月5日16 min read
This post is not yet translated. The original Korean version is shown below.

시험을 앞두고 문제집을 풀 때, 정답지를 먼저 들여다보고 공부했다면 — 모의고사 점수는 만점이 나오겠지만 실전에선 완전히 무너지겠죠. 데이터 누수가 바로 그 상황입니다. 모델이 "정답(테스트 데이터의 통계)"을 보면서 학습했으니 검증 점수는 놀랍도록 높지만, 실제 운영에서는 그 답지가 없으니 성능이 곤두박질칩니다.

ML 모델의 성능은 모델 자체보다 피처의 품질에서 갈립니다. 그리고 대규모 데이터에서 피처를 만드는 일은 단순 변환이 아니라 — 메모리, 누수(leakage), 학습-서빙 일관성이 얽힌 난제입니다. 특히 데이터 누수는 검증에서는 완벽해 보이다가 프로덕션에서 모델을 무너뜨리는, 가장 비싸고 은밀한 버그입니다.

이 글은 PySpark MLlib 로 대규모 피처 파이프라인을 구성하는 법, 범주형 인코딩, 그리고 무엇보다 누수를 구조적으로 막는 패턴을 정리합니다.

이 글에서 배우는 것

  • 데이터 누수의 두 종류(Train/Test 누수, 시간 누수)와 각각의 증상
  • MLlib 의 fit/transform 분리가 누수를 막는 원리
  • Pipeline 으로 전처리를 하나로 묶어 학습-서빙 일관성을 보장하는 방법
  • 고카디널리티 범주형 변수를 안전하게 인코딩하는 전략
  • 윈도우 집계와 as-of 조인으로 시간 누수를 구조적으로 차단하는 패턴

1. 가장 위험한 적 — 데이터 누수

누수는 조용하고 교활합니다. 코드가 오류를 내지 않고, 성능 지표는 오히려 더 좋아 보이기 때문에 알아채기가 어렵죠. 데이터 누수는 학습 시점에 알 수 없는 정보가 피처에 들어가는 것입니다. 두 종류가 있습니다.

① Train/Test 누수: 테스트 데이터의 통계가 학습 전처리에 섞임
   예) 전체 데이터로 평균을 구해 정규화 → 테스트 정보가 학습에 누설
 
② 시간 누수(미래 정보): 예측 시점 이후의 데이터가 피처에 포함
   예) "현재" 고객 등급으로 과거 라벨을 예측 → 미래값 사용
누수증상방어
Train/Test검증 점수 비현실적으로 높음fit 은 train 에만
시간(미래)백테스트 좋은데 실전 붕괴as-of join, point-in-time

핵심 원칙: 모든 통계(평균·분산·인코딩 매핑)는 train 데이터에서만 학습하고, test 와 서빙에는 그 학습된 변환을 적용만 합니다. 이것이 MLlib 의 fit/transform 분리가 존재하는 이유입니다.

2. fit / transform 분리 — 누수 방지의 기본 구조

MLlib 의 API 설계 자체가 여러분을 옳은 방향으로 이끕니다. Transformer/Estimator 모델이 누수 방지를 구조적으로 강제합니다. fit 은 통계를 학습(train 에서만), transform 은 적용(어디에나)입니다.

from pyspark.ml.feature import StandardScaler
 
# 먼저 train/test 분리 (전처리보다 먼저!)
train, test = df.randomSplit([0.8, 0.2], seed=42)
 
scaler = StandardScaler(inputCol="features", outputCol="scaled")
 
model = scaler.fit(train)        # ✅ 통계(평균·표준편차)를 train 에서만 학습
train_s = model.transform(train) # 적용
test_s  = model.transform(test)  # 같은 모델로 적용 (test 통계 안 씀)
# ❌ 누수: 전체로 fit 한 뒤 split → test 정보가 scaler 에 섞임
model = scaler.fit(df)           # 전체 통계 사용 → 누수
train, test = df.randomSplit(...)

순서가 핵심입니다 — split 을 먼저, fit 은 train 에만. 이 한 가지 규칙을 지키는 것만으로도 가장 흔한 누수 패턴의 절반을 막을 수 있습니다.

한 문장으로: 데이터를 먼저 나누고, 나눈 train 데이터에서만 통계를 배우세요.

3. Pipeline — 변환을 하나로 묶기

전처리 단계가 여러 개라면, 각 단계를 따로 관리하다 보면 순서를 바꾸거나 일부를 빠뜨리는 실수가 생기기 쉽습니다. 여러 전처리 단계를 Pipeline 으로 묶으면, fit/transform 이 전체에 일관되게 적용되어 누수를 구조적으로 막고 학습-서빙 일관성도 보장됩니다.

from pyspark.ml import Pipeline
from pyspark.ml.feature import StringIndexer, OneHotEncoder, VectorAssembler
 
# 범주형 인코딩 → 벡터 조립 → 스케일링을 한 파이프라인으로
indexer = StringIndexer(inputCols=["city", "device"],
                        outputCols=["city_idx", "device_idx"],
                        handleInvalid="keep")          # 미지 카테고리 처리
encoder = OneHotEncoder(inputCols=["city_idx", "device_idx"],
                        outputCols=["city_oh", "device_oh"])
assembler = VectorAssembler(
    inputCols=["age", "amount", "city_oh", "device_oh"],
    outputCol="features",
    handleInvalid="skip")
scaler = StandardScaler(inputCol="features", outputCol="scaled")
 
pipeline = Pipeline(stages=[indexer, encoder, assembler, scaler])
 
model = pipeline.fit(train)      # 전체 파이프라인을 train 에서 학습
train_f = model.transform(train)
test_f  = model.transform(test)
 
# 파이프라인 모델을 저장 → 서빙에서 동일 변환 보장
model.write().overwrite().save("/models/feature_pipeline")

Pipeline 모델을 저장해두면 학습 때와 똑같은 변환을 서빙에서 재현할 수 있습니다. "학습 때와 다르게 전처리해서 서빙 성능이 떨어지는" 흔한 사고(training-serving skew)를 막는 핵심입니다.

4. 범주형 인코딩 — 고카디널리티 함정

범주형 변수를 숫자로 바꾸는 인코딩은 ML 파이프라인의 필수 단계입니다. 그런데 카테고리 수가 많아질수록(고카디널리티) 단순한 방법이 큰 문제를 일으킬 수 있습니다. 어떤 인코더를 쓸지는 카디널리티에 따라 달라집니다.

인코더용도함정
StringIndexer문자열 → 인덱스미지 카테고리(handleInvalid)
OneHotEncoder인덱스 → 희소 벡터고카디널리티면 차원 폭발
FeatureHasher해시 기반 인코딩고카디널리티에 적합(충돌 허용)

고카디널리티 범주(상품 ID 수십만)를 OneHot 하면 수십만 차원이 됩니다. 이럴 때는 FeatureHasher(해시 트릭)나 target/frequency 인코딩을 씁니다.

from pyspark.ml.feature import FeatureHasher
 
hasher = FeatureHasher(inputCols=["product_id", "city"],
                       outputCol="hashed", numFeatures=1 << 18)

handleInvalid="keep" 를 꼭 설정하세요. 서빙에서 학습 때 없던 카테고리를 만나면, 이 옵션이 없으면 에러가 납니다. 프로덕션에서는 미지 카테고리가 반드시 등장합니다.

5. 시간 누수 방지 — Point-in-Time 피처

시계열 데이터에서 시간 누수는 특히 발견하기 어렵습니다. 코드가 에러 없이 실행되고, 숫자도 그럴듯해 보이거든요 — 그런데 그 숫자가 "미래를 봤기 때문"에 좋은 숫자일 수 있습니다. 시계열·이벤트 데이터에서 피처는 "예측 시점에 유효했던" 값만 써야 합니다. "현재값" 테이블을 그냥 조인하면 미래 정보가 새어 들어갑니다.

# ❌ 누수: 라벨 시점 이후일 수 있는 현재 등급을 조인
features = labels.join(dim_users_current, "user_id")
 
# ✅ as-of: 라벨 시점 이전의 유효값만
features = labels.join(dim_users_history,
    (labels.user_id == dim_users_history.user_id) &
    (labels.label_time >= dim_users_history.valid_from) &
    (labels.label_time <  dim_users_history.valid_to))

이 point-in-time 조인은 별도 글 "PySpark As-of Join"에서 자세히 다뤘습니다. 피처 스토어가 존재하는 핵심 이유가 바로 이 시점 정합성 보장입니다.

⚠️ 백테스트 점수가 실전보다 지나치게 좋다면 시간 누수를 가장 먼저 의심하세요. 특히 "현재 기준" 테이블을 과거 라벨에 조인하는 코드를 찾아보면 됩니다.

6. 집계 피처와 누수

집계 피처는 직관적으로 강력한 신호처럼 보이지만, 범위를 잘못 잡으면 미래를 들여다보는 피처가 됩니다. "사용자별 최근 30일 평균 구매액" 같은 윈도우 집계 피처도 누수 위험이 큽니다. 집계 범위가 라벨 시점을 넘으면 미래를 봅니다.

from pyspark.sql.window import Window
from pyspark.sql import functions as F
 
# 라벨 시점 "이전" 데이터로만 집계 (rangeBetween 으로 미래 차단)
w = (Window.partitionBy("user_id")
            .orderBy(F.col("event_time").cast("long"))
            .rangeBetween(-30*86400, -1))   # 직전 30일 ~ 현재 직전(미래 배제)
 
features = df.withColumn("avg_30d", F.avg("amount").over(w))

rangeBetween 의 상한을 -1(또는 currentRow 인지)로 두어 현재·미래를 명시적으로 배제하는 것이 핵심입니다. 이 한 줄이 윈도우 집계 피처에서 시간 누수를 막는 열쇠입니다.

7. 대규모에서의 성능

피처 파이프라인이 수억 행에서 동작할 때는 정확성뿐 아니라 성능도 챙겨야 합니다. 어떤 단계가 병목이 되는지, 어디서 메모리가 부족해지는지 미리 알아두면 도움이 됩니다.

항목주의
OneHot 희소 벡터고차원이면 메모리 — FeatureHasher
윈도우 집계 피처큰 파티션 OOM(별도 글 시계열)
조인 피처차원 테이블 broadcast, 스큐 점검
Pipeline 단계불필요한 중간 캐시 피하기
피처 저장Iceberg/Delta 피처 테이블로 재사용

피처는 한 번 계산해 피처 테이블(Lakehouse)로 저장하고 여러 모델이 재사용하는 것이 효율적입니다(피처 스토어의 기본 아이디어). 매번 새로 계산하면 비용과 시간 낭비일 뿐 아니라, 모델마다 다른 방식으로 계산해 불일치가 생길 위험도 있습니다.

8. 정리

영역핵심
누수 방지split 먼저, fit 은 train 에만
구조화Pipeline 으로 묶어 일관성·재현성
범주형handleInvalid, 고카디널리티는 hashing
시간 누수as-of/유효기간 조인, 윈도우 미래 배제
학습-서빙Pipeline 모델 저장·재사용

대규모 ML 피처 엔지니어링의 핵심 통찰은 "누수는 성능 문제가 아니라 정확성 문제"라는 것입니다. 검증 점수가 비현실적으로 좋다면 거의 항상 누수를 의심해야 합니다. MLlib 의 fit/transform 분리와 Pipeline 을 규율로 삼아 통계는 train 에서만 학습하고, 시간 피처는 point-in-time 으로 미래를 차단하세요. 그러면 검증에서 본 성능이 프로덕션에서도 재현되는 — 신뢰할 수 있는 모델을 만들 수 있습니다.

마치며 — 핵심 요약

  • 누수는 조용한 버그입니다. 코드는 잘 돌고, 성능은 오히려 좋아 보입니다. 검증 점수가 비현실적으로 높다면 누수를 가장 먼저 의심하세요.
  • split 먼저, fit 은 train 에만. 이 순서 하나를 지키는 것이 Train/Test 누수를 막는 첫 번째 방어선입니다.
  • Pipeline 으로 묶어 저장하세요. 전처리 단계를 파이프라인으로 관리하고 저장해 두면 학습과 서빙에서 동일한 변환이 보장되어 training-serving skew 를 막을 수 있습니다.
  • 고카디널리티엔 FeatureHasher 를 쓰세요. 수십만 카테고리에 OneHot 을 적용하면 차원이 폭발합니다. 해시 트릭이 실용적인 대안입니다.
  • 시간 피처는 반드시 point-in-time 으로. as-of 조인과 rangeBetween 상한을 -1 로 설정해 미래 정보가 스며들지 않게 하는 것이 시간 누수를 막는 핵심입니다.
  • 피처는 한 번 계산해 재사용하세요. Lakehouse 피처 테이블로 저장해 두면 여러 모델이 일관된 피처를 공유하고, 매번 재계산하는 낭비도 줄일 수 있습니다.

시험 정답지를 보지 않고 공부해야 진짜 실력이 쌓이듯, 여러분의 모델도 정직한 피처로 학습해야 프로덕션에서 진가를 발휘합니다. 이 글의 패턴들이 그 기반이 되길 바랍니다.


이 글은 Spark 3.5 + MLlib 기준으로 작성되었습니다. 대규모 ML 피처 파이프라인·피처 스토어 설계가 필요하시면 언제든 문의해 주세요.

— Data Dynamics 엔지니어링 팀