티스토리 뷰
Python으로 공부하는 OpenAI 5편 — FastAPI에서 OpenAI 서비스 계층을 제대로 나누는 방법
octo54 2026. 3. 23. 10:26Python으로 공부하는 OpenAI 5편 — FastAPI에서 OpenAI 서비스 계층을 제대로 나누는 방법
여기까지 오면 이제 슬슬 이런 생각이 듭니다.
“아 이제 장난감 코드는 좀 알겠고… 진짜 API 서버처럼 만들고 싶다.”
맞아요.
1~4편까지는 OpenAI를 Python에서 이해하고, 프롬프트를 다루고, Structured Outputs로 JSON을 받고, Pydantic으로 검증하는 흐름을 잡았습니다.
근데 실무는 거기서 끝이 아니죠.
실무에서는 늘 이런 문제가 생깁니다.
- 라우터에 OpenAI 호출 코드가 다 들어가서 지저분해짐
- .env 읽는 코드가 여기저기 흩어짐
- 예외 처리 방식이 매번 다름
- 테스트할 때 실제 OpenAI 호출이 섞여버림
- 나중에 모델 바꾸거나 프롬프트 버전 바꿀 때 수정 지점이 너무 많아짐
그래서 이번 글에서는 진짜 백엔드 개발자 느낌이 나는 구조로 갑니다.
FastAPI + OpenAI + Pydantic + 서비스 계층 분리
이 흐름을 한 번 제대로 잡아볼게요.
OpenAI 공식 Python SDK는 현재 기본 인터페이스로 Responses API를 안내하고 있고, FastAPI 공식 문서는 큰 애플리케이션에서는 여러 파일로 구조를 나누고, 설정은 의존성과 @lru_cache()를 활용해 관리하는 패턴을 권장합니다. 또 테스트 시에는 dependency_overrides로 의존성을 바꿔 끼울 수 있습니다. (GitHub)
1. 왜 라우터에서 바로 OpenAI를 부르면 금방 꼬이나
처음엔 다 이렇게 시작합니다.
@app.post("/ai/summarize")
def summarize(req: SummaryRequest):
response = client.responses.create(...)
data = json.loads(response.output_text)
result = SummaryResponse.model_validate(data)
return result
한 파일에서 보면 편해 보여요.
근데 조금만 커지면 바로 꼬입니다.
왜냐하면 여기엔 너무 많은 책임이 한꺼번에 들어가 있기 때문이죠.
- HTTP 요청 받기
- 요청 검증
- OpenAI 호출
- 프롬프트 관리
- 응답 파싱
- 예외 처리
- 응답 반환
이걸 라우터가 다 해버리면, 나중엔 라우터가 비대해지고 테스트도 어려워집니다.
FastAPI 공식 문서도 애플리케이션이 커지면 여러 파일과 APIRouter로 구조를 나누는 방식을 안내합니다. Flask의 Blueprint와 비슷한 감각이라고 설명하죠. (FastAPI)
제가 실무에서 느낀 건 이겁니다.
AI 기능이 들어간다고 해서 백엔드 기본 원칙이 사라지는 건 아니더라. 오히려 더 중요해집니다.
2. 이번 글에서 만들 구조
오늘은 아래 구조로 갑니다.
app/
├── main.py
├── core/
│ └── config.py
├── schemas/
│ └── summary.py
├── services/
│ └── openai_summary_service.py
└── api/
└── summary_router.py
이 구조의 역할은 단순합니다.
- core/config.py
환경변수와 설정 관리 - schemas/summary.py
요청/응답 Pydantic 모델 - services/openai_summary_service.py
OpenAI 호출, Structured Outputs, 파싱, 검증 - api/summary_router.py
FastAPI 라우터와 HTTP 예외 처리 - main.py
앱 생성과 라우터 등록
FastAPI의 Settings 문서는 .env 기반 설정을 Pydantic Settings로 관리하고, @lru_cache()로 매 요청마다 다시 읽지 않게 만드는 패턴을 보여줍니다. (FastAPI)
3. 먼저 설정부터 분리합시다
이 부분, 은근히 많이 대충 넘어가는데 중요합니다.
app/core/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "OpenAI FastAPI Example"
openai_api_key: str
openai_model: str = "gpt-5.4-mini"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()
왜 이렇게 하냐
- .env 파일을 통해 설정을 주입할 수 있고
- 코드에 API 키를 하드코딩하지 않아도 되고
- @lru_cache()로 설정 객체를 한 번만 생성해서 재사용할 수 있습니다
FastAPI 공식 문서는 설정을 의존성으로 주입하면 테스트가 쉬워지고, .env도 쓸 수 있으며, @lru_cache()를 쓰면 요청마다 dotenv를 다시 읽지 않아도 된다고 설명합니다. (FastAPI)
.env
OPENAI_API_KEY=your_openai_api_key
OPENAI_MODEL=gpt-5.4-mini
여기서 이름이 openai_api_key, openai_model인 이유는
Pydantic Settings가 환경변수 이름과 자연스럽게 매핑해주기 때문입니다.
4. 요청/응답 스키마는 라우터 밖으로 빼야 합니다
다음은 API가 받을 입력과 반환할 출력을 정의합니다.
app/schemas/summary.py
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class SummaryRequest(BaseModel):
topic: str = Field(min_length=2, max_length=200, description="요약할 주제")
class SummaryResult(BaseModel):
model_config = ConfigDict(extra="forbid")
topic: str
difficulty: Literal["beginner", "intermediate", "advanced"]
summary: str
keywords: list[str]
@field_validator("summary")
@classmethod
def validate_summary(cls, value: str) -> str:
if len(value.strip()) < 20:
raise ValueError("summary는 최소 20자 이상이어야 합니다.")
return value
@field_validator("keywords")
@classmethod
def validate_keywords(cls, value: list[str]) -> list[str]:
if len(value) < 2:
raise ValueError("keywords는 최소 2개 이상이어야 합니다.")
return value
이렇게 해두면 좋은 점이 명확합니다.
- 라우터는 모델을 가져다 쓰기만 하면 됨
- 서비스 계층도 같은 모델을 재사용 가능
- API 문서(OpenAPI)도 깔끔해짐
- 타입 안정성이 좋아짐
Pydantic v2의 field_validator와 extra="forbid"는 이런 식으로 필드 검증과 추가 속성 차단에 활용할 수 있습니다. (OpenAI 개발자)
5. 이제 핵심인 OpenAI 서비스 계층입니다
이제 진짜 중요한 파일입니다.
여기서 OpenAI 호출을 감춥니다.
app/services/openai_summary_service.py
import json
from openai import OpenAI
from pydantic import ValidationError
from app.core.config import Settings
from app.schemas.summary import SummaryResult
class OpenAISummaryService:
def __init__(self, settings: Settings):
self.settings = settings
self.client = OpenAI(api_key=settings.openai_api_key)
def summarize_topic(self, topic: str) -> SummaryResult:
schema = {
"type": "object",
"properties": {
"topic": {"type": "string"},
"difficulty": {
"type": "string",
"enum": ["beginner", "intermediate", "advanced"],
},
"summary": {"type": "string"},
"keywords": {
"type": "array",
"items": {"type": "string"},
},
},
"required": ["topic", "difficulty", "summary", "keywords"],
"additionalProperties": False,
}
response = self.client.responses.create(
model=self.settings.openai_model,
instructions=(
"당신은 주니어 개발자를 돕는 Python 멘토입니다. "
"반드시 주어진 JSON Schema에 맞는 결과만 반환하세요."
),
input=f"다음 주제를 초보자 관점에서 설명할 수 있게 요약해줘: {topic}",
text={
"format": {
"type": "json_schema",
"name": "topic_summary",
"schema": schema,
"strict": True,
}
},
)
raw = json.loads(response.output_text)
try:
return SummaryResult.model_validate(raw)
except ValidationError:
raise
여기서 핵심 포인트
이 파일은 딱 하나의 책임에 집중합니다.
“주제를 받아 OpenAI로 요약 결과를 만들어 검증된 객체로 반환한다.”
이게 되게 별거 아닌 것 같아도,
한 번 이렇게 나눠두면 나중에:
- 모델 교체
- 프롬프트 교체
- Structured Output 변경
- 재시도 로직 추가
- 로깅 추가
를 이 파일에서만 바꾸면 됩니다.
OpenAI의 Structured Outputs 가이드는 text.format에 json_schema를 주고 strict: true를 사용해 응답이 공급한 JSON Schema를 따르도록 만드는 흐름을 설명합니다. Responses API로 이동할 때도 Chat Completions의 response_format 대신 Responses에서는 text.format을 사용한다고 안내합니다. (OpenAI 개발자)
6. FastAPI 의존성으로 서비스를 주입합시다
이제 이 서비스를 라우터에서 직접 생성하지 말고,
의존성 함수로 꺼내오는 게 좋습니다.
app/api/summary_router.py
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import ValidationError
from app.core.config import Settings, get_settings
from app.schemas.summary import SummaryRequest, SummaryResult
from app.services.openai_summary_service import OpenAISummaryService
router = APIRouter(prefix="/ai", tags=["ai"])
def get_summary_service(
settings: Annotated[Settings, Depends(get_settings)],
) -> OpenAISummaryService:
return OpenAISummaryService(settings)
@router.post("/summarize", response_model=SummaryResult)
def summarize_topic(
request: SummaryRequest,
service: Annotated[OpenAISummaryService, Depends(get_summary_service)],
) -> SummaryResult:
try:
return service.summarize_topic(request.topic)
except ValidationError as e:
raise HTTPException(
status_code=500,
detail={
"message": "OpenAI 응답 검증 실패",
"errors": e.errors(),
},
) from e
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"OpenAI 호출 실패: {str(e)}",
) from e
왜 이 구조가 좋냐
라우터는 이제 훨씬 단순해집니다.
- 요청 받기
- 서비스 호출
- 실패를 HTTP 예외로 변환
끝입니다.
서비스는 비즈니스 로직을 담당하고,
라우터는 HTTP 레이어만 담당합니다.
FastAPI 공식 문서는 의존성이 또 다른 하위 의존성을 가질 수 있고, FastAPI가 이를 자동으로 해결한다고 설명합니다. 이런 구조 덕분에 설정 → 서비스 → 라우터 흐름이 자연스럽게 이어집니다. (FastAPI)
7. 앱 조립은 최대한 단순하게
app/main.py
from fastapi import FastAPI
from app.api.summary_router import router as summary_router
from app.core.config import get_settings
settings = get_settings()
app = FastAPI(title=settings.app_name)
app.include_router(summary_router)
여기는 진짜 심플해야 합니다.
앱 생성하고, 라우터 등록하고, 끝.
FastAPI 공식 Bigger Applications 문서도 이런 식으로 main.py는 앱을 만들고 라우터를 등록하는 중심 파일로 두는 패턴을 보여줍니다. (FastAPI)
8. 실행 방법
패키지 설치:
pip install fastapi uvicorn openai pydantic pydantic-settings python-dotenv
실행:
uvicorn app.main:app --reload
그다음 Swagger UI에서 테스트:
POST /ai/summarize
요청 예시:
{
"topic": "파이썬 데코레이터"
}
예상 응답 예시:
{
"topic": "파이썬 데코레이터",
"difficulty": "beginner",
"summary": "파이썬 데코레이터는 기존 함수를 직접 수정하지 않고도 추가 기능을 감싸서 붙일 수 있게 도와주는 문법입니다.",
"keywords": ["python", "decorator", "wrapper"]
}
OpenAI 텍스트 생성 가이드는 Responses API를 새 프로젝트용 권장 API로 소개하며, 모델이 자연어뿐 아니라 구조화된 JSON 데이터도 생성할 수 있다고 설명합니다. (OpenAI 개발자)
9. 이 구조가 테스트에 강한 이유
이 부분이 진짜 큽니다.
서비스를 의존성으로 주입하면 테스트가 쉬워집니다.
예를 들어 테스트에서 진짜 OpenAI API를 부르지 않고, 가짜 서비스를 넣을 수 있어요.
from fastapi.testclient import TestClient
from app.main import app
from app.api.summary_router import get_summary_service
from app.schemas.summary import SummaryResult
class FakeSummaryService:
def summarize_topic(self, topic: str) -> SummaryResult:
return SummaryResult(
topic=topic,
difficulty="beginner",
summary="테스트용 요약입니다. 충분히 긴 문자열입니다.",
keywords=["test", "python"],
)
def override_summary_service():
return FakeSummaryService()
app.dependency_overrides[get_summary_service] = override_summary_service
client = TestClient(app)
def test_summarize_topic():
response = client.post("/ai/summarize", json={"topic": "FastAPI"})
assert response.status_code == 200
data = response.json()
assert data["topic"] == "FastAPI"
assert data["difficulty"] == "beginner"
FastAPI 공식 문서는 app.dependency_overrides로 원래 의존성을 테스트용 구현으로 바꿔치기할 수 있다고 설명합니다. 이게 서비스 계층 분리의 엄청 큰 장점입니다. (FastAPI)
솔직히 저는 이 지점에서 “아 구조를 나누길 잘했다”는 생각을 제일 많이 했습니다.
처음엔 귀찮아 보여도, 테스트 한 번 만들려는 순간 체감이 확 와요.
10. 실무에서 곧바로 추가하게 되는 것들
지금 예제는 입문용으로 깔끔하게 줄여놨습니다.
실무에서는 보통 여기서 몇 가지를 더 붙입니다.
재시도 로직
네트워크나 일시 오류 때문에 실패할 수 있으니 재시도를 둡니다.
로깅
요청 주제, 모델명, 실패 사유, 검증 오류 등을 로그로 남깁니다.
커스텀 예외
OpenAIServiceError, OpenAIValidationError 같은 예외 클래스를 따로 둡니다.
프롬프트 버전 관리
instructions를 코드 문자열 하나로 두지 않고, 파일이나 상수 버전으로 관리합니다.
응답 시간 측정
얼마나 오래 걸렸는지 기록해서 운영 지표를 봅니다.
store: false 여부 검토
Responses API 마이그레이션 가이드는 Responses가 기본적으로 저장된다고 안내합니다. 민감한 데이터나 정책상 저장을 원하지 않는 경우 이 옵션을 검토해야 합니다. (OpenAI 개발자)
11. 초보자가 여기서 자주 하는 실수
실수 1. OpenAI() 클라이언트를 라우터마다 새로 만든다
작동은 하지만 구조가 산만해집니다.
서비스 안에 모아두는 게 낫습니다.
실수 2. 설정을 매 파일에서 직접 읽는다
os.getenv()가 여기저기 흩어지면 나중에 바꾸기 힘듭니다.
실수 3. 라우터가 비즈니스 로직까지 다 가진다
처음엔 빨라 보여도 유지보수가 어렵습니다.
실수 4. Structured Outputs와 Pydantic 둘 중 하나만 믿는다
실무에서는 둘 다 쓰는 게 안정적입니다.
OpenAI 단계에서 구조를 최대한 고정하고, 서버 단계에서 한 번 더 검증하는 거죠. (OpenAI 개발자)
실수 5. 테스트에서 진짜 OpenAI를 부른다
테스트는 빠르고 예측 가능해야 합니다.
의존성 오버라이드로 가짜 서비스를 주입하는 습관이 좋습니다. (FastAPI)
12. 지금 단계에서 기억해야 할 핵심
오늘 구조는 사실 엄청 화려한 게 아닙니다.
그런데 이상하게 이런 기본기가 프로젝트 수명을 갈라요.
제가 여러 번 느낀 건 이거예요.
처음 AI 기능 붙일 때 사람들은 모델, 프롬프트, 성능, 에이전트 이런 데 눈이 먼저 갑니다.
근데 실제로 오래 버티는 코드는 늘 비슷했습니다.
- 설정 분리
- 서비스 분리
- 검증 분리
- 테스트 가능한 구조
- 수정 지점이 적은 구조
AI가 들어가도 백엔드는 결국 백엔드더라… 진짜 그렇더라고요.
13. 오늘 글의 핵심 요약
이번 글에서 꼭 가져가야 할 건 이것입니다.
FastAPI에서 OpenAI를 제대로 쓰려면, 라우터에서 바로 호출하지 말고 서비스 계층으로 분리해야 한다.
실무형 흐름은 이렇게 정리하면 됩니다.
- Settings로 환경변수를 관리한다.
- Pydantic으로 요청/응답 모델을 정의한다.
- OpenAI 호출은 서비스 클래스 안에 넣는다.
- 라우터는 HTTP 처리만 맡는다.
- 의존성 주입으로 테스트 가능한 구조를 만든다.
OpenAI 공식 SDK는 Responses API를 기본 인터페이스로 안내하고, FastAPI 공식 문서는 큰 앱에서는 여러 파일 구조와 APIRouter 사용, 설정 의존성, @lru_cache(), 테스트용 dependency override 패턴을 권장합니다. 지금 만든 구조는 그 흐름을 그대로 실전형으로 옮긴 형태입니다. (GitHub)
다음 편 예고
다음 글에서는 여기서 한 단계 더 올라가겠습니다.
FastAPI에서 OpenAI 스트리밍 응답 만들기
이 주제로,
- 스트리밍이 왜 필요한지
- 일반 응답과 스트리밍 응답 차이
- FastAPI에서 SSE/StreamingResponse로 붙이는 방법
- 프론트엔드에서 어떻게 받는지
- 연결 종료와 예외를 어떻게 처리하는지
까지 이어가보겠습니다.
출처
- OpenAI Python SDK README — 현재 기본 인터페이스는 Responses API. (GitHub)
- OpenAI Structured Outputs 가이드 — json_schema, strict: true, JSON Schema 준수 응답. (OpenAI 개발자)
- OpenAI Responses API 마이그레이션 가이드 — Responses에서는 text.format 사용, 응답 저장 기본 동작 안내. (OpenAI 개발자)
- OpenAI Text generation 가이드 — 새 프로젝트에 Responses API 권장, 구조화된 JSON 데이터 생성 가능. (OpenAI 개발자)
- FastAPI Settings and Environment Variables — .env, 의존성, @lru_cache() 패턴. (FastAPI)
- FastAPI Bigger Applications / APIRouter — 여러 파일 구조와 라우터 분리. (FastAPI)
- FastAPI Testing Dependencies with Overrides — app.dependency_overrides 사용. (FastAPI)
Python, OpenAI, FastAPI, OpenAI Python SDK, Responses API, Structured Outputs, Pydantic, Dependency Injection, APIRouter, Python 백엔드, AI 백엔드, 주니어 개발자, FastAPI 프로젝트 구조, OpenAI 서비스 계층, API 서버 설계
'study > Python으로 시작하는 OpenAI 개발 입문' 카테고리의 다른 글
| Python으로 공부하는 OpenAI 7편 — FastAPI에서 대화 히스토리를 이어가는 채팅 API 만들기 (0) | 2026.03.30 |
|---|---|
| Python으로 공부하는 OpenAI 6편 — FastAPI에서 OpenAI 스트리밍 응답 만들기 (0) | 2026.03.26 |
| Python으로 공부하는 OpenAI 4편 — Pydantic으로 응답을 검증해야 진짜 백엔드 코드가 됩니다 (0) | 2026.03.20 |
| Python으로 공부하는 OpenAI 3편 — 자유문장 말고 JSON으로 받아야 실무가 시작됩니다 (0) | 2026.03.19 |
| Python으로 공부하는 OpenAI 2편 — 프롬프트를 잘 쓰는 사람과 헤매는 사람의 차이 (0) | 2026.03.18 |
- Total
- Today
- Yesterday
- Prisma
- llm
- SEO최적화
- 딥러닝
- 개발블로그
- 쿠버네티스
- DevOps
- fastapi
- Next.js
- ai철학
- Redis
- nextJS
- kotlin
- rag
- JAX
- Python
- 압박면접
- REACT
- flax
- LangChain
- Docker
- 웹개발
- node.js
- Express
- NestJS
- 백엔드개발
- PostgreSQL
- seo 최적화 10개
- JWT
- CI/CD
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
