o2o-negosium-original/negodata/backend/docs/image-upload-design.md
Mina Choi 3280cab088 [feat] negodata/backend: 상품 이미지 업로드(Azure Blob) 엔드포인트 추가
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>
2026-06-18 16:21:58 +09:00

7.1 KiB

상품 이미지 업로드 설계 보고서

대상: 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
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 을 form imageUrl 에 세팅 (미리보기는 로컬 objectURL 유지 가능)
  • ProductFormSheet.tsx:154 의 unsplash 플레이스홀더 폴백 제거/정리

6. 결정 필요 (열린 질문)

  1. 저장 위치: 로컬디스크(A)로 시작 확정? 아니면 처음부터 S3? — 권장: A.
  2. 접근 제어: /static 을 완전 public 으로 둘지, 서명 URL/인증 프록시로 막을지. 상품 이미지가 민감하지 않다면 public 로 충분.
  3. 이미지 가공: 업로드 시 리사이즈/webp 변환/썸네일 생성 할지(목록 성능). 1차는 원본 저장만 권장.
  4. 고아 파일 정리: 생성 전 업로드 후 폼 취소 시 남는 파일 — 1차는 방치, 추후 배치 정리.