POST /v1/item/image — SAS 토큰을 URL에 붙여 httpx PUT 으로 Azure Blob 업로드. 이미지 타입/용량 검증 + IMAGE_* 에러코드. config 에 azure_blob_base_url/sas/root 추가. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.1 KiB
7.1 KiB
상품 이미지 업로드 설계 보고서
대상: negodata/backend (+ negodata/front 연동)
작성일: 2026-06-18
0. 결론
- 별도 엔드포인트
POST /v1/item/image를 신설한다. create/update 와 합치지 않는다. - create/update 는 지금처럼 JSON 바디 그대로 두고
image_url(짧은 URL 문자열)만 받는다. - 저장은 로컬디스크 + StaticFiles 로 시작하고, 업로드 로직을 service 로 추상화해 추후 S3 로 교체한다.
- DB 스키마(
items.image_url String(255)) 변경 불필요 — 반환 URL이 짧은 경로이므로 그대로 들어간다.
1. 현재 상태 — 무엇이 되어 있고 무엇이 깨졌나
| 계층 | 현재 | 비고 |
|---|---|---|
| DB | items.image_url = Column(String(255), nullable=True) |
URL 문자열 255자만 수용 |
| protocol | image_url: Optional[str] (Create/Update/Data) |
protocol.py:19 |
| front 컴포넌트 | ImageDropzone 가 파일 → readAsDataURL → base64 data URL 생성 |
ImageDropzone.tsx:50 |
| front 제출 | image_url: v.imageUrl || 'https://...unsplash' |
ProductFormSheet.tsx:154 |
| 저장 인프라 | StaticFiles mount / S3 / upload dir 설정 전무 | router.py:61 라우터만 include |
왜 "구현 안 됨" 인가:
이미지를 실제로 드롭하면 수십 KB짜리 base64 문자열을 String(255) 칸에 insert → 길이 초과로 실패/잘림.
드롭하지 않으면 unsplash 플레이스홀더가 박힘. 즉 UI(dropzone)는 있으나 바이너리를 받아 저장하고 짧은 URL을 돌려줄 백엔드 조각이 없다.
현재 UploadFile 사용처는 엑셀 스텁 2개뿐 — item.py:54, supplier.py:54.
2. 핵심 결정 — 왜 별도 엔드포인트인가
| 기준 | create 와 합치기 (multipart 한 방) | 별도 엔드포인트 (권장) |
|---|---|---|
| 요청 형식 | multipart/form-data 강제 → 모든 필드를 Form() 수동 파싱 |
create/update 는 JSON pydantic 유지 |
| 기존 패턴 | Req_CreateItem.model_dump(exclude_unset=True) 흐름 파괴 |
그대로 보존 |
| 생성 전 업로드 | 불가 (아이템이 있어야 첨부) | 신규 폼에서 먼저 업로드 → URL 확보 가능 |
| 재사용 | create/update 마다 multipart 중복 | 업로드 1곳, 협력사 로고 등 확장 |
| 생성 클라이언트(orval) | 혼합 바디라 타입 지저분 | 깔끔히 분리 생성 |
이 코드베이스는 이미 "JSON 바디"와 "multipart 파일"을 엔드포인트 단위로 분리해 둠(엑셀 업로드). 동일 원칙을 따른다.
3. 제안 API 계약
3.1 신규 엔드포인트
POST /v1/item/image
- auth: IsValidAccessToken (company_id 스코프)
- body: multipart/form-data, field "file": UploadFile
- 검증: content-type image/*, 용량 ≤ 4MB (front ImageDropzone 와 동일 한계)
- 동작: 저장 → 짧은 public URL 생성
- response_model: Res_ItemImage { image_url: str, ... }
create/update 는 변경 없음 — front 가 위에서 받은 image_url 문자열을 기존 JSON 바디에 실어 보낸다.
3.2 protocol 추가 (주석 금지·auth 스타일 유지)
# router/v1/item/protocol.py
class Res_ItemImage(Res_WebPacketProtocol):
image_url: Optional[str] = None
filename: Optional[str] = None
size: Optional[int] = None
3.3 router 스케치
# router/v1/item/item.py
@router.post(path="/image", response_model=Res_ItemImage, summary="상품 이미지 업로드")
async def upload_item_image(
service: ItemService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
file: UploadFile = File(...),
):
return RemoveNoneResponse(await service.upload_image(user_info.company_id, file))
3.4 service 스케치 (저장 추상화 — 여기만 갈아끼우면 S3 전환)
# services/item_service.py
async def upload_image(self, company_id: str, file: UploadFile) -> Res_ItemImage:
res = Res_ItemImage()
# 1) 검증: content_type.startswith("image/"), size ≤ 4MB → 실패 시 res.result.SetResult(...)
# 2) 저장: 키 = f"{company_id}/{uuid4()}.{ext}" ← 회사별 디렉터리로 격리
# storage.save(key, await file.read()) # 로컬: upload_dir/key, S3: put_object
# 3) res.image_url = f"{static_base_url}/items/{key}" ← String(255) 안에 들어가는 짧은 경로
return res
4. 저장 방식
| 방식 | 지금 채택 | 비고 |
|---|---|---|
| A. 로컬디스크 + StaticFiles | ✅ now | app.mount("/static", StaticFiles(directory=upload_dir)) 한 줄. 단일 서버/데모에 충분 |
| B. S3 / MinIO / GCS | later | boto3 미설치. service 의 storage.save 만 교체하면 됨 (CDN URL 반환) |
멀티워커(
process_count)·다중 인스턴스로 가면 로컬디스크는 인스턴스마다 갈라지므로 그 시점에 B로 전환해야 한다. 지금 단계에서는 A로 충분.
4.1 config 추가 제안
# config/config_models.py — 신규 섹션
class StorageConfig(ConfigModel):
upload_dir: str = "./uploads" # 로컬 저장 루트
static_base_url: str = "" # 예: "http://localhost:8000/static"
max_image_mb: int = 4 # front ImageDropzone(4MB)와 일치
client_url(CORS, config_models.py:10)과 동일하게 config.local.toml 에 값을 채운다. .example 에도 키 추가.
5. 변경 체크리스트
backend
config_models.py에StorageConfig추가 +config.local.toml(.example)키 채움router.py에app.mount("/static", StaticFiles(...))(방식 A)protocol.py에Res_ItemImage추가item.py에POST /v1/item/image라우트item_service.py에upload_image()+ storage 추상화(local 구현)- 검증 실패용
ErrorType(예:IMAGE_INVALID_TYPE,IMAGE_TOO_LARGE) 추가 - 테스트:
tests/test_item.py에 업로드 정상/타입오류/용량초과
front
- orval 재생성 →
POST /v1/item/image클라이언트 확보 ImageDropzone가 base64 대신 선택 파일을 업로드 호출 → 반환image_url을 formimageUrl에 세팅 (미리보기는 로컬 objectURL 유지 가능)ProductFormSheet.tsx:154의 unsplash 플레이스홀더 폴백 제거/정리
6. 결정 필요 (열린 질문)
- 저장 위치: 로컬디스크(A)로 시작 확정? 아니면 처음부터 S3? — 권장: A.
- 접근 제어:
/static을 완전 public 으로 둘지, 서명 URL/인증 프록시로 막을지. 상품 이미지가 민감하지 않다면 public 로 충분. - 이미지 가공: 업로드 시 리사이즈/webp 변환/썸네일 생성 할지(목록 성능). 1차는 원본 저장만 권장.
- 고아 파일 정리: 생성 전 업로드 후 폼 취소 시 남는 파일 — 1차는 방치, 추후 배치 정리.