"""
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())