""" Azure Blob Storage 업로드 유틸리티 Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다. 파일 경로 또는 바이트 데이터를 직접 업로드할 수 있습니다. URL 경로 형식: - 음악: {BASE_URL}/{user_uuid}/{task_id}/song/{파일명} - 영상: {BASE_URL}/{user_uuid}/{task_id}/video/{파일명} - 이미지: {BASE_URL}/{user_uuid}/{task_id}/image/{파일명} 사용 예시: from app.utils.upload_blob_as_request import AzureBlobUploader uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123") # 파일 경로로 업로드 success = await uploader.upload_music(file_path="my_song.mp3") success = await uploader.upload_video(file_path="my_video.mp4") success = await uploader.upload_image(file_path="my_image.png") # 바이트 데이터로 직접 업로드 (media 저장 없이) success = await uploader.upload_music_bytes(audio_bytes, "my_song") success = await uploader.upload_video_bytes(video_bytes, "my_video") success = await uploader.upload_image_bytes(image_bytes, "my_image.png") print(uploader.public_url) # 마지막 업로드의 공개 URL 성능 최적화: - HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀 재사용 - 동시 업로드: 공유 클라이언트를 통해 동시 요청 처리가 개선됩니다. """ import asyncio import base64 import os import re import time from collections.abc import AsyncIterator from pathlib import Path from urllib.parse import urlencode import aiofiles import httpx from fastapi import UploadFile from app.utils.logger import get_logger from config import azure_blob_settings # 로거 설정 logger = get_logger("blob") class BlobUploadTooLargeError(ValueError): """스트리밍 중 파일 크기 상한을 초과했을 때 발생합니다.""" def __init__(self, max_size_bytes: int): self.max_size_bytes = max_size_bytes super().__init__(f"업로드 파일은 {max_size_bytes} bytes를 초과할 수 없습니다.") # ============================================================================= # 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴) # ============================================================================= # 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용) _shared_blob_client: httpx.AsyncClient | None = None async def get_shared_blob_client() -> httpx.AsyncClient: """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" global _shared_blob_client if _shared_blob_client is None or _shared_blob_client.is_closed: logger.info("[AzureBlobUploader] Creating shared HTTP client...") _shared_blob_client = httpx.AsyncClient( timeout=httpx.Timeout(180.0, connect=10.0), limits=httpx.Limits(max_keepalive_connections=10, max_connections=20), ) logger.info("[AzureBlobUploader] Shared HTTP client created - " "max_connections: 20, max_keepalive: 10") return _shared_blob_client async def close_shared_blob_client() -> None: """공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요.""" global _shared_blob_client if _shared_blob_client is not None and not _shared_blob_client.is_closed: await _shared_blob_client.aclose() _shared_blob_client = None logger.info("[AzureBlobUploader] Shared HTTP client closed") class AzureBlobUploader: """Azure Blob Storage 업로드 클래스 Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다. URL 형식: {BASE_URL}/{user_uuid}/{task_id}/{category}/{file_name}?{SAS_TOKEN} 카테고리별 경로: - 음악: {user_uuid}/{task_id}/song/{file_name} - 영상: {user_uuid}/{task_id}/video/{file_name} - 이미지: {user_uuid}/{task_id}/image/{file_name} Attributes: user_uuid: 사용자 고유 식별자 (UUID) task_id: 작업 고유 식별자 """ # Content-Type 매핑 IMAGE_CONTENT_TYPES = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", ".heic": "image/heic", ".heif": "image/heif", } def __init__(self, user_uuid: str, task_id: str): """AzureBlobUploader 초기화 Args: user_uuid: 사용자 고유 식별자 (UUID) task_id: 작업 고유 식별자 """ self._user_uuid = user_uuid self._task_id = task_id self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL.rstrip("/") self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN self._last_public_url: str = "" @property def user_uuid(self) -> str: """사용자 고유 식별자 (UUID)""" return self._user_uuid @property def task_id(self) -> str: """작업 고유 식별자""" return self._task_id @property def public_url(self) -> str: """마지막 업로드의 공개 URL (SAS 토큰 제외)""" return self._last_public_url def _sanitize_filename(self, file_name: str) -> str: """파일명에서 공백/특수문자 제거, 한글/영문/숫자만 허용 Args: file_name: 원본 파일명 Returns: str: 정리된 파일명 (한글, 영문, 숫자만 포함) Example: >>> self._sanitize_filename("my file (1).mp4") 'myfile1.mp4' >>> self._sanitize_filename("테스트 파일!@#.png") '테스트파일.png' """ stem = Path(file_name).stem suffix = Path(file_name).suffix # 한글(가-힣), 영문(a-zA-Z), 숫자(0-9)만 남기고 제거 sanitized = re.sub(r'[^가-힣a-zA-Z0-9]', '', stem) # 빈 문자열이면 기본값 사용 if not sanitized: sanitized = "file" return f"{sanitized}{suffix}" def _build_upload_url(self, category: str, file_name: str) -> str: """업로드 URL 생성 (SAS 토큰 포함)""" # SAS 토큰 앞뒤의 ?, ', " 제거 sas_token = self._sas_token.strip("?'\"") return ( f"{self._base_url}/{self._user_uuid}/{self._task_id}/{category}/{file_name}?{sas_token}" ) def _build_public_url(self, category: str, file_name: str) -> str: """공개 URL 생성 (SAS 토큰 제외)""" return f"{self._base_url}/{self._user_uuid}/{self._task_id}/{category}/{file_name}" async def _upload_bytes( self, file_content: bytes, upload_url: str, headers: dict, timeout: float, log_prefix: str, ) -> bool: """바이트 데이터를 업로드하는 공통 내부 메서드 Args: file_content: 업로드할 바이트 데이터 upload_url: 업로드 URL headers: HTTP 헤더 timeout: 요청 타임아웃 (초) log_prefix: 로그 접두사 Returns: bool: 업로드 성공 여부 """ size = len(file_content) start_time = time.perf_counter() try: logger.info(f"[{log_prefix}] Starting upload") logger.debug(f"[{log_prefix}] Getting shared client...") client = await get_shared_blob_client() client_time = time.perf_counter() elapsed_ms = (client_time - start_time) * 1000 logger.debug(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms") logger.debug(f"[{log_prefix}] Starting upload... " f"(size: {size} bytes, timeout: {timeout}s)") request_headers = { **headers, "x-ms-version": azure_blob_settings.AZURE_BLOB_API_VERSION, } response = await asyncio.wait_for( client.put(upload_url, content=file_content, headers=request_headers), timeout=timeout, ) upload_time = time.perf_counter() duration_ms = (upload_time - start_time) * 1000 if response.status_code in [200, 201]: logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, " f"Duration: {duration_ms:.1f}ms") logger.debug(f"[{log_prefix}] Public URL: {self._last_public_url}") return True # 업로드 실패 logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}, " f"Duration: {duration_ms:.1f}ms") logger.error(f"[{log_prefix}] Response: {response.text[:500]}") return False except asyncio.TimeoutError: elapsed = time.perf_counter() - start_time logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s") return False except httpx.ConnectError as e: elapsed = time.perf_counter() - start_time logger.error(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - " f"{type(e).__name__}: {e}") return False except httpx.ReadError as e: elapsed = time.perf_counter() - start_time logger.error(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - " f"{type(e).__name__}: {e}") return False except Exception as e: elapsed = time.perf_counter() - start_time logger.error(f"[{log_prefix}] ERROR after {elapsed:.1f}s - " f"{type(e).__name__}: {e}") return False @staticmethod def _append_query(upload_url: str, **params: str) -> str: """SAS 쿼리를 유지하며 Azure REST API 쿼리를 추가합니다.""" separator = "&" if "?" in upload_url else "?" return f"{upload_url}{separator}{urlencode(params)}" async def _delete_upload_url(self, upload_url: str, log_prefix: str) -> bool: """실패한 업로드의 커밋/미커밋 Blob을 정리합니다.""" try: client = await get_shared_blob_client() response = await client.delete( upload_url, headers={"x-ms-version": azure_blob_settings.AZURE_BLOB_API_VERSION}, ) if response.status_code in {202, 404}: return True logger.warning( f"[{log_prefix}] Blob cleanup failed - Status: " f"{response.status_code}, Response: {response.text[:500]}" ) except Exception as exc: logger.warning( f"[{log_prefix}] Blob cleanup error - {type(exc).__name__}: {exc}" ) return False async def _upload_stream( self, chunks: AsyncIterator[bytes], upload_url: str, content_type: str, timeout: float, log_prefix: str, *, max_size_bytes: int | None = None, expected_size_bytes: int | None = None, cleanup_blob_on_failure: bool = False, ) -> bool: """Azure Block Blob API로 비동기 청크 스트림을 업로드합니다. 각 블록만 메모리에 유지하므로 파일 전체 크기와 무관하게 메모리 사용량이 일정합니다. 커밋 전 오류가 발생하면 업로드 대상 Blob 삭제를 시도합니다. """ block_ids: list[str] = [] block_id_nonce = os.urandom(16) uploaded_size = 0 start_time = time.perf_counter() async def cleanup_failed_stream() -> None: # 기존 deterministic key는 미커밋 블록만 TTL 정리되게 두어 정상 Blob을 보존합니다. if cleanup_blob_on_failure: await self._delete_upload_url(upload_url, log_prefix) try: client = await get_shared_blob_client() async with asyncio.timeout(timeout): async for chunk in chunks: if not chunk: continue uploaded_size += len(chunk) if max_size_bytes is not None and uploaded_size > max_size_bytes: raise BlobUploadTooLargeError(max_size_bytes) raw_block_id = block_id_nonce + len(block_ids).to_bytes(4, "big") block_id = base64.b64encode(raw_block_id).decode("ascii") block_url = self._append_query( upload_url, comp="block", blockid=block_id, ) response = await client.put( block_url, content=chunk, headers={ "Content-Type": "application/octet-stream", "x-ms-version": ( azure_blob_settings.AZURE_BLOB_API_VERSION ), }, ) if response.status_code != 201: logger.error( f"[{log_prefix}] Block upload failed - Status: " f"{response.status_code}, Response: {response.text[:500]}" ) await cleanup_failed_stream() return False block_ids.append(block_id) if uploaded_size == 0: logger.warning(f"[{log_prefix}] Empty upload stream") await cleanup_failed_stream() return False if ( expected_size_bytes is not None and uploaded_size != expected_size_bytes ): logger.error( f"[{log_prefix}] Stream size changed - expected: " f"{expected_size_bytes}, actual: {uploaded_size}" ) await cleanup_failed_stream() return False block_list = "".join( f"{block_id}" for block_id in block_ids ) commit_body = ( f'' f"{block_list}" ).encode("utf-8") commit_url = self._append_query(upload_url, comp="blocklist") response = await client.put( commit_url, content=commit_body, headers={ "Content-Type": "application/xml; charset=utf-8", "x-ms-blob-content-type": content_type, "x-ms-version": azure_blob_settings.AZURE_BLOB_API_VERSION, }, ) if response.status_code not in {200, 201}: logger.error( f"[{log_prefix}] Block list commit failed - Status: " f"{response.status_code}, Response: {response.text[:500]}" ) await cleanup_failed_stream() return False duration_ms = (time.perf_counter() - start_time) * 1000 logger.info( f"[{log_prefix}] SUCCESS - blocks: {len(block_ids)}, " f"size: {uploaded_size} bytes, Duration: {duration_ms:.1f}ms" ) return True except BlobUploadTooLargeError: await cleanup_failed_stream() raise except asyncio.CancelledError: # 클라이언트 연결 종료 중에도 가능한 범위에서 staged block을 정리합니다. await asyncio.shield(cleanup_failed_stream()) raise except TimeoutError: elapsed = time.perf_counter() - start_time logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s") except httpx.HTTPError as exc: elapsed = time.perf_counter() - start_time logger.error( f"[{log_prefix}] HTTP_ERROR after {elapsed:.1f}s - " f"{type(exc).__name__}: {exc}" ) except Exception as exc: elapsed = time.perf_counter() - start_time logger.error( f"[{log_prefix}] ERROR after {elapsed:.1f}s - " f"{type(exc).__name__}: {exc}" ) await cleanup_failed_stream() return False async def _upload_file( self, file_path: str, category: str, content_type: str, timeout: float, log_prefix: str, ) -> bool: """파일을 Azure Blob Storage에 업로드하는 내부 메서드 Args: file_path: 업로드할 파일 경로 category: 카테고리 (song, video, image) content_type: Content-Type 헤더 값 timeout: 요청 타임아웃 (초) log_prefix: 로그 접두사 Returns: bool: 업로드 성공 여부 """ # 파일 경로에서 파일명 추출 후 정리 (공백/특수문자 제거) file_name = self._sanitize_filename(Path(file_path).name) upload_url = self._build_upload_url(category, file_name) self._last_public_url = self._build_public_url(category, file_name) logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") async def iter_file() -> AsyncIterator[bytes]: async with aiofiles.open(file_path, "rb") as file: while chunk := await file.read( azure_blob_settings.AZURE_BLOB_UPLOAD_BLOCK_SIZE_BYTES ): yield chunk return await self._upload_stream( chunks=iter_file(), upload_url=upload_url, content_type=content_type, timeout=timeout, log_prefix=log_prefix, expected_size_bytes=Path(file_path).stat().st_size, ) async def upload_music(self, file_path: str) -> bool: """음악 파일을 Azure Blob Storage에 업로드합니다. URL 경로: {user_uuid}/{task_id}/song/{파일명} Args: file_path: 업로드할 파일 경로 Returns: bool: 업로드 성공 여부 Example: uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123") success = await uploader.upload_music(file_path="my_song.mp3") print(uploader.public_url) """ return await self._upload_file( file_path=file_path, category="song", content_type="audio/mpeg", timeout=120.0, log_prefix="upload_music", ) async def upload_music_bytes( self, file_content: bytes, file_name: str ) -> bool: """음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다. URL 경로: {user_uuid}/{task_id}/song/{파일명} Args: file_content: 업로드할 파일 바이트 데이터 file_name: 저장할 파일명 (확장자가 없으면 .mp3 추가) Returns: bool: 업로드 성공 여부 Example: uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123") success = await uploader.upload_music_bytes(audio_bytes, "my_song") print(uploader.public_url) """ # 파일명 정리 (공백/특수문자 제거) 후 확장자가 없으면 .mp3 추가 file_name = self._sanitize_filename(file_name) if not Path(file_name).suffix: file_name = f"{file_name}.mp3" upload_url = self._build_upload_url("song", file_name) self._last_public_url = self._build_public_url("song", file_name) log_prefix = "upload_music_bytes" logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"} return await self._upload_bytes( file_content=file_content, upload_url=upload_url, headers=headers, timeout=120.0, log_prefix=log_prefix, ) async def upload_video(self, file_path: str) -> bool: """영상 파일을 Azure Blob Storage에 업로드합니다. URL 경로: {user_uuid}/{task_id}/video/{파일명} Args: file_path: 업로드할 파일 경로 Returns: bool: 업로드 성공 여부 Example: uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123") success = await uploader.upload_video(file_path="my_video.mp4") print(uploader.public_url) """ return await self._upload_file( file_path=file_path, category="video", content_type="video/mp4", timeout=180.0, log_prefix="upload_video", ) async def upload_video_bytes( self, file_content: bytes, file_name: str ) -> bool: """영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다. URL 경로: {user_uuid}/{task_id}/video/{파일명} Args: file_content: 업로드할 파일 바이트 데이터 file_name: 저장할 파일명 (확장자가 없으면 .mp4 추가) Returns: bool: 업로드 성공 여부 Example: uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123") success = await uploader.upload_video_bytes(video_bytes, "my_video") print(uploader.public_url) """ # 파일명 정리 (공백/특수문자 제거) 후 확장자가 없으면 .mp4 추가 file_name = self._sanitize_filename(file_name) if not Path(file_name).suffix: file_name = f"{file_name}.mp4" upload_url = self._build_upload_url("video", file_name) self._last_public_url = self._build_public_url("video", file_name) log_prefix = "upload_video_bytes" logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"} return await self._upload_bytes( file_content=file_content, upload_url=upload_url, headers=headers, timeout=180.0, log_prefix=log_prefix, ) async def upload_image(self, file_path: str) -> bool: """이미지 파일을 Azure Blob Storage에 업로드합니다. URL 경로: {user_uuid}/{task_id}/image/{파일명} Args: file_path: 업로드할 파일 경로 Returns: bool: 업로드 성공 여부 Example: uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123") success = await uploader.upload_image(file_path="my_image.png") print(uploader.public_url) """ extension = Path(file_path).suffix.lower() content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg") return await self._upload_file( file_path=file_path, category="image", content_type=content_type, timeout=60.0, log_prefix="upload_image", ) async def upload_image_bytes( self, file_content: bytes, file_name: str ) -> bool: """이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다. URL 경로: {user_uuid}/{task_id}/image/{파일명} Args: file_content: 업로드할 파일 바이트 데이터 file_name: 저장할 파일명 Returns: bool: 업로드 성공 여부 Example: uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123") with open("my_image.png", "rb") as f: content = f.read() success = await uploader.upload_image_bytes(content, "my_image.png") print(uploader.public_url) """ # Content-Type 결정을 위해 먼저 확장자 추출 extension = Path(file_name).suffix.lower() content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg") # 파일명 정리 (공백/특수문자 제거) file_name = self._sanitize_filename(file_name) upload_url = self._build_upload_url("image", file_name) self._last_public_url = self._build_public_url("image", file_name) log_prefix = "upload_image_bytes" logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"} return await self._upload_bytes( file_content=file_content, upload_url=upload_url, headers=headers, timeout=60.0, log_prefix=log_prefix, ) async def upload_image_stream( self, file: UploadFile, file_name: str, *, expected_size_bytes: int | None = None, max_size_bytes: int | None = None, ) -> bool: """FastAPI UploadFile을 Azure Block Blob으로 청크 업로드합니다.""" extension = Path(file_name).suffix.lower() content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg") file_name = self._sanitize_filename(file_name) upload_url = self._build_upload_url("image", file_name) self._last_public_url = self._build_public_url("image", file_name) log_prefix = "upload_image_stream" chunk_size = azure_blob_settings.AZURE_BLOB_UPLOAD_BLOCK_SIZE_BYTES max_size = ( max_size_bytes if max_size_bytes is not None else azure_blob_settings.IMAGE_UPLOAD_MAX_FILE_SIZE_BYTES ) async def iter_upload() -> AsyncIterator[bytes]: await file.seek(0) while chunk := await file.read(chunk_size): yield chunk return await self._upload_stream( chunks=iter_upload(), upload_url=upload_url, content_type=content_type, timeout=60.0, log_prefix=log_prefix, max_size_bytes=max_size, expected_size_bytes=expected_size_bytes, cleanup_blob_on_failure=True, ) async def delete_image(self, file_name: str) -> bool: """이미지 Blob을 삭제합니다. DB 저장 실패 보상 처리용입니다.""" sanitized_name = self._sanitize_filename(file_name) upload_url = self._build_upload_url("image", sanitized_name) return await self._delete_upload_url(upload_url, "delete_image") # 사용 예시: # import asyncio # # async def main(): # uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123") # # # 음악 업로드 -> {BASE_URL}/user-abc/task-123/song/my_song.mp3 # await uploader.upload_music("my_song.mp3") # print(uploader.public_url) # # # 영상 업로드 -> {BASE_URL}/user-abc/task-123/video/my_video.mp4 # await uploader.upload_video("my_video.mp4") # print(uploader.public_url) # # # 이미지 업로드 -> {BASE_URL}/user-abc/task-123/image/my_image.png # await uploader.upload_image("my_image.png") # print(uploader.public_url) # # asyncio.run(main())