서비스 학습
SOURCE · be/app/api/router.py · 2026-09-06

main.py가 URL 경로를
하나도 모르게 만든다

여덟 줄짜리 파일이고 함수도 class도 없다. 3단계에서 채팅 route가 늘었을 때 바뀐 곳은 이 파일 두 줄뿐이고 main.py는 그대로였다. 조립의 중간 층이다.

이 파일을 만든 이유

main.py는 application을 조립하고, routes/system.py/healthz/readyz를 구현한다. 둘을 직접 연결하면 main.pyfrom app.api.routes import system을 하고 include_router(system.router)를 부르게 된다. endpoint 묶음이 늘어날 때마다 main.py의 import와 호출이 함께 늘어난다.

이 파일은 그 증가를 한 곳에 가둔다. main.py는 언제나 api_router 하나만 붙이고, 새 endpoint 묶음은 여기에서만 추가된다. 채팅·인증·대화 저장 route가 생기는 3~7단계에서 main.py는 한 글자도 바뀌지 않는다.

전체 구조에서의 위치

Application 조립main.pyinclude_router(api_router) 한 번
모으는 층api/router.py여러 route 묶음을 하나로
구현routes/system.py@router.get("/healthz")

세 층 모두 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 두 줄, 객체 생성 한 줄, 등록 두 줄이다. defclass도 없으므로 이 파일은 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.pychat.py둘 다 router라는 이름을 쓰기 때문에, 객체만 가져왔다면 이름이 충돌한다. module 이름을 남겨 두면 system.router·chat.router로 구분된다.

api_router = APIRouter()

비어 있는 묶음을 만든다. 이 시점에는 경로가 하나도 없다. 인수를 주지 않았으므로 prefixtags도 없다.

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.pyapplication.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 호출이 빠진 것이다.

자주 발생하는 오류

증상원인진단
모든 경로가 404main.py에서 include_router(api_router)를 빠뜨림위 명령으로 api_router.routes는 비어 있지 않은데 /healthz가 404면 조립 단계 문제다
ImportError: circular importroutes/system.pyapp.main을 importroute는 application을 import하면 안 된다. 필요한 값은 request.app.stateDepends로 받는다
경로가 중복 등록같은 router를 두 번 include_routerFastAPI는 막지 않는다. 먼저 등록된 규칙이 이긴다
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.pyAPIRouter(tags=["system"])로 이미 지정했다. 태그는 endpoint의 성격이므로 endpoint를 정의한 곳에 두는 편이 가깝다. 여기서 다시 주면 두 태그가 겹쳐 /docs 화면이 중복 분류된다.

이전 단계와 다음 파일

main.py는 이 파일이 만든 api_router를 받아 application에 붙였다. 그 다음 질문은 “route가 실행될 때 추론 engine을 어떻게 건네받는가”이며, 답은 dependencies.py에 있다. 실제 /healthz·/readyz 구현은 routes/system.py에서 읽는다.

← 이전Lifespan에서 조립다음 →Engine 의존성 주입