본문 바로가기

개발자 생활

FastAPI 연재 6편 — Form + File로 받는 API, 그리고 “현업스러운” 검증들

반응형

이번 편은 진짜 실전이다.

POST /api/v1/diaries는 다음을 동시에 받는다.

  • date: 폼 데이터
  • place_visits_json: 폼 데이터(문자열인데 안에는 JSON)
  • photos: 파일 업로드(최대 3장)

즉, “그냥 JSON Body 받는” 튜토리얼이 아니라…
현업에서 흔한 multipart + 검증 + 예외처리 세트다.


핵심: FastAPI는 Form, File로 multipart를 찢어서 받을 수 있다

대표 이미지 (Form/File 업로드 + 검증)

대충 이런 구조.

@router.post("")
async def create_diary(
    date: date = Form(...),
    body: str | None = Form(default=None),
    one_liner: str | None = Form(default=None),
    place_visits_json: str | None = Form(default=None),
    tone: DiaryTone = Form(default=DEFAULT_TONE),
    photos: list[UploadFile] = File(default=[]),
    session: Session = Depends(get_db_session),
    generator: GeminiDiaryGenerator | None = Depends(get_diary_generator),
):
    ...

여기서 “아… 이걸 이렇게 받는구나”가 한 번에 들어온다.


검증 포인트 1) 사진 장수 제한 (최대 3장)

if len(photos) > 3:
    raise HTTPException(status_code=422, detail="사진은 최대 3장...")

이건 그냥 기능이 아니라, 모델 제한(Gemini 프롬프트당 이미지 수 제한) 같은 현실적인 제약이랑 연결된다.


검증 포인트 2) 파일 타입은 이미지로만

ct = photo.content_type or ""
if not ct.startswith("image/"):
    raise HTTPException(status_code=422, detail="허용되지 않는 파일 형식...")

“일단 받고 나중에 처리”가 아니라, 초입에서 잘라내는 게 깔끔하다.


검증 포인트 3) 용량 제한 (장당 7MB)

multipart는 일단 파일이 들어오고 나서 읽게 되니까, 읽은 뒤에 크기 검증을 한다.

content = await photo.read()
if len(content) > 7 * 1024 * 1024:
    raise HTTPException(status_code=422, detail="사진 1장의 최대 크기...")

검증 포인트 4) “문자열 JSON”을 제대로 파싱하기

“422를 덜 맞는” 입력 검증 이미지

place_visits_json은 문자열이다. 근데 그 안에 JSON 배열이 들어간다.

여기서 내가 배운 포인트: TypeAdapter.validate_json이 생각보다 깔끔하다.

_place_visits_adapter = TypeAdapter(list[PlaceVisit])
return _place_visits_adapter.validate_json(place_visits_json)

그리고 실패하면 422로 예쁘게 떨어뜨린다.


(중요) sync 작업을 async에서 돌릴 때: run_in_threadpool

EXIF 추출은 보통 CPU 작업 + 라이브러리 호출이라 sync 함수일 가능성이 높다.

그래서 event loop를 막지 않게 이렇게 한다.

exif = await run_in_threadpool(extract_exif, pd.data, pd.filename)

이거 한 번 해두면, 나중에 이미지 리사이징/압축 같은 것도 같은 패턴으로 처리할 수 있다.


실습: curl로 multipart 요청 보내보기

1) 수동 저장(사진 없이)

curl -sS -X POST "http://127.0.0.1:8000/api/v1/diaries" \
  -F "date=2026-03-10" \
  -F "body=오늘은 FastAPI를 공부했다." \
  -F "one_liner=FastAPI 입문 끝."

2) 사진 1장 포함(테스트 이미지 아무거나)

curl -sS -X POST "http://127.0.0.1:8000/api/v1/diaries" \
  -F "date=2026-03-10" \
  -F "body=사진도 넣어봤다." \
  -F "one_liner=업로드 성공." \
  -F "photos=@./test.jpg;type=image/jpeg"

3) 일부러 실패시키기(4장 올려서 422 확인)

curl -sS -X POST "http://127.0.0.1:8000/api/v1/diaries" \
  -F "date=2026-03-10" \
  -F "body=실패 유도" \
  -F "one_liner=422 떠야 함" \
  -F "photos=@./a.jpg;type=image/jpeg" \
  -F "photos=@./b.jpg;type=image/jpeg" \
  -F "photos=@./c.jpg;type=image/jpeg" \
  -F "photos=@./d.jpg;type=image/jpeg"

마무리

multipart는 한 번 익숙해지면 “이게 별거 아니네?”가 되는데, 처음에는 진짜 귀찮다 ㅋㅋ
다음 편에서는 DI(Depends)로 “외부 의존성(날씨/AI)”을 어떻게 다루는지 보자. 키 없을 때 503, 외부 장애는 502로 예쁘게 떨어뜨리는 그 부분.


참고

반응형