티스토리 뷰

반응형

Python으로 공부하는 OpenAI 18편 — 기능이 늘수록 중요한 건 코드 실력보다 폴더 구조입니다

이쯤 오면 조금 묘한 순간이 옵니다.
처음엔 파일 몇 개로 시작했는데, 어느 날 보니까 이런 것들이 다 생겨 있어요.

  • 채팅 API
  • RAG 검색
  • 스트리밍 응답
  • background job
  • usage 로그
  • settings
  • retry 로직
  • webhook 처리
  • 테스트 코드

그러면 코드가 안 되는 게 아니라,
어디에 뭐가 있는지 감이 안 오는 상태가 먼저 옵니다.

이 상태가 은근 무섭습니다.
버그 하나 고치려고 열 파일 넘게 뒤지게 되고,
비슷한 OpenAI 호출 코드가 여기저기 복붙돼 있고,
FastAPI 라우터가 서비스 로직까지 먹어버리고,
나중엔 “이건 누가 건드려도 무섭다…” 분위기가 생겨요.

그래서 이번 글은 기능 추가보다 더 중요한 얘기입니다.

OpenAI 기능이 많아질수록, 프로젝트 구조를 어떻게 나눌 것인가

FastAPI 공식 문서는 앱이 커지면 APIRouter와 의존성을 별도 모듈로 분리하는 “Bigger Applications - Multiple Files” 구조를 권장합니다. 또 설정은 BaseSettings와 .env, @lru_cache()를 사용해 별도 설정 모듈로 분리하는 패턴을 안내합니다. OpenAI API Overview는 모델 동작이 스냅샷과 패밀리 사이에서 달라질 수 있으므로, 일관성을 원하면 pinned model versionsevals를 권장합니다. 이 말은 곧 OpenAI 관련 코드도 한 군데에 모아 관리하는 편이 낫다는 뜻이기도 해요. (FastAPI)


1. 처음엔 한 파일로도 되는데, 왜 갑자기 구조가 중요해지나

초기엔 진짜 한 파일이어도 됩니다.

from fastapi import FastAPI
from openai import OpenAI

app = FastAPI()
client = OpenAI()

@app.post("/chat")
def chat(payload: dict):
    response = client.responses.create(
        model="gpt-5.4-mini",
        input=payload["message"],
    )
    return {"answer": response.output_text}

이런 코드는 공부할 때는 좋아요.
근데 기능이 늘면 바로 문제가 보입니다.

  • OpenAI SDK 호출이 라우터 안에 박힘
  • settings를 어디서 바꿔야 할지 모름
  • retry나 usage logging을 공통 적용하기 어려움
  • RAG 엔드포인트와 일반 채팅 엔드포인트가 비슷한 코드를 복사하게 됨
  • 테스트에서 fake client 넣기가 불편함

FastAPI 공식 문서가 의존성과 APIRouter를 별도 모듈로 빼라고 하는 이유가 딱 이거예요. 공통 의존성과 라우터를 분리해두면 앱이 커져도 구조를 유지하기 쉽습니다. (FastAPI)

제가 느끼기엔,
OpenAI 기능이 붙은 백엔드는 보통 일반 CRUD보다 구조가 더 빨리 복잡해집니다.
왜냐하면 단순 DB 호출만 있는 게 아니라:

  • 모델 호출
  • prompt 조립
  • structured output 검증
  • retrieval
  • 후처리
  • 비용/usage 로그

같은 단계가 한 요청 안에 같이 들어오기 때문이에요.


2. 제일 먼저 버려야 하는 구조: 라우터가 다 하는 구조

주니어 때 정말 많이 하는 패턴입니다.

@router.post("/rag-chat")
async def rag_chat(request: RagChatRequest):
    settings = get_settings()
    client = OpenAI(api_key=settings.openai_api_key)

    chunks = search_vector_store(request.message)
    prompt = build_prompt(request.message, chunks)

    response = client.responses.create(
        model=settings.openai_model,
        input=prompt,
    )

    save_history(request.session_id, request.message, response.output_text)

    return {"answer": response.output_text}

이 코드는 당장은 빨라요.
근데 나중엔 거의 꼭 무너집니다.

왜냐하면 라우터가:

  • 요청 검증
  • 설정 읽기
  • 검색
  • 프롬프트 조립
  • OpenAI 호출
  • 히스토리 저장
  • 응답 반환

을 전부 하고 있기 때문이죠.

이 상태가 되면 조금만 기능이 늘어도 라우터 파일이 거대해집니다.
FastAPI 문서가 라우터와 공통 의존성, 설정을 별도 모듈로 분리하는 이유가 바로 이 문제를 피하기 위해서예요. (FastAPI)


3. 제가 추천하는 기본 원칙은 딱 네 가지입니다

복잡하게 생각하지 말고, 일단 이 네 가지만 기억하면 됩니다.

1) 라우터는 HTTP만 신경 쓴다

요청 받고, 서비스 호출하고, 응답 돌려주는 데 집중

2) 서비스는 유스케이스를 담당한다

예: “문서 기반 답변 생성”, “채팅 세션 이어가기”, “문서 요약 작업 시작”

3) OpenAI SDK 호출은 adapter/client 계층으로 감싼다

모델명, usage, retry, 공통 파라미터를 한 군데서 관리

4) 스키마와 설정은 무조건 분리한다

Pydantic 모델과 Settings는 라우터/서비스 밖에 두기

이 네 가지만 지켜도 코드가 확 덜 엉킵니다.


4. 지금 시점에서 가장 무난한 프로젝트 구조

OpenAI 기능이 여러 개 붙는 FastAPI 프로젝트라면 저는 대체로 이 구조를 추천합니다.

app/
├── main.py
├── core/
│   ├── config.py
│   ├── logging.py
│   └── dependencies.py
├── api/
│   ├── routers/
│   │   ├── chat_router.py
│   │   ├── rag_router.py
│   │   ├── jobs_router.py
│   │   └── webhook_router.py
│   └── __init__.py
├── schemas/
│   ├── chat.py
│   ├── rag.py
│   ├── job.py
│   └── usage.py
├── services/
│   ├── chat_service.py
│   ├── rag_service.py
│   ├── retrieval_service.py
│   ├── job_service.py
│   └── observability_service.py
├── adapters/
│   ├── openai_client.py
│   ├── vector_store_client.py
│   └── webhook_client.py
├── repositories/
│   ├── chat_repository.py
│   ├── job_repository.py
│   └── usage_repository.py
└── workers/
    └── worker_main.py

이 구조의 좋은 점은 역할이 비교적 분명하다는 겁니다.

  • core/: 앱 전역 설정과 공통 의존성
  • api/routers/: 엔드포인트 정의
  • schemas/: 요청/응답/도메인 모델
  • services/: 실제 유스케이스 로직
  • adapters/: OpenAI나 외부 서비스 연동
  • repositories/: DB/Redis 같은 저장소 접근
  • workers/: 백그라운드 실행용 코드

FastAPI 공식 문서의 Bigger Applications 예제도 공통 의존성과 라우터를 여러 파일로 나누는 방식을 권장합니다. 설정은 별도 모듈과 @lru_cache()를 통해 재사용하도록 안내합니다. (FastAPI)


5. core에는 “어디서든 필요한 것”만 넣는 게 좋습니다

반응형

core 폴더는 은근 남용하기 쉬워요.
여기저기 애매한 걸 다 넣기 시작하면 결국 또 잡탕이 됩니다.

저는 보통 core에는 이 정도만 넣습니다.

  • config.py: 환경변수와 설정
  • logging.py: 구조화 로그 설정
  • dependencies.py: 공통 DI 함수
  • security.py: 인증/보안 관련 공통 처리

예를 들면 config.py는 이렇게 갑니다.

from functools import lru_cache

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    app_name: str = "OpenAI FastAPI App"
    app_env: str = "dev"

    openai_api_key: str
    openai_model: str = "gpt-5.4-mini"
    openai_vector_store_id: str | None = None

    database_url: str
    redis_url: str | None = None

    enable_docs: bool = True
    log_level: str = "INFO"

    model_config = SettingsConfigDict(
        env_file=".env",
        extra="ignore",
    )


@lru_cache
def get_settings() -> Settings:
    return Settings()

FastAPI 설정 문서는 BaseSettings와 .env, @lru_cache() 조합을 권장하면서, 이렇게 하면 매 요청마다 설정 파일을 다시 읽지 않아도 되고 테스트에서 override도 쉬워진다고 설명합니다. (FastAPI)


6. adapters를 두면 OpenAI 코드가 훨씬 덜 퍼집니다

이건 개인적으로 정말 추천합니다.

많은 프로젝트가 OpenAI SDK 호출을 서비스마다 직접 합니다.
그러면 나중에 이런 게 힘들어요.

  • model 이름 변경
  • retry 정책 변경
  • 공통 instructions 추가
  • usage logging 공통화
  • pinned model version 적용
  • SDK 인터페이스 변경 대응

OpenAI API Overview는 모델 스냅샷과 패밀리에 따라 동작이 달라질 수 있으니, 일관성이 중요하면 pinned model versions와 evals를 권장합니다. 이걸 프로젝트 구조로 풀면, 모델 관련 결정을 여기저기 흩뿌리지 말고 한 군데 adapter에서 관리하는 게 낫다는 뜻이에요. (OpenAI 개발자)

예를 들어:

app/adapters/openai_client.py

from openai import OpenAI


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

    def generate_text(self, input_data, instructions: str | None = None) -> 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

이제 서비스는 OpenAI SDK 상세 구조를 몰라도 됩니다.


7. services는 “기술”이 아니라 “행동” 기준으로 나누는 게 좋습니다

주니어 때 자주 하는 실수 중 하나가
서비스를 기술별로 쪼개는 겁니다.

예:

  • openai_service.py
  • redis_service.py
  • vector_service.py

이런 식으로만 나누면, 실제 유스케이스 흐름이 코드에서 잘 안 보입니다.

저는 보통 서비스는 행동 기준으로 나누는 걸 더 추천합니다.

예:

  • chat_service.py
  • rag_service.py
  • document_index_service.py
  • job_service.py
  • usage_report_service.py

즉, “무슨 기술을 쓰는가”보다
**“이 서비스가 무슨 일을 끝내는가”**가 파일 이름에 드러나는 편이 좋아요.

예를 들면:

app/services/rag_service.py

from app.adapters.openai_client import OpenAITextAdapter
from app.schemas.rag import RetrievedChunk


class RagService:
    def __init__(self, llm: OpenAITextAdapter):
        self.llm = llm

    def answer_with_sources(
        self,
        question: str,
        retrieved_chunks: list[RetrievedChunk],
    ) -> dict:
        docs_text = "\n\n".join(
            f"[{idx}] 출처: {chunk.source}\n{chunk.content}"
            for idx, chunk in enumerate(retrieved_chunks, start=1)
        )

        messages = [
            {
                "role": "system",
                "content": (
                    "당신은 문서 근거 기반으로 답변하는 도우미입니다. "
                    "문서에 없는 내용은 추측하지 마세요."
                ),
            },
            {
                "role": "system",
                "content": f"참고 문서:\n\n{docs_text}",
            },
            {
                "role": "user",
                "content": question,
            },
        ]

        answer, usage = self.llm.generate_text(messages)

        return {
            "answer": answer,
            "usage": usage,
            "sources": [
                {"id": str(idx), "source": chunk.source}
                for idx, chunk in enumerate(retrieved_chunks, start=1)
            ],
        }

이 파일만 봐도 “문서 기반 답변 생성”이라는 책임이 보입니다.


8. repositories는 DB 코드가 서비스 안으로 새어 나오지 않게 막아줍니다

처음엔 repository 패턴이 좀 과해 보일 수 있어요.
근데 AI 기능이 붙으면 생각보다 빨리 필요해집니다.

왜냐하면 저장할 게 많거든요.

  • 채팅 히스토리
  • background jobs
  • usage log
  • RAG source log
  • cached session summary

이걸 서비스 안에서 SQLAlchemy나 Redis 명령으로 직접 다루기 시작하면,
서비스 로직과 저장소 로직이 뒤엉킵니다.

그래서 repository는 꽤 쓸 만합니다.

app/repositories/chat_repository.py

from app.schemas.chat import ChatMessage


class InMemoryChatRepository:
    def __init__(self) -> None:
        self._store: dict[str, list[ChatMessage]] = {}

    def append(self, session_id: str, message: ChatMessage) -> None:
        self._store.setdefault(session_id, []).append(message)

    def get_recent(self, session_id: str, limit: int = 8) -> list[ChatMessage]:
        return self._store.get(session_id, [])[-limit:]

이런 식으로 시작해두면,
나중에 PostgreSQL이나 Redis로 바꿔도 서비스 로직 변화가 적습니다.


9. 라우터는 진짜로 얇을수록 좋습니다

이건 여러 번 강조해도 부족하지 않아요.

FastAPI 라우터는 HTTP 경계입니다.
여기서 많이 하면 할수록 테스트가 어려워지고, 코드가 금방 비대해집니다.

좋은 라우터는 대체로 이 정도만 합니다.

  • request model 받기
  • dependency 주입받기
  • service 호출하기
  • response model 반환하기

app/api/routers/chat_router.py

from typing import Annotated

from fastapi import APIRouter, Depends

from app.core.dependencies import get_chat_service
from app.schemas.chat import ChatRequest, ChatResponse
from app.services.chat_service import ChatService

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


@router.post("", response_model=ChatResponse)
def chat(
    request: ChatRequest,
    service: Annotated[ChatService, Depends(get_chat_service)],
) -> ChatResponse:
    result = service.answer(request.session_id, request.message)
    return ChatResponse(**result)

FastAPI 공식 문서의 Bigger Applications 예제도 이런 식으로 APIRouter를 분리하고, 공통 의존성은 별도 모듈에서 주입하는 구조를 보여줍니다. (FastAPI)


10. dependencies.py는 앱 조립용 파일이라고 생각하면 편합니다

이 파일이 있으면 앱 구조가 훨씬 선명해집니다.

여기서 하는 일은 대체로 이겁니다.

  • settings 만들기
  • adapter 만들기
  • repository 만들기
  • service 만들기

즉, 객체 조립을 여기서 합니다.

app/core/dependencies.py

from app.adapters.openai_client import OpenAITextAdapter
from app.core.config import get_settings
from app.repositories.chat_repository import InMemoryChatRepository
from app.services.chat_service import ChatService

_chat_repository = InMemoryChatRepository()


def get_chat_repository() -> InMemoryChatRepository:
    return _chat_repository


def get_openai_adapter() -> OpenAITextAdapter:
    settings = get_settings()
    return OpenAITextAdapter(
        api_key=settings.openai_api_key,
        model=settings.openai_model,
    )


def get_chat_service() -> ChatService:
    repo = get_chat_repository()
    llm = get_openai_adapter()
    return ChatService(repo=repo, llm=llm)

이렇게 두면 테스트에서도 dependency override를 하기가 편해집니다.

FastAPI는 의존성 기반 구조를 쓰면 테스트에서 override하기 쉽다고 공식 문서에서 설명합니다. settings도 dependency로 제공하면 테스트에서 바꿔 끼우기 쉬워집니다. (FastAPI)


11. OpenAI 관련 코드는 “공통 정책”을 한 군데에 모아야 합니다

이건 진짜 많이 중요합니다.

모델명, retry, 공통 instructions, usage 수집, pinned model version 같은 걸
서비스마다 따로 관리하면 나중에 거의 반드시 꼬입니다.

OpenAI API Overview는 모델 스냅샷 간 prompting behavior가 달라질 수 있으므로 pinned model versions와 evals가 중요하다고 설명합니다. 이런 특성 때문에 모델 관련 결정은 한 군데서 통제하는 편이 훨씬 낫습니다. (OpenAI 개발자)

예를 들면 adapter 안에:

  • 기본 model
  • 응답 usage 추출
  • 공통 timeout/retry 정책
  • trace/log hook
  • developer instructions 기본값

같은 걸 넣어두면 서비스는 훨씬 단순해집니다.


12. 폴더 구조를 너무 일찍 거대하게 만드는 것도 조심해야 합니다

여기서 반대 극단도 있어요.

처음부터:

  • domain/
  • application/
  • infrastructure/
  • presentation/
  • ports/
  • adapters/
  • usecases/
  • handlers/
  • facades/
  • gateways/

이렇게 너무 크게 벌리면,
오히려 주니어 단계에서는 길을 잃기 쉽습니다.

저는 보통 이렇게 생각합니다.

기능이 적을 때

  • api
  • schemas
  • services
  • core

외부 연동이 늘 때

  • adapters
  • repositories

background job, worker, toolchain이 늘 때

  • workers
  • tasks
  • integrations

즉, 문제가 생길 때 분리하되, 미리 과하게 분리하지는 않는다
이게 제일 무난합니다.

FastAPI 문서도 처음부터 거대한 아키텍처 용어를 요구하는 게 아니라, 여러 파일과 APIRouter, 공통 의존성 모듈 정도부터 시작하는 흐름을 보여줍니다. (FastAPI)


13. 주니어가 처음부터 피하면 좋은 구조적 실수

이건 진짜 많이 봤습니다.

실수 1. 라우터가 비즈니스 로직까지 다 한다

제일 흔합니다. 처음엔 빨라 보여도 금방 커집니다.

실수 2. OpenAI SDK 호출이 여기저기 흩어진다

모델 변경, usage 추적, retry 정책이 다 따로 놀게 됩니다.

실수 3. settings를 아무 파일에서나 직접 읽는다

환경 분리와 테스트가 점점 불편해집니다.

실수 4. repository 없이 DB/Redis 접근이 서비스 안에 다 섞인다

로직 수정이 저장소 기술에 과하게 묶입니다.

실수 5. RAG 검색, 프롬프트 조립, 최종 생성이 한 함수에 들어간다

디버깅이 진짜 힘들어집니다.

실수 6. background worker 코드와 API 서버 코드를 구분 안 한다

운영 구조가 커질수록 위험해집니다.


14. 지금 단계에서 가장 현실적인 추천 구조

주니어 개발자가 OpenAI + FastAPI 프로젝트를 키울 때는 저는 이 정도를 추천합니다.

1단계

  • main.py
  • api/routers
  • schemas
  • services
  • core/config.py

2단계

  • adapters/openai_client.py
  • repositories
  • core/dependencies.py

3단계

  • workers
  • observability_service
  • job_service
  • webhook_router

이 순서대로 커지면,
불필요하게 과하지도 않고,
나중에 구조를 다시 엎을 가능성도 줄어듭니다.


15. 오늘 글의 핵심 요약

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

OpenAI 기능이 늘어날수록 중요한 건 새로운 파일을 더 만드는 게 아니라, 라우터·서비스·adapter·repository·settings의 책임을 분리해서 코드가 덜 엉키게 만드는 것이다.

정리하면:

  • FastAPI는 앱이 커질수록 APIRouter와 공통 의존성 모듈로 나누는 구조를 권장합니다. (FastAPI)
  • 설정은 BaseSettings, .env, @lru_cache() 기반으로 한 군데에서 관리하는 편이 좋습니다. (FastAPI)
  • OpenAI 모델 동작은 스냅샷과 패밀리 사이에서 달라질 수 있으므로, 모델 관련 결정은 흩어놓기보다 adapter 계층에서 통제하는 편이 유리합니다. (OpenAI 개발자)
  • 따라서 라우터는 HTTP, 서비스는 유스케이스, adapter는 외부 연동, repository는 저장소 접근에 집중시키는 구조가 오래 버팁니다.

기능이 많아질수록
“어디에 뭘 넣어야 하지?”가 헷갈리기 시작하는데,
그때 구조를 한 번만 잘 잡아두면 진짜 오래 편합니다.
저는 이게 실력보다도 습관에 더 가깝다고 느껴요.


다음 편 예고

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

Python + FastAPI에서 OpenAI 기능을 멀티모달로 확장해 텍스트, 이미지, 파일 입력을 함께 다루는 방법

이 주제로,

  • 어떤 입력이 텍스트만으로 부족한지
  • 이미지와 파일을 같이 다룰 때 구조를 어떻게 바꿔야 하는지
  • 업로드, 저장, 파싱, 모델 입력 조립을 어떻게 나눌지
  • 멀티모달이 붙어도 구조가 안 무너지게 만드는 법

까지 이어가보겠습니다.


출처

  • FastAPI Bigger Applications - Multiple Files — APIRouter, 공통 dependencies 모듈, 여러 파일 구조 예시. (FastAPI)
  • FastAPI Settings and Environment Variables — BaseSettings, .env, @lru_cache()로 settings를 분리하고 재사용하는 패턴. (FastAPI)
  • OpenAI API Overview — 모델 스냅샷 간 동작 차이 가능성, pinned model versions와 evals 권장. (OpenAI 개발자)

Python, OpenAI, FastAPI, 프로젝트 구조, APIRouter, Pydantic Settings, BaseSettings, dependency injection, adapter pattern, repository pattern, AI 백엔드 구조, 주니어 개발자, FastAPI 아키텍처, OpenAI 프로젝트 설계, 백엔드 모듈화

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
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
글 보관함
반응형