본문 바로가기

응답 모델 사용하기

1. 라우팅 및 세팅

1.1 URL 정보

이번 챕터의 URL 구성은 아래와 같습니다.

경로함수명메서드설명
/itemread_itemsGET물품 목록을 반환합니다.
/itemcreate_itemPOST물품을 등록합니다.
/item/{item_id}read_itemGET물품 상세 정보를 반환합니다.

1.2 기본 세팅

이번 실습 폴더는 02_5_model입니다. VSC 터미널에서 사용할 명령어 입니다. 가상환경은 벗어난 상태에서 실행해야 합니다. 앞 실습의 서버가 실행 중이면 Ctrl + C로 멈추고, 터미널 입력창 앞에 (venv)라고 되어 있다면 deactivate 명령어로 가상환경을 나간 상태에서 cd ..으로 상위 폴더로 나와 아래 명령어를 실행해주세요.

mkdir 02_5_model
cd 02_5_model
python -m venv venv
.\venv\Scripts\Activate.ps1
pip install "fastapi[standard]"

macOS/Linux에서는 python -m venv venv 대신 python3 -m venv venv를, 활성화 명령 대신 source ./venv/bin/activate를 사용합니다. 이후 명령은 가상환경이 활성화된 상태에서 실행합니다.

2. 응답 모델 소개

응답 모델은 API가 반환하는 데이터의 구조를 정의합니다. FastAPI에서는 Pydantic 모델을 사용하여 응답 데이터의 형식을 명확히 지정할 수 있습니다. API 문서화 또한 자동으로 이루어집니다. 앞 절에서 Pydantic 모델로 들어오는 데이터를 검증했다면, 이번에는 나가는 데이터를 검증합니다.

들어오는 데이터를 검증하는 이유는 명확합니다. 사용자가 이상한 값을 보낼 수 있으니까요. 그런데 나가는 데이터는 내가 만든 것인데 왜 검증할까요. 이유는 검증보다 걸러내기에 있습니다.

예를 들어 사용자 정보를 조회하는 API를 만들었다고 해봅시다. 데이터베이스에서 가져온 사용자 객체에는 비밀번호 해시가 들어 있습니다. 이걸 그대로 반환하면 비밀번호 해시가 클라이언트에게 그대로 넘어갑니다. 응답 모델은 "이 API는 id, username, email 세 개만 내보낸다"고 미리 정해두어, 실수로 새어 나가는 것을 막아줍니다.

2.1 Pydantic 다시 보기

Pydantic은 Python의 타입 힌트를 사용하여 데이터 유효성 검사와 직렬화를 수행하는 라이브러리입니다. FastAPI는 Pydantic을 기반으로 하여 API의 요청과 응답을 검증하고 직렬화합니다. FastAPI를 설치하면 Pydantic도 함께 설치됩니다. Python 라이브러리이기 때문에 FastAPI 없이 Pydantic 단독으로도 사용할 수 있습니다. 아래 코드는 colab에서도 사용이 가능합니다.

아래 코드를 이해하지 못하더라도 수업을 진행하는 것에는 문제가 없으니 가볍게 읽어보세요.

from pydantic import BaseModel, ValidationError


class User(BaseModel):
    name: str  # 필수 필드
    age: int  # 필수 필드
    email: str  # 필수 필드
    hobbies: list[str] | None = None  # 선택적 필드, 기본값은 None


# 유효성 검사 및 객체 생성 함수
def validate_user(user_data: dict) -> User | None:
    try:
        return User(**user_data)
    except ValidationError as e:
        print(f"유효성 검사 오류: {e}")
        return None


# 올바른 데이터
valid_data = {
    "name": "홍길동",
    "age": 30,
    "email": "hong@example.com",
    "hobbies": ["독서", "등산"],
}

# 잘못된 데이터
invalid_data = {
    "name": "김철수",
    "age": "스물다섯",  # 숫자로 바꿀 수 없는 문자열
    "hobbies": "독서",  # 문자열 대신 리스트여야 함
}

# 올바른 데이터로 사용자 생성
user = validate_user(valid_data)
if user:
    print("유효한 사용자:", user)
    print("직렬화된 사용자:", user.model_dump())

# 잘못된 데이터로 사용자 생성 시도
invalid_user = validate_user(invalid_data)

여기서 입력값에 대한 유효성 검사를 수행하고, 유효한 경우 사용자 객체를 생성합니다. 이렇게 타입 힌트를 사용하여 데이터 유효성 검사를 수행하는 것은 Pydantic의 주요 기능 중 하나입니다.

여기서 hobbies: list[str] | None = None 부분은 선택적 필드를 정의하는 방법입니다. list[str]는 문자열 리스트를 의미하며, | None은 해당 타입이 리스트 또는 None 필드임을 나타냅니다. 선택적 필드는 필수가 아니며, 입력되지 않을 경우 기본값인 = None이 사용됩니다.

직렬화는 파이썬의 객체를 JSON으로 바꿀 수 있는 형태로 변환하는 과정을 의미합니다. Pydantic은 이러한 직렬화를 자동으로 수행합니다. model_dump() 메서드를 사용하면 Pydantic 모델을 딕셔너리로 변환할 수 있습니다.

정리를 하자면 Pydantic은 아래와 같은 기능을 제공합니다.

기능설명
기본 데이터 타입 검증str, int 등의 타입 검증
선택적 필드X | None = None으로 필수가 아닌 필드 정의
값 범위 제한Field(ge=, le=, max_length=) 등
커스텀 검증@field_validator 데코레이터로 추가 검증
에러 처리ValidationError를 통한 에러 처리

3. 기본 응답 모델 사용하기

응답 모델을 사용하려면 먼저 Pydantic 모델을 정의합니다. 응답 모델을 지정하는 방법은 두 가지인데, 지금은 함수의 반환 타입 애너테이션을 쓰는 방식이 권장됩니다. Item을 생성하는 코드를 작성해보겠습니다.

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()


# Pydantic 모델 정의
class Item(BaseModel):
    name: str
    price: float


# 메모리에 데이터를 저장할 리스트
items: list[Item] = []


@app.post("/item")
async def create_item(item: Item) -> Item:
    items.append(item)
    return item


@app.get("/item")
async def read_items() -> list[Item]:
    return items


@app.get("/item/{item_id}")
async def read_item(item_id: int) -> Item:
    if 0 <= item_id < len(items):
        return items[item_id]
    raise HTTPException(status_code=404, detail="Item not found")

-> Item, -> list[Item]이 반환 타입 애너테이션입니다. 이 예제에서 Item 모델은 응답의 구조를 정의합니다. FastAPI는 반환된 데이터를 이 모델에 맞게 검증하고 직렬화합니다. 이렇게 적으면 세 가지가 동시에 이루어집니다.

  1. FastAPI가 반환값을 Item 형태로 걸러서 내보냅니다.
  2. /docs 문서의 응답 예시에 이 구조가 표시됩니다.
  3. 에디터와 타입 검사기가 반환값을 확인해줍니다.

response_model=은 언제 쓰나요

데코레이터에 response_model=Item처럼 적는 방식도 있습니다. 두 방식은 대부분 같은 일을 하지만, 아래 경우에는 response_model=을 써야 합니다.

  • 반환하는 실제 객체가 모델과 다를 때 (4장의 데이터베이스 객체가 그렇습니다)
  • response_model_exclude 같은 세부 옵션이 필요할 때 (이 절의 6번에서 다룹니다)

두 개를 동시에 적으면 response_model= 쪽이 이깁니다. 반환 타입 애너테이션은 파이썬 표준 문법이라 에디터가 함께 검사해준다는 장점이 있습니다.

아래와 같이 실행하여 FastAPI 서버를 실행합니다.

fastapi dev

api.http로 확인해보겠습니다.

@baseUrl = http://127.0.0.1:8000

### 물품 등록
POST {{baseUrl}}/item
Content-Type: application/json

{
    "name": "item1",
    "price": 100
}

### 목록 조회
GET {{baseUrl}}/item

### 상세 조회
GET {{baseUrl}}/item/0

### 없는 물품
GET {{baseUrl}}/item/99

이때 보내는 데이터를 아래와 같이 변경해도 price는 자동으로 숫자로 변경이 됩니다.

{
    "name": "item1",
    "price": "100"
}

다만 숫자로 변경할 수 없는 데이터를 보내면 오류가 발생합니다.

{
    "name": "item1",
    "price": "hello"
}

메시지는 아래와 같습니다.

{
    "detail": [
        {
            "type": "float_parsing",
            "loc": [
                "body",
                "price"
            ],
            "msg": "Input should be a valid number, unable to parse string as a number",
            "input": "hello"
        }
    ]
}

이 메시지는 price 필드가 숫자로 변환할 수 없는 문자열을 포함하고 있음을 알려줍니다. 이러한 기능은 Pydantic이 제공하는 기능 중 하나입니다.

4. 응답 모델이 필드를 걸러내는 것 확인하기

응답 모델의 진짜 역할을 눈으로 확인해보겠습니다. 사용자 정보를 다루는 예제로 바꿔봅니다.

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()


class UserCreate(BaseModel):
    username: str
    email: str
    password: str  # 들어올 때는 필요합니다


class UserPublic(BaseModel):
    username: str
    email: str  # 나갈 때는 비밀번호가 없습니다


users: list[UserCreate] = []


@app.post("/user")
async def create_user(user: UserCreate) -> UserPublic:
    users.append(user)
    return user  # UserCreate 객체를 그대로 반환합니다


@app.get("/user/{user_id}")
async def read_user(user_id: int) -> UserPublic:
    if 0 <= user_id < len(users):
        return users[user_id]
    raise HTTPException(status_code=404, detail="User not found")

create_user 함수는 password가 들어 있는 UserCreate 객체를 반환합니다. 그런데 반환 타입은 UserPublic입니다. 실제로 요청을 보내보세요.

### 사용자 등록
POST {{baseUrl}}/user
Content-Type: application/json

{
    "username": "licat",
    "email": "licat@weniv.co.kr",
    "password": "verysecret1234"
}

응답에는 password가 없습니다.

{
  "username": "licat",
  "email": "licat@weniv.co.kr"
}

함수 안에서 아무것도 지우지 않았는데 걸러졌습니다. 이 패턴은 앞으로 계속 쓰이므로 이름까지 기억해두시면 좋습니다. 들어오는 모델과 나가는 모델을 나누고, 이름을 XxxCreate와 XxxPublic처럼 붙이는 방식입니다.

중요한 주의사항

UserPublic에 없는 필드는 응답에서 사라지지만, 데이터베이스나 메모리에는 그대로 남아 있습니다. 응답 모델은 내보내는 것을 거르는 장치일 뿐 데이터를 지우지 않습니다. 비밀번호는 애초에 평문으로 저장하면 안 되며, 이 부분은 4장에서 해시로 저장하는 방법을 다룹니다.

5. 상태 코드 설정

FastAPI에서는 status_code 매개변수를 사용하여 응답의 HTTP 상태 코드를 설정할 수 있습니다. 기본값은 200입니다.

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel

app = FastAPI()


# Pydantic 모델 정의
class Item(BaseModel):
    name: str
    price: float


# 메모리에 데이터를 저장할 리스트
items: list[Item] = []


@app.post("/item", status_code=status.HTTP_201_CREATED)
async def create_item(item: Item) -> Item:
    items.append(item)
    return item


@app.get("/item")
async def read_items() -> list[Item]:
    return items


@app.get("/item/{item_id}")
async def read_item(item_id: int) -> Item:
    if 0 <= item_id < len(items):
        return items[item_id]
    raise HTTPException(status_code=404, detail="Item not found")

이 예제에서는 아이템 생성 시 201 Created 상태 코드를 반환합니다. 자주 쓰는 상태 코드는 아래와 같습니다.

상수코드언제 쓰나
HTTP_200_OK200조회 성공 (기본값)
HTTP_201_CREATED201새 자원을 만들었을 때
HTTP_204_NO_CONTENT204삭제 성공, 돌려줄 내용이 없을 때
HTTP_400_BAD_REQUEST400요청이 잘못되었을 때
HTTP_401_UNAUTHORIZED401로그인하지 않았을 때
HTTP_403_FORBIDDEN403로그인은 했지만 권한이 없을 때
HTTP_404_NOT_FOUND404자원이 없을 때
HTTP_422_UNPROCESSABLE_CONTENT422검증에 실패했을 때 (FastAPI가 자동으로 사용)

status.HTTP_201_CREATED 대신 그냥 201이라고 적어도 동작합니다. 상수를 쓰면 오타를 냈을 때 실행 전에 발견할 수 있고, 코드를 읽을 때 숫자를 외우지 않아도 된다는 장점이 있습니다.

api.http로 아래 POST 요청을 보내보세요.

### 물품 등록
POST {{baseUrl}}/item
Content-Type: application/json

{
    "name": "item1",
    "price": 100
}

응답창 상단에서 201 Created 상태 코드를 확인할 수 있습니다. 이번에는 응답 코드를 @app.post("/item", status_code=404)와 같이 변경하고 post를 실행해보세요. 실제 데이터는 저장되지만 상태 코드가 404 Not Found로 변경됩니다. 상태 코드는 FastAPI가 판단해서 붙이는 것이 아니라, 개발자가 정하는 값이라는 것을 알 수 있습니다.

이러한 상태 코드는 \venv\Lib\site-packages\starlette\status.py에 위치하고 있습니다. starlette도 FastAPI와 함께 설치되는 모듈입니다. FastAPI와 별개로도 사용할 수 있습니다.

6. 응답 모델의 필드 제어

때로는 모델의 일부 필드만 응답에 포함시키고 싶을 수 있습니다. 4번에서 본 것처럼 모델을 따로 나누는 방법이 기본이지만, 모델을 새로 만들지 않고 데코레이터 옵션으로 걸러내는 방법도 있습니다. 이 옵션은 response_model=과 함께 씁니다.

6.1 응답에서 특정 필드 제외하기

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel

app = FastAPI()


# Pydantic 모델 정의
class Item(BaseModel):
    name: str
    price: float


# 메모리에 데이터를 저장할 리스트
items: list[Item] = []


@app.post(
    "/item",
    response_model=Item,
    status_code=status.HTTP_201_CREATED,
    response_model_exclude={"price"},
)
async def create_item(item: Item):
    items.append(item)
    return item


@app.get("/item")
async def read_items() -> list[Item]:
    return items


@app.get("/item/{item_id}")
async def read_item(item_id: int) -> Item:
    if 0 <= item_id < len(items):
        return items[item_id]
    raise HTTPException(status_code=404, detail="Item not found")

이 예제에서는 price 필드가 응답에서 제외됩니다.

6.2 응답에 특정 필드만 포함하기

@app.post(
    "/item",
    response_model=Item,
    status_code=status.HTTP_201_CREATED,
    response_model_include={"name", "price"},
)
async def create_item(item: Item):
    items.append(item)
    return item

이 예제에서는 name과 price 필드만 응답에 포함됩니다. 여기서 name만 남기게 되면 price는 자동으로 제외됩니다.

7. 여러 가지 응답 모델 사용하기

때로는 하나의 엔드포인트가 여러 가지 다른 응답을 반환해야 할 수 있습니다. 이런 경우 |로 여러 타입을 이어 적습니다.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


# Pydantic 모델 정의
class Item(BaseModel):
    name: str
    price: float


class Message(BaseModel):
    message: str


# 메모리에 데이터를 저장할 리스트
items: list[Item] = []


@app.post("/item")
async def create_item(item: Item) -> Item:
    items.append(item)
    return item


@app.get("/item/{item_id}")
async def read_item(item_id: int) -> Item | Message:
    if 0 <= item_id < len(items):
        return items[item_id]
    return Message(message="Item not found")

이 예제에서는 아이템이 존재하면 Item 모델을, 그렇지 않으면 Message 모델을 반환합니다. -> Item | Message는 "둘 중 하나가 반환된다"는 뜻입니다. /docs 문서에도 두 가지 형태가 모두 표시됩니다.

이 방식과 HTTPException 중 무엇을 쓸까

위 코드는 아이템이 없을 때도 상태 코드 200으로 응답합니다. 클라이언트 입장에서는 "성공했다"고 판단한 뒤 응답 내용을 보고 다시 판단해야 합니다.

대부분의 경우에는 raise HTTPException(status_code=404, ...)가 더 낫습니다. 상태 코드만 보고 성공과 실패를 구분할 수 있기 때문입니다. Item | Message 형태는 "실패는 아니지만 결과가 다른 경우", 예를 들어 검색 결과가 하나일 때와 여러 개일 때처럼 둘 다 정상인 상황에 적합합니다.

Union[Item, Message]라고 적은 코드도 보게 되실 텐데, Item | Message와 같은 의미이며 import가 필요 없는 지금 방식이 더 간결합니다.

연습문제

  1. 사용자 정보를 반환하는 API를 만들어보세요. 저장은 id, username, email, password 네 개를 하고, 응답에는 password를 제외한 세 개만 나가도록 모델을 나눠보세요.

  2. 상품 목록을 반환하는 API를 만들어보세요. 각 상품은 id, name, price, stock을 가지고 있습니다. detail이라는 쿼리 매개변수가 true면 모든 필드를, false(기본값)면 id, name, price만 반환하도록 구현해보세요. 힌트로, 반환 타입을 ItemPublic | ItemDetail로 두면 됩니다.

  3. 2번 API의 /docs 문서를 열어 응답 스키마가 어떻게 표시되는지 확인해보세요.

  4. 물품 삭제 엔드포인트를 만들고 status_code=status.HTTP_204_NO_CONTENT를 지정한 뒤, 아무것도 반환하지 않도록 만들어보세요. 응답 본문이 비어 있는지 api.http로 확인해보세요.