coredot.today
첫 FastAPI 앱과 라우팅 — 주소를 함수로 잇는 법 (FastAPI 입문 2편)
블로그로 돌아가기
FastAPIPython라우팅경로 매개변수쿼리 매개변수타입 힌트422uvicornfastapi dev입문튜토리얼

첫 FastAPI 앱과 라우팅 — 주소를 함수로 잇는 법 (FastAPI 입문 2편)

설치부터 fastapi dev로 첫 서버를 띄우기까지, 그리고 FastAPI의 가장 기본인 라우팅 — @app.get 데코레이터, 경로 매개변수, 쿼리 매개변수, 타입 힌트로 자동 검증되는 422 에러, 라우트 순서 함정, 405와 307 — 을 실제 실행 결과와 함께 설명한다. 주소를 입력하면 어느 함수로 가는지 보여 주는 라우트 매칭기 위젯으로 직접 확인해 볼 수 있다.

코어닷투데이2026-09-2732분

들어가며 — 라우팅은 ‘주소록’이다

주소를 함수로 잇는 라우팅크게 보기

1편에서 백엔드 프레임워크가 하는 일 다섯 가지 중 첫 번째가 라우팅이라고 했다. 라우팅은 “이 주소로 온 요청은 이 함수가 처리한다”는 주소록이다. FastAPI 코드를 처음 열었을 때 가장 먼저 읽어야 하는 것도, 협업할 때 가장 자주 이야기하는 것도 이 주소록이다.

이 글을 다 읽으면 다음을 할 수 있다.

  • FastAPI를 설치하고 개발 서버를 띄운다.
  • @app.get("/...")가 무슨 뜻인지 설명한다.
  • 경로 매개변수(/users/42)와 쿼리 매개변수(?limit=10)를 구분해서 쓴다.
  • 422 에러를 읽고 무엇이 틀렸는지 안다.
  • 라우트 순서 때문에 생기는 버그를 피한다.

1. 설치하고 첫 서버 띄우기

1-1. 프로젝트 폴더와 가상 환경

프로젝트마다 가상 환경을 따로 만든다. 가상 환경은 “이 프로젝트 전용 파이썬 패키지 보관함”이다. 이걸 안 쓰면 A 프로젝트의 패키지 버전이 B 프로젝트를 망가뜨린다.

먼저 파이썬 버전을 확인한다. 3.13을 권장하고, 최소 3.10이어야 한다(이유는 1편 7절).

bash
python3 --version        # Python 3.13.x 이면 OK
bash
mkdir todo-api && cd todo-api
python3 -m venv .venv
source .venv/bin/activate          # Windows PowerShell: .venv\Scripts\Activate.ps1
pip install "fastapi[standard]"

uv를 쓴다면 이렇게 줄일 수 있다.

bash
uv init --python 3.13 todo-api && cd todo-api
uv add "fastapi[standard]"

uv는 컴퓨터에 3.13이 없으면 알아서 받아 설치하고, 폴더에 .python-version 파일을 만들어 팀원 모두가 같은 버전을 쓰게 한다.

1-2. 다섯 줄짜리 API

main.py 파일을 만들고 아래를 붙여 넣는다.

python
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def root():
    return {"message": "안녕하세요, FastAPI!"}

그리고 실행한다.

bash
fastapi dev main.py

터미널에 이런 메시지가 나오면 성공이다(실제 출력을 옮겼다).

fastapi dev main.py

⚡️ Starting FastAPI in development mode

🐍 Using import string: main:app

🌐 Server started at http://127.0.0.1:8000

   Documentation at http://127.0.0.1:8000/docs

INFO: Started reloader process using WatchFiles

INFO: Application startup complete.

브라우저에서 http://127.0.0.1:8000을 열면 {"message":"안녕하세요, FastAPI!"}가 보인다. 파일을 고치고 저장하면 서버가 자동으로 재시작된다(reloader). 개발할 때는 fastapi dev, 실제 배포할 때는 자동 재시작을 끈 fastapi run을 쓴다.

💡
uvicorn은 뭔가요? 예전 글에서는 uvicorn main:app --reload로 실행하는 경우가 많다. fastapi dev는 그 uvicorn을 편하게 부르는 명령이다. 둘 다 같은 서버(uvicorn)를 띄운다. main:app은 “main.py 파일 안의 app 객체”라는 뜻이다.

1-3. 코드 한 줄씩 읽기

코드뜻
app = FastAPI()API 서버 객체를 하나 만든다. 앞으로 모든 라우트를 여기에 등록한다.
@app.get("/")“GET 메서드로 / 주소에 요청이 오면, 바로 아래 함수를 불러라.” 이것이 라우트 한 줄이다.
def root():요청을 처리할 함수(FastAPI 용어로 경로 작동 함수, path operation function). 이름은 자유지만 무슨 일을 하는지 드러나게 짓는다.
return {...}파이썬 딕셔너리를 돌려주면 FastAPI가 JSON으로 바꿔서 응답한다. 상태 코드는 기본 200.

@로 시작하는 줄을 데코레이터라고 한다. 어렵게 생각할 필요 없다. 함수 위에 붙이는 이름표다. @app.get("/")라는 이름표를 붙이면 FastAPI의 주소록에 “GET / → root 함수”라고 한 줄이 추가된다.

메서드별로 이름표가 따로 있다. 5·6편에서 하나씩 쓴다.

@app.get
읽기
@app.post
만들기
@app.put
통째로 바꾸기
@app.patch
일부 고치기
@app.delete
지우기

2. 경로 매개변수 — 주소 안에 값을 넣기

“42번 사용자 정보”처럼 특정한 하나를 가리킬 때는 주소 안에 값을 넣는다.

python
@app.get("/users/{user_id}")
def read_user(user_id: int):
    return {"user_id": user_id}

{user_id} 자리에 오는 값이 같은 이름의 함수 인자로 들어온다. 여기서 중요한 것은 : int다. 실제로 요청해 보면 이렇다.

요청상태 코드응답 본문
GET /users/42200{"user_id": 42} — 문자열 “42”가 정수 42로 바뀌었다
GET /users/abc422함수가 실행되지도 않고 에러 응답이 나간다 (아래)

/users/abc의 실제 응답은 이렇다.

json
{
  "detail": [
    {
      "type": "int_parsing",
      "loc": ["path", "user_id"],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "abc"
    }
  ]
}

이 모양은 앞으로 계속 보게 되니 읽는 법을 외워 두자.

loc
어디가 틀렸나 — ["path", "user_id"]는 “경로의 user_id”. 쿼리면 "query", 본문이면 "body"가 온다.
type
어떤 종류의 오류인가 — int_parsing은 정수로 못 바꿨다는 뜻. 프로그램이 분기할 때 쓰기 좋은 고정 값이다.
msg
사람이 읽는 설명.
input
실제로 들어온 값 "abc". 디버깅할 때 가장 먼저 본다.
⚠️
422는 “네가 보낸 게 틀렸다”는 뜻이다. 422(Unprocessable Content)는 서버 버그(500)가 아니라 요청하는 쪽의 문제다. 프론트엔드 개발자가 422를 받으면 백엔드에 “서버가 죽었어요”가 아니라 loc을 보고 “user_id를 문자열로 보내고 있었네”를 먼저 확인하면 된다. 이 구분만 팀에서 공유해도 불필요한 메시지가 확 준다.

2-1. 함정 — 라우트 순서

“내 정보” API를 추가한다고 하자.

python
@app.get("/users/{user_id}")
def read_user(user_id: int):
    return {"user_id": user_id}


@app.get("/users/me")          # ← 위 라우트 아래에 추가
def read_me():
    return {"user_id": "현재 로그인한 사용자"}

GET /users/me를 부르면 결과는 422다. 실제 응답의 input이 "me"로 찍힌다. 왜일까?

FastAPI는 요청이 오면 위에서부터 라우트를 비교해서 모양이 맞는 첫 번째를 고른다. 이때는 타입을 보지 않는다. /users/me는 /users/{user_id} 모양에 맞으니 첫 번째 라우트가 선택되고, 그다음에 "me"를 정수로 바꾸려다 실패한다.

해결은 간단하다. 고정 경로를 매개변수 경로보다 위에 둔다.

python
@app.get("/users/me")          # 고정 경로가 먼저
def read_me():
    return {"user_id": "현재 로그인한 사용자"}


@app.get("/users/{user_id}")   # 매개변수 경로는 나중에
def read_user(user_id: int):
    return {"user_id": user_id}

2-2. 정해진 값만 받기 — Enum

카테고리처럼 허용 값이 정해진 경우 Enum을 쓰면 검증과 문서가 한 번에 된다.

python
from enum import Enum


class Category(str, Enum):
    book = "book"
    food = "food"
    toy = "toy"


@app.get("/categories/{category}")
def read_category(category: Category):
    return {"category": category, "is_book": category == Category.book}

/categories/car를 부르면 422와 함께 "msg": "Input should be 'book', 'food' or 'toy'"가 돌아온다. /docs 화면에는 세 값이 드롭다운으로 나온다. 프론트엔드 개발자가 허용 값을 따로 물어볼 필요가 없다.

3. 쿼리 매개변수 — 주소 뒤에 조건 붙이기

경로 매개변수와 쿼리 매개변수크게 보기

목록을 가져올 때 “20번째부터 5개만”, “‘사과’가 들어간 것만”처럼 조건을 붙이고 싶다. 이럴 때는 주소 뒤에 ?이름=값&이름=값을 붙인다. 이것이 쿼리 매개변수다.

FastAPI에서는 경로에 없는 함수 인자는 전부 쿼리 매개변수가 된다. 따로 표시할 것이 없다.

python
@app.get("/items")
def list_items(skip: int = 0, limit: int = 10, q: str | None = None, in_stock: bool = False):
    return {"skip": skip, "limit": limit, "q": q, "in_stock": in_stock}
요청상태함수가 받은 값
/items200skip=0, limit=10, q=None, in_stock=False — 전부 기본값
/items?skip=20&limit=5&q=사과&in_stock=yes200skip=20, limit=5, q="사과", in_stock=True
/items?limit=열개422loc: ["query", "limit"], int_parsing
/items?limit=5&color=red200선언하지 않은 color는 조용히 무시된다

규칙은 세 가지다.

  1. 기본값이 있으면 선택, 없으면 필수다. def f(q: str)로 쓰면 q를 안 보냈을 때 422(missing)가 난다.
  2. “보내도 되고 안 보내도 되는 값”은 str | None = None으로 쓴다.
  3. bool은 너그럽다. true, 1, yes, on은 True, false, 0, no, off는 False로 받는다. maybe는 422.

3-1. 경로냐 쿼리냐 — 고르는 기준

질문경로 매개변수쿼리 매개변수
무엇을 나타내나어떤 것인가 (자원의 정체)어떻게 보여 줄까 (필터·정렬·페이지)
빠지면?주소 자체가 달라진다 — 보통 필수기본값으로 동작 — 보통 선택
예/users/42, /shops/3/items/7/items?sort=new&limit=20, /tasks?done=true
비유아파트 동·호수쇼핑몰의 필터 체크박스

둘을 섞어 쓸 수도 있다.

python
@app.get("/shops/{shop_id}/items/{item_id}")
def read_shop_item(shop_id: int, item_id: int, detail: bool = False):
    return {"shop_id": shop_id, "item_id": item_id, "detail": detail}

/shops/3/items/7?detail=true → {"shop_id": 3, "item_id": 7, "detail": true}. FastAPI는 이름을 보고 알아서 나눈다. {} 안에 있는 이름은 경로, 나머지는 쿼리다.

3-2. 범위 제한 걸기 — Query와 Annotated

“limit은 1~100 사이만”처럼 규칙을 더 걸고 싶으면 Annotated와 Query를 쓴다.

python
from typing import Annotated

from fastapi import Query


@app.get("/products")
def list_products(
    limit: Annotated[int, Query(ge=1, le=100, description="한 번에 가져올 개수")] = 10,
    tags: Annotated[list[str] | None, Query()] = None,
):
    return {"limit": limit, "tags": tags}
  • ge=1은 1 이상(greater than or equal), le=100은 100 이하(less than or equal). /products?limit=500은 422(less_than_equal)가 난다.
  • description은 /docs 화면에 설명으로 나온다.
  • list[str]로 선언하면 /products?tags=a&tags=b처럼 같은 이름을 여러 번 보내 ["a", "b"]로 받는다. 리스트는 Query()를 꼭 붙여야 쿼리로 인식된다.

경로 매개변수에도 같은 방식으로 Path(ge=1)을 쓸 수 있다.

4. 라우트 매칭기로 직접 확인하기

지금까지 나온 라우트를 모두 담은 main.py를 위젯으로 옮겼다. 주소를 입력하면 FastAPI처럼 위에서부터 비교해 어느 함수로 가는지, 값이 어떻게 변환되는지, 응답이 무엇인지 보여 준다. 응답 JSON은 실제 FastAPI 0.141의 출력과 같다.

해 볼 만한 것들:

  • /users/abc와 /orders를 비교해 보자. 둘 다 실패지만 하나는 422(경로는 맞는데 값이 틀림), 하나는 404(그런 주소 없음)다.
  • 체크박스로 /users/me를 아래로 옮긴 뒤 /users/me를 요청해 보자. 2-1의 함정이 그대로 재현된다.
  • 메서드를 POST로 바꿔 /users/42를 보내 보자. 주소는 있지만 POST 함수가 없어서 405 Method Not Allowed가 나온다.
  • /items/처럼 끝에 슬래시를 붙여 보자. FastAPI는 등록된 /items로 307 리다이렉트한다.

5. 상태 코드 한눈에 보기 (지금까지 나온 것)

코드이름언제 나오나누가 고쳐야 하나
200OK정상 처리—
307Temporary Redirect끝 슬래시 차이 등으로 다른 주소로 안내클라이언트가 정확한 주소를 쓰면 사라짐
404Not Found그런 주소(또는 자원)가 없다주소 오타 확인 (요청자)
405Method Not Allowed주소는 있는데 그 메서드는 없다메서드 확인 (요청자)
422Unprocessable Content값의 타입·범위·필수 여부가 틀렸다loc·msg 보고 수정 (요청자)
500Internal Server Error서버 코드에서 예외가 났다백엔드 개발자

6. 잠깐 — def와 async def

FastAPI 예제를 보면 def로 쓴 함수와 async def로 쓴 함수가 섞여 있다. 입문 단계에서는 이렇게만 기억하면 된다.

쓰는 방법언제FastAPI가 하는 일
def잘 모르겠으면 이것. 일반 라이브러리(대부분의 DB 드라이버, requests 등)를 쓸 때별도 스레드에서 실행해 다른 요청을 막지 않는다
async defawait를 쓰는 비동기 라이브러리(httpx.AsyncClient, asyncpg 등)를 쓸 때이벤트 루프에서 실행하며, await로 기다리는 동안 다른 요청을 처리한다
⚠️
가장 흔한 실수: async def 함수 안에서 time.sleep()이나 requests.get()처럼 기다리는(블로킹) 코드를 부르는 것. 그 순간 서버 전체가 멈춘다. 비동기 라이브러리를 쓸 게 아니라면 그냥 def로 쓰는 편이 안전하다.

7. 연습 문제

직접 main.py에 추가하고 /docs나 브라우저로 확인해 보자.

  1. GET /hello/{name} — {"message": "안녕하세요, {name}님"}을 돌려준다.
  2. GET /calc/add?a=3&b=5 — {"result": 8}을 돌려준다. a, b는 필수 정수다. 하나를 빼고 보내면 어떤 422가 나오는지 확인하자.
  3. GET /posts?page=1&size=20 — size는 1~50만 허용한다. size=100을 보내 less_than_equal 에러를 확인하자.
예시 답안 보기
python
from typing import Annotated

from fastapi import FastAPI, Query

app = FastAPI()


@app.get("/hello/{name}")
def hello(name: str):
    return {"message": f"안녕하세요, {name}님"}


@app.get("/calc/add")
def add(a: int, b: int):
    return {"result": a + b}


@app.get("/posts")
def list_posts(
    page: Annotated[int, Query(ge=1)] = 1,
    size: Annotated[int, Query(ge=1, le=50)] = 20,
):
    return {"page": page, "size": size}

정리

🗺️
라우트 = 메서드 + 경로 + 함수
@app.get("/users/{user_id}") 한 줄이 주소록 한 칸이다. FastAPI는 위에서부터 모양이 맞는 첫 라우트를 고른다. 고정 경로는 매개변수 경로보다 위에.
🔢
타입 힌트 = 변환 + 검증
{} 안 이름은 경로, 나머지 인자는 쿼리. 기본값이 있으면 선택, 없으면 필수. 타입이 안 맞으면 함수 실행 전에 422.
🧾
422를 읽을 줄 알면 협업이 빨라진다
loc(어디) · type(무슨 종류) · msg(설명) · input(들어온 값). 404·405·422·500을 구분해서 말하는 것만으로 대화가 짧아진다.

그런데 여기까지 오면서 우리는 요청을 확인하려고 매번 브라우저 주소창에 주소를 쳤다. POST처럼 본문을 보내야 하는 요청은 주소창으로는 보낼 수도 없다. 다음 편에서는 FastAPI가 공짜로 주는 /docs 화면으로 요청을 보내고, 그 화면을 팀의 공용 API 명세서로 쓰는 법을 다룬다.

ℹ️
실행 환경. 이 글의 모든 코드와 응답(422 본문, 405, 307 리다이렉트, bool 변환 규칙, 라우트 순서 함정)은 FastAPI 0.141.1 · Pydantic 2.13.5 · Starlette 1.7 · Python 3.11에서 직접 실행해 확인한 결과를 옮겼다. 버전이 다르면 에러 문구(msg)가 조금 다를 수 있지만 type과 loc의 구조는 같다. 삽화는 코어닷투데이가 생성했다.