콘텐츠로 이동

Request Body 및 Schema#

2024 iThome 철인 대회

이 글은 Django Ninja 입문 가이드의 12번째 글이에요.

이전 글들을 통해 경로 매개변수와 쿼리 매개변수를 처리하는 방법을 배웠어요. 하지만 실제 현실의 세계에서는 훨씬 더 복잡한 요청 데이터를 다루어야 할 때가 많아요.

예를 들면 사용자가 제출한 폼 데이터나 업로드된 파일 등이 있죠. API 환경에서 가장 흔히 볼 수 있는 것은 바로 JSON 형식의 request body예요.

이번 글에서는 Django Ninja가 어떻게 request body를 처리하는지 알아보고, Schema를 통해 데이터를 정의하고 검증하는 방법을 소개할게요.

이번 글의 모든 코드 변경 사항은 이 PR에서 확인하실 수 있어요.

GitHub 예제 프로젝트#

👉 Django-Ninja-Tutorial


1. Request Body란 무엇인가요?#

Request body란 HTTP 요청과 함께 전달되는 데이터를 말하며, 보통 POST, PUT'자원'을 생성하거나 업데이트해야 하는 요청에 주로 사용돼요.

이러한 데이터는 URL에 나타나지 않고, JSON이나 다른 형식(예: XML, form-data)으로 요청의 주체(본문)로서 역할을 해요.

예를 들어 사용자가 새로운 글을 작성하려고 할 때, 다음과 같은 JSON 형식의 request body를 보낼 수 있어요:

{
    "title": "나의 첫 번째 글",
    "content": "이것은 닌자 포럼에 남기는 나의 첫 번째 글입니다. 다들 좋아해 주셨으면 좋겠어요!"
}

이 request body에는 titlecontent라는 두 가지 필드가 포함되어 있어요. Django Ninja는 이 데이터들을 처리하고 검증하는 과정을 도와줄 거예요.


2. 예제 프로젝트 변경 사항#

예제 프로젝트request body를 받아들이는 API인 "글 작성"을 만들어 볼게요.

또한 Django의 post app 디렉토리 안에 Python 모듈인 schemas.py를 하나 추가할 거예요. 이 모듈은 API에서 사용되는 모든 Schema를 모아두는 장소가 될 거예요.

├── NinjaForum
   ├── ...
├── post
   ├── api.py
   ├── schemas.py # 새로 추가된 모듈
   ├── ...

구체적인 코드는 이어지는 설명에서 다룰게요.

이번 편부터 브랜치 이름에는 한글을 사용하지 않으려고 해요. 왜냐하면 한글 브랜치 이름을 사용하면 GitHub가 계속해서 이런 경고를 보여주거든요:

The head ref may contain hidden characters: ...

게다가 실무에서 git 브랜치 이름으로 한글(혹은 중국어 등)을 사용하는 사람도 매우 드물 거예요! 처음엔 독자분들이 조금이라도 읽기 편하게 만들려고 한글로 만들었지만 🥹

그래서 이번 브랜치부터는 숫자+영문 조합으로, 예를 들어 이번 글은 12-request-body와 같이 변경할게요. 단, PR의 제목은 여전히 알아보기 쉽게 유지할게요.


3. Schema를 사용하여 Request Body 정의 및 검증하기#

FastAPI와 마찬가지로 Django Ninja는 Pydantic BaseModel을 사용하여 요청 body를 처리해요.

다만 BaseModel이라는 이름이 Django의 Models와 혼동되기 쉽기 때문에, Django Ninja에서는 이를 Schema라는 이름으로 바꿔 부르고 있어요.

Schema는 BaseModel을 상속받으므로, 두 가지의 실제 내용은 매우 비슷해요 (Django Ninja가 자신만의 양념을 약간 추가하긴 했지만요):

# Django Ninja 소스 코드
class Schema(BaseModel, metaclass=ResolverMetaclass):
    ...

프로젝트로 돌아와서, "글 작성" API의 request body를 정의하는 Schema의 예시를 살펴볼게요:

# post/schemas.py
from ninja import Schema

class CreatePostRequest(Schema):
    title: str
    content: str
    user_id: int

이 Schema는 body 데이터가 title, content, user_id 세 가지 필드를 반드시 포함해야 하며, 데이터의 타입 역시 일치해야 함을 요구하고 있어요.

뷰 함수에서 Schema 사용하기#

"요청" Schema를 정의했다면, 이제 뷰 함수에서 이를 "함수 매개변수(parameter)"의 형태로 사용할 수 있어요:

from post.schemas import CreatePostRequest
...

@router.post(path='/posts/')
def create_post(..., payload: CreatePostRequest):  # 여기를 주목하세요
    ...

함수의 payload 매개변수에 대한 type hint를 우리가 방금 정의한 CreatePostRequest로 설정했어요.

이 API로 요청이 보내지면, Django Ninja는 이 CreatePostRequest Schema를 이용해 body 내부의 데이터를 파싱(parsing)하고 검증(validation)해요.

검증에 성공하면 데이터를 뷰 함수의 payload로 전달하죠. 이때 함수 내부의 payload 매개변수는 본질적으로 하나의 Schema (즉, Pydantic BaseModel) 객체가 돼요.

자동 데이터 검증 및 에러 처리#

요청 body에 필드가 누락되어 있거나 데이터 타입이 올바르지 않다면, Django Ninja는 자동으로 422 응답을 반환하며 구체적인 에러 정보를 제공해 줘요:

{
    "detail": [
        {
            "type": "missing",
            "loc": [
                "body",
                "payload",
                "content"
            ],
            "msg": "Field required"
        }
    ]
}

이 에러 메시지는 본문에 content라는 필드가 빠져 있다는 뜻이에요.


4. 선택적(Optional) 필드와 기본값#

실제 API 개발 시에는 모든 요청 필드가 항상 필수적인 것은 아니에요.

우리는 Pydantic과 type hints를 사용하여 선택적 필드를 정의할 수 있어요. 예를 들어, 글의 내용이 완벽하게 선택 사항이라고 가정해 볼까요: (content 필드를 눈여겨보세요)

class CreatePostRequest(Schema):
    title: str
    content: str | None = None
    user_id: int

= 연산자를 사용하여 content 필드의 기본값None으로 설정하면, 해당 필드는 선택적 필드가 돼요. 이때 content의 type hint도 str | None으로 변경해야 해요.

짚고 넘어갈 점은, Schema를 요청에 사용할 경우 이렇게 설정하면 검증은 통과할 수 있지만, 이후 매핑되는 Django Model 필드(즉 DB 컬럼)가 NULL을 허용하는지를 반드시 주의해야 한다는 점이에요. 그렇지 않으면 다음과 같은 에러가 발생하게 돼요:

django.db.utils.IntegrityError: NOT NULL constraint failed: post_post.content

필드를 선택 사항으로 만드는 것 외에도, 빈 문자열과 같이 기본값을 직접 지정할 수도 있어요. 사용자가 아무것도 입력하지 않으면 바로 기본값이 채워지게 돼요:

class CreatePostRequest(Schema):
    title: str
    content: str = ''
    user_id: int

하지만 기본값을 None으로 하는 것을 제외하고는, Schema에서 기본값을 지정하는 행위는 매우 신중해야 해요. 이 부분에 대해서는 〈18편: Pydantic Field 설정 예시 및 기본값〉에서 다시 한번 자세히 다루도록 할게요.


5. Django Ninja의 매개변수 판단 순서#

뷰 함수의 매개변수 종류가 이렇게 다양한데, Django Ninja는 어떤 값이 어디에 대응되어야 하는지 어떻게 아는 걸까요? 한 번쯤 궁금해해 보셨을 텐데요.

사실 Django Ninja는 뷰 함수의 매개변수 시그니처를 바탕으로, 해당 매개변수가 경로 매개변수인지, 쿼리 매개변수인지, 아니면 요청 body인지 그 출처를 자동으로 판단해요. 그 판단 순서는 다음과 같아요:

  1. 경로 매개변수: URL path 내에 정의된 변수(예: /items/{id}id)는 가장 먼저 경로 매개변수로 식별돼요.
  2. 쿼리 매개변수: 함수 내에 있는 다른 단일 타입 매개변수들(list, dict가 아닌 int, float, bool, str 등)이 경로 매개변수로 표시되지 않았다면, 쿼리 매개변수로 인식돼요.
  3. Request body: 오직 Schema 타입의 매개변수만이 요청 body로 간주돼요.

원칙적으로 뷰 함수는 단 하나의 Schema 매개변만 가질 수 있어요. 하나의 요청에는 단 하나의 body만 존재하기 때문이죠.


2절의 마무리#

이번 절의 내용은 대략 마무리되었어요.

이번 절에서는 Django Ninja를 사용하여 HTTP 요청을 처리하는 방법을 배웠고, Schema의 기본 사용법도 소개했어요.

Schema의 활용법과 변형은 아직 많지만, 여기서는 가볍게 맛보기만 보여드렸어요. 제3절인 'HTTP 응답'에서는 Schema에 대한 더 많은 설정 방법들을 만나볼 수 있을 거예요.

다음 절로 넘어가기 전에, 중간 휴식과 함께 약간의 준비를 해볼까요?


중간 휴식 및 준비#

다음 절에서는 프로젝트의 API가 실제로 작동하도록 만들 예정이에요. 앞서 프로젝트가 현재 동작하지 않는 이유를 언급했던 것 기억나시나요?

  1. DB에 데이터가 없어요.
  2. Schema를 작성하지 않았어요.

이제 우리는 Schema를 어떻게 사용하는지 배웠어요(아직 전체적인 그림은 아니지만요). 그렇다면 "DB 데이터" 문제도 해결해야 해요.

Django Fixtures#

물론 POST API를 호출해서 사용자와 글 데이터를 수동으로 추가할 수도 있지만, 그건 너무 번거로워요! 게다가 프로젝트에는 아직 "사용자 추가" API조차 없는걸요.

그러니 수고스럽게 하지 말아요.

우리는 Django fixtures를 사용하여 제가 미리 정의해둔 가상의 데이터를 바로 가져올(import) 거예요.

Django fixtures에 대한 설명은 〈Django Fixture로 데이터 가져오기 및 내보내기〉 글을 참조해 주세요.

다음 편인 13-response 브랜치의 진도를 살펴보면, 제가 미리 추출해 둔 fixtures 데이터 파일을 확인할 수 있어요:

  1. users.json.
  2. posts.json.

이것들을 사용하고 싶다면, 다음과 같이 순서대로 가져오기만 하면 돼요:

python manage.py loaddata users.json
python manage.py loaddata posts.json

반드시 users를 먼저 가져와야 해요. 그렇지 않으면 글에 작성자가 매칭되지 않아 관계 설정에 실패하게 돼요.

데이터를 성공적으로 가져오면 2명의 사용자(Alice와 Bob)가 생기고, 각각 30편씩 발행한 글들을 확인할 수 있어요.

어... 제 테스트용 첫 번째 글이 섞여 있네요. 너그러이 양해해 주세요 😅

가져오기를 성공적으로 마쳤다면, 이제 다음 단계로 계속 나아갈 수 있어요.