실무 백엔드·AI 연동에서 일주일 안에 다시 보는 Python 체크리스트

실무 백엔드·AI 연동에서 일주일 안에 다시 보는 Python 체크리스트

저는 1999년 전후부터 Java로 시스템을 만들어 온 사람입니다. Python을 다시 붙잡은 이유는 문법이 쉬워서가 아닙니다. RAG 검색기, 임베딩 배치, FastAPI 게이트웨이, 모델 서버 앞단의 스키마 검증이 모두 Python으로 붙어 있고, 그 경계가 무너지면 Java 백엔드가 아무리 단단해도 전체가 흔들립니다. 이 글은 iffor, 클래스 헬로월드를 다시 늘어놓지 않습니다. 이미 다른 언어로 서비스를 내본 사람이, 일주일 안에 손봐야 하는 지점만 적습니다.

Python Control BoundariesENGINEERING STANDARDPython Control BoundariesISP-PY-03L4InterfaceJSON / API 계약L3ValidationPydantic · 예외→HTTPL2Domain서비스 로직 · dataclassL1Operations구조화 로그 · venv/uv검증이 서비스 품질을 결정한다 · AI Strategy ISP
Python Control Boundaries · ISP-PY-03 · 16:9 ISP 인포그래픽

대상은 두 부류입니다. 엔터프라이즈 백엔드를 오래 하다가 LLM 연동을 맡은 사람, 그리고 노트북에서 돌아가던 스크립트를 API로 올려야 하는 사람입니다. 입문 나열은 검색으로 이미 넘칩니다. 판단 기준이 남는 글만 남깁니다.

이 글에서 다루지 않는 것

  • 변수, 리스트 컴프리헨션, 클래스 문법 자체
  • 데이터 분석용 노트북 예제와 pandas 입문
  • 프레임워크 전체 튜토리얼. FastAPI 본편은 REST API 구축 글에 있습니다

여기서 고정하는 것은 다섯 가지입니다. 환경 격리, 타입힌트의 범위, dict와 dataclass와 Pydantic의 경계, API 예외 매핑, 로그. 이 다섯이 무너진 프로젝트는 모델이 좋아도 운영에서 먼저 죽습니다.

1일차. 환경을 고정한다 — venv와 uv

전역 인터프리터에 pip install을 하는 습관은, Java에서 공유 WAS에 아무 JAR나 넣는 것과 같습니다. 사고는 지금이 아니라 세 달 뒤, 다른 사람이 같은 서버에 다른 프로젝트를 올릴 때 납니다.

도구 쓰는 때 쓰지 않는 때
venv + pip 고객 표준이 pip이고, 변경 권한이 없을 때 의존성이 많고 잠금 파일이 없을 때. 재현이 안 됩니다
uv 새 서비스, 로컬 실험, CI에서 해석 속도가 필요할 때 고객 이미지가 특정 pip 절차를 감사 항목으로 고정했을 때
시스템 Python 없습니다. OS 도구용입니다 서비스 런타임. 배포와 개발이 갈라집니다

실무에서 저는 새 저장소를 uv init으로 열고, python-version과 잠금 파일을 커밋합니다. 폐쇄망이면 사내 인덱스를 UV_INDEX_URL로 고정합니다. 노트북에서만 되는 환경은 환경이 아닙니다. 다른 사람이 같은 커밋으로 같은 해시를 받을 수 있어야 환경입니다.

최소 규칙 세 줄만 적습니다. 인터프리터는 3.11 또는 3.12로 고정합니다. 의존성은 애플리케이션과 개발 도구를 나눕니다. CI는 잠금 파일만 설치하고, 해석을 다시 하지 않습니다. 이 세 줄이 없으면 이후의 타입힌트와 스키마는 사람마다 다른 라이브러리 위에서 싸웁니다.

2일차. 타입힌트는 장식이 아니다

Java의 타입은 컴파일러가 강제합니다. Python의 타입힌트는 기본이 문서입니다. 그래서 “전부 달기”는 보통 실패합니다. 도움이 되는 힌트만 답니다.

  • 공개 함수의 인자·반환에는 반드시 답니다. 이 경계가 계약입니다.
  • 외부 JSON이 들어오는 자리는 Pydantic 모델로 올립니다. dict[str, Any]를 컨트롤러에 두지 않습니다.
  • 내부 계산 루프 변수에는 달지 않습니다. 노이즈입니다.
  • Optional| None은 실패 가능 지점에만 씁니다. 기본값을 숨기려고 쓰지 않습니다.
  • 검사는 pyright 또는 mypysrc/에만 겁니다. 노트북과 실험 스크립트는 제외합니다.

힌트가 가치를 만드는 지점은 리팩터링입니다. 검색기 반환을 list[tuple[str, float]]에서 레코드로 바꿀 때, 호출부가 한 번에 드러나야 합니다. 그 반대, 모든 지역 변수에 타입을 다는 작업은 리뷰를 느리게 할 뿐 장애를 줄이지 않습니다.

타입힌트는 Java의 제네릭을 흉내 내는 장식이 아닙니다. “이 함수가 무엇을 받고, 무엇을 보장하지 않는가”를 컴파일 없이 고정하는 계약입니다.

3일차. dict, dataclass, Pydantic을 나누는 기준

현장 코드가 가장 빨리 썩는 지점입니다. 모든 것을 dict로 나르면 키 오타가 런타임에 터지고, 모든 것을 Pydantic으로 올리면 배치와 내부 루프가 무거워집니다. 기준은 데이터가 어디서 끝나느냐입니다.

형태 적합한 자리 부적합한 자리 Java 감각
dict 일회성 변환, 로그 extra, 외부 SDK가 dict를 요구하는 지점 서비스 계층을 가로지르는 전달 객체 Map<String, Object>. 편하고, 한 달 뒤 키가 사라집니다
dataclass 프로세스 내부 레코드, 순수 계산 결과, 테스트 픽스처 HTTP 입출력, 사용자 입력을 그대로 받는 자리 검증 없는 DTO. 빠르고, 경계 검증이 없습니다
Pydantic BaseModel API 요청·응답, 설정 파일, 모델 서버 I/O 초당 수십만 건의 내부 핫패스 Bean Validation이 붙은 DTO. 경계에서만 비용이 정당합니다

실무 규칙으로 줄이면 이렇습니다. 신뢰할 수 없는 입력은 Pydantic, 우리 코드가 만든 값은 dataclass, 남이 만든 SDK와 맞추는 접착면만 dict. 이 순서를 뒤집으면 검증이 안쪽까지 침투하거나, 바깥에서 키가 유실됩니다.

AI 연동에서 자주 보는 실패는 모델 응답을 dict로 받은 뒤 ["choices"][0]["message"]["content"]를 여기저기 복사하는 것입니다. 공급자가 필드를 바꾸는 순간 호출부 전부가 깨집니다. 응답은 한 곳에서 모델로 파싱하고, 내부는 dataclass로만 흐르게 합니다. 프롬프트 문자열이 서비스 계층을 가로지르는 것도 같은 부류의 실수입니다.

4일차. API 서버의 예외 경계

Python에는 checked exception이 없습니다. 그래서 경계가 더 분명해야 합니다. 엔터프라이즈 Java에서는 서비스 예외와 컨트롤러 어드바이스가 나뉩니다. Python API도 같아야 합니다. 도메인 함수가 HTTP 상태 코드를 알 이유가 없습니다.

계층 하는 일 하지 않는 일
검색·모델·도메인 구체적인 예외를 던진다. TimeoutError, 도메인 ValueError HTTPException을 던지지 않는다. 웹 프레임워크를 모른다
API 경계 예외를 상태 코드로 매핑한다. 요청 아이디를 로그에 남긴다 스택을 클라이언트에 그대로 보여 주지 않는다
워커·배치 재시도 가능한 오류와 독성 메시지를 나눈다 except Exception: pass로 삼키지 않는다

매핑 기준은 단순하게 고정합니다. 입력 오류는 422, 인증·권한은 401·403, 의존 시스템 지연은 504, 내부 버그는 500. LLM 호출 실패를 500으로 뭉개면 클라이언트가 재시도 정책을 못 만듭니다. 검색기 타임아웃과 프롬프트 검증 실패는 다른 사건입니다.

except Exception은 로깅 후 다시 던지는 최상위 핸들러에만 둡니다. 그 아래에서 넓게 잡으면 원인 통계가 사라집니다. 폐쇄망 프로젝트에서 장애 리포트를 쓸 때, “Exception 한 줄”만 남은 로그는 분석이 아니라 흔적입니다.

5일차. print를 끊고 logging으로 바꾼다

print는 개발 중 확인용입니다. 워커가 여러 개이면 순서가 섞이고, 레벨이 없고, 요청 아이디가 없습니다. 운영에서 print를 남기는 것은 로그 수집기를 포기하겠다는 뜻입니다.

  • 표준 라이브러리 logging을 기본으로 씁니다. 앱 로거는 모듈 이름으로 엽니다.
  • 요청 아이디, 사용자 식별자, 모델 이름, 지연 시간을 extra로 붙입니다.
  • 컨테이너 환경이면 JSON 한 줄로 포맷합니다. 사람이 읽는 포맷은 로컬만 허용합니다.
  • 프롬프트와 문서 원문은 기본 로그에 넣지 않습니다. 개인정보와 기밀이 섞입니다.
  • 라이브러리 로거는 WARNING 이상으로 올립니다. httpx와 urllib3가 INFO를 쏟습니다.

디버그 출력은 log.debug로 내리고, 프로덕션 기본 레벨은 INFO입니다. “일단 print로 보고 나중에 바꾸자”는 나중에가 오지 않습니다. 코드 리뷰에서 print(가 보이면 머지를 멈춥니다.

6~7일차. FastAPI 인접 최소 골격

프레임워크 전체가 아니라, 앞에서 정한 규칙이 한 엔드포인트에 모이는지만 확인합니다. 검색기를 가정한 짧은 예입니다.

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
import logging

log = logging.getLogger("infer")
app = FastAPI()

class InferReq(BaseModel):
    query: str = Field(min_length=1, max_length=2000)
    top_k: int = Field(default=5, ge=1, le=20)

@app.post("/v1/infer")
def infer(req: InferReq) -> dict:
    try:
        hits = retrieve(req.query, req.top_k)
    except TimeoutError:
        log.exception("retriever timeout")
        raise HTTPException(status_code=504, detail="retriever timeout")
    except ValueError as e:
        raise HTTPException(status_code=422, detail=str(e)) from e
    return {"hits": hits}

이 조각에서 확인할 것은 세 가지뿐입니다. 입력은 Pydantic이 검증합니다. 도메인 함수 retrieve는 HTTP를 모릅니다. 타임아웃과 검증 실패가 다른 상태 코드로 나갑니다. 응답을 다시 모델로 고정할지는 호출자 계약에 따릅니다. 내부 실험이면 dict로 내보내도 되고, 외부에 여는 API면 응답 모델을 따로 둡니다.

배포 직전 하루는 이 엔드포인트에 스키마 스모크 테스트 하나만 붙입니다. 필수 필드가 빠지면 422가 나와야 하고, 검색기가 멈추면 504가 나와야 합니다. 이 두 케이스가 통과하면 일주일 작업의 뼈대는 선 것입니다.

일주일 점검 순서

  1. 런타임 버전과 잠금 파일을 커밋합니다. 전역 pip를 제거합니다.
  2. 패키지 레이아웃을 src/로 모으고, 스크립트에서 경로를 조작하지 않습니다.
  3. HTTP·설정·모델 I/O를 Pydantic으로 올립니다. 내부 레코드는 dataclass로 내립니다.
  4. 공개 함수에만 타입힌트를 달고, pyright를 CI에 넣습니다.
  5. 도메인 예외와 HTTP 매핑을 한 파일로 모읍니다. 광역 except를 지웁니다.
  6. 모든 print를 검색해 로거로 바꿉니다. 요청 아이디를 미들웨어에서 넣습니다.
  7. 엔드포인트 하나에 입력 검증 실패와 의존 시스템 실패 테스트를 붙입니다.

이 순서를 건너뛰고 프롬프트부터 만지면, 데모는 되고 운영은 안 됩니다. 모델 품질 논의는 그 다음입니다.

Takeaway

  • Python 실무의 핵심은 문법이 아니라 경계입니다. 환경, 스키마, 예외, 로그가 경계입니다.
  • dict는 접착면, dataclass는 내부 레코드, Pydantic은 신뢰할 수 없는 입력에 둡니다.
  • 타입힌트는 공개 계약에만 답니다. 전부 다는 일은 보통 유지되지 않습니다.
  • API 서버에서 도메인 코드는 HTTP를 몰라야 합니다. 상태 코드는 한곳에서 매핑합니다.
  • print는 머지하지 않습니다. 로그에 요청 아이디가 없으면 장애 분석이 시작되지 않습니다.
  • uv는 새 프로젝트의 기본값으로 두고, 고객 표준이 pip이면 잠금 파일만 양보합니다.

같은 톤의 API 구현은 Python으로 REST API 서버를 빠르게 구축하는 방법에 이어집니다. 오라클·온체인 연동 쪽은 부동산 유동화 NFT 논문 해설에서 Flask 오라클을 비판적으로 다시 읽습니다.

8일차. 동기 코드로 LLM·검색 호출을 묶으면 처리량이 죽는다

AI 연동 백엔드에서 가장 흔한 실수는 임베딩 API, LLM 호출, 벡터 검색을 동기 requests로 순차 호출하는 것입니다. 각 호출이 200~2000ms가 걸리는 I/O 바운드 작업인데, 동기로 묶으면 동시 요청이 늘어날수록 워커가 그대로 블로킹됩니다. FastAPI를 쓴다면 이 호출들은 async def 안에서 httpx.AsyncClient 같은 비동기 클라이언트로 감싸야 이벤트 루프가 다른 요청을 처리할 여유를 가집니다.

import httpx

async def call_llm(prompt: str) -> str:
    async with httpx.AsyncClient(timeout=30.0) as client:
        resp = await client.post(LLM_ENDPOINT, json={"prompt": prompt})
        resp.raise_for_status()
        return resp.json()["text"]

# 검색과 LLM 호출이 서로 독립적이면 동시에 실행합니다
import asyncio
retrieval_task = asyncio.create_task(retrieve_async(query))
draft_task = asyncio.create_task(call_llm(query))
hits, draft = await asyncio.gather(retrieval_task, draft_task)

동기 라이브러리(requests, 동기 DB 드라이버)를 async def 안에서 그대로 호출하면 오히려 동기 버전보다 느려질 수 있습니다. 이벤트 루프 전체가 그 호출이 끝날 때까지 멈추기 때문입니다. 부득이하게 동기 라이브러리를 써야 한다면 run_in_executor로 스레드 풀에 위임해 이벤트 루프를 막지 않게 합니다.

9일차. LLM 호출을 흉내 내는 테스트

실제 모델 API를 호출하는 테스트는 느리고, 비용이 들고, 응답이 매번 달라 재현이 안 됩니다. pytest의 fixture와 monkeypatch로 외부 호출을 대체하는 것이 실무 표준입니다.

import pytest

@pytest.fixture
def mock_llm(monkeypatch):
    async def fake_call(prompt: str) -> str:
        return "고정된 테스트 응답"
    monkeypatch.setattr("app.services.call_llm", fake_call)

def test_infer_endpoint(client, mock_llm):
    resp = client.post("/v1/infer", json={"query": "테스트"})
    assert resp.status_code == 200

여기서 확인할 것은 모델이 “정답을 맞히는가”가 아닙니다. 모델 응답이 비어 있거나 예상 스키마와 다를 때 API 경계가 올바른 상태 코드로 반응하는가입니다. 실제 모델 품질 검증(골든셋 기반 정확도 측정)은 별도의 평가 파이프라인에서 하고, 단위 테스트는 어디까지나 코드 경계의 계약을 지키는지만 확인합니다.

패키징과 CI — 잠금 파일만으로 끝나지 않는 것들

일주일 점검 순서의 1일차에서 잠금 파일 커밋까지는 다뤘지만, CI 파이프라인에서 추가로 고정해야 할 것이 있습니다. 첫째, 잠금 파일 해시가 실제 설치된 패키지와 일치하는지 CI에서 검증합니다(uv라면 uv sync –frozen이 잠금 파일과 다르면 실패하게 만듭니다). 둘째, pyright·ruff 같은 정적 검사를 PR 게이트에 걸어, 타입힌트가 빠진 공개 함수가 병합되지 않게 합니다. 셋째, print( 검색을 CI 스크립트에 넣어 로깅 규칙 위반을 자동으로 잡습니다. 코드 리뷰에서 사람이 매번 확인하는 항목은 결국 놓치게 됩니다.

Takeaway 추가

  • I/O 바운드 호출(LLM, 검색, 외부 API)은 async/await로 묶고, 독립적인 호출은 asyncio.gather로 동시 실행해야 처리량이 유지됩니다.
  • 동기 라이브러리를 async 함수 안에서 그대로 호출하면 이벤트 루프가 멈춰 오히려 동기 버전보다 느려질 수 있습니다.
  • LLM 호출은 단위 테스트에서 monkeypatch로 대체하고, 실제 모델 품질은 별도의 골든셋 평가로 검증합니다.
  • 잠금 파일 검증, 정적 타입 검사, print 검색 스크립트를 CI 게이트에 넣어 규칙 위반이 코드 리뷰어의 기억력에 의존하지 않게 합니다.

댓글 남기기