티스토리 뷰

반응형

Python으로 공부하는 OpenAI 19편 — 텍스트만 받던 FastAPI를 이미지·파일까지 처리하는 멀티모달 백엔드로 확장하는 방법

여기까지 오면 슬슬 이런 순간이 옵니다.
텍스트 질문만 받는 챗 API는 만들었는데, 실제 서비스에서 사용자는 텍스트만 보내지 않거든요. 스크린샷을 올리고, PDF를 붙이고, 이미지 안에 있는 표를 보여주고, 문서를 통째로 넣고 “이거 설명해줘”라고 합니다. 지금 OpenAI API 흐름도 이쪽으로 많이 정리돼 있습니다. 공식 문서는 최신 모델들이 텍스트와 이미지 입력을 지원하고, Responses API가 기본 인터페이스라고 안내합니다. (OpenAI 개발자)

이번 글에서는 그다음 단계, 그러니까 FastAPI 백엔드를 멀티모달 입력에 맞게 바꾸는 구조를 다룹니다.
핵심은 단순히 “이미지를 보내는 코드”가 아니라, 텍스트 전용 설계를 파일 업로드, 저장, 파싱, 모델 입력 조립까지 버티는 구조로 바꾸는 겁니다. OpenAI 공식 가이드도 이미지 입력은 vision 가이드에서, 파일 입력은 별도 file inputs 가이드에서 다루고 있고, Responses API로 함께 묶는 방향을 보여줍니다. (OpenAI 개발자)


1. 텍스트 전용 설계가 멀티모달에서 바로 무너지는 이유

텍스트 전용 API는 보통 이런 흐름이면 충분합니다.

  1. 문자열 받기
  2. 프롬프트 만들기
  3. responses.create() 호출
  4. 텍스트 반환

그런데 이미지나 파일이 들어오면 갑자기 고려할 게 많아집니다.

  • 파일을 어디에 임시 저장할지
  • MIME 타입을 어떻게 검증할지
  • 원본을 그대로 모델에 보낼지, 파일 ID로 보낼지
  • 업로드 크기 제한은 어떻게 둘지
  • 문서형 입력과 이미지형 입력을 같은 엔드포인트에서 받을지
  • 모델에 전달할 입력 블록을 어떻게 조립할지

OpenAI file inputs 가이드는 Responses API에서 파일을 input_file 아이템으로 보낼 수 있고, 방식도 Base64 데이터, Files API의 file ID, 외부 URL 세 가지를 지원한다고 설명합니다. 이미지 입력은 vision 가이드에서 텍스트와 이미지 블록을 함께 보내는 방식을 안내합니다. 즉, 구조 자체를 바꾸지 않으면 금방 코드가 지저분해집니다. (OpenAI 개발자)


2. 지금 기준으로 멀티모달 입력은 이렇게 이해하면 편합니다

OpenAI Responses API 기준으로 보면, 입력은 더 이상 “문자열 하나”가 아닙니다.
텍스트 블록, 이미지 블록, 파일 블록이 함께 들어갈 수 있는 구조화된 입력 배열에 가깝습니다. Migration 가이드도 단순 텍스트는 쉽게 옮길 수 있지만, 멀티모달 입력은 Responses API 방식으로 생각하는 편이 낫다고 설명합니다. (OpenAI 개발자)

실무 감각으로는 이렇게 나누면 됩니다.

  • 텍스트 질문: 사용자 의도
  • 이미지 입력: 화면, 사진, 차트, UI, 오류 캡처
  • 파일 입력: PDF, 문서, 분석 대상 자료
  • 지시문(instructions): 답변 스타일, 포맷, 제약조건

이걸 한 함수 안에서 전부 처리하려고 하면 금방 꼬입니다.
그래서 멀티모달로 갈수록 입력 조립 계층을 따로 두는 게 중요해집니다. (OpenAI 개발자)


3. 처음부터 추천하는 프로젝트 구조

멀티모달이 붙으면 저는 보통 이 정도 구조를 추천합니다.

app/
├── main.py
├── core/
│   ├── config.py
│   └── dependencies.py
├── api/
│   └── routers/
│       └── multimodal_router.py
├── schemas/
│   └── multimodal.py
├── services/
│   ├── upload_service.py
│   ├── multimodal_input_builder.py
│   └── multimodal_service.py
├── adapters/
│   └── openai_client.py
└── repositories/
    └── file_repository.py

핵심은 역할 분리입니다.

  • upload_service.py: 업로드 파일 검증과 임시 저장
  • multimodal_input_builder.py: 텍스트/이미지/파일을 Responses 입력 포맷으로 조립
  • multimodal_service.py: 실제 유스케이스 실행
  • openai_client.py: SDK 호출 캡슐화

FastAPI는 큰 앱에서 APIRouter와 의존성 분리를 권장하고, OpenAI 쪽은 Responses API를 기본 인터페이스로 안내하므로 이 둘을 맞춰 가는 편이 자연스럽습니다. (OpenAI 개발자)


4. 어떤 입력이 텍스트만으로 부족한가

이건 서비스 기획과도 연결됩니다.
멀티모달은 “멋있어 보여서” 붙이는 게 아니라, 텍스트로 설명하기 어려운 입력이 있을 때 의미가 큽니다.

예를 들어 이런 경우입니다.

  • 에러 화면 스크린샷을 올리고 원인 분석 요청
  • 관리자 대시보드 차트 이미지를 보여주고 해석 요청
  • PDF 보고서를 넣고 핵심 요약 요청
  • 계약서 파일을 올리고 특정 조항 설명 요청
  • 제품 UI 캡처를 올리고 개선 포인트 요청

OpenAI vision 가이드는 이미지 이해, 스크린샷 분석, 차트/시각 자료 해석 같은 use case를 다루고, file inputs 가이드는 문서 파일을 Responses API에 넣는 흐름을 설명합니다. 그러니까 “텍스트 질문 + 이미지/파일 근거” 조합은 이제 꽤 정석적인 구조입니다. (OpenAI 개발자)


5. FastAPI 요청 스키마는 이렇게 잡으면 덜 꼬입니다

업로드는 보통 multipart/form-data로 받게 됩니다.
텍스트 질문 하나와 파일 여러 개를 함께 받을 수 있게 설계하는 편이 현실적입니다.

app/schemas/multimodal.py

from pydantic import BaseModel, Field


class MultimodalResponse(BaseModel):
    answer: str = Field(description="모델이 생성한 최종 답변")
    file_count: int = Field(description="처리된 파일 개수")

요청은 Pydantic 본문 모델보다 FastAPI의 Form과 UploadFile 조합이 더 자연스럽습니다.
FastAPI는 파일 업로드에 UploadFile 사용을 권장하고, bytes보다 메모리 사용 측면에서 더 유리하다고 설명합니다. UploadFile은 큰 파일을 다룰 때 스풀드 파일을 사용하므로 멀티모달 입력에 특히 적합합니다. (OpenAI 개발자)


6. 업로드 검증 서비스는 꼭 따로 두는 게 좋습니다

파일 검증 로직을 라우터에 쓰기 시작하면 금방 길어집니다.
그래서 업로드 서비스로 분리하는 편이 좋습니다.

app/services/upload_service.py

from pathlib import Path
from uuid import uuid4

from fastapi import UploadFile


ALLOWED_CONTENT_TYPES = {
    "image/png",
    "image/jpeg",
    "image/webp",
    "application/pdf",
    "text/plain",
}

MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024  # 10MB


class UploadService:
    def __init__(self, upload_dir: str = "tmp/uploads") -> None:
        self.upload_dir = Path(upload_dir)
        self.upload_dir.mkdir(parents=True, exist_ok=True)

    async def save_upload(self, file: UploadFile) -> Path:
        if file.content_type not in ALLOWED_CONTENT_TYPES:
            raise ValueError(f"지원하지 않는 파일 타입입니다: {file.content_type}")

        suffix = Path(file.filename or "uploaded").suffix
        target_path = self.upload_dir / f"{uuid4()}{suffix}"

        size = 0
        with target_path.open("wb") as f:
            while True:
                chunk = await file.read(1024 * 1024)
                if not chunk:
                    break
                size += len(chunk)
                if size > MAX_FILE_SIZE_BYTES:
                    target_path.unlink(missing_ok=True)
                    raise ValueError("파일 크기가 제한을 초과했습니다.")
                f.write(chunk)

        await file.close()
        return target_path

이 코드는 업로드 파일을 검증하고 임시 저장합니다.
멀티모달 구조에서 이런 계층이 필요한 이유는, 모델 입력 조립 전에 파일 검증과 저장을 끝내야 하기 때문입니다. OpenAI file inputs 가이드는 파일을 Base64, file ID, 외부 URL로 보낼 수 있다고 설명하니, 앱 내부에서는 먼저 “저장 가능한 파일”로 다루는 게 자연스럽습니다. (OpenAI 개발자)


7. 입력 조립기는 멀티모달에서 사실상 핵심입니다

반응형

이건 진짜 중요합니다.
이미지와 파일이 붙으면 가장 많이 꼬이는 부분이 “모델에 어떤 형태로 넣느냐”예요.

OpenAI vision 가이드는 이미지 입력을 텍스트와 함께 블록 형태로 보내는 흐름을 보여주고, file inputs 가이드는 input_file 아이템을 쓰는 방식을 설명합니다. 즉, 앱 내부에서는 업로드 파일을 OpenAI 입력 블록으로 변환하는 서비스가 필요합니다. (OpenAI 개발자)

app/services/multimodal_input_builder.py

import base64
from pathlib import Path


class MultimodalInputBuilder:
    def build_input(
        self,
        question: str,
        file_paths: list[Path],
    ) -> list[dict]:
        input_blocks: list[dict] = [
            {
                "role": "user",
                "content": [
                    {
                        "type": "input_text",
                        "text": question,
                    }
                ],
            }
        ]

        for path in file_paths:
            suffix = path.suffix.lower()

            if suffix in {".png", ".jpg", ".jpeg", ".webp"}:
                mime_type = {
                    ".png": "image/png",
                    ".jpg": "image/jpeg",
                    ".jpeg": "image/jpeg",
                    ".webp": "image/webp",
                }[suffix]

                b64 = base64.b64encode(path.read_bytes()).decode("utf-8")

                input_blocks[0]["content"].append(
                    {
                        "type": "input_image",
                        "image_url": f"data:{mime_type};base64,{b64}",
                    }
                )

            else:
                b64 = base64.b64encode(path.read_bytes()).decode("utf-8")
                input_blocks[0]["content"].append(
                    {
                        "type": "input_file",
                        "filename": path.name,
                        "file_data": b64,
                    }
                )

        return input_blocks

이미지 블록과 파일 블록을 같은 질문 안에 조립하는 구조입니다.
OpenAI 공식 가이드는 이미지와 파일을 각각 지원한다고 설명하므로, 앱 차원에서는 “질문 1개 + 첨부 여러 개” 형태로 묶어두는 것이 멀티모달 UX에 잘 맞습니다. (OpenAI 개발자)


8. OpenAI 호출은 adapter에서 감싸두는 편이 좋습니다

멀티모달이 붙으면 공통 호출 정책이 더 중요해집니다.

  • 모델명
  • usage 추적
  • instructions
  • timeout / retry
  • future migration 대응

이걸 서비스마다 직접 호출하면 금방 퍼집니다.
OpenAI Python 라이브러리 문서는 Responses API가 기본 인터페이스라고 안내하고, SDK 예제도 OpenAI() 클라이언트 중심으로 설명합니다. (OpenAI 개발자)

app/adapters/openai_client.py

from openai import OpenAI


class OpenAIMultimodalAdapter:
    def __init__(self, api_key: str, model: str) -> None:
        self.client = OpenAI(api_key=api_key)
        self.model = model

    def answer(self, input_data: list[dict], instructions: str) -> tuple[str, dict]:
        response = self.client.responses.create(
            model=self.model,
            input=input_data,
            instructions=instructions,
        )

        usage = getattr(response, "usage", None)
        usage_dict = {
            "input_tokens": getattr(usage, "input_tokens", 0) if usage else 0,
            "output_tokens": getattr(usage, "output_tokens", 0) if usage else 0,
            "total_tokens": getattr(usage, "total_tokens", 0) if usage else 0,
        }

        return response.output_text, usage_dict

이제 서비스는 “멀티모달 질문에 답해줘”만 신경 쓰면 됩니다.
SDK 상세 구조는 adapter가 흡수하게 두는 편이 테스트와 유지보수에 훨씬 좋습니다. (OpenAI 개발자)


9. 유스케이스 서비스는 이렇게 얇게 잡는 편이 좋습니다

app/services/multimodal_service.py

from pathlib import Path

from app.adapters.openai_client import OpenAIMultimodalAdapter
from app.services.multimodal_input_builder import MultimodalInputBuilder


class MultimodalService:
    def __init__(
        self,
        llm: OpenAIMultimodalAdapter,
        input_builder: MultimodalInputBuilder,
    ) -> None:
        self.llm = llm
        self.input_builder = input_builder

    def answer(self, question: str, file_paths: list[Path]) -> dict:
        input_data = self.input_builder.build_input(
            question=question,
            file_paths=file_paths,
        )

        answer, usage = self.llm.answer(
            input_data=input_data,
            instructions=(
                "당신은 주니어 개발자를 돕는 멀티모달 분석 도우미입니다. "
                "질문과 첨부 파일을 함께 참고해서 한국어로 명확하게 답하세요. "
                "확실하지 않으면 추측하지 말고 불확실하다고 말하세요."
            ),
        )

        return {
            "answer": answer,
            "usage": usage,
            "file_count": len(file_paths),
        }

이 구조가 좋은 이유는 멀티모달에서도 책임이 선명하기 때문입니다.

  • 파일 저장은 upload service
  • 입력 조립은 input builder
  • 모델 호출은 adapter
  • 유스케이스는 multimodal service

기능이 더 붙어도 덜 무너집니다. (OpenAI 개발자)


10. FastAPI 라우터는 최대한 얇게 유지합니다

app/api/routers/multimodal_router.py

from typing import Annotated

from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile

from app.schemas.multimodal import MultimodalResponse
from app.services.multimodal_service import MultimodalService
from app.services.upload_service import UploadService

router = APIRouter(prefix="/multimodal", tags=["multimodal"])


def get_upload_service() -> UploadService:
    return UploadService()


def get_multimodal_service() -> MultimodalService:
    raise NotImplementedError("실제 앱에서는 dependencies.py에서 주입")


@router.post("", response_model=MultimodalResponse)
async def ask_multimodal(
    question: Annotated[str, Form(...)],
    files: Annotated[list[UploadFile], File(default=[])],
    upload_service: Annotated[UploadService, Depends(get_upload_service)],
    multimodal_service: Annotated[MultimodalService, Depends(get_multimodal_service)],
) -> MultimodalResponse:
    try:
        saved_paths = [await upload_service.save_upload(file) for file in files]
        result = multimodal_service.answer(question=question, file_paths=saved_paths)
        return MultimodalResponse(
            answer=result["answer"],
            file_count=result["file_count"],
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e

라우터는 정말 이 정도면 충분합니다.
파일 받기, 서비스 호출, 오류를 HTTP로 변환하기.
FastAPI의 큰 앱 구조 가이드가 라우터를 여러 파일로 분리하고 공통 의존성을 빼는 이유와도 맞습니다. (OpenAI 개발자)


11. 파일을 Base64로 직접 보낼지, Files API를 거칠지는 용도에 따라 봐야 합니다

OpenAI file inputs 가이드는 Responses API에서 파일을 세 방식으로 보낼 수 있다고 설명합니다.

  • Base64 데이터
  • Files API에서 받은 file ID
  • 외부 URL

이 말은 곧 앱 구조도 세 가지 선택지가 있다는 뜻입니다. (OpenAI 개발자)

Base64 직접 전송

  • 구현이 단순
  • 작은 파일, 빠른 프로토타입에 좋음
  • 다만 서버 메모리와 요청 크기 관리가 중요

Files API + file ID

  • 파일 업로드와 모델 호출을 분리할 수 있음
  • 재사용과 비동기 흐름에 유리
  • 문서 처리 파이프라인이 길어질수록 더 자연스러움

외부 URL

  • 이미 안전한 스토리지 URL이 있는 경우 유리
  • 다만 접근 제어와 서명 URL 설계가 중요

처음엔 Base64로 시작해도 괜찮지만, 문서 업로드가 많아지면 file ID 방식도 검토할 가치가 큽니다. (OpenAI 개발자)


12. 멀티모달이 붙으면 업로드 저장소도 설계 대상이 됩니다

텍스트 전용 API에선 이 부분이 거의 없었죠.
그런데 이미지와 파일이 붙으면, 저장소 전략이 바로 중요해집니다.

  • 임시 파일만 둘 건지
  • 영구 저장할 건지
  • 원본 파일과 전처리본을 나눌 건지
  • 업로드 직후 바로 삭제할 건지
  • 백그라운드 인덱싱까지 이어질 건지

이 부분은 OpenAI 자체보다도 백엔드 구조 문제입니다.
특히 PDF 같은 문서형 입력은 업로드 → 저장 → 파싱/색인 → 모델 입력으로 이어질 수 있어서, 13편에서 본 job/worker 구조와도 잘 연결됩니다. File inputs 가이드는 파일이 Responses 입력의 일부가 될 수 있다고 설명하니, 앱에서는 파일 생명주기 관리가 먼저 필요합니다. (OpenAI 개발자)


13. 멀티모달이 붙으면 비용과 지연도 같이 달라집니다

이건 꼭 같이 봐야 합니다.
이미지나 파일을 붙인 요청은 텍스트 전용보다 입력량이 커질 수 있고, 결과적으로 지연과 비용도 달라질 수 있습니다.

OpenAI 모델 문서는 최신 모델들이 이미지 입력을 지원한다고 설명하고, Responses API가 멀티모달 입력의 기본 경로라고 안내합니다. 파일 입력 가이드는 파일 자체를 입력 항목으로 보내는 방식을 소개하므로, 텍스트만 있을 때보다 입력 토큰/처리량이 달라질 수 있다는 감각이 필요합니다. (OpenAI 개발자)

그래서 observability도 바뀌어야 합니다.

  • 파일 개수
  • 파일 타입
  • 총 업로드 크기
  • parsing/저장 시간
  • 모델 호출 latency
  • 응답당 usage

이런 필드를 같이 봐야 “느려진 원인”을 더 빨리 찾을 수 있습니다.


14. 자주 하는 실수들

실수 1. 파일 검증 없이 바로 모델에 보낸다

이러면 큰 파일, 잘못된 MIME 타입, 이상한 확장자 때문에 금방 흔들립니다.
업로드 검증은 모델 호출 전에 끝내는 편이 좋습니다.

실수 2. 라우터 안에서 Base64 인코딩까지 다 한다

파일 처리 로직이 라우터에 들어가면 금방 길어집니다.
입력 조립은 builder나 service로 빼는 게 낫습니다.

실수 3. 이미지와 파일을 같은 방식으로만 다루려 한다

이미지는 vision 입력 블록, 문서는 file input 블록처럼 나뉘는 편이 자연스럽습니다. OpenAI도 vision 가이드와 file inputs 가이드를 따로 두고 있습니다. (OpenAI 개발자)

실수 4. 임시 파일 정리를 안 한다

로컬에선 티가 덜 나지만, 운영에선 업로드 디렉터리가 금방 불어납니다.

실수 5. 멀티모달을 붙였는데 observability는 텍스트 기준 그대로 둔다

파일 수, 크기, 저장 시간 같은 필드를 안 보면 어디서 느려졌는지 찾기 어렵습니다.


15. 지금 단계에서 추천하는 가장 현실적인 출발점

주니어 개발자가 처음 멀티모달 FastAPI를 만들 때는 이 정도가 가장 균형이 좋습니다.

  1. 질문 텍스트 + 파일 여러 개를 multipart로 받기
  2. UploadFile로 파일을 임시 저장하기
  3. 업로드 검증 서비스 분리
  4. 멀티모달 입력 조립기 따로 두기
  5. OpenAI 호출은 adapter에서 감싸기
  6. usage와 파일 개수 로그 남기기
  7. 작은 파일은 Base64로 시작하고, 커지면 file ID 방식 검토하기

이 정도만 해도 구조가 꽤 오래 갑니다.
괜히 처음부터 이미지 편집, OCR 대체, 대량 문서 파이프라인을 한 엔드포인트에 다 넣으려 하지 않는 게 좋아요.


16. 오늘 글의 핵심 요약

이번 글에서 꼭 가져가야 할 건 이것입니다.

멀티모달 백엔드는 “이미지를 보낼 수 있다”보다 “텍스트·이미지·파일 입력을 검증하고 저장하고 조립하는 구조를 따로 가져간다”가 더 중요하다.

정리하면:

  • 최신 OpenAI 모델과 Responses API는 텍스트와 이미지 입력을 지원합니다. (OpenAI 개발자)
  • 파일 입력은 Responses API에서 Base64, file ID, 외부 URL 방식으로 보낼 수 있습니다. (OpenAI 개발자)
  • 따라서 FastAPI 쪽에서는 업로드 검증, 임시 저장, 입력 조립, 모델 호출을 분리하는 구조가 훨씬 건강합니다. (OpenAI 개발자)
  • 멀티모달이 붙으면 observability도 파일 개수, 크기, 처리 시간까지 같이 봐야 합니다. (OpenAI 개발자)

텍스트 전용 백엔드는 어느 정도 익숙한데,
멀티모달이 붙는 순간부터는 진짜로 “입력 파이프라인”을 설계해야 합니다.
저는 여기서부터 AI 앱이 좀 더 실제 제품처럼 느껴지기 시작하더라고요.


다음 편 예고

다음 글에서는 여기서 더 실무적으로 들어가겠습니다.

Python + FastAPI에서 OpenAI 기능을 에이전트/툴 호출 구조로 확장하는 첫걸음

이 주제로,

  • 언제 단순 프롬프트를 넘어서 tool calling이 필요한지
  • 함수 호출 구조를 FastAPI 서비스와 어떻게 연결할지
  • 에이전트처럼 보이지만 과하지 않은 구조는 어떤 모습인지
  • 멀티모달 입력과 툴 호출이 같이 붙을 때 주의할 점

까지 이어가보겠습니다.


출처

  • OpenAI Images and vision 가이드 — 이미지 입력 분석과 vision use case 설명. (OpenAI 개발자)
  • OpenAI File inputs 가이드 — Responses API에서 파일을 Base64, Files API의 file ID, 외부 URL로 입력할 수 있다고 설명. (OpenAI 개발자)
  • OpenAI Models 가이드 — 최신 모델들이 텍스트와 이미지 입력, vision을 지원한다고 설명. (OpenAI 개발자)
  • OpenAI Python API library 문서 — Responses API가 기본 인터페이스라고 안내. (OpenAI 개발자)
  • OpenAI Quickstart — Python SDK 설치와 첫 호출 흐름 안내. (OpenAI 개발자)
  • Migrate to the Responses API — 멀티모달 입력은 Responses API 흐름으로 생각하는 편이 자연스럽다고 설명. (OpenAI 개발자)

Python, OpenAI, FastAPI, 멀티모달, image input, file input, Responses API, vision, 파일 업로드, UploadFile, AI 백엔드, OpenAI Python SDK, 문서 분석, 이미지 분석, 주니어 개발자

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함
반응형