동적 실행 엔진라우팅FastAPI진단

Mount 접두어 매칭이 라우트를 가릴 때

배포도 성공했고 라우트도 등록됐는데 호출은 404 였다. FastAPI 의 Mount 가 접두어로 첫 매치를 쓰기 때문이고, 별칭에 슬래시를 허용한 것이 원인이었다.

2026년 9월 1일4분

한 고객사에서 신규 에이전트의 API 호출이 항상 404 를 반환한다는 문의가 왔다.

POST /agent/api/v2/a1/main
-> {"detail":"Not Found"}

확인할 수 있는 건 전부 정상이었다. 관리 콘솔의 상태는 배포완료였고, API 접근 토글은 켜져 있었고, 등록 API 의 응답은 200 이었다. 같은 사이트의 다른 에이전트는 멀쩡했다.

POST /agent/api/a10/test   -> 200

게이트웨이는 아니었다

404 면 보통 앞단부터 본다. 라우팅 규칙이 잘못됐거나 인그레스가 경로를 못 찾는 경우다.

그런데 파드 로그에 응답시간 24ms 로 요청이 남아 있었으니, 요청은 앞단을 통과해 애플리케이션까지 도달한 것이었다. 응답 바디도 {"detail":"Not Found"}, 우리가 쓰는 응답 봉투와 다른 FastAPI 의 기본 404 포맷이었다.

앱이 요청을 받고 라우트를 못 찾았다고 답한 건데, 그 라우트는 등록돼 있었다.

마운트는 접두어로 매칭된다

이 엔진은 사용자가 등록한 에이전트마다 별도의 FastAPI 서브앱을 만들고, 별칭을 경로로 삼아 루트 앱에 붙인다.

app.mount(f"/{agent_alias}", subapp)

문제의 사이트에는 별칭이 이런 두 에이전트가 있었다.

에이전트별칭마운트 경로
첫 번째api/api
두 번째api/v2/api/v2

Starlette 의 라우터는 등록된 라우트를 순서대로 순회하며 첫 매치를 사용하고, Mount 는 접두어로 매칭한다.

클라이언트루트 앱/api 서브앱/api/v2 서브앱POST /api/v2/a1/main첫 매치 — 접두어 /api 일치남은 경로 /v2/a1/main 을 찾는다404 · FastAPI 기본 포맷
1 / 4

01두 번째 에이전트를 호출하려는 요청이다.

배포는 성공했고 라우트도 있는데, 앞선 마운트에 가려져서 호출되지 않는 것이다.

마운트 순서를 바꿔서 재현했다

프로젝트 가상환경에서 재현해 봤다.

from fastapi import FastAPI
from fastapi.testclient import TestClient
 
sa = FastAPI()
@sa.post("/a10/test")
def _a(): return {"ok": 1}
 
sb = FastAPI()
@sb.post("/a1/main")
def _b(): return {"ok": 2}
 
app = FastAPI()
app.mount("/api", sa)      # 짧은 경로 먼저
app.mount("/api/v2", sb)
 
c = TestClient(app)
c.post("/api/a10/test")    # 200
c.post("/api/v2/a1/main")  # 404
같은 두 에이전트, 마운트 순서만 다르게
요청
POST /api/v2/a1/main
↓
응답
/api/a10/test200
/api/v2/a1/main404 · /api 서브앱이 가로챈다
실패운영에서 관측된 증상과 동일하다.

결과가 마운트 순서에 달려 있는데, 마운트 순서는 파드가 재시작할 때 배포 상태를 복원하는 순서에서 나오고 그 순서는 보장되지 않는다. 그래서 파드를 재시작하면 증상이 사라졌다가 다음 재시작에 다시 나타날 수도 있다.

마운트를 정렬할지, 별칭을 막을지

등록할 때 슬래시가 든 별칭을 거부하는 방법이 있었고, 마운트를 경로 길이 내림차순으로 정렬해서 긴 경로가 먼저 매칭되게 하는 방법이 있었다.

정렬 쪽이 더 근본적으로 보였다. 어떤 별칭이 들어와도 안전하니까. 하지만 에이전트는 런타임에 계속 추가되고 삭제되니 그때마다 루트 앱의 라우트 목록을 재정렬해야 하고, 마운트 해제와 재마운트 사이에 요청이 들어오면 경합이 생긴다. 트래픽을 받는 중에 라우팅 테이블을 흔드는 코드를 계속 안고 가야 한다.

그래서 정렬이 실제로 무엇을 막아주는지 확인해 봤다.

app.mount("/api",   ...)   # 먼저
app.mount("/apiv2", ...)   # 나중
-> /api/x    200
-> /apiv2/y  200

Starlette 의 Mount 는 세그먼트 경계에서 매칭해서, /api 는 /apiv2/y 를 가로채지 않는다. 접두어라는 말이 문자열 접두어가 아니라 경로 접두어라는 뜻이었다.

그러면 충돌은 별칭 하나가 다른 별칭의 경로 접두어일 때만 나고, 그건 별칭에 슬래시가 있어야만 가능하다. 별칭을 단일 세그먼트로 제한하면 충돌이 생길 수 없고, 정렬은 아무것도 바꾸지 않는 코드가 된다.

운영 중인 슬래시 별칭이 없다는 것도 확인했다. 문제의 api/v2 는 테스트하려고 처음 만들어진 것이었다. 정렬은 넣지 않고, 등록할 때 슬래시가 든 별칭을 거부하게 했다.

등록에서 막아야 했던 이유

기존에는 별칭 필드가 문자열로만 선언돼 있고 형식 검증이 없었다. 등록할 때 확인하는 건 같은 에이전트의 기존 별칭과 일치하는지 하나뿐이라, 슬래시가 든 별칭이 그대로 통과했다.

잘못된 입력이 성공으로 기록된 게 문제를 키웠다. 등록 API 는 200 을 돌려줬고, 콘솔은 배포완료를 표시했고, 라우트도 실제로 만들어져서 사용자가 볼 수 있는 지표는 전부 정상이었다. 잘못됐다는 신호는 몇 시간 뒤 다른 에이전트를 호출할 때 404 로 나타났고, 원인과는 아무 관련 없어 보이는 곳이었다.

이제는 등록 단계에서 막으니 새 충돌은 생기지 않는다. 다만 콘솔이 말하는 배포 성공은 "파일이 쓰였고 라우트가 등록됨"까지라서, "그 라우트에 실제로 도달할 수 있는지"는 아무도 확인하지 않는다. 배포 후에 자기 자신을 호출해 보는 검사가 있었다면 이 장애는 등록 직후에 드러났을 것이다.

순서에 따라 나는 결함이라 테스트로 미리 잡기도 어렵다. 위 재현 코드도 원인을 안 뒤에 쓴 것이고, 원인을 모르는 상태에서 잡으려면 두 에이전트를 특정 순서로 등록하는 테스트를 미리 떠올려야 했다. Mount 가 접두어로 매칭하고 첫 매치를 쓴다는 건 문서에도 나와 있는데, 그게 별칭에 슬래시를 허용하면 어떤 에이전트는 영영 도달할 수 없다는 뜻이라는 건 재현해 보고서야 연결됐다.

끝