Loading...
Loading...
02-reusable-code-python/security/secure_data_loader.py
"""
@source: 260313 heath-infer-step01
@extracted: 2026-03-14
@description: 환경변수 기반 보안 데이터 로더. 코드와 데이터를 분리하여 GitHub에 코드만 공개 가능.
로컬 경로 + 원격 스토리지(Supabase) + 폴백 경로 3계층 우선순위 로딩.
사용법:
loader = SecureDataLoader(env_prefix="MY_APP")
data = loader.load_json('outputs/current/statistics.json')
text = loader.load_text('config/settings.txt')
files = loader.list_files('data/', pattern='*.csv')
환경 변수:
{ENV_PREFIX}_DATA_PATH: 데이터 루트 경로 (기본: 프로젝트 루트)
{ENV_PREFIX}_DATA_FALLBACK: 폴백 경로 사용 여부 (기본: true)
SUPABASE_URL: Supabase 프로젝트 URL (선택)
SUPABASE_ANON_KEY: Supabase 익명 키 (선택)
{ENV_PREFIX}_DATA_BUCKET: Supabase Storage 버킷 이름 (기본: app-data)
의존성 (선택):
pip install httpx (Supabase Storage 사용 시)
"""
import json
import os
import tempfile
from pathlib import Path
from typing import Any, Optional
class SecureDataLoader:
"""
환경 변수 기반 보안 데이터 로더
코드와 데이터를 분리하여 GitHub에 코드만 공개할 수 있도록 합니다.
Supabase Storage에서 데이터를 다운로드하는 기능을 지원합니다.
데이터 로드 우선순위:
1. 환경변수 지정 로컬 경로
2. Supabase Storage (설정된 경우)
3. 프로젝트 내 폴백 경로
"""
def __init__(
self,
env_prefix: str = "APP",
data_root: Optional[Path] = None,
fallback_root: Optional[Path] = None,
path_mapping: Optional[dict[str, str]] = None,
):
"""
데이터 로더 초기화
Args:
env_prefix: 환경 변수 접두사 (예: "APP" → APP_DATA_PATH)
data_root: 데이터 루트 경로 (None이면 환경 변수에서 로드)
fallback_root: 폴백 루트 경로 (None이면 현재 작업 디렉토리)
path_mapping: 로컬→원격 경로 매핑 딕셔너리
"""
self._env_prefix = env_prefix.upper()
# 환경 변수에서 데이터 경로 로드
if data_root is not None:
self.data_root = data_root
else:
env_path = os.environ.get(f'{self._env_prefix}_DATA_PATH')
if env_path:
self.data_root = Path(env_path)
else:
# 기본 경로: 현재 작업 디렉토리
self.data_root = Path.cwd()
# 폴백 사용 여부
self.use_fallback = os.environ.get(
f'{self._env_prefix}_DATA_FALLBACK', 'true'
).lower() == 'true'
# 폴백 경로
self._fallback_root = fallback_root or Path.cwd()
# Supabase Storage 설정
self._supabase_url = os.environ.get('SUPABASE_URL')
self._supabase_key = os.environ.get('SUPABASE_ANON_KEY')
self._supabase_bucket = os.environ.get(
f'{self._env_prefix}_DATA_BUCKET', 'app-data'
)
self._supabase_client = None
self._supabase_cache_dir: Optional[Path] = None
# Supabase Storage 사용 여부
self.use_supabase = bool(self._supabase_url and self._supabase_key)
# 경로 매핑 (로컬 경로 → Supabase 경로)
self._path_mapping = path_mapping or {}
def _get_supabase_client(self):
"""Supabase httpx 클라이언트 (지연 초기화)"""
if self._supabase_client is not None:
return self._supabase_client
if not self.use_supabase:
return None
try:
import httpx
self._supabase_client = httpx.Client(timeout=30.0)
return self._supabase_client
except ImportError:
print("[WARN] httpx 패키지 미설치 - Supabase Storage 비활성화")
self.use_supabase = False
return None
except Exception as e:
print(f"[WARN] HTTP 클라이언트 생성 실패: {e}")
self.use_supabase = False
return None
def _get_cache_dir(self) -> Path:
"""Supabase 다운로드 캐시 디렉토리"""
if self._supabase_cache_dir is None:
self._supabase_cache_dir = Path(tempfile.gettempdir()) / f"{self._env_prefix.lower()}-data-cache"
self._supabase_cache_dir.mkdir(parents=True, exist_ok=True)
return self._supabase_cache_dir
def _map_to_remote_path(self, relative_path: str) -> str:
"""로컬 경로를 원격 스토리지 경로로 변환"""
for local_prefix, remote_prefix in self._path_mapping.items():
if relative_path.startswith(local_prefix):
return relative_path.replace(local_prefix, remote_prefix, 1)
return relative_path
def _download_from_supabase(self, relative_path: str) -> Optional[Path]:
"""Supabase Storage에서 파일 다운로드 (REST API 사용)"""
client = self._get_supabase_client()
if not client:
return None
supabase_path = self._map_to_remote_path(relative_path)
cache_path = self._get_cache_dir() / relative_path
# 캐시 확인
if cache_path.exists():
return cache_path
try:
# Supabase Storage REST API로 다운로드
url = f"{self._supabase_url}/storage/v1/object/{self._supabase_bucket}/{supabase_path}"
headers = {
"Authorization": f"Bearer {self._supabase_key}",
"apikey": self._supabase_key,
}
response = client.get(url, headers=headers)
if response.status_code != 200:
return None
# 캐시에 저장
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_bytes(response.content)
return cache_path
except Exception:
# 404나 기타 오류는 조용히 무시 (폴백으로 처리)
return None
def _list_supabase_directory(self, folder_path: str) -> list[dict]:
"""Supabase Storage에서 디렉토리 내 파일 목록 조회 (REST API 사용)"""
client = self._get_supabase_client()
if not client:
return []
try:
url = f"{self._supabase_url}/storage/v1/object/list/{self._supabase_bucket}"
headers = {
"Authorization": f"Bearer {self._supabase_key}",
"apikey": self._supabase_key,
"Content-Type": "application/json",
}
response = client.post(
url,
headers=headers,
json={"prefix": folder_path, "limit": 1000},
)
if response.status_code == 200:
return response.json()
else:
print(f"[WARN] Supabase 디렉토리 목록 조회 실패: {response.status_code}")
return []
except Exception as e:
print(f"[WARN] Supabase 디렉토리 목록 조회 실패: {e}")
return []
def list_remote_files(self, relative_dir: str, pattern: str = "*.json") -> list[str]:
"""
Supabase Storage에서 파일 목록 조회
Args:
relative_dir: 상대 디렉토리 경로
pattern: 파일 패턴 (glob 형식)
Returns:
파일명 목록
"""
if not self.use_supabase:
return []
supabase_folder = self._map_to_remote_path(relative_dir) + "/"
files_info = self._list_supabase_directory(supabase_folder)
import fnmatch
result = []
for file_info in files_info:
name = file_info.get('name', '')
if name and file_info.get('id') and fnmatch.fnmatch(name, pattern):
result.append(name)
return result
def get_path(self, relative_path: str) -> Path:
"""
상대 경로를 절대 경로로 변환
Args:
relative_path: 데이터 루트 기준 상대 경로
Returns:
절대 경로
"""
return self.data_root / relative_path
def exists(self, relative_path: str) -> bool:
"""
파일/디렉토리 존재 여부 확인 (3계층 우선순위)
Args:
relative_path: 상대 경로
Returns:
존재 여부
"""
# 1. 로컬 경로 확인
path = self.get_path(relative_path)
if path.exists():
return True
# 2. Supabase Storage 확인 (다운로드 시도)
if self.use_supabase:
supabase_path = self._download_from_supabase(relative_path)
if supabase_path and supabase_path.exists():
return True
# 3. 폴백 경로 확인
if self.use_fallback:
fallback_path = self._fallback_root / relative_path
return fallback_path.exists()
return False
def load_json(self, relative_path: str) -> Optional[dict[str, Any]]:
"""
JSON 파일 로드 (3계층 우선순위)
Args:
relative_path: 상대 경로
Returns:
JSON 데이터 또는 None
"""
# 1. 로컬 경로 시도
path = self.get_path(relative_path)
if path.exists():
return self._load_json_from_path(path)
# 2. Supabase Storage 시도
if self.use_supabase:
supabase_path = self._download_from_supabase(relative_path)
if supabase_path and supabase_path.exists():
return self._load_json_from_path(supabase_path)
# 3. 폴백 경로 시도
if self.use_fallback:
fallback_path = self._fallback_root / relative_path
if fallback_path.exists():
return self._load_json_from_path(fallback_path)
return None
def _load_json_from_path(self, path: Path) -> Optional[dict[str, Any]]:
"""경로에서 JSON 로드"""
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
print(f"[WARN] JSON 로드 실패: {path} - {e}")
return None
def load_text(self, relative_path: str) -> Optional[str]:
"""
텍스트 파일 로드 (3계층 우선순위)
Args:
relative_path: 상대 경로
Returns:
텍스트 내용 또는 None
"""
# 1. 로컬 경로 시도
path = self.get_path(relative_path)
if path.exists():
return self._load_text_from_path(path)
# 2. Supabase Storage 시도
if self.use_supabase:
supabase_path = self._download_from_supabase(relative_path)
if supabase_path and supabase_path.exists():
return self._load_text_from_path(supabase_path)
# 3. 폴백 경로 시도
if self.use_fallback:
fallback_path = self._fallback_root / relative_path
if fallback_path.exists():
return self._load_text_from_path(fallback_path)
return None
def _load_text_from_path(self, path: Path) -> Optional[str]:
"""경로에서 텍스트 로드"""
try:
return path.read_text(encoding='utf-8')
except IOError as e:
print(f"[WARN] 파일 로드 실패: {path} - {e}")
return None
def list_files(self, relative_dir: str, pattern: str = "*.json") -> list[Path]:
"""
디렉토리 내 파일 목록 조회
Args:
relative_dir: 상대 디렉토리 경로
pattern: 파일 패턴 (glob)
Returns:
파일 경로 목록
"""
dir_path = self.get_path(relative_dir)
if not dir_path.exists() and self.use_fallback:
dir_path = self._fallback_root / relative_dir
if not dir_path.exists():
return []
return list(dir_path.glob(pattern))
@property
def is_external_data(self) -> bool:
"""
외부 데이터 경로 사용 여부
환경 변수가 설정되어 프로젝트 외부에서 데이터를 로드하는지 확인
"""
env_path = os.environ.get(f'{self._env_prefix}_DATA_PATH')
return env_path is not None and Path(env_path) != self._fallback_root
def __repr__(self) -> str:
return (
f"SecureDataLoader("
f"prefix={self._env_prefix}, "
f"data_root={self.data_root}, "
f"is_external={self.is_external_data}, "
f"supabase={self.use_supabase}, "
f"fallback={self.use_fallback})"
)
# 싱글톤 인스턴스
_loader: Optional[SecureDataLoader] = None
def get_data_loader(env_prefix: str = "APP") -> SecureDataLoader:
"""
데이터 로더 싱글톤 인스턴스 반환
Args:
env_prefix: 환경 변수 접두사
Returns:
SecureDataLoader 인스턴스
"""
global _loader
if _loader is None:
_loader = SecureDataLoader(env_prefix=env_prefix)
return _loader