- Published on
Feast Feature Store 실전 운영 가이드: 피처 엔지니어링부터 실시간 서빙과 학습-서빙 스큐 방지까지
- Authors
- Name
- 들어가며
- Feature Store 필요성
- Feast 아키텍처
- 설치와 초기 설정
- 피처 정의와 엔티티 관리
- Feature View와 Feature Service
- 오프라인 학습 데이터 생성
- 온라인 서빙 파이프라인
- Materialization 전략
- 학습-서빙 스큐 방지
- 스트리밍 피처 통합
- Feature Store 비교
- 모니터링
- 트러블슈팅
- 운영 시 주의사항
- 실패 사례와 복구
- 체크리스트
- 참고자료

들어가며
ML 모델이 프로덕션에서 실패하는 원인 중 상당수는 모델 자체가 아니라 피처(Feature) 에서 비롯된다. 학습 시 계산한 피처와 서빙 시 조회되는 피처가 달라지는 학습-서빙 스큐(Training-Serving Skew), 피처 파이프라인 장애로 인한 stale 데이터 서빙, 피처 정의 변경 시 하위 모델과의 호환성 파괴 등이 대표적이다. Feature Store는 이러한 문제를 아키텍처 수준에서 해결하기 위해 등장한 인프라 컴포넌트다.
Feast(Feature Store)는 2019년 Gojek과 Google에 의해 시작된 오픈소스 Feature Store 프로젝트로, 현재 가장 활발한 커뮤니티를 보유하고 있다. 이 글에서는 Feast를 프로덕션 환경에서 실제로 운영할 때 마주치는 설계 결정, 아키텍처 선택, 장애 대응, 그리고 학습-서빙 스큐를 구조적으로 방지하는 전략까지 포괄적으로 다룬다. 단순 튜토리얼 수준이 아닌, 수십 개의 Feature View와 수백만 엔티티를 관리하는 팀이 참고할 수 있는 실전 운영 가이드를 목표로 한다.
Feature Store 필요성
피처 관리의 현실적 문제
Feature Store 없이 ML 시스템을 운영하면 다음과 같은 문제가 반복적으로 발생한다.
- 피처 로직 중복: 데이터 사이언티스트가 Jupyter Notebook에서 작성한 피처 변환 코드와 백엔드 엔지니어가 서빙 서버에 구현한 코드가 미묘하게 다르다. 같은
avg_purchase_amount_30d라는 피처인데 학습에서는 NULL을 0으로 채우고 서빙에서는 평균값으로 채운다. - 피처 발견성 부재: 팀 A가 이미 계산해둔
user_click_rate_7d피처를 팀 B가 모르고 다시 계산한다. 조직 내 피처 자산이 관리되지 않는다. - 시간 여행(Time Travel) 불가: "2주 전 시점에서 이 유저의 피처 값은 얼마였는가?"에 답할 수 없다. 재학습이나 디버깅이 불가능해진다.
- 서빙 레이턴시: 모델 추론 시 여러 데이터 소스에서 실시간으로 피처를 계산하면 p99 레이턴시가 수백 밀리초까지 치솟는다.
Feature Store가 해결하는 것
Feature Store는 피처의 단일 진실 공급원(Single Source of Truth) 역할을 한다. 하나의 피처 정의에서 오프라인 학습 데이터와 온라인 서빙 데이터를 모두 생성하므로 로직 불일치가 원천적으로 제거된다. 중앙 레지스트리를 통해 피처를 검색하고 재사용할 수 있으며, Point-in-Time Join을 통해 과거 시점의 피처를 정확하게 재현할 수 있다.
Feast 아키텍처
Feast의 아키텍처는 크게 오프라인 스토어, 온라인 스토어, 레지스트리, 그리고 피처 서버 네 가지 핵심 컴포넌트로 구성된다.
오프라인 스토어 (Offline Store)
오프라인 스토어는 대량의 히스토리컬 피처 데이터를 저장하고, 학습 데이터 생성 시 Point-in-Time Join을 수행하는 역할을 한다. BigQuery, Snowflake, Redshift, S3/Parquet 등 대규모 분석용 데이터 웨어하우스나 데이터 레이크를 백엔드로 사용한다. 읽기 성능이 중요하며, 수십 TB 규모의 데이터를 효율적으로 스캔할 수 있어야 한다.
온라인 스토어 (Online Store)
온라인 스토어는 실시간 서빙을 위한 저지연 키-값 저장소다. Redis, DynamoDB, Bigtable 등이 백엔드로 사용되며, 엔티티 키를 기준으로 최신 피처 값을 p99 10ms 이내에 반환해야 한다. Materialization 프로세스를 통해 오프라인 스토어의 데이터가 온라인 스토어로 복사된다.
레지스트리 (Registry)
레지스트리는 엔티티, Feature View, Feature Service 등의 메타데이터를 저장하는 중앙 카탈로그다. 파일 기반(로컬, S3, GCS)이나 SQL 기반(PostgreSQL, MySQL)으로 운영할 수 있다. 프로덕션에서는 SQL 기반 레지스트리를 권장한다. 여러 팀이 동시에 feast apply를 실행할 때 충돌을 방지할 수 있기 때문이다.
피처 서버 (Feature Server)
Feast 내장 피처 서버는 REST/gRPC 엔드포인트를 통해 온라인 피처를 서빙한다. Go 기반의 고성능 서버로, 모델 서빙 인프라(KServe, Seldon 등)에서 사이드카나 독립 서비스로 배포할 수 있다.
설치와 초기 설정
Feast 설치 및 프로젝트 초기화
# Feast 설치 (Redis, PostgreSQL 백엔드 포함)
pip install "feast[redis,postgres]"
# 새 프로젝트 초기화
feast init recommendation_features
cd recommendation_features
# 프로젝트 구조 확인
# recommendation_features/
# feature_repo/
# __init__.py
# feature_store.yaml # Feast 프로젝트 설정
# entities.py # 엔티티 정의
# feature_views.py # Feature View 정의
# feature_services.py # Feature Service 정의
# data/ # 로컬 테스트용 데이터
프로덕션 feature_store.yaml 설정
# feature_store.yaml - 프로덕션 환경 구성
project: recommendation_platform
provider: local
entity_key_serialization_version: 3
registry:
registry_type: sql
path: postgresql://feast_user:${FEAST_REGISTRY_PASSWORD}@pg-registry.internal:5432/feast_registry
cache_ttl_seconds: 60
sqlalchemy_config_kwargs:
pool_size: 10
max_overflow: 20
pool_pre_ping: true
offline_store:
type: bigquery
project_id: ml-platform-prod
dataset: feast_offline_store
location: asia-northeast3
online_store:
type: redis
connection_string: redis://:${REDIS_PASSWORD}@redis-feast.internal:6379
key_ttl_seconds: 172800 # 48시간 TTL
redis_type: redis_cluster
flags:
alpha_features: true
on_demand_transforms: true
이 설정에서 주의할 점은 cache_ttl_seconds다. 레지스트리 캐시 TTL을 너무 길게 설정하면 feast apply 후 변경 사항이 즉시 반영되지 않아 서빙 장애가 발생할 수 있다. 반대로 너무 짧게 설정하면 레지스트리 DB에 부하가 집중된다. 프로덕션에서는 30~120초가 적절하다.
피처 정의와 엔티티 관리
엔티티 정의
엔티티는 피처의 주체를 나타낸다. 유저, 아이템, 주문 등 비즈니스 도메인의 핵심 개체를 엔티티로 정의한다.
# entities.py
from feast import Entity, ValueType
# 유저 엔티티
user = Entity(
name="user_id",
description="서비스 유저의 고유 식별자. UUID v4 형식.",
value_type=ValueType.STRING,
tags={
"owner": "ml-platform-team",
"pii": "true",
"domain": "user",
},
)
# 아이템(상품) 엔티티
item = Entity(
name="item_id",
description="상품 카탈로그의 고유 식별자. 정수형 ID.",
value_type=ValueType.INT64,
tags={
"owner": "recommendation-team",
"domain": "catalog",
},
)
# 유저-아이템 상호작용 엔티티 (복합 키)
user_item = Entity(
name="user_item_id",
description="유저-아이템 상호작용 복합 키. 'user_id:item_id' 형식.",
value_type=ValueType.STRING,
tags={
"owner": "recommendation-team",
"domain": "interaction",
},
)
엔티티 설계 시 핵심 원칙은 하나의 엔티티가 하나의 비즈니스 개체를 대표해야 한다는 것이다. 복합 키가 필요할 때는 별도 엔티티를 정의하되, 조인 성능을 고려하여 키 카디널리티가 과도하게 높아지지 않도록 주의한다.
Feature View와 Feature Service
Feature View 정의
Feature View는 하나의 데이터 소스에서 파생된 관련 피처들의 논리적 그룹이다. 오프라인/온라인 스토어 모두에서 일관된 방식으로 피처를 제공하는 핵심 추상화다.
# feature_views.py
from datetime import timedelta
from feast import (
FeatureView,
Field,
BigQuerySource,
PushSource,
StreamSource,
)
from feast.types import Float32, Float64, Int64, String, UnixTimestamp
# 유저 구매 통계 Feature View
user_purchase_source = BigQuerySource(
name="user_purchase_stats_source",
table="ml-platform-prod.feast_offline_store.user_purchase_stats",
timestamp_field="event_timestamp",
created_timestamp_column="created_at",
description="유저별 구매 통계 집계 테이블. 매시간 dbt로 갱신.",
)
user_purchase_stats = FeatureView(
name="user_purchase_stats",
entities=[user],
ttl=timedelta(days=7),
schema=[
Field(name="total_purchases_7d", dtype=Int64, description="최근 7일 총 구매 건수"),
Field(name="total_purchases_30d", dtype=Int64, description="최근 30일 총 구매 건수"),
Field(name="avg_order_value_7d", dtype=Float64, description="최근 7일 평균 주문 금액"),
Field(name="avg_order_value_30d", dtype=Float64, description="최근 30일 평균 주문 금액"),
Field(name="max_order_value_30d", dtype=Float64, description="최근 30일 최대 주문 금액"),
Field(name="purchase_frequency_score", dtype=Float32, description="구매 빈도 정규화 점수 (0~1)"),
Field(name="last_purchase_days_ago", dtype=Int64, description="마지막 구매 후 경과 일수"),
],
source=user_purchase_source,
online=True,
tags={
"team": "recommendation",
"sla_freshness_minutes": "60",
"data_quality_owner": "data-eng@company.com",
},
)
# 유저 세션 피처 (실시간 Push 소스 포함)
user_session_push_source = PushSource(
name="user_session_push",
batch_source=BigQuerySource(
name="user_session_batch_source",
table="ml-platform-prod.feast_offline_store.user_session_features",
timestamp_field="event_timestamp",
),
)
user_session_features = FeatureView(
name="user_session_features",
entities=[user],
ttl=timedelta(hours=24),
schema=[
Field(name="session_count_24h", dtype=Int64, description="최근 24시간 세션 수"),
Field(name="avg_session_duration_min", dtype=Float32, description="평균 세션 시간(분)"),
Field(name="pages_viewed_1h", dtype=Int64, description="최근 1시간 조회 페이지 수"),
Field(name="last_active_minutes_ago", dtype=Int64, description="마지막 활동 후 경과 분"),
Field(name="is_currently_active", dtype=Int64, description="현재 활성 세션 여부 (0/1)"),
],
source=user_session_push_source,
online=True,
tags={
"team": "recommendation",
"sla_freshness_minutes": "5",
"realtime": "true",
},
)
On-Demand Feature View
학습과 서빙 모두에서 동일한 변환 로직을 적용해야 할 때 On-Demand Feature View를 사용한다. 이것이 학습-서빙 스큐를 방지하는 핵심 메커니즘 중 하나다.
# on_demand_features.py
from feast import on_demand_feature_view, Field
from feast.types import Float32, Int64
import pandas as pd
@on_demand_feature_view(
sources=[user_purchase_stats, user_session_features],
schema=[
Field(name="engagement_score", dtype=Float32),
Field(name="purchase_recency_bucket", dtype=Int64),
Field(name="activity_intensity", dtype=Float32),
],
description="구매와 세션 피처를 결합하여 계산하는 파생 피처",
)
def user_engagement_features(inputs: pd.DataFrame) -> pd.DataFrame:
"""
학습과 서빙에서 동일하게 실행되는 변환 로직.
이 함수 안에서 정의된 로직은 get_historical_features()와
get_online_features() 양쪽에서 동일하게 적용된다.
"""
df = pd.DataFrame()
# 참여도 점수: 구매 빈도와 세션 활동을 결합
purchase_norm = inputs["purchase_frequency_score"].fillna(0.0)
session_norm = (
inputs["session_count_24h"].fillna(0).clip(upper=50) / 50.0
)
df["engagement_score"] = (0.6 * purchase_norm + 0.4 * session_norm).astype("float32")
# 구매 최신성 버킷: 0(오늘), 1(1~3일), 2(4~7일), 3(8~14일), 4(15일+)
days = inputs["last_purchase_days_ago"].fillna(999)
df["purchase_recency_bucket"] = pd.cut(
days,
bins=[-1, 0, 3, 7, 14, float("inf")],
labels=[0, 1, 2, 3, 4],
).astype("int64")
# 활동 강도: 최근 1시간 페이지 뷰 기반
df["activity_intensity"] = (
inputs["pages_viewed_1h"].fillna(0).clip(upper=100) / 100.0
).astype("float32")
return df
Feature Service 정의
Feature Service는 특정 모델이 사용하는 피처 세트를 하나로 묶어 버전 관리하는 단위다. 모델과 Feature Service를 1:1로 매핑하면 어떤 모델이 어떤 피처에 의존하는지 명확해진다.
# feature_services.py
from feast import FeatureService
# 추천 모델 v3용 Feature Service
recommendation_v3_service = FeatureService(
name="recommendation_v3",
features=[
user_purchase_stats[["total_purchases_7d", "avg_order_value_30d", "purchase_frequency_score"]],
user_session_features[["session_count_24h", "last_active_minutes_ago"]],
user_engagement_features, # On-Demand Feature View 전체 포함
],
description="추천 모델 v3이 사용하는 피처 세트. 모델 버전 변경 시 새 FeatureService를 생성할 것.",
tags={
"model_version": "v3.2.1",
"model_owner": "rec-team",
"last_validated": "2026-03-07",
},
)
# 이탈 예측 모델용 Feature Service
churn_prediction_service = FeatureService(
name="churn_prediction_v1",
features=[
user_purchase_stats, # 전체 피처 사용
user_session_features[["session_count_24h", "avg_session_duration_min"]],
],
description="이탈 예측 모델 v1용 피처 세트",
tags={
"model_version": "v1.0.0",
"model_owner": "growth-team",
},
)
오프라인 학습 데이터 생성
학습 데이터를 생성할 때는 반드시 Point-in-Time Join을 사용해야 한다. 이는 예측 시점 기준으로 그 시점에 실제로 알 수 있었던 피처 값만 사용한다는 원칙이다. 이 원칙을 어기면 Feature Leakage가 발생하여 모델이 학습 환경에서는 좋은 성능을 보이지만 실제 서빙에서는 성능이 크게 떨어진다.
# training_data_generator.py
from feast import FeatureStore
from datetime import datetime, timedelta
import pandas as pd
store = FeatureStore(repo_path="./feature_repo")
# 1. 엔티티 데이터프레임 준비: 예측 대상 + 예측 시점
# 각 row가 "이 유저에 대해 이 시점에 예측해야 했다"는 의미
entity_df = pd.DataFrame({
"user_id": ["u_1001", "u_1002", "u_1003", "u_1004", "u_1005"],
"event_timestamp": [
datetime(2026, 2, 15, 10, 0, 0),
datetime(2026, 2, 20, 14, 30, 0),
datetime(2026, 2, 25, 9, 0, 0),
datetime(2026, 3, 1, 11, 15, 0),
datetime(2026, 3, 5, 16, 45, 0),
],
})
# 2. Feature Service를 통해 학습 데이터 조회
# Point-in-Time Join이 자동으로 적용됨
training_df = store.get_historical_features(
entity_df=entity_df,
features=store.get_feature_service("recommendation_v3"),
).to_df()
print(f"학습 데이터 shape: {training_df.shape}")
print(f"컬럼: {list(training_df.columns)}")
# 3. 대규모 학습 데이터 생성 시 BigQuery에서 직접 실행
# 수백만 건의 엔티티 데이터는 BigQuery로 직접 읽어 효율적으로 처리
large_entity_df = pd.read_gbq(
"""
SELECT
user_id,
prediction_timestamp AS event_timestamp
FROM `ml-platform-prod.labels.recommendation_labels`
WHERE prediction_timestamp BETWEEN '2026-01-01' AND '2026-03-01'
""",
project_id="ml-platform-prod",
)
training_df_large = store.get_historical_features(
entity_df=large_entity_df,
features=store.get_feature_service("recommendation_v3"),
).to_df()
# 4. 학습 데이터 품질 검증
assert training_df_large.isnull().sum().sum() / training_df_large.size < 0.05, \
"NULL 비율이 5%를 초과합니다. 데이터 소스를 점검하세요."
training_df_large.to_parquet(
"gs://ml-datasets/recommendation/training_2026_q1.parquet",
index=False,
)
온라인 서빙 파이프라인
Feast Feature Server 배포
# online_serving.py
"""
모델 서빙 시 Feast에서 온라인 피처를 조회하는 클라이언트 코드.
KServe Transformer 또는 서빙 애플리케이션에서 사용한다.
"""
from feast import FeatureStore
from typing import Dict, List, Any
import time
import logging
logger = logging.getLogger(__name__)
class FeatureClient:
"""
프로덕션용 Feast 온라인 피처 클라이언트.
타임아웃, fallback, 메트릭 수집을 포함한다.
"""
def __init__(
self,
repo_path: str = "./feature_repo",
timeout_ms: int = 50,
fallback_enabled: bool = True,
):
self.store = FeatureStore(repo_path=repo_path)
self.timeout_ms = timeout_ms
self.fallback_enabled = fallback_enabled
self._default_values = self._load_default_values()
def _load_default_values(self) -> Dict[str, Any]:
"""Feature View별 기본값을 사전에 로드한다."""
return {
"total_purchases_7d": 0,
"avg_order_value_30d": 0.0,
"purchase_frequency_score": 0.0,
"session_count_24h": 0,
"last_active_minutes_ago": 1440,
"engagement_score": 0.0,
"purchase_recency_bucket": 4,
"activity_intensity": 0.0,
}
def get_features_for_prediction(
self, user_ids: List[str]
) -> Dict[str, List[Any]]:
"""
추천 모델 추론을 위한 피처 조회.
타임아웃 시 fallback 기본값을 반환한다.
"""
entity_rows = [{"user_id": uid} for uid in user_ids]
start_time = time.monotonic()
try:
result = self.store.get_online_features(
features=self.store.get_feature_service("recommendation_v3"),
entity_rows=entity_rows,
).to_dict()
elapsed_ms = (time.monotonic() - start_time) * 1000
logger.info(
f"Feature retrieval: {len(user_ids)} entities, {elapsed_ms:.1f}ms"
)
if elapsed_ms > self.timeout_ms:
logger.warning(
f"Feature retrieval exceeded SLA: {elapsed_ms:.1f}ms > {self.timeout_ms}ms"
)
return result
except Exception as e:
elapsed_ms = (time.monotonic() - start_time) * 1000
logger.error(
f"Feature retrieval failed after {elapsed_ms:.1f}ms: {e}"
)
if self.fallback_enabled:
return self._build_fallback_response(user_ids)
raise
def _build_fallback_response(
self, user_ids: List[str]
) -> Dict[str, List[Any]]:
"""피처 조회 실패 시 기본값으로 fallback 응답을 생성한다."""
response = {"user_id": user_ids}
for feat_name, default_val in self._default_values.items():
response[feat_name] = [default_val] * len(user_ids)
logger.warning(
f"Returning fallback features for {len(user_ids)} entities"
)
return response
# 사용 예시
client = FeatureClient(repo_path="./feature_repo")
features = client.get_features_for_prediction(["u_1001", "u_1002"])
Materialization 전략
Materialization은 오프라인 스토어의 데이터를 온라인 스토어로 복사하는 프로세스다. 배치 서빙과 실시간 서빙의 요구사항에 따라 전략이 달라진다.
배치 Materialization
# 전체 Feature View를 특정 시간 범위로 materialize
feast materialize 2026-03-06T00:00:00 2026-03-07T00:00:00
# 점진적 materialization (마지막 실행 이후 ~ 현재)
feast materialize-incremental $(date -u +"%Y-%m-%dT%H:%M:%S")
프로그래밍 방식 Materialization (Airflow DAG)
# airflow_materialization_dag.py
"""
Airflow DAG: 1시간 간격으로 Feast Feature View를 materialize한다.
Feature View별로 독립적인 태스크로 실행하여 병렬 처리하고,
실패 시 개별 재시도가 가능하도록 구성한다.
"""
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.python import PythonOperator
from feast import FeatureStore
FEATURE_VIEWS_TO_MATERIALIZE = [
"user_purchase_stats",
"user_session_features",
"item_popularity_stats",
]
def materialize_feature_view(feature_view_name: str, **context):
"""개별 Feature View를 materialize한다."""
store = FeatureStore(repo_path="/opt/feast/feature_repo")
end_date = context["data_interval_end"]
start_date = end_date - timedelta(hours=2) # 2시간 lookback (안전 마진)
store.materialize(
start_date=start_date,
end_date=end_date,
feature_views=[feature_view_name],
)
# 성공 메트릭 기록
row_count = _verify_materialization(store, feature_view_name, end_date)
context["ti"].xcom_push(
key=f"{feature_view_name}_materialized_rows",
value=row_count,
)
def _verify_materialization(store, fv_name, end_date):
"""Materialization 결과를 검증한다."""
# 샘플 엔티티로 온라인 스토어 조회 테스트
sample_result = store.get_online_features(
features=[f"{fv_name}:total_purchases_7d"],
entity_rows=[{"user_id": "u_health_check"}],
).to_dict()
return len(sample_result.get("user_id", []))
with DAG(
dag_id="feast_materialization",
schedule_interval="0 * * * *", # 매시간
start_date=datetime(2026, 3, 1),
catchup=False,
default_args={
"retries": 3,
"retry_delay": timedelta(minutes=5),
"execution_timeout": timedelta(minutes=30),
},
tags=["feast", "feature-store", "materialization"],
) as dag:
for fv_name in FEATURE_VIEWS_TO_MATERIALIZE:
PythonOperator(
task_id=f"materialize_{fv_name}",
python_callable=materialize_feature_view,
op_args=[fv_name],
)
Materialization 주기 결정 가이드
| 피처 유형 | 변경 빈도 | 권장 Materialization 주기 | 예시 |
|---|---|---|---|
| 정적 프로필 | 거의 변경 없음 | 일 1회 | 유저 가입일, 성별, 지역 |
| 일별 집계 | 일 1회 갱신 | 일 1~2회 | 30일 평균 구매액, 주간 방문 수 |
| 시간별 집계 | 시간 단위 갱신 | 매시간 | 최근 24시간 세션 수 |
| 실시간 피처 | 초 단위 갱신 | Push 기반 / 스트리밍 | 현재 세션 활성 여부, 최근 클릭 |
학습-서빙 스큐 방지
학습-서빙 스큐(Training-Serving Skew)는 ML 시스템에서 가장 교묘하고 발견하기 어려운 버그다. 모델이 학습 시 본 피처 분포와 서빙 시 받는 피처 분포가 달라져 성능이 저하되는 현상이다. Feast를 사용한다고 자동으로 해결되는 것이 아니며, 의식적인 설계와 검증이 필요하다.
스큐 발생의 주요 원인
- 변환 로직 이중 구현: 학습용 SQL과 서빙용 Python 코드가 분리되어 미묘하게 다르다.
- Materialization 지연: 온라인 스토어에 오래된 피처가 남아 있다.
- NULL 처리 불일치: 오프라인에서는 NULL을 제거했는데 온라인에서는 0으로 채운다.
- 시간대 차이: 오프라인은 UTC, 온라인은 KST로 계산하여 "최근 7일" 범위가 달라진다.
- Feature Leakage: 학습 시 미래 데이터가 혼입된다.
구조적 방지 전략
# skew_prevention_test.py
"""
학습-서빙 스큐를 자동으로 탐지하는 CI 테스트.
정기적으로 실행하여 온라인-오프라인 값의 일관성을 검증한다.
"""
import pytest
import pandas as pd
import numpy as np
from datetime import datetime
from feast import FeatureStore
@pytest.fixture(scope="module")
def store():
return FeatureStore(repo_path="./feature_repo")
@pytest.fixture(scope="module")
def sample_entity_ids():
"""검증용 샘플 엔티티. 프로덕션 엔티티 중 랜덤 샘플링."""
return ["u_1001", "u_1002", "u_1003", "u_1005", "u_1010"]
FEATURE_LIST = [
"user_purchase_stats:total_purchases_7d",
"user_purchase_stats:avg_order_value_30d",
"user_purchase_stats:purchase_frequency_score",
"user_session_features:session_count_24h",
]
def test_online_offline_consistency(store, sample_entity_ids):
"""
온라인 스토어 값과 오프라인 스토어 최신 값이 일치하는지 검증.
Materialization이 정상적으로 동작하면 두 값은 동일해야 한다.
"""
# 온라인 조회
online_result = store.get_online_features(
features=FEATURE_LIST,
entity_rows=[{"user_id": eid} for eid in sample_entity_ids],
).to_dict()
# 오프라인 조회 (현재 시점 기준)
entity_df = pd.DataFrame({
"user_id": sample_entity_ids,
"event_timestamp": [datetime.utcnow()] * len(sample_entity_ids),
})
offline_result = store.get_historical_features(
entity_df=entity_df,
features=FEATURE_LIST,
).to_df()
# 각 피처별 온라인-오프라인 값 비교
for feat in FEATURE_LIST:
feat_short = feat.split(":")[1]
for i, eid in enumerate(sample_entity_ids):
online_val = online_result[feat_short][i]
offline_val = offline_result.loc[
offline_result["user_id"] == eid, feat_short
].iloc[0]
if online_val is None and pd.isna(offline_val):
continue # 둘 다 NULL이면 일치
if isinstance(online_val, float):
assert np.isclose(online_val, offline_val, rtol=1e-5), (
f"Skew detected for {eid}.{feat_short}: "
f"online={online_val}, offline={offline_val}"
)
else:
assert online_val == offline_val, (
f"Skew detected for {eid}.{feat_short}: "
f"online={online_val}, offline={offline_val}"
)
def test_null_handling_consistency(store, sample_entity_ids):
"""
NULL 처리가 온라인과 오프라인에서 동일한지 검증.
존재하지 않는 엔티티를 조회했을 때 양쪽의 동작이 같아야 한다.
"""
ghost_entities = ["u_nonexistent_001", "u_nonexistent_002"]
online_result = store.get_online_features(
features=FEATURE_LIST,
entity_rows=[{"user_id": eid} for eid in ghost_entities],
).to_dict()
for feat in FEATURE_LIST:
feat_short = feat.split(":")[1]
for val in online_result[feat_short]:
assert val is None, (
f"Non-existent entity should return None, got {val} for {feat_short}"
)
스큐 탐지 대시보드 지표
| 지표 | 정상 범위 | 알림 임계치 | 설명 |
|---|---|---|---|
| Online-Offline Parity Rate | 100% | < 99.5% | 온라인/오프라인 값 일치율 |
| Feature Distribution Drift (KL Divergence) | < 0.01 | > 0.05 | 학습 시점 대비 서빙 시점 피처 분포 변화 |
| NULL Rate Difference | 0% | > 1% | 온라인/오프라인 NULL 비율 차이 |
| Materialization Lag p95 | < 60min | > 120min | Materialization 완료까지 소요 시간 |
| Stale Feature Rate | 0% | > 5% | TTL 초과 피처 비율 |
스트리밍 피처 통합
배치 Materialization만으로는 실시간 피처(현재 세션 상태, 직전 클릭 등)를 서빙할 수 없다. Feast의 Push API와 Kafka 스트리밍을 결합하여 실시간 피처를 온라인 스토어에 반영한다.
# streaming_feature_ingest.py
"""
Kafka에서 유저 세션 이벤트를 소비하여 Feast Online Store에
실시간으로 반영하는 스트리밍 워커.
Faust(Python stream processing) 또는 Kafka Consumer로 구현한다.
"""
import json
from datetime import datetime
from confluent_kafka import Consumer, KafkaError
from feast import FeatureStore
import pandas as pd
store = FeatureStore(repo_path="./feature_repo")
consumer = Consumer({
"bootstrap.servers": "kafka-broker:9092",
"group.id": "feast-session-materializer",
"auto.offset.reset": "latest",
"enable.auto.commit": False,
"max.poll.interval.ms": 300000,
"session.timeout.ms": 45000,
})
consumer.subscribe(["user-session-events"])
MICRO_BATCH_SIZE = 200
FLUSH_INTERVAL_SEC = 5
buffer = []
last_flush = datetime.utcnow()
try:
while True:
msg = consumer.poll(timeout=1.0)
if msg is not None and not msg.error():
event = json.loads(msg.value().decode("utf-8"))
buffer.append({
"user_id": event["user_id"],
"session_count_24h": event["session_count_24h"],
"avg_session_duration_min": event["avg_session_duration_min"],
"pages_viewed_1h": event["pages_viewed_1h"],
"last_active_minutes_ago": 0, # 방금 활동했으므로 0
"is_currently_active": 1,
"event_timestamp": datetime.utcnow(),
})
elif msg is not None and msg.error():
if msg.error().code() != KafkaError._PARTITION_EOF:
raise Exception(f"Kafka error: {msg.error()}")
elapsed = (datetime.utcnow() - last_flush).total_seconds()
should_flush = (
len(buffer) >= MICRO_BATCH_SIZE
or (buffer and elapsed >= FLUSH_INTERVAL_SEC)
)
if should_flush:
df = pd.DataFrame(buffer)
# PushSource를 통해 온라인 + 오프라인 스토어 동시 기록
store.push(
push_source_name="user_session_push",
df=df,
to=store.PushMode.ONLINE_AND_OFFLINE,
)
consumer.commit()
buffer.clear()
last_flush = datetime.utcnow()
finally:
consumer.close()
Feature Store 비교
| 항목 | Feast | Tecton | Hopsworks | Databricks Feature Store |
|---|---|---|---|---|
| 라이선스 | Apache 2.0 (오픈소스) | 상용 (SaaS/Self-hosted) | AGPL + 상용 | Databricks 플랫폼 종속 |
| 배포 방식 | Self-managed | Managed SaaS | Self-managed / Managed | Databricks Workspace 내장 |
| 오프라인 스토어 | BigQuery, Snowflake, Redshift, S3 Parquet, File | Spark, Snowflake, Rift | HopsFS, S3 | Delta Lake (Unity Catalog) |
| 온라인 스토어 | Redis, DynamoDB, Bigtable, PostgreSQL, SQLite | DynamoDB (내장 최적화) | RonDB (MySQL NDB Cluster) | Cosmos DB, DynamoDB |
| 스트리밍 지원 | Push API, Kafka (수동 구현) | 네이티브 Spark Streaming | 네이티브 Kafka/Spark | Spark Structured Streaming |
| 변환 엔진 | On-Demand (Python), dbt 연동 | Tecton SDK (Spark/Rift) | Hopsworks Feature Pipeline | Spark UDF |
| Feature Registry | SQL / File 기반 | 내장 (풍부한 UI) | 내장 (Hopsworks UI) | Unity Catalog 통합 |
| 학습-서빙 스큐 방지 | On-Demand FV, Point-in-Time Join | Time-Travel, Materialization 보장 | Provenance 추적 | Time-Travel (Delta Lake) |
| 커뮤니티 활성도 | 매우 높음 (GitHub 5.5k+ stars) | 중간 (상용 중심) | 높음 (학술/오픈소스) | 높음 (Databricks 생태계) |
| 적합한 팀 | 클라우드 비종속, 커스터마이징 필요 팀 | 엔터프라이즈, 완전관리 선호 팀 | On-prem, GPU 학습 중심 팀 | 이미 Databricks 사용 중인 팀 |
모니터링
핵심 모니터링 지표
프로덕션 Feature Store를 운영할 때 반드시 추적해야 하는 지표들이 있다.
- Freshness: 온라인 스토어의 피처가 얼마나 최신인가.
현재시각 - 피처의 event_timestamp로 측정한다. - Serving Latency: 온라인 피처 조회의 p50/p95/p99 레이턴시. SLA는 보통 p99 < 50ms.
- Materialization Success Rate: Materialization 작업의 성공/실패 비율.
- Feature Staleness Rate: TTL이 초과된 피처의 비율.
- Online Store Hit Rate: 조회 요청 중 실제 값이 반환된 비율 (NULL이 아닌 응답 비율).
# feast_metrics_exporter.py
"""
Feast 상태를 Prometheus 메트릭으로 내보내는 exporter.
Kubernetes CronJob으로 5분 간격 실행을 권장한다.
"""
from prometheus_client import Gauge, Histogram, start_http_server
from feast import FeatureStore
import time
# Prometheus 메트릭 정의
FRESHNESS_GAUGE = Gauge(
"feast_feature_view_freshness_seconds",
"Feature View의 온라인 스토어 freshness (초)",
["feature_view", "project"],
)
MATERIALIZATION_LAG = Gauge(
"feast_materialization_lag_seconds",
"마지막 Materialization 이후 경과 시간 (초)",
["feature_view"],
)
ONLINE_STORE_LATENCY = Histogram(
"feast_online_store_latency_seconds",
"온라인 스토어 조회 레이턴시",
["feature_view"],
buckets=[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5],
)
def collect_metrics():
store = FeatureStore(repo_path="./feature_repo")
feature_views = store.list_feature_views()
for fv in feature_views:
# Freshness 측정
try:
start = time.monotonic()
result = store.get_online_features(
features=[f"{fv.name}:{fv.features[0].name}"],
entity_rows=[{"user_id": "u_health_check"}],
).to_dict()
elapsed = time.monotonic() - start
ONLINE_STORE_LATENCY.labels(feature_view=fv.name).observe(elapsed)
except Exception:
pass
if __name__ == "__main__":
start_http_server(8000)
while True:
collect_metrics()
time.sleep(300) # 5분 간격
트러블슈팅
문제 1: feast apply 후 온라인 서빙에서 피처를 찾을 수 없음
증상: feast apply 성공 후 get_online_features()에서 KeyError 발생
에러: KeyError: "Feature view 'user_purchase_stats_v2' not found in registry"
원인: 레지스트리 캐시 TTL 내에 오래된 메타데이터를 참조
해결:
1. 레지스트리 캐시를 즉시 갱신:
store = FeatureStore(repo_path="./feature_repo")
store.refresh_registry()
2. 배포 프로세스에 registry refresh를 포함
3. cache_ttl_seconds를 60초 이하로 설정
문제 2: Materialization 중 메모리 부족
증상: feast materialize 실행 중 OOMKilled
에러: Container killed due to OOM (exit code 137)
원인: 대규모 Feature View를 한 번에 materialize할 때 전체 데이터를
메모리에 로드하려 함
해결:
1. 시간 범위를 좁혀서 분할 실행:
feast materialize 2026-03-06T00:00:00 2026-03-06T06:00:00
feast materialize 2026-03-06T06:00:00 2026-03-06T12:00:00
2. Feature View별로 개별 materialize
3. Kubernetes Job의 메모리 limit 증가 (최소 4Gi 권장)
4. 배치 크기를 줄이는 환경변수 설정:
FEAST_BATCH_MATERIALIZATION_MAX_WORKERS=2
문제 3: Redis 온라인 스토어 연결 타임아웃 급증
증상: 서빙 레이턴시 p99가 200ms 이상으로 급증
에러: redis.exceptions.TimeoutError: Timeout reading from socket
원인: Redis 클러스터 리밸런싱 또는 단일 노드 과부하
해결:
1. Redis 클러스터 노드별 메모리/CPU 확인
2. 핫 키 분석: MONITOR 또는 redis-cli --hotkeys로 확인
3. connection_string에 timeout 파라미터 추가:
redis://host:6379?socket_timeout=0.1&socket_connect_timeout=0.1
4. 서빙 코드에 circuit breaker + fallback 패턴 적용
운영 시 주의사항
TTL 설계
Feature View의 ttl 값은 "이 피처가 얼마나 오래된 것까지 유효한가"를 정의한다. TTL을 너무 짧게 설정하면 Materialization 주기와 맞지 않아 온라인 조회 시 NULL이 반환된다. TTL은 반드시 Materialization 주기의 2배 이상으로 설정한다. 예를 들어 매시간 materialize하면 TTL은 최소 2시간 이상이어야 한다.
엔티티 키 설계
엔티티 키의 카디널리티가 온라인 스토어의 크기를 결정한다. 유저 수가 1억 명이고 Feature View가 10개라면 온라인 스토어에는 10억 개의 키-값 쌍이 저장된다. Redis 기준으로 키당 약 500바이트를 점유한다고 가정하면 약 500GB의 메모리가 필요하다. 비활성 유저를 TTL로 자동 만료시키거나, 최근 N일 이내 활동한 유저만 materialize하는 전략이 필요하다.
feast apply와 배포 순서
Feature View 스키마 변경 시 반드시 하위 호환성(backward compatibility) 을 유지해야 한다. 새 피처를 추가하는 것은 안전하지만, 기존 피처를 삭제하거나 타입을 변경하면 의존 모델이 즉시 장애를 일으킨다. 배포 순서는 다음과 같다.
- 새 피처를 추가한 Feature View 정의 작성
feast apply로 레지스트리 업데이트- Materialization 실행하여 온라인 스토어에 새 피처 적재
- 새 피처를 사용하는 모델 배포
- 이전 피처가 더 이상 사용되지 않으면 다음 릴리스에서 제거
실패 사례와 복구
사례 1: Materialization 장애로 48시간 stale 피처 서빙
상황: Airflow DAG 장애로 Materialization이 48시간 중단됨.
그 동안 온라인 스토어에는 이틀 전 피처가 서빙됨.
영향: 추천 모델의 CTR이 15% 하락.
"최근 24시간 세션 수"가 48시간 전 값이었기 때문.
복구 절차:
1. Airflow DAG 원인 분석 및 수정 (BigQuery 인증 토큰 만료)
2. 수동 backfill materialization 실행:
feast materialize 2026-03-05T00:00:00 2026-03-07T12:00:00
3. 온라인 스토어 freshness 확인 후 알림 해제
4. 사후 조치: Materialization 실패 시 30분 내 PagerDuty 알림 설정
교훈: Materialization 상태를 별도로 모니터링하지 않으면
모델 성능 저하로만 발견되어 대응이 늦어진다.
사례 2: Feature View 스키마 변경으로 전체 서빙 다운
상황: "avg_order_value_30d" 피처의 타입을 Float64에서 Int64로 변경하고
feast apply를 실행한 직후, 서빙 중인 추천 모델에서 타입 에러 발생.
영향: 5분간 추천 API 500 에러. 긴급 롤백 수행.
복구 절차:
1. git revert로 Feature View 정의를 이전 버전으로 복원
2. feast apply 재실행
3. 서빙 정상화 확인
교훈: Feature View 스키마 변경은 반드시 staging 환경에서 먼저 테스트하고,
의존 모델의 호환성 테스트를 통과한 후에만 프로덕션에 적용해야 한다.
사례 3: Push 기반 스트리밍 피처의 중복 이벤트 처리
상황: Kafka consumer의 at-least-once 보장으로 인해 동일 이벤트가
2회 처리되어 session_count_24h가 실제보다 높게 기록됨.
영향: 일부 유저의 engagement_score가 비정상적으로 높아져
추천 결과 왜곡 발생.
복구 절차:
1. 중복 이벤트 탐지 로직 추가 (이벤트 ID 기반 dedup)
2. 영향받은 엔티티 식별 후 재 materialize
3. Kafka consumer에 idempotent 처리 추가
교훈: 스트리밍 피처 파이프라인에는 반드시 멱등성(idempotency) 보장이
필요하다. 이벤트 ID를 Redis Set으로 관리하여 중복을 제거한다.
체크리스트
초기 구축 체크리스트
- 프로덕션
feature_store.yaml설정 완료 (SQL 레지스트리, Redis 온라인 스토어) - 엔티티 정의 및 명명 규칙 수립
- Feature View 정의 및 TTL 설정
- Feature Service와 모델 버전 매핑
-
feast apply를 CI/CD 파이프라인에 통합 - Materialization DAG 구축 및 스케줄링
- 온라인 스토어 용량 산정 및 프로비저닝
일상 운영 체크리스트
- Materialization 성공 여부 일일 점검
- 온라인 스토어 freshness SLA 준수 여부 확인
- Feature View 변경 시 하위 호환성 검증
- 온라인-오프라인 parity 테스트 주간 실행
- 온라인 스토어 메모리 사용량 추세 모니터링
- 피처 서빙 레이턴시 p99 추적
Feature View 변경 배포 체크리스트
- staging 환경에서
feast apply및 materialization 테스트 - 스키마 호환성 테스트 (의존 모델 전체)
- Point-in-Time Leakage 검증 통과
- Online-Offline Parity 테스트 통과
- 프로덕션
feast apply실행 - Materialization 정상 완료 확인
- 서빙 레이턴시 및 에러율 10분간 관찰
- 롤백 절차 문서화 및 테스트 완료