main.py가 URL 경로를
하나도 모르게 만든다
여덟 줄짜리 파일이고 함수도 class도 없다. 3단계에서 채팅 route가 늘었을 때 바뀐 곳은 이 파일 두 줄뿐이고 main.py는 그대로였다. 조립의 중간 층이다.
이 파일을 만든 이유
main.py는 application을 조립하고, routes/system.py는 /healthz와 /readyz를 구현한다. 둘을 직접 연결하면 main.py가 from app.api.routes import system을 하고 include_router(system.router)를 부르게 된다. endpoint 묶음이 늘어날 때마다 main.py의 import와 호출이 함께 늘어난다.
이 파일은 그 증가를 한 곳에 가둔다. main.py는 언제나 api_router 하나만 붙이고, 새 endpoint 묶음은 여기에서만 추가된다. 채팅·인증·대화 저장 route가 생기는 3~7단계에서 main.py는 한 글자도 바뀌지 않는다.
전체 구조에서의 위치
세 층 모두 APIRouter 라는 같은 도구를 쓴다. 다른 것은 역할뿐이다. 아래층으로 갈수록 구체적인 URL을 알고, 위층으로 갈수록 모른다.
전체 코드
from fastapi import APIRouter
from app.api.routes import chat, system
api_router = APIRouter()
api_router.include_router(system.router)
api_router.include_router(chat.router, prefix="/api/v1")
import 두 줄, 객체 생성 한 줄, 등록 두 줄이다. def도 class도 없으므로 이 파일은 import 되는 순간 전부 실행된다.
한 줄씩 해석
from fastapi import APIRouter
FastAPI가 제공하는 route 묶음 객체다. FastAPI() application과 달리 스스로 HTTP server를 열지 않는다. 경로와 처리 함수의 쌍을 보관하다가, 누군가 include_router로 가져갈 때 실제 application에 복사된다.
from app.api.routes import system
두 module을 통째로 가져온다. from ... import router로 객체만 가져오지 않은 이유가 3단계에서 그대로 드러났다. system.py와 chat.py가 둘 다 router라는 이름을 쓰기 때문에, 객체만 가져왔다면 이름이 충돌한다. module 이름을 남겨 두면 system.router·chat.router로 구분된다.
api_router = APIRouter()
비어 있는 묶음을 만든다. 이 시점에는 경로가 하나도 없다. 인수를 주지 않았으므로 prefix도 tags도 없다.
api_router.include_router(system.router)
등록이 두 줄이고 서로 다르게 붙는다. 상태 route는 prefix 없이 붙어 경로가 /healthz·/readyz 그대로다. 채팅 route는 prefix="/api/v1"과 함께 붙어 chat.py에 선언된 /chat이 /api/v1/chat이 된다.
접두사를 chat.py가 아니라 여기에서 주는 이유는, 버전 경로가 route 하나의 성질이 아니라 묶음 전체의 배치이기 때문이다. 나중에 /api/v2로 옮길 때 고칠 곳이 이 한 줄이다.
실행 순서 — 언제 무엇이 일어나는가
uvicorn app.main:app
└ import app.main
└ import app.api.router ← 이 파일
├ import app.api.routes.system
│ @router.get("/healthz") 규칙이 system.router 에 등록
│ @router.get("/readyz")
└ import app.api.routes.chat
@router.post("/chat") 규칙이 chat.router 에 등록
api_router = APIRouter() 빈 묶음 생성
include_router(system.router) → /healthz /readyz
include_router(chat.router, prefix="/api/v1") → /api/v1/chat
create_app()
application.include_router(api_router) 규칙 3개가 application 으로 복사즉 규칙은 세 번 복사되며 전부 서버가 뜨기 전에 끝난다. 요청이 들어올 때마다 이 파일이 실행되는 일은 없다.
입력과 출력
| 구분 | 내용 |
|---|---|
| 입력 | app.api.routes.system module이 가진 router 객체 하나 |
| 출력 | module 수준 변수 api_router — /healthz와 /readyz 규칙을 담은 APIRouter |
| 부작용 | 없음. 네트워크도 파일도 건드리지 않는다 |
| 소비자 | main.py의 application.include_router(api_router) |
실제로 확인하는 방법
Python 대화형 실행으로 묶음 안의 route를 직접 볼 수 있다. 서버를 띄우지 않아도 된다.
cd be
.venv/bin/python -c "
from app.main import create_app
paths = create_app().openapi()['paths']
for path, operations in sorted(paths.items()):
for method in operations:
print(method.upper(), path)
"
POST /api/v1/chat
GET /healthz
GET /readyz
/chat이 /api/v1/chat으로 바뀌어 있는 점이 핵심이다. chat.py는 /chat이라고만 선언했고, 접두사는 이 파일이 붙였다. 목록이 비어 있다면 include_router 호출이 빠진 것이다.
자주 발생하는 오류
| 증상 | 원인 | 진단 |
|---|---|---|
| 모든 경로가 404 | main.py에서 include_router(api_router)를 빠뜨림 | 위 명령으로 api_router.routes는 비어 있지 않은데 /healthz가 404면 조립 단계 문제다 |
| ImportError: circular import | routes/system.py가 app.main을 import | route는 application을 import하면 안 된다. 필요한 값은 request.app.state나 Depends로 받는다 |
| 경로가 중복 등록 | 같은 router를 두 번 include_router | FastAPI는 막지 않는다. 먼저 등록된 규칙이 이긴다 |
| prefix가 두 번 붙음 | 이 파일과 main.py 양쪽에서 prefix 지정 | prefix는 한 층에서만 준다 |
설계 선택과 대안
이 파일을 없애고 main.py에서 직접 system.router를 붙이면?
지금은 동작이 같다. 차이는 12단계까지 갔을 때 드러난다. route 묶음이 5~6개가 되면 main.py의 import 목록과 include_router 호출이 그만큼 늘어나고, application 조립 코드와 URL 목록이 한 파일에서 섞인다. 이 파일은 그 둘을 분리해 둔다.
왜 prefix="/api"를 지금 주지 않는가?
/healthz와 /readyz는 container orchestrator와 load balancer가 호출하는 운영용 endpoint다. 버전이 붙은 애플리케이션 API와 성격이 다르므로 접두사 없이 root에 둔다.
3단계에서 실제로 그렇게 갈라졌다. 채팅 묶음에만 prefix="/api/v1"을 주었고 상태 route는 그대로 root에 남았다. 한 묶음에 한 접두사라는 규칙 덕분에 chat.py는 자기 경로가 어느 버전 아래에 놓일지 몰라도 된다.
tags는 왜 여기서 주지 않는가?
routes/system.py가 APIRouter(tags=["system"])로 이미 지정했다. 태그는 endpoint의 성격이므로 endpoint를 정의한 곳에 두는 편이 가깝다. 여기서 다시 주면 두 태그가 겹쳐 /docs 화면이 중복 분류된다.
이전 단계와 다음 파일
main.py는 이 파일이 만든 api_router를 받아 application에 붙였다. 그 다음 질문은 “route가 실행될 때 추론 engine을 어떻게 건네받는가”이며, 답은 dependencies.py에 있다. 실제 /healthz·/readyz 구현은 routes/system.py에서 읽는다.