이번 편은 진짜 실전이다.
POST /api/v1/diaries는 다음을 동시에 받는다.
date: 폼 데이터place_visits_json: 폼 데이터(문자열인데 안에는 JSON)photos: 파일 업로드(최대 3장)
즉, “그냥 JSON Body 받는” 튜토리얼이 아니라…
현업에서 흔한 multipart + 검증 + 예외처리 세트다.
핵심: FastAPI는 Form, File로 multipart를 찢어서 받을 수 있다
대충 이런 구조.
@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”을 제대로 파싱하기
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로 예쁘게 떨어뜨리는 그 부분.