검색/AI에게 FastAPI 서비스를 만들게 하고, 리뷰에서 흔한 실수 찾기

AI에게 FastAPI 서비스를 만들게 하고, 리뷰에서 흔한 실수 찾기

AI에게 FastAPI 서비스를 만들거나 리뷰하게 할 때 쓴다. 최소 버전, 비동기·동기 라우트 선택, Pydantic 모델과 의존성 주입 작성법, 데이터베이스와 테스트 방법을 정하고, 코드 차이에서 찾아야 할 흔한 실수와 고치는 법을 표로 보여 준다.

이 AI 규칙을 사용하려면
## Python·FastAPI·라이브러리의 최소 버전 정하기 모든 의존성을 아래 버전 이상으로 고정한다. 이 문서의 모든 가이드는 이 기준선을 전제로 한다. 더 낮은 버전을 쓰면 뒤에서 다루는 관용구들 — 비동기 API, `Annotated` 의존성, v2 직렬화 — 이 조용히 깨진다. | 의존성 | 최소 버전 | 이 기준이 필요한 이유 | |---|---|---| | Python | 3.11 | `StrEnum`과 `X | Y` 유니온 연산자 | | FastAPI | 0.115 | `Annotated[T, Depends(...)]`가 표준 관용구가 됨 | | Pydantic | 2.7 | v1 API(`json_encoders`, `.dict()`)가 제거됨 | | pydantic-settings | 2.4 | Pydantic v2부터 별도 패키지로 분리됨 | | SQLAlchemy | 2.0 | 비동기 API(`AsyncSession`, `async_sessionmaker`) 제공 | | Alembic | 1.13 | 비동기를 인식하는 마이그레이션 | | httpx | 0.27 | 인프로세스 테스트용 `ASGITransport` | | PyJWT | 2.9 | 유지보수 중; `python-jose`를 대체함 | | ruff | 0.6 | 린트·포맷·임포트 정렬을 하나로 처리하는 도구 | ## 파일 종류가 아니라 도메인별로 폴더 나누기 디렉터리 구조는 파일 종류가 아니라 도메인(바운디드 컨텍스트) 기준으로 정리한다. 각 도메인에 자신만의 패키지를 주고, 공통 관심사를 위한 얇은 전역 레이어만 따로 둔다. - 도메인별 패키지: `router.py`(엔드포인트), `schemas.py`(Pydantic 모델), `models.py`(ORM 모델), `service.py`(비즈니스 로직), `dependencies.py`, `config.py`(도메인 범위 설정), `constants.py`, `exceptions.py`, `utils.py`. - 전역 최상위: `config.py`(전역 설정), `models.py`(공유 베이스 클래스), `exceptions.py`, `database.py`(비동기 엔진과 세션 팩토리), `main.py`(앱과 lifespan). - 도메인 간 임포트: 항상 모듈을 명시적으로 임포트하고 별칭을 붙인다(예: 어떤 도메인의 `service`를 `auth_service`로 임포트). 와일드카드 임포트나 깊은 내부 경로를 직접 참조하는 방식은 절대 쓰지 않는다 — 둘 다 도메인을 강하게 결합시키고 리팩터링을 막는다. ## 앱이 멈추지 않게 async def와 def 라우트 구분하기 라우트가 실제로 하는 작업에 맞춰 스타일을 선택한다. 잘못 고르면 이벤트 루프가 멈추거나 스레드를 낭비하게 된다. | 라우트가 하는 작업 | 선택 | |---|---| | await 가능한 논블로킹 I/O | `async def` | | 블로킹 I/O이고 비동기 클라이언트가 없음 | `def`(동기, 스레드풀에서 실행) | | 두 가지가 섞인 경우 | `async def` + 블로킹 호출에는 `run_in_threadpool` | | 약 50ms를 넘는 CPU 바운드 작업 | 워커 프로세스로 위임(Celery / Arq / RQ) | `time.sleep`, `requests`, 동기 DB 드라이버, 일반 `open()` 같은 블로킹 호출은 절대 `async def` 안에 넣지 않는다 — 그 워커의 모든 요청이 멈춰버린다. 동기 `def` 라우트는 스레드풀 워커 하나만 막기 때문에, 피할 수 없는 블로킹 호출을 두기에 안전한 자리다. `async def` 안에서는 await 가능한 대응 함수(`asyncio.sleep`, `aiofiles`, 비동기 드라이버)를 쓰거나, 동기 라이브러리를 `run_in_threadpool`로 감싼다. 주의할 점: 기본 스레드풀은 약 40개 스레드뿐이라 가득 차면 모든 동기 라우트가 느려지고, 스레드는 코루틴보다 비용이 크다 — 블로킹할 이유가 없다면 동기 라우트를 고르지 않는다. ## Pydantic으로 입력을 검사하고 설정을 도메인별로 나누기 - 직접 만든 검증 로직보다 내장 검증기와 타입을 우선 사용한다: `Field(min_length=, max_length=, pattern=, ge=)`, `EmailStr`, `AnyUrl`, 그리고 닫힌 집합에는 `StrEnum`. - 제약 조건과 모순되는 기본값을 함께 쓰지 않는다. `Field(ge=18, default=None)`은 버그다: 필수(`Field(ge=18)`)로 할지 선택적(`int | None = Field(default=None, ge=18)`)으로 할지 정한다. - 최신 방식의 직렬화를 사용한다. `json_encoders`는 Pydantic v2에서 폐기(deprecated)되었으므로, 필드별 규칙에는 `@field_serializer`로, 또는 타입에 `PlainSerializer`를 붙여 대체한다. - 설정을 도메인별로 나눈다. `pydantic-settings`는 별도 패키지이며, 모든 도메인이 읽는 하나의 전역 설정 객체 대신 각 도메인에 고유한 `env_prefix`를 가진 `BaseSettings` 서브클래스를 하나씩 준다. ## Annotated로 의존성을 주입하고 데이터 검사까지 맡기기 - `Annotated[T, Depends(fn)]` 형태로 주입한다. FastAPI 0.95 이후 표준 관용구다. 기본 인자 형태(`param: T = Depends(fn)`)는 피한다 — 레거시 방식이고 기본값 관련 함정이 있다. - 의존성이 단순 주입만이 아니라 검증까지 하게 만든다. 의존성이 id로 리소스를 불러오고, 없으면 도메인 404를 던지고, 검증된 객체를 반환하면 라우트는 이미 확인된 값을 받게 된다. - 의존성을 체이닝해서 로직을 재사용한다. 하나의 의존성이 다른 의존성에 의존할 수 있다 — 예를 들어 소유권 검사가 id 기반 로드 의존성과 토큰 파싱 의존성 둘 다에 의존하는 식이다. - 규칙: 의존성은 요청 단위로 캐시되므로, 같은 `Depends(x)`를 한 요청 안에서 여러 번 써도 한 번만 실행된다. 의존성은 `async def`를 우선한다 — 동기 의존성은 스레드풀에서 실행되어, 작은 CPU 전용 검사에는 불필요한 오버헤드가 된다. 공유하는 경로 변수에 같은 이름을 붙이면 여러 엔드포인트에서 의존성을 재사용할 수 있다. ## JWT 인증과 비동기 데이터베이스 접근 작성하기 - JWT: 더 이상 유지보수되지 않는 `python-jose` 대신 `PyJWT`(`import jwt`)를 쓴다. `InvalidTokenError`에 대한 가드 안에서 디코딩하고, 원본 예외가 그대로 새어나가게 두지 말고 도메인 자격 증명 오류를 던진다. - 세션: SQLAlchemy 2.0의 비동기 API(`create_async_engine`, `async_sessionmaker`, `AsyncSession`)를 우선 사용하고, `get_db` 비동기 제너레이터 의존성을 통해 라우트에 세션을 넘긴다. `encode/databases`는 유지보수 모드에 들어갔으므로 신규 프로젝트에는 선택하지 않는다. - 네이밍: `lower_case_snake`를 쓴다. 테이블명은 단수형(`post`, `user`, `post_like`)으로 한다. 관련 테이블은 공통 접두사로 묶는다(`payment_account`, `payment_bill`). datetime에는 `_at`, date에는 `_date` 접미사를 붙인다. 외래 키 컬럼명은 등장하는 모든 곳에서 동일하게 재사용한다. - `MetaData`에 프로젝트 전체에 적용되는 인덱스·제약 조건 네이밍 컨벤션을 하나만 정해서, 생성되는 이름이 예측 가능하게 유지한다. - SQL 우선, Pydantic은 그다음: 조인·집계·JSON 형태 변환은 SQL에서 처리하고, Pydantic으로의 하이드레이션은 데이터 변환용이 아니라 응답 검증용으로만 사용한다. ## 백그라운드 태스크와 Celery 같은 태스크 큐 중에서 고르기 작업의 비용과 신뢰성 요구 수준에 맞는 도구를 고른다. | 인프로세스 백그라운드 태스크를 쓸 때 | 실제 큐(Celery / Arq / RQ)를 쓸 때 | |---|---| | 작업이 1초 이내에 끝날 때 | 작업이 몇 초에서 몇 분까지 걸릴 때 | | 조용히 유실돼도 괜찮을 때 | 재시도, 데드레터, 가시성이 필요할 때 | | 인프로세스로 처리 가능할 때(메일 발송, 행 로깅) | CPU 부하가 크거나 전용 풀이 필요할 때 | | 스케줄링이 필요 없을 때 | cron, ETA, 속도 제한이 필요할 때 | 인프로세스 백그라운드 태스크는 응답이 전송된 뒤 같은 워커 프로세스 안에서 실행된다. 그 워커가 죽으면 태스크는 그대로 사라지고 재시도도 없다. 그러니 온콜 엔지니어를 호출할 만한 중요한 작업에는 절대 쓰지 않는다. ## httpx와 실제 데이터베이스로 테스트하기 - 처음부터 비동기 HTTP 클라이언트를 사용한다: `ASGITransport(app=app)` 위에서 `httpx.AsyncClient`로 앱을 인프로세스로 구동한다. `async_asgi_testclient`는 더 이상 유지보수되지 않으므로 쓰지 않는다. - 내부를 몽키패칭하는 대신 프레임워크에 내장된 `app.dependency_overrides[dep] = fake`로 의존성을 오버라이드하고, 각 테스트 후에는 오버라이드를 초기화한다. - 통합 테스트에는 실제 데이터베이스(임시 스키마나 일회용 컨테이너)를 쓰고, 인증과 외부 서비스만 오버라이드한다. 데이터베이스를 모킹하면 목(mock)과 운영 환경 사이의 차이가 가려지고, 그 차이는 결국 운영 환경에서 터진다. ## 마이그레이션을 되돌릴 수 있게 하고 API마다 문서 쓰기 - 마이그레이션: 모든 마이그레이션을 정적이고 되돌릴 수 있게 유지하고, 비동기 템플릿에서 생성하며, 이력이 순서대로 읽히도록 설명적이고 타임스탬프가 붙은 파일명(날짜 접두사 + 슬러그)을 사용한다. - API 문서: 현재 환경이 허용 목록에 없을 때는 OpenAPI URL을 `None`으로 설정해 지정한 환경 밖에서는 대화형 문서를 숨긴다. 각 엔드포인트는 응답 모델, 상태 코드, summary, description, 태그, 그리고 오류 상태마다 하나씩의 응답 항목까지 빠짐없이 문서화한다. - 린팅: 포맷터와 린터를 실행하고(예: `ruff check --fix` 후 `ruff format`), 이를 pre-commit 훅이나 CI에 연결한다. 하나의 최신 도구가 별도였던 포맷터, 임포트 정렬기, 데드코드 탐지 도구를 대체한다. ## FastAPI 코드 차이에서 AI의 흔한 실수를 찾아 고치기 diff를 리뷰할 때 에이전트가 저지르기 쉬운 이런 실수들을 짚어낸다. 각 행은 실제로 발생하는 실패 패턴과 그 해결책이다. | 안티패턴 | 잘못된 이유 | 해결책 | |---|---|---| | `async def` 안의 `requests.get(...)` | 이벤트 루프를 블로킹함 | `httpx.AsyncClient` 또는 `await run_in_threadpool(...)` | | `async def` 안의 `time.sleep` / `open()` / 동기 DB 드라이버 | 루프를 블로킹함 | 비동기 대응 함수(`asyncio.sleep`, `aiofiles`, 비동기 드라이버) | | `from jose import jwt` | 유지보수되지 않는 라이브러리 | `import jwt`(PyJWT) | | `async_asgi_testclient` | 유지보수되지 않음 | `httpx.AsyncClient` + `ASGITransport` | | `ConfigDict(json_encoders=...)` | Pydantic v2에서 폐기됨 | `@field_serializer` 또는 `PlainSerializer` | | `Field(ge=18, default=None)` | 제약 조건이 기본값과 모순됨 | 필수 또는 선택 중 하나로만 정하기 | | 기본 인자 형태의 `Depends(...)` | 레거시 방식의 기본값 함정 | `Annotated[T, Depends(...)]` | | 라우트 본문을 감싼 맨 `Exception` 캐치 | 버그를 숨기고 500 오류를 조용한 200으로 바꿔버림 | 구체적인 예외 클래스를 잡고 명시적인 HTTP 오류를 던지기 | | 호출이 필요한 중요 작업에 백그라운드 태스크 사용 | 재시도 없고 워커와 함께 죽음 | Celery / Arq / RQ | | `async def` 안의 동기 ORM 세션 | 루프를 블로킹하고 풀을 데드락시킬 수 있음 | `AsyncSession` | | `response_model`을 설정한 채로 그 모델을 그대로 반환 | 모델이 두 번 생성됨 | dict나 row를 반환하거나 `response_model`을 제거하기 | | 깊은 도메인 간 임포트 | 강한 결합 | 도메인 모듈을 임포트하고 별칭 붙이기 | | 앱 전체에 하나의 `BaseSettings` | 모든 도메인이 모든 변수를 읽게 됨 | 도메인마다 별도의 `BaseSettings` | | 통합 테스트에서 데이터베이스 모킹 | 목과 운영 환경의 차이 발생 | 실제 DB + 인증·외부 서비스만 의존성 오버라이드 |