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>
149 lines
7.1 KiB
Markdown
149 lines
7.1 KiB
Markdown
# 상품 이미지 업로드 설계 보고서
|
|
|
|
대상: `negodata/backend` (+ `negodata/front` 연동)
|
|
작성일: 2026-06-18
|
|
|
|
---
|
|
|
|
## 0. 결론
|
|
|
|
1. **별도 엔드포인트 `POST /v1/item/image` 를 신설한다.** create/update 와 합치지 않는다.
|
|
2. create/update 는 지금처럼 **JSON 바디** 그대로 두고 `image_url`(짧은 URL 문자열)만 받는다.
|
|
3. 저장은 **로컬디스크 + StaticFiles** 로 시작하고, 업로드 로직을 service 로 추상화해 추후 S3 로 교체한다.
|
|
4. 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](../router/v1/item/protocol.py#L19) |
|
|
| front 컴포넌트 | `ImageDropzone` 가 파일 → `readAsDataURL` → **base64 data URL** 생성 | [ImageDropzone.tsx:50](../../front/src/components/ImageDropzone.tsx#L50) |
|
|
| front 제출 | `image_url: v.imageUrl \|\| 'https://...unsplash'` | [ProductFormSheet.tsx:154](../../front/src/features/products/components/ProductFormSheet.tsx#L154) |
|
|
| 저장 인프라 | StaticFiles mount / S3 / upload dir 설정 **전무** | [router.py:61](../router/router.py#L61) 라우터만 include |
|
|
|
|
**왜 "구현 안 됨" 인가:**
|
|
이미지를 실제로 드롭하면 수십 KB짜리 base64 문자열을 `String(255)` 칸에 insert → 길이 초과로 실패/잘림.
|
|
드롭하지 않으면 unsplash 플레이스홀더가 박힘. 즉 **UI(dropzone)는 있으나 바이너리를 받아 저장하고 짧은 URL을 돌려줄 백엔드 조각이 없다.**
|
|
|
|
현재 `UploadFile` 사용처는 엑셀 스텁 2개뿐 — [item.py:54](../router/v1/item/item.py#L54), [supplier.py:54](../router/v1/supplier/supplier.py#L54).
|
|
|
|
---
|
|
|
|
## 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 스타일 유지)
|
|
|
|
```python
|
|
# 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 스케치
|
|
|
|
```python
|
|
# 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 전환)
|
|
|
|
```python
|
|
# 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 추가 제안
|
|
|
|
```python
|
|
# 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/config_models.py#L10))과 동일하게 `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`** 을 form `imageUrl` 에 세팅 (미리보기는 로컬 objectURL 유지 가능)
|
|
- [ ] `ProductFormSheet.tsx:154` 의 unsplash 플레이스홀더 폴백 제거/정리
|
|
|
|
---
|
|
|
|
## 6. 결정 필요 (열린 질문)
|
|
|
|
1. **저장 위치**: 로컬디스크(A)로 시작 확정? 아니면 처음부터 S3? — 권장: A.
|
|
2. **접근 제어**: `/static` 을 완전 public 으로 둘지, 서명 URL/인증 프록시로 막을지. 상품 이미지가 민감하지 않다면 public 로 충분.
|
|
3. **이미지 가공**: 업로드 시 리사이즈/webp 변환/썸네일 생성 할지(목록 성능). 1차는 원본 저장만 권장.
|
|
4. **고아 파일 정리**: 생성 전 업로드 후 폼 취소 시 남는 파일 — 1차는 방치, 추후 배치 정리.
|