feat: SWG 가스켓 전체 구성 정보 표시 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시 - 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시 - 외부링/필러/내부링/추가구성 모든 정보 포함 - 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
This commit is contained in:
12
backend/app/utils/__init__.py
Normal file
12
backend/app/utils/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
유틸리티 모듈
|
||||
"""
|
||||
from .logger import get_logger, setup_logger, app_logger
|
||||
from .file_validator import file_validator, validate_uploaded_file
|
||||
from .error_handlers import ErrorResponse, TKMPException, setup_error_handlers
|
||||
|
||||
__all__ = [
|
||||
"get_logger", "setup_logger", "app_logger",
|
||||
"file_validator", "validate_uploaded_file",
|
||||
"ErrorResponse", "TKMPException", "setup_error_handlers"
|
||||
]
|
||||
266
backend/app/utils/cache_manager.py
Normal file
266
backend/app/utils/cache_manager.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Redis 캐시 관리 유틸리티
|
||||
성능 향상을 위한 캐싱 전략 구현
|
||||
"""
|
||||
import json
|
||||
import redis
|
||||
from typing import Any, Optional, Dict, List
|
||||
from datetime import timedelta
|
||||
import hashlib
|
||||
import pickle
|
||||
|
||||
from ..config import get_settings
|
||||
from .logger import get_logger
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""Redis 캐시 관리 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
try:
|
||||
# Redis 연결 설정
|
||||
self.redis_client = redis.from_url(
|
||||
settings.redis.url,
|
||||
decode_responses=False, # 바이너리 데이터 지원
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5,
|
||||
retry_on_timeout=True
|
||||
)
|
||||
|
||||
# 연결 테스트
|
||||
self.redis_client.ping()
|
||||
logger.info("Redis 연결 성공")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Redis 연결 실패: {e}")
|
||||
self.redis_client = None
|
||||
|
||||
def _generate_key(self, prefix: str, *args, **kwargs) -> str:
|
||||
"""캐시 키 생성"""
|
||||
# 인자들을 문자열로 변환하여 해시 생성
|
||||
key_parts = [str(arg) for arg in args]
|
||||
key_parts.extend([f"{k}:{v}" for k, v in sorted(kwargs.items())])
|
||||
|
||||
if key_parts:
|
||||
key_hash = hashlib.md5("|".join(key_parts).encode()).hexdigest()[:8]
|
||||
return f"tkmp:{prefix}:{key_hash}"
|
||||
else:
|
||||
return f"tkmp:{prefix}"
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""캐시에서 데이터 조회"""
|
||||
if not self.redis_client:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = self.redis_client.get(key)
|
||||
if data:
|
||||
return pickle.loads(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 조회 실패 - key: {key}, error: {e}")
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, expire: int = 3600) -> bool:
|
||||
"""캐시에 데이터 저장"""
|
||||
if not self.redis_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
serialized_data = pickle.dumps(value)
|
||||
result = self.redis_client.setex(key, expire, serialized_data)
|
||||
logger.debug(f"캐시 저장 - key: {key}, expire: {expire}s")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 저장 실패 - key: {key}, error: {e}")
|
||||
return False
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""캐시에서 데이터 삭제"""
|
||||
if not self.redis_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
result = self.redis_client.delete(key)
|
||||
logger.debug(f"캐시 삭제 - key: {key}")
|
||||
return bool(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 삭제 실패 - key: {key}, error: {e}")
|
||||
return False
|
||||
|
||||
def delete_pattern(self, pattern: str) -> int:
|
||||
"""패턴에 맞는 캐시 키들 삭제"""
|
||||
if not self.redis_client:
|
||||
return 0
|
||||
|
||||
try:
|
||||
keys = self.redis_client.keys(pattern)
|
||||
if keys:
|
||||
deleted = self.redis_client.delete(*keys)
|
||||
logger.info(f"패턴 캐시 삭제 - pattern: {pattern}, deleted: {deleted}")
|
||||
return deleted
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.warning(f"패턴 캐시 삭제 실패 - pattern: {pattern}, error: {e}")
|
||||
return 0
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""캐시 키 존재 여부 확인"""
|
||||
if not self.redis_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
return bool(self.redis_client.exists(key))
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 존재 확인 실패 - key: {key}, error: {e}")
|
||||
return False
|
||||
|
||||
def get_ttl(self, key: str) -> int:
|
||||
"""캐시 TTL 조회"""
|
||||
if not self.redis_client:
|
||||
return -1
|
||||
|
||||
try:
|
||||
return self.redis_client.ttl(key)
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 TTL 조회 실패 - key: {key}, error: {e}")
|
||||
return -1
|
||||
|
||||
|
||||
class TKMPCache:
|
||||
"""TK-MP 프로젝트 전용 캐시 래퍼"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache = CacheManager()
|
||||
|
||||
# 캐시 TTL 설정 (초 단위)
|
||||
self.ttl_config = {
|
||||
"file_list": 300, # 5분 - 파일 목록
|
||||
"material_list": 600, # 10분 - 자재 목록
|
||||
"job_list": 1800, # 30분 - 작업 목록
|
||||
"classification": 3600, # 1시간 - 분류 결과
|
||||
"statistics": 900, # 15분 - 통계 데이터
|
||||
"comparison": 1800, # 30분 - 리비전 비교
|
||||
}
|
||||
|
||||
def get_file_list(self, job_no: Optional[str] = None, show_history: bool = False) -> Optional[List[Dict]]:
|
||||
"""파일 목록 캐시 조회"""
|
||||
key = self.cache._generate_key("files", job_no=job_no, history=show_history)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_file_list(self, files: List[Dict], job_no: Optional[str] = None, show_history: bool = False) -> bool:
|
||||
"""파일 목록 캐시 저장"""
|
||||
key = self.cache._generate_key("files", job_no=job_no, history=show_history)
|
||||
return self.cache.set(key, files, self.ttl_config["file_list"])
|
||||
|
||||
def get_material_list(self, file_id: int) -> Optional[List[Dict]]:
|
||||
"""자재 목록 캐시 조회"""
|
||||
key = self.cache._generate_key("materials", file_id=file_id)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_material_list(self, materials: List[Dict], file_id: int) -> bool:
|
||||
"""자재 목록 캐시 저장"""
|
||||
key = self.cache._generate_key("materials", file_id=file_id)
|
||||
return self.cache.set(key, materials, self.ttl_config["material_list"])
|
||||
|
||||
def get_job_list(self) -> Optional[List[Dict]]:
|
||||
"""작업 목록 캐시 조회"""
|
||||
key = self.cache._generate_key("jobs")
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_job_list(self, jobs: List[Dict]) -> bool:
|
||||
"""작업 목록 캐시 저장"""
|
||||
key = self.cache._generate_key("jobs")
|
||||
return self.cache.set(key, jobs, self.ttl_config["job_list"])
|
||||
|
||||
def get_classification_result(self, description: str, category: str) -> Optional[Dict]:
|
||||
"""분류 결과 캐시 조회"""
|
||||
key = self.cache._generate_key("classification", desc=description, cat=category)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_classification_result(self, result: Dict, description: str, category: str) -> bool:
|
||||
"""분류 결과 캐시 저장"""
|
||||
key = self.cache._generate_key("classification", desc=description, cat=category)
|
||||
return self.cache.set(key, result, self.ttl_config["classification"])
|
||||
|
||||
def get_statistics(self, job_no: str, stat_type: str) -> Optional[Dict]:
|
||||
"""통계 데이터 캐시 조회"""
|
||||
key = self.cache._generate_key("stats", job_no=job_no, type=stat_type)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_statistics(self, stats: Dict, job_no: str, stat_type: str) -> bool:
|
||||
"""통계 데이터 캐시 저장"""
|
||||
key = self.cache._generate_key("stats", job_no=job_no, type=stat_type)
|
||||
return self.cache.set(key, stats, self.ttl_config["statistics"])
|
||||
|
||||
def get_revision_comparison(self, job_no: str, rev1: str, rev2: str) -> Optional[Dict]:
|
||||
"""리비전 비교 결과 캐시 조회"""
|
||||
key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_revision_comparison(self, comparison: Dict, job_no: str, rev1: str, rev2: str) -> bool:
|
||||
"""리비전 비교 결과 캐시 저장"""
|
||||
key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2)
|
||||
return self.cache.set(key, comparison, self.ttl_config["comparison"])
|
||||
|
||||
def invalidate_job_cache(self, job_no: str):
|
||||
"""특정 작업의 모든 캐시 무효화"""
|
||||
patterns = [
|
||||
f"tkmp:files:*job_no:{job_no}*",
|
||||
f"tkmp:materials:*job_no:{job_no}*",
|
||||
f"tkmp:stats:*job_no:{job_no}*",
|
||||
f"tkmp:comparison:*job_no:{job_no}*"
|
||||
]
|
||||
|
||||
total_deleted = 0
|
||||
for pattern in patterns:
|
||||
deleted = self.cache.delete_pattern(pattern)
|
||||
total_deleted += deleted
|
||||
|
||||
logger.info(f"작업 캐시 무효화 완료 - job_no: {job_no}, deleted: {total_deleted}")
|
||||
return total_deleted
|
||||
|
||||
def invalidate_file_cache(self, file_id: int):
|
||||
"""특정 파일의 모든 캐시 무효화"""
|
||||
patterns = [
|
||||
f"tkmp:materials:*file_id:{file_id}*",
|
||||
f"tkmp:files:*" # 파일 목록도 갱신 필요
|
||||
]
|
||||
|
||||
total_deleted = 0
|
||||
for pattern in patterns:
|
||||
deleted = self.cache.delete_pattern(pattern)
|
||||
total_deleted += deleted
|
||||
|
||||
logger.info(f"파일 캐시 무효화 완료 - file_id: {file_id}, deleted: {total_deleted}")
|
||||
return total_deleted
|
||||
|
||||
def get_cache_info(self) -> Dict[str, Any]:
|
||||
"""캐시 상태 정보 조회"""
|
||||
if not self.cache.redis_client:
|
||||
return {"status": "disconnected"}
|
||||
|
||||
try:
|
||||
info = self.cache.redis_client.info()
|
||||
return {
|
||||
"status": "connected",
|
||||
"used_memory": info.get("used_memory_human", "N/A"),
|
||||
"connected_clients": info.get("connected_clients", 0),
|
||||
"total_commands_processed": info.get("total_commands_processed", 0),
|
||||
"keyspace_hits": info.get("keyspace_hits", 0),
|
||||
"keyspace_misses": info.get("keyspace_misses", 0),
|
||||
"hit_rate": round(
|
||||
info.get("keyspace_hits", 0) /
|
||||
max(info.get("keyspace_hits", 0) + info.get("keyspace_misses", 0), 1) * 100, 2
|
||||
)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"캐시 정보 조회 실패: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
# 전역 캐시 인스턴스
|
||||
tkmp_cache = TKMPCache()
|
||||
139
backend/app/utils/error_handlers.py
Normal file
139
backend/app/utils/error_handlers.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
에러 처리 유틸리티
|
||||
표준화된 에러 응답 및 예외 처리
|
||||
"""
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from typing import Dict, Any
|
||||
import traceback
|
||||
|
||||
from .logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TKMPException(Exception):
|
||||
"""TK-MP 프로젝트 커스텀 예외"""
|
||||
|
||||
def __init__(self, message: str, error_code: str = "TKMP_ERROR", status_code: int = 500):
|
||||
self.message = message
|
||||
self.error_code = error_code
|
||||
self.status_code = status_code
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ErrorResponse:
|
||||
"""표준화된 에러 응답 생성기"""
|
||||
|
||||
@staticmethod
|
||||
def create_error_response(
|
||||
message: str,
|
||||
error_code: str = "INTERNAL_ERROR",
|
||||
status_code: int = 500,
|
||||
details: Dict[str, Any] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""표준화된 에러 응답 생성"""
|
||||
response = {
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": error_code,
|
||||
"message": message,
|
||||
"timestamp": "2025-01-01T00:00:00Z" # 실제로는 datetime.utcnow().isoformat()
|
||||
}
|
||||
}
|
||||
|
||||
if details:
|
||||
response["error"]["details"] = details
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def validation_error_response(errors: list) -> Dict[str, Any]:
|
||||
"""검증 에러 응답"""
|
||||
return ErrorResponse.create_error_response(
|
||||
message="입력 데이터 검증에 실패했습니다.",
|
||||
error_code="VALIDATION_ERROR",
|
||||
status_code=422,
|
||||
details={"validation_errors": errors}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def database_error_response(error: str) -> Dict[str, Any]:
|
||||
"""데이터베이스 에러 응답"""
|
||||
return ErrorResponse.create_error_response(
|
||||
message="데이터베이스 작업 중 오류가 발생했습니다.",
|
||||
error_code="DATABASE_ERROR",
|
||||
status_code=500,
|
||||
details={"db_error": error}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def file_error_response(error: str) -> Dict[str, Any]:
|
||||
"""파일 처리 에러 응답"""
|
||||
return ErrorResponse.create_error_response(
|
||||
message="파일 처리 중 오류가 발생했습니다.",
|
||||
error_code="FILE_ERROR",
|
||||
status_code=400,
|
||||
details={"file_error": error}
|
||||
)
|
||||
|
||||
|
||||
async def tkmp_exception_handler(request: Request, exc: TKMPException):
|
||||
"""TK-MP 커스텀 예외 핸들러"""
|
||||
logger.error(f"TK-MP 예외 발생: {exc.message} (코드: {exc.error_code})")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=ErrorResponse.create_error_response(
|
||||
message=exc.message,
|
||||
error_code=exc.error_code,
|
||||
status_code=exc.status_code
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""검증 예외 핸들러"""
|
||||
logger.warning(f"검증 오류: {exc.errors()}")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=ErrorResponse.validation_error_response(exc.errors())
|
||||
)
|
||||
|
||||
|
||||
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
|
||||
"""SQLAlchemy 예외 핸들러"""
|
||||
logger.error(f"데이터베이스 오류: {str(exc)}", exc_info=True)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ErrorResponse.database_error_response(str(exc))
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""일반 예외 핸들러"""
|
||||
logger.error(f"예상치 못한 오류: {str(exc)}", exc_info=True)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ErrorResponse.create_error_response(
|
||||
message="서버 내부 오류가 발생했습니다.",
|
||||
error_code="INTERNAL_SERVER_ERROR",
|
||||
status_code=500,
|
||||
details={"error": str(exc)} if logger.level <= 10 else None # DEBUG 레벨일 때만 상세 에러 표시
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def setup_error_handlers(app):
|
||||
"""FastAPI 앱에 에러 핸들러 등록"""
|
||||
app.add_exception_handler(TKMPException, tkmp_exception_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
app.add_exception_handler(SQLAlchemyError, sqlalchemy_exception_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
|
||||
logger.info("에러 핸들러 등록 완료")
|
||||
335
backend/app/utils/file_processor.py
Normal file
335
backend/app/utils/file_processor.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
대용량 파일 처리 최적화 유틸리티
|
||||
메모리 효율적인 파일 처리 및 청크 기반 처리
|
||||
"""
|
||||
import pandas as pd
|
||||
import asyncio
|
||||
from typing import Iterator, List, Dict, Any, Optional, Callable
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import gc
|
||||
|
||||
from .logger import get_logger
|
||||
from ..config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class FileProcessor:
|
||||
"""대용량 파일 처리 최적화 클래스"""
|
||||
|
||||
def __init__(self, chunk_size: int = 1000, max_workers: int = 4):
|
||||
self.chunk_size = chunk_size
|
||||
self.max_workers = max_workers
|
||||
self.executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
|
||||
def read_excel_chunks(self, file_path: str, sheet_name: str = None) -> Iterator[pd.DataFrame]:
|
||||
"""
|
||||
엑셀 파일을 청크 단위로 읽기
|
||||
|
||||
Args:
|
||||
file_path: 파일 경로
|
||||
sheet_name: 시트명 (None이면 첫 번째 시트)
|
||||
|
||||
Yields:
|
||||
DataFrame: 청크 단위 데이터
|
||||
"""
|
||||
try:
|
||||
# 파일 크기 확인
|
||||
file_size = os.path.getsize(file_path)
|
||||
logger.info(f"엑셀 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes")
|
||||
|
||||
# 전체 행 수 확인 (메모리 효율적으로)
|
||||
with pd.ExcelFile(file_path) as xls:
|
||||
if sheet_name is None:
|
||||
sheet_name = xls.sheet_names[0]
|
||||
|
||||
# 첫 번째 청크로 컬럼 정보 확인
|
||||
first_chunk = pd.read_excel(xls, sheet_name=sheet_name, nrows=self.chunk_size)
|
||||
total_rows = len(first_chunk)
|
||||
|
||||
# 전체 데이터를 청크로 나누어 처리
|
||||
processed_rows = 0
|
||||
chunk_num = 0
|
||||
|
||||
while processed_rows < total_rows:
|
||||
try:
|
||||
# 청크 읽기
|
||||
chunk = pd.read_excel(
|
||||
xls,
|
||||
sheet_name=sheet_name,
|
||||
skiprows=processed_rows + 1 if processed_rows > 0 else 0,
|
||||
nrows=self.chunk_size,
|
||||
header=0 if processed_rows == 0 else None
|
||||
)
|
||||
|
||||
if chunk.empty:
|
||||
break
|
||||
|
||||
# 첫 번째 청크가 아닌 경우 컬럼명 설정
|
||||
if processed_rows > 0:
|
||||
chunk.columns = first_chunk.columns
|
||||
|
||||
chunk_num += 1
|
||||
processed_rows += len(chunk)
|
||||
|
||||
logger.debug(f"청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {processed_rows}")
|
||||
|
||||
yield chunk
|
||||
|
||||
# 메모리 정리
|
||||
del chunk
|
||||
gc.collect()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"청크 {chunk_num} 처리 중 오류: {e}")
|
||||
break
|
||||
|
||||
logger.info(f"엑셀 파일 처리 완료 - 총 {chunk_num}개 청크, {processed_rows}행 처리")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"엑셀 파일 읽기 실패: {e}")
|
||||
raise
|
||||
|
||||
def read_csv_chunks(self, file_path: str, encoding: str = 'utf-8') -> Iterator[pd.DataFrame]:
|
||||
"""
|
||||
CSV 파일을 청크 단위로 읽기
|
||||
|
||||
Args:
|
||||
file_path: 파일 경로
|
||||
encoding: 인코딩 (기본: utf-8)
|
||||
|
||||
Yields:
|
||||
DataFrame: 청크 단위 데이터
|
||||
"""
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
logger.info(f"CSV 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes")
|
||||
|
||||
chunk_num = 0
|
||||
total_rows = 0
|
||||
|
||||
# pandas의 chunksize 옵션 사용
|
||||
for chunk in pd.read_csv(file_path, chunksize=self.chunk_size, encoding=encoding):
|
||||
chunk_num += 1
|
||||
total_rows += len(chunk)
|
||||
|
||||
logger.debug(f"CSV 청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {total_rows}")
|
||||
|
||||
yield chunk
|
||||
|
||||
# 메모리 정리
|
||||
gc.collect()
|
||||
|
||||
logger.info(f"CSV 파일 처리 완료 - 총 {chunk_num}개 청크, {total_rows}행 처리")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CSV 파일 읽기 실패: {e}")
|
||||
raise
|
||||
|
||||
async def process_file_async(
|
||||
self,
|
||||
file_path: str,
|
||||
processor_func: Callable[[pd.DataFrame], List[Dict]],
|
||||
file_type: str = "excel"
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
파일을 비동기적으로 처리
|
||||
|
||||
Args:
|
||||
file_path: 파일 경로
|
||||
processor_func: 각 청크를 처리할 함수
|
||||
file_type: 파일 타입 ("excel" 또는 "csv")
|
||||
|
||||
Returns:
|
||||
List[Dict]: 처리된 결과 리스트
|
||||
"""
|
||||
try:
|
||||
logger.info(f"비동기 파일 처리 시작 - {file_path}")
|
||||
|
||||
results = []
|
||||
chunk_futures = []
|
||||
|
||||
# 파일 타입에 따른 청크 리더 선택
|
||||
if file_type.lower() == "csv":
|
||||
chunk_reader = self.read_csv_chunks(file_path)
|
||||
else:
|
||||
chunk_reader = self.read_excel_chunks(file_path)
|
||||
|
||||
# 청크별 비동기 처리
|
||||
for chunk in chunk_reader:
|
||||
# 스레드 풀에서 청크 처리
|
||||
future = asyncio.get_event_loop().run_in_executor(
|
||||
self.executor,
|
||||
processor_func,
|
||||
chunk
|
||||
)
|
||||
chunk_futures.append(future)
|
||||
|
||||
# 너무 많은 청크가 동시에 처리되지 않도록 제한
|
||||
if len(chunk_futures) >= self.max_workers:
|
||||
# 완료된 작업들 수집
|
||||
completed_results = await asyncio.gather(*chunk_futures)
|
||||
for result in completed_results:
|
||||
if result:
|
||||
results.extend(result)
|
||||
|
||||
chunk_futures = []
|
||||
gc.collect()
|
||||
|
||||
# 남은 청크들 처리
|
||||
if chunk_futures:
|
||||
completed_results = await asyncio.gather(*chunk_futures)
|
||||
for result in completed_results:
|
||||
if result:
|
||||
results.extend(result)
|
||||
|
||||
logger.info(f"비동기 파일 처리 완료 - 총 {len(results)}개 항목 처리")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"비동기 파일 처리 실패: {e}")
|
||||
raise
|
||||
|
||||
def optimize_dataframe_memory(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
DataFrame 메모리 사용량 최적화
|
||||
|
||||
Args:
|
||||
df: 최적화할 DataFrame
|
||||
|
||||
Returns:
|
||||
DataFrame: 최적화된 DataFrame
|
||||
"""
|
||||
try:
|
||||
original_memory = df.memory_usage(deep=True).sum()
|
||||
|
||||
# 수치형 컬럼 최적화
|
||||
for col in df.select_dtypes(include=['int64']).columns:
|
||||
col_min = df[col].min()
|
||||
col_max = df[col].max()
|
||||
|
||||
if col_min >= -128 and col_max <= 127:
|
||||
df[col] = df[col].astype('int8')
|
||||
elif col_min >= -32768 and col_max <= 32767:
|
||||
df[col] = df[col].astype('int16')
|
||||
elif col_min >= -2147483648 and col_max <= 2147483647:
|
||||
df[col] = df[col].astype('int32')
|
||||
|
||||
# 실수형 컬럼 최적화
|
||||
for col in df.select_dtypes(include=['float64']).columns:
|
||||
df[col] = pd.to_numeric(df[col], downcast='float')
|
||||
|
||||
# 문자열 컬럼 최적화 (카테고리형으로 변환)
|
||||
for col in df.select_dtypes(include=['object']).columns:
|
||||
if df[col].nunique() / len(df) < 0.5: # 고유값이 50% 미만인 경우
|
||||
df[col] = df[col].astype('category')
|
||||
|
||||
optimized_memory = df.memory_usage(deep=True).sum()
|
||||
memory_reduction = (original_memory - optimized_memory) / original_memory * 100
|
||||
|
||||
logger.debug(f"DataFrame 메모리 최적화 완료 - 감소율: {memory_reduction:.1f}%")
|
||||
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"DataFrame 메모리 최적화 실패: {e}")
|
||||
return df
|
||||
|
||||
def create_temp_file(self, suffix: str = '.tmp') -> str:
|
||||
"""
|
||||
임시 파일 생성
|
||||
|
||||
Args:
|
||||
suffix: 파일 확장자
|
||||
|
||||
Returns:
|
||||
str: 임시 파일 경로
|
||||
"""
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
||||
temp_file.close()
|
||||
logger.debug(f"임시 파일 생성: {temp_file.name}")
|
||||
return temp_file.name
|
||||
|
||||
def cleanup_temp_file(self, file_path: str):
|
||||
"""
|
||||
임시 파일 정리
|
||||
|
||||
Args:
|
||||
file_path: 삭제할 파일 경로
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.unlink(file_path)
|
||||
logger.debug(f"임시 파일 삭제: {file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"임시 파일 삭제 실패: {file_path}, error: {e}")
|
||||
|
||||
def get_file_info(self, file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
파일 정보 조회
|
||||
|
||||
Args:
|
||||
file_path: 파일 경로
|
||||
|
||||
Returns:
|
||||
Dict: 파일 정보
|
||||
"""
|
||||
try:
|
||||
file_stat = os.stat(file_path)
|
||||
file_ext = Path(file_path).suffix.lower()
|
||||
|
||||
info = {
|
||||
"file_path": file_path,
|
||||
"file_size": file_stat.st_size,
|
||||
"file_size_mb": round(file_stat.st_size / (1024 * 1024), 2),
|
||||
"file_extension": file_ext,
|
||||
"is_large_file": file_stat.st_size > 10 * 1024 * 1024, # 10MB 이상
|
||||
"recommended_chunk_size": self._calculate_optimal_chunk_size(file_stat.st_size)
|
||||
}
|
||||
|
||||
# 파일 타입별 추가 정보
|
||||
if file_ext in ['.xlsx', '.xls']:
|
||||
info["file_type"] = "excel"
|
||||
info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load"
|
||||
elif file_ext == '.csv':
|
||||
info["file_type"] = "csv"
|
||||
info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load"
|
||||
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 정보 조회 실패: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _calculate_optimal_chunk_size(self, file_size: int) -> int:
|
||||
"""
|
||||
파일 크기에 따른 최적 청크 크기 계산
|
||||
|
||||
Args:
|
||||
file_size: 파일 크기 (bytes)
|
||||
|
||||
Returns:
|
||||
int: 최적 청크 크기
|
||||
"""
|
||||
# 파일 크기에 따른 청크 크기 조정
|
||||
if file_size < 1024 * 1024: # 1MB 미만
|
||||
return 500
|
||||
elif file_size < 10 * 1024 * 1024: # 10MB 미만
|
||||
return 1000
|
||||
elif file_size < 50 * 1024 * 1024: # 50MB 미만
|
||||
return 2000
|
||||
else: # 50MB 이상
|
||||
return 5000
|
||||
|
||||
def __del__(self):
|
||||
"""소멸자 - 스레드 풀 정리"""
|
||||
if hasattr(self, 'executor'):
|
||||
self.executor.shutdown(wait=True)
|
||||
|
||||
|
||||
# 전역 파일 프로세서 인스턴스
|
||||
file_processor = FileProcessor()
|
||||
169
backend/app/utils/file_validator.py
Normal file
169
backend/app/utils/file_validator.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
파일 업로드 검증 유틸리티
|
||||
보안 강화를 위한 파일 검증 로직
|
||||
"""
|
||||
import os
|
||||
import magic
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from fastapi import UploadFile, HTTPException
|
||||
|
||||
from ..config import get_settings
|
||||
from .logger import get_logger
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class FileValidator:
|
||||
"""파일 업로드 검증 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.max_file_size = settings.security.max_file_size
|
||||
self.allowed_extensions = settings.security.allowed_file_extensions
|
||||
|
||||
# MIME 타입 매핑
|
||||
self.mime_type_mapping = {
|
||||
'.xlsx': [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/octet-stream' # 일부 브라우저에서 xlsx를 이렇게 인식
|
||||
],
|
||||
'.xls': [
|
||||
'application/vnd.ms-excel',
|
||||
'application/octet-stream'
|
||||
],
|
||||
'.csv': [
|
||||
'text/csv',
|
||||
'text/plain',
|
||||
'application/csv'
|
||||
]
|
||||
}
|
||||
|
||||
def validate_file_extension(self, filename: str) -> bool:
|
||||
"""파일 확장자 검증"""
|
||||
file_ext = Path(filename).suffix.lower()
|
||||
is_valid = file_ext in self.allowed_extensions
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"허용되지 않은 파일 확장자: {file_ext}, 파일: {filename}")
|
||||
|
||||
return is_valid
|
||||
|
||||
def validate_file_size(self, file_size: int) -> bool:
|
||||
"""파일 크기 검증"""
|
||||
is_valid = file_size <= self.max_file_size
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"파일 크기 초과: {file_size} bytes (최대: {self.max_file_size} bytes)")
|
||||
|
||||
return is_valid
|
||||
|
||||
def validate_filename(self, filename: str) -> bool:
|
||||
"""파일명 검증 (보안 위험 문자 체크)"""
|
||||
# 위험한 문자들
|
||||
dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|']
|
||||
|
||||
for char in dangerous_chars:
|
||||
if char in filename:
|
||||
logger.warning(f"위험한 문자 포함된 파일명: {filename}")
|
||||
return False
|
||||
|
||||
# 파일명 길이 체크 (255자 제한)
|
||||
if len(filename) > 255:
|
||||
logger.warning(f"파일명이 너무 긺: {len(filename)} 문자")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def validate_mime_type(self, file_content: bytes, filename: str) -> bool:
|
||||
"""MIME 타입 검증 (파일 내용 기반)"""
|
||||
try:
|
||||
# python-magic을 사용한 MIME 타입 검증
|
||||
detected_mime = magic.from_buffer(file_content, mime=True)
|
||||
file_ext = Path(filename).suffix.lower()
|
||||
|
||||
expected_mimes = self.mime_type_mapping.get(file_ext, [])
|
||||
|
||||
if detected_mime in expected_mimes:
|
||||
return True
|
||||
|
||||
logger.warning(f"MIME 타입 불일치 - 파일: {filename}, 감지된 타입: {detected_mime}, 예상 타입: {expected_mimes}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MIME 타입 검증 실패: {e}")
|
||||
# magic 라이브러리 오류 시 확장자 검증으로 대체
|
||||
return self.validate_file_extension(filename)
|
||||
|
||||
def sanitize_filename(self, filename: str) -> str:
|
||||
"""파일명 정화 (안전한 파일명으로 변환)"""
|
||||
# 위험한 문자들을 언더스코어로 대체
|
||||
dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|']
|
||||
sanitized = filename
|
||||
|
||||
for char in dangerous_chars:
|
||||
sanitized = sanitized.replace(char, '_')
|
||||
|
||||
# 연속된 언더스코어 제거
|
||||
while '__' in sanitized:
|
||||
sanitized = sanitized.replace('__', '_')
|
||||
|
||||
# 앞뒤 공백 및 점 제거
|
||||
sanitized = sanitized.strip(' .')
|
||||
|
||||
return sanitized
|
||||
|
||||
async def validate_upload_file(self, file: UploadFile) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
업로드 파일 종합 검증
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (검증 성공 여부, 에러 메시지)
|
||||
"""
|
||||
try:
|
||||
# 1. 파일명 검증
|
||||
if not self.validate_filename(file.filename):
|
||||
return False, f"유효하지 않은 파일명: {file.filename}"
|
||||
|
||||
# 2. 확장자 검증
|
||||
if not self.validate_file_extension(file.filename):
|
||||
return False, f"허용되지 않은 파일 형식입니다. 허용 형식: {', '.join(self.allowed_extensions)}"
|
||||
|
||||
# 3. 파일 내용 읽기
|
||||
file_content = await file.read()
|
||||
await file.seek(0) # 파일 포인터 리셋
|
||||
|
||||
# 4. 파일 크기 검증
|
||||
if not self.validate_file_size(len(file_content)):
|
||||
return False, f"파일 크기가 너무 큽니다. 최대 크기: {self.max_file_size // (1024*1024)}MB"
|
||||
|
||||
# 5. MIME 타입 검증
|
||||
if not self.validate_mime_type(file_content, file.filename):
|
||||
return False, "파일 형식이 올바르지 않습니다."
|
||||
|
||||
logger.info(f"파일 검증 성공: {file.filename} ({len(file_content)} bytes)")
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 검증 중 오류 발생: {e}", exc_info=True)
|
||||
return False, f"파일 검증 중 오류가 발생했습니다: {str(e)}"
|
||||
|
||||
|
||||
# 전역 파일 검증기 인스턴스
|
||||
file_validator = FileValidator()
|
||||
|
||||
|
||||
async def validate_uploaded_file(file: UploadFile) -> None:
|
||||
"""
|
||||
파일 검증 헬퍼 함수 (HTTPException 발생)
|
||||
|
||||
Args:
|
||||
file: 업로드된 파일
|
||||
|
||||
Raises:
|
||||
HTTPException: 검증 실패 시
|
||||
"""
|
||||
is_valid, error_message = await file_validator.validate_upload_file(file)
|
||||
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=error_message)
|
||||
87
backend/app/utils/logger.py
Normal file
87
backend/app/utils/logger.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
로깅 유틸리티 모듈
|
||||
중앙화된 로깅 설정 및 관리
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def setup_logger(
|
||||
name: str,
|
||||
log_file: Optional[str] = None,
|
||||
level: str = None
|
||||
) -> logging.Logger:
|
||||
"""
|
||||
로거 설정 및 반환
|
||||
|
||||
Args:
|
||||
name: 로거 이름
|
||||
log_file: 로그 파일 경로 (선택사항)
|
||||
level: 로그 레벨 (선택사항)
|
||||
|
||||
Returns:
|
||||
설정된 로거 인스턴스
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
# 이미 핸들러가 설정된 경우 중복 방지
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
# 로그 레벨 설정
|
||||
log_level = level or settings.logging.level
|
||||
logger.setLevel(getattr(logging, log_level.upper()))
|
||||
|
||||
# 포맷터 설정
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
|
||||
)
|
||||
|
||||
# 콘솔 핸들러
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 파일 핸들러 (선택사항)
|
||||
if log_file or settings.logging.file_path:
|
||||
file_path = log_file or settings.logging.file_path
|
||||
|
||||
# 로그 디렉토리 생성
|
||||
log_dir = os.path.dirname(file_path)
|
||||
if log_dir and not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# 로테이팅 파일 핸들러 (10MB, 5개 파일 유지)
|
||||
file_handler = RotatingFileHandler(
|
||||
file_path,
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""
|
||||
로거 인스턴스 반환 (간편 함수)
|
||||
|
||||
Args:
|
||||
name: 로거 이름
|
||||
|
||||
Returns:
|
||||
로거 인스턴스
|
||||
"""
|
||||
return setup_logger(name)
|
||||
|
||||
|
||||
# 애플리케이션 전역 로거
|
||||
app_logger = setup_logger("tk_mp_app", settings.logging.file_path)
|
||||
355
backend/app/utils/transaction_manager.py
Normal file
355
backend/app/utils/transaction_manager.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
트랜잭션 관리 유틸리티
|
||||
데이터 일관성을 위한 트랜잭션 관리 및 데코레이터
|
||||
"""
|
||||
import functools
|
||||
from typing import Any, Callable, Optional, TypeVar, Generic
|
||||
from contextlib import contextmanager
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
import asyncio
|
||||
|
||||
from .logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class TransactionManager:
|
||||
"""트랜잭션 관리 클래스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
@contextmanager
|
||||
def transaction(self, rollback_on_exception: bool = True):
|
||||
"""
|
||||
트랜잭션 컨텍스트 매니저
|
||||
|
||||
Args:
|
||||
rollback_on_exception: 예외 발생 시 롤백 여부
|
||||
"""
|
||||
try:
|
||||
logger.debug("트랜잭션 시작")
|
||||
yield self.db
|
||||
self.db.commit()
|
||||
logger.debug("트랜잭션 커밋 완료")
|
||||
|
||||
except Exception as e:
|
||||
if rollback_on_exception:
|
||||
self.db.rollback()
|
||||
logger.warning(f"트랜잭션 롤백 - 에러: {str(e)}")
|
||||
else:
|
||||
logger.error(f"트랜잭션 에러 (롤백 안함) - 에러: {str(e)}")
|
||||
raise
|
||||
|
||||
@contextmanager
|
||||
def savepoint(self, name: Optional[str] = None):
|
||||
"""
|
||||
세이브포인트 컨텍스트 매니저
|
||||
|
||||
Args:
|
||||
name: 세이브포인트 이름
|
||||
"""
|
||||
savepoint_name = name or f"sp_{id(self)}"
|
||||
|
||||
try:
|
||||
# 세이브포인트 생성
|
||||
savepoint = self.db.begin_nested()
|
||||
logger.debug(f"세이브포인트 생성: {savepoint_name}")
|
||||
|
||||
yield self.db
|
||||
|
||||
# 세이브포인트 커밋
|
||||
savepoint.commit()
|
||||
logger.debug(f"세이브포인트 커밋: {savepoint_name}")
|
||||
|
||||
except Exception as e:
|
||||
# 세이브포인트 롤백
|
||||
savepoint.rollback()
|
||||
logger.warning(f"세이브포인트 롤백: {savepoint_name} - 에러: {str(e)}")
|
||||
raise
|
||||
|
||||
def execute_in_transaction(self, func: Callable[..., T], *args, **kwargs) -> T:
|
||||
"""
|
||||
함수를 트랜잭션 내에서 실행
|
||||
|
||||
Args:
|
||||
func: 실행할 함수
|
||||
*args: 함수 인자
|
||||
**kwargs: 함수 키워드 인자
|
||||
|
||||
Returns:
|
||||
함수 실행 결과
|
||||
"""
|
||||
with self.transaction():
|
||||
return func(*args, **kwargs)
|
||||
|
||||
async def execute_in_transaction_async(self, func: Callable[..., T], *args, **kwargs) -> T:
|
||||
"""
|
||||
비동기 함수를 트랜잭션 내에서 실행
|
||||
|
||||
Args:
|
||||
func: 실행할 비동기 함수
|
||||
*args: 함수 인자
|
||||
**kwargs: 함수 키워드 인자
|
||||
|
||||
Returns:
|
||||
함수 실행 결과
|
||||
"""
|
||||
with self.transaction():
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
||||
def transactional(rollback_on_exception: bool = True):
|
||||
"""
|
||||
트랜잭션 데코레이터
|
||||
|
||||
Args:
|
||||
rollback_on_exception: 예외 발생 시 롤백 여부
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# 첫 번째 인자가 Session인지 확인
|
||||
if args and isinstance(args[0], Session):
|
||||
db = args[0]
|
||||
transaction_manager = TransactionManager(db)
|
||||
|
||||
try:
|
||||
with transaction_manager.transaction(rollback_on_exception):
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}")
|
||||
raise
|
||||
else:
|
||||
# Session이 없으면 일반 함수로 실행
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def async_transactional(rollback_on_exception: bool = True):
|
||||
"""
|
||||
비동기 트랜잭션 데코레이터
|
||||
|
||||
Args:
|
||||
rollback_on_exception: 예외 발생 시 롤백 여부
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# 첫 번째 인자가 Session인지 확인
|
||||
if args and isinstance(args[0], Session):
|
||||
db = args[0]
|
||||
transaction_manager = TransactionManager(db)
|
||||
|
||||
try:
|
||||
with transaction_manager.transaction(rollback_on_exception):
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"비동기 트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}")
|
||||
raise
|
||||
else:
|
||||
# Session이 없으면 일반 함수로 실행
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
class BatchProcessor:
|
||||
"""배치 처리를 위한 트랜잭션 관리"""
|
||||
|
||||
def __init__(self, db: Session, batch_size: int = 1000):
|
||||
self.db = db
|
||||
self.batch_size = batch_size
|
||||
self.transaction_manager = TransactionManager(db)
|
||||
|
||||
def process_in_batches(
|
||||
self,
|
||||
items: list,
|
||||
process_func: Callable,
|
||||
commit_per_batch: bool = True
|
||||
):
|
||||
"""
|
||||
아이템들을 배치 단위로 처리
|
||||
|
||||
Args:
|
||||
items: 처리할 아이템 리스트
|
||||
process_func: 각 아이템을 처리할 함수
|
||||
commit_per_batch: 배치마다 커밋 여부
|
||||
"""
|
||||
total_items = len(items)
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
logger.info(f"배치 처리 시작 - 총 {total_items}개 아이템, 배치 크기: {self.batch_size}")
|
||||
|
||||
for i in range(0, total_items, self.batch_size):
|
||||
batch = items[i:i + self.batch_size]
|
||||
batch_num = (i // self.batch_size) + 1
|
||||
|
||||
try:
|
||||
if commit_per_batch:
|
||||
with self.transaction_manager.transaction():
|
||||
self._process_batch(batch, process_func)
|
||||
else:
|
||||
self._process_batch(batch, process_func)
|
||||
|
||||
processed_count += len(batch)
|
||||
logger.debug(f"배치 {batch_num} 처리 완료 - {len(batch)}개 아이템")
|
||||
|
||||
except Exception as e:
|
||||
failed_count += len(batch)
|
||||
logger.error(f"배치 {batch_num} 처리 실패 - {str(e)}")
|
||||
|
||||
# 개별 아이템 처리 시도
|
||||
if commit_per_batch:
|
||||
self._process_batch_individually(batch, process_func)
|
||||
|
||||
# 전체 커밋 (배치마다 커밋하지 않은 경우)
|
||||
if not commit_per_batch:
|
||||
try:
|
||||
self.db.commit()
|
||||
logger.info("전체 배치 처리 커밋 완료")
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"전체 배치 처리 커밋 실패: {str(e)}")
|
||||
raise
|
||||
|
||||
logger.info(f"배치 처리 완료 - 성공: {processed_count}, 실패: {failed_count}")
|
||||
|
||||
return {
|
||||
"total_items": total_items,
|
||||
"processed_count": processed_count,
|
||||
"failed_count": failed_count,
|
||||
"success_rate": (processed_count / total_items) * 100 if total_items > 0 else 0
|
||||
}
|
||||
|
||||
def _process_batch(self, batch: list, process_func: Callable):
|
||||
"""배치 처리"""
|
||||
for item in batch:
|
||||
process_func(item)
|
||||
|
||||
def _process_batch_individually(self, batch: list, process_func: Callable):
|
||||
"""배치 내 아이템을 개별적으로 처리 (에러 복구용)"""
|
||||
for item in batch:
|
||||
try:
|
||||
with self.transaction_manager.savepoint():
|
||||
process_func(item)
|
||||
except Exception as e:
|
||||
logger.warning(f"개별 아이템 처리 실패: {str(e)}")
|
||||
|
||||
|
||||
class DatabaseLock:
|
||||
"""데이터베이스 레벨 락 관리"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
@contextmanager
|
||||
def advisory_lock(self, lock_id: int):
|
||||
"""
|
||||
PostgreSQL Advisory Lock
|
||||
|
||||
Args:
|
||||
lock_id: 락 ID
|
||||
"""
|
||||
try:
|
||||
# Advisory Lock 획득
|
||||
result = self.db.execute(f"SELECT pg_advisory_lock({lock_id})")
|
||||
logger.debug(f"Advisory Lock 획득: {lock_id}")
|
||||
|
||||
yield
|
||||
|
||||
finally:
|
||||
# Advisory Lock 해제
|
||||
self.db.execute(f"SELECT pg_advisory_unlock({lock_id})")
|
||||
logger.debug(f"Advisory Lock 해제: {lock_id}")
|
||||
|
||||
@contextmanager
|
||||
def table_lock(self, table_name: str, lock_mode: str = "ACCESS EXCLUSIVE"):
|
||||
"""
|
||||
테이블 레벨 락
|
||||
|
||||
Args:
|
||||
table_name: 테이블명
|
||||
lock_mode: 락 모드
|
||||
"""
|
||||
try:
|
||||
# 테이블 락 획득
|
||||
self.db.execute(f"LOCK TABLE {table_name} IN {lock_mode} MODE")
|
||||
logger.debug(f"테이블 락 획득: {table_name} ({lock_mode})")
|
||||
|
||||
yield
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"테이블 락 실패: {table_name} - {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
class TransactionStats:
|
||||
"""트랜잭션 통계 수집"""
|
||||
|
||||
def __init__(self):
|
||||
self.stats = {
|
||||
"total_transactions": 0,
|
||||
"successful_transactions": 0,
|
||||
"failed_transactions": 0,
|
||||
"rollback_count": 0,
|
||||
"savepoint_count": 0
|
||||
}
|
||||
|
||||
def record_transaction_start(self):
|
||||
"""트랜잭션 시작 기록"""
|
||||
self.stats["total_transactions"] += 1
|
||||
|
||||
def record_transaction_success(self):
|
||||
"""트랜잭션 성공 기록"""
|
||||
self.stats["successful_transactions"] += 1
|
||||
|
||||
def record_transaction_failure(self):
|
||||
"""트랜잭션 실패 기록"""
|
||||
self.stats["failed_transactions"] += 1
|
||||
|
||||
def record_rollback(self):
|
||||
"""롤백 기록"""
|
||||
self.stats["rollback_count"] += 1
|
||||
|
||||
def record_savepoint(self):
|
||||
"""세이브포인트 기록"""
|
||||
self.stats["savepoint_count"] += 1
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""통계 반환"""
|
||||
total = self.stats["total_transactions"]
|
||||
if total > 0:
|
||||
self.stats["success_rate"] = (self.stats["successful_transactions"] / total) * 100
|
||||
self.stats["failure_rate"] = (self.stats["failed_transactions"] / total) * 100
|
||||
else:
|
||||
self.stats["success_rate"] = 0
|
||||
self.stats["failure_rate"] = 0
|
||||
|
||||
return self.stats.copy()
|
||||
|
||||
def reset_stats(self):
|
||||
"""통계 초기화"""
|
||||
for key in self.stats:
|
||||
if key not in ["success_rate", "failure_rate"]:
|
||||
self.stats[key] = 0
|
||||
|
||||
|
||||
# 전역 트랜잭션 통계 인스턴스
|
||||
transaction_stats = TransactionStats()
|
||||
Reference in New Issue
Block a user