# 상품 이미지 업로드 설계 보고서 대상: `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차는 방치, 추후 배치 정리.