다중 필드 조회#

Django Ninja 입문 가이드의 27번째 글이에요.
이전 글에서는 Django ORM의 Q 객체와 Django Ninja의 FilterSchema에 대해 배웠지만, FilterSchema는 아직 절반만 배운 것 같은 느낌이 들었죠.
뷰 함수에서 FilterSchema의 매개변수 정의 방식에 대해 많은 이야기를 나누었고 그게 정말 중요하긴 했지만, 그것은 FilterSchema의 일부분일 뿐이었어요.
이번 글에서는 나머지 내용을 마저 채워보려 해요:
- FilterSchema 완성하기: "더 자연스러운(idiomatic)" 작성 방식을 사용하여 FilterSchema의 진정한 힘을 발휘해 봐요.
- 더 고급스러운 필드 조회 기능 구현하기: 다중 필드 조회 - 날짜 구간 필터링.
- 20편에서 배운 "교차 필드 검증(cross-field validation)" 추가 구현하기: 조회 매개변수의 날짜 구간이 유효한지 검증해요.
내용이 가득 찬 글이 될 것 같네요. 바로 시작해 볼까요!
이번 글의 모든 코드 변경 사항은 이 PR을 참고해 주세요.
GitHub 예제 프로젝트#
1. 조회 로직을 FilterSchema로 마이그레이션하기#
이전 글에서 구현한 코드를 기억하시나요?
분명히 FilterSchema를 추가로 정의했는데, 뷰 함수 안의 코드는 줄어들기는커녕 오히려 늘어났었죠! (두 필드를 동시에 조회해야 해서 조회 로직 자체가 늘어나긴 했지만요)
@router.get(...)
@paginate(CustomPagination)
def get_posts(
request: HttpRequest,
filters: PostFilterSchema = Query(), # FilterSchema 사용
) -> QuerySet[Post]:
"""
게시글 목록 조회
"""
posts = Post.objects.all()
if filters.query:
q = Q(title__icontains=filters.query) | \
Q(content__icontains=filters.query)
posts = posts.filter(q)
return posts
정말 황당하죠🐸
그 이유는 FilterSchema를 그렇게 사용하는 게 아니기 때문이에요!
FilterSchema의 "올바른" 사용법#
우리는 조회 로직을 가능하면 FilterSchema 안에 캡슐화해야 해요. 그래야 뷰 함수가 간결해지고 "관심사의 분리" 효과를 얻을 수 있거든요.
더 합리적인 작성 방식을 살펴볼까요? 조회 로직을 FilterSchema로 옮겼어요:
class PostFilterSchema(FilterSchema):
query: str | None = Field(
None,
q=['title__icontains', 'author__username__icontains'],
min_length=2,
max_length=10,
)
가장 큰 변화는 query 필드의 Field 부분에 이제 q= 매개변수 내용이 추가되었다는 점이에요:
눈에 익으시죠? 맞아요, 이건 사실 Q 객체의 조건 구문이에요. Django Ninja는 보이지 않는 곳에서 자동으로 Q 객체를 호출하여 이 조회들을 실행해 준답니다.
Mypy의 경고#
q= 매개변수를 사용하기 시작하면 Mypy가 또 다시 경고를 보낼 거예요:
Unexpected keyword argument "q" for "Field"
Mypy의 말이 틀린 건 아니에요. Pydantic의 Field에는 실제로 이런 매개변수가 없으니까요. 이건 Django Ninja가 자체적으로 구현한 거예요.
그냥 무시하시거나 필요한 주석(type: ignore 등)을 추가하시면 돼요.
뷰 함수 간소화#
이렇게 하면 뷰 함수는 단지 이렇게만 작성하면 돼요:
...
def get_posts(
request: HttpRequest,
filters: PostFilterSchema = Query(),
) -> QuerySet[Post]:
"""
게시글 목록 조회
"""
posts = Post.objects.select_related('author')
posts = filters.filter(posts)
return posts
훨씬 간단해지지 않았나요?
조회 로직이 뷰 함수에서 "분리"되었기 때문에 뷰 함수의 책임이 더 단일해지고 유지보수하기 쉬워졌어요.
2. 다중 필드 조회: 날짜 필터링 기능 추가#
새로운 요구사항: 게시글 제목이나 작성자 이름을 조회하는 것 외에도 이제 "작성일" 기준의 필터링을 추가해야 해요!
두 개의 새로운 URL 쿼리 매개변수를 도입할 거예요:
start_dateend_date
이 두 가지는 Post 모델의 created_at 필드(즉, 작성일)를 조회하고 필터링하여 특정 시간 범위 내의 게시글 데이터를 걸러내는 데 사용될 거예요.
여기에 추가 조건이 있어요: 이 두 매개변수는 "전부 있거나 전부 없거나(all or nothing)" 해야 해요. 둘 다 없을 수는 있지만 어느 하나만 입력할 수는 없어요. 날짜 구간 조회이기 때문에 시작과 끝이 모두 있어야 하거든요.
이것은 아주 전형적인 "다중 필드" 조회이기도 해요.
코드 추가하기#
위의 로직을 추가한 FilterSchema예요:
class PostFilterSchema(FilterSchema):
query: str | None = Field(
None, q=["title__icontains", "author__username__icontains"])
start_date: str | None = Field(None, q="created_at__gte")
end_date: str | None = Field(None, q="created_at__lte")
여기서 start_date와 end_date는 모두 모델 필드인 created_at에 대한 조회 조건이에요.
따라서 우리는 조건에 맞는 데이터를 걸러내기 위해 created_at__gte와 created_at__lte를 사용하여 필터링 로직을 설명해요. (이 둘은 각각 대응하는 Q 객체와 연결된답니다.)
그럼 뷰 함수는 어떨까요? 짐작하셨겠지만, 전혀 수정할 필요가 없어요!
이것이 바로 FilterSchema를 사용하는 장점이죠.
API 문서 렌더링 문제#
흥미롭게도, 이러한 쿼리 매개변수들에 문서 예제를 추가하려고 다음과 같이 작성하면:
API 문서를 확인할 때 이런 에러를 보게 돼요:
😱 Could not render Parameters, see the console.
하지만 example='2021-01-01'이라고 적으면 성공해요.
이건 아마도 Django Ninja와 Pydantic 사이의 통합에서 발생하는 버그인 것 같아요. 당분간은 그냥 넘어가도록 해요!
클라이언트 조회 예시#
특정 기간 동안의 게시글을 조회하려면 다음과 같은 URL 쿼리 매개변수를 사용할 수 있어요:
이렇게 하면 2023년 1월 한 달 동안의 모든 게시글을 쉽게 조회할 수 있어요.
참고로, 날짜에 시간을 지정하지 않았기 때문에 기본값은 0시 0분 0초예요. 따라서 시작과 종료를 같은 날짜로 입력하면 아무것도 조회되지 않아요.
이 부분은 개선이나 조정이 필요한 디테일인데, 실무에서는 코드 내부에서 end_date에 1일을 더해주는 방법이 흔히 쓰여요. 저는 그냥 두 날짜가 같을 수 없도록 제한하는 방식을 택했어요 XD. 실제로 어떻게 할지는 여러분의 요구사항에 달려 있어요.
단순 기간 조회 외에도 특정 작성자가 특정 기간 동안 쓴 게시글을 조회할 수도 있어요. 작성자 Alice를 예로 들면:
이 결과는 Alice가 2023년 1월에 쓴 모든 게시글을 보여주게 돼요.
FilterSchema의 기본 조회 조건 관계#
이 부분은 특별히 짚고 넘어가야 해요. 공식 문서에 따르면 기본적으로 다음과 같이 동작해요:
- Field-level expressions are joined together using
ORoperator. - The fields themselves are joined together using
ANDoperator.
즉, 단일 필드 안의 여러 Q 구문은 서로 OR 관계예요. 예를 들어 위에서 query의 경우:
게시글 제목 '또는' 작성자 이름으로 조회할 수 있었죠.
반면에 서로 다른 필드의 조건들(모두 존재할 경우)은 AND 관계예요. 모두 동시에 충족해야만 해요. 그래서 작성자 이름 조건과 날짜 구간 조건이 모두 주어지면 두 조건을 동시에 만족해야 한답니다.
이러한 기본 로직은 마음대로 변경할 수 있어요. 자세한 내용은 위 링크의 공식 문서를 참고해 주세요.
3. 날짜 구간 검증#
이번 예제에서는 필드 조회뿐만 아니라 사용자가 입력한 시작 날짜가 종료 날짜보다 반드시 이전이어야 함을 보장해야 해요.
게다가 두 필드의 조회 값은 "전부 있거나" 아니면 "전부 없어야" 해요. (전부 없다면 검증할 필요가 없죠).
이 요구사항 아주 낯익지 않나요? 바로 20편에서 다루었던 "교차 필드 검증"이 떠오르네요!
맞아요, 우리는 Pydantic의 model_validator를 사용하여 이를 구현할 거예요. 이를 통해 검증 과정 중 입력 데이터에 대한 사용자 정의 논리 검사를 수행할 수 있죠.
Pydantic model_validator를 사용한 날짜 구간 검증 구현#
코드가 좀 기니까 핵심만 바로 볼까요:
class PostFilterSchema(FilterSchema):
...
start_date: str | None = Field(None, q='created_at__gte')
end_date: str | None = Field(None, q='created_at__lte')
@model_validator(mode='after')
def check_date_range(self) -> Self:
# 시작 날짜와 종료 날짜가 모두 None이면 아무런 검사도 하지 않아요
if self.start_date is None and self.end_date is None:
return self
if not all([self.start_date, self.end_date]):
raise ValueError('시작 날짜와 종료 날짜는 모두 제공되거나 모두 제공되지 않아야 합니다')
try:
start_date_dt = datetime.strptime(self.start_date, '%Y-%m-%d')
end_date_dt = datetime.strptime(self.end_date, '%Y-%m-%d')
except ValueError:
raise ValueError('날짜 형식이 유효하지 않습니다. YYYY-MM-DD 형식이어야 합니다')
if start_date_dt > end_date_dt:
raise ValueError('시작 날짜는 종료 날짜보다 이전이어야 합니다')
return self
model_validator를 사용하여 교차 필드 검증을 수행함으로써, 사용자가 입력한 날짜가 유효하고 합리적인지 확인해요.
사실 교차 필드 검증은 종종 많은 세부 사항을 고려해야 해요. 그렇지 않으면 놓치는 부분이 생겨 간접적으로 새로운 버그를 만들어낼 수 있거든요.
이 예제가 바로 그런 전형적인 사례예요.
우리는 날짜 형식이 올바른지, 날짜 범위가 합리적인지, 두 날짜 필드가 동시에 존재하는지 또는 동시에 비어 있는지 등 발생할 수 있는 모든 입력 상황을 전반적으로 고려해야 해요.
세심한 검증 로직은 API의 신뢰성을 높이고, 유효하지 않거나 불합리한 입력으로 인해 시스템에 예상치 못한 동작이 발생하는 것을 방지해 줘요. 반대로 허술한 로직은 문제를 일으킬 수 있죠.
에러 응답 테스트#
PS: 여기서 에러를 발생시키는 부분에서 예제 코드는 여전히 ValueError를 사용하고 있어요. 제가 Django의 ValidationError로 변경하지 않았거든요. (최신 버전에서는 수정되었어요)
하지만 아래의 응답 결과는 불필요한 중복을 줄이기 위해 Django의 ValidationError를 시뮬레이션한 것이에요.
첫째, 시작 날짜만 입력했을 때: (프론트엔드에서 보통 제한하기 때문에 발생 확률은 높지 않아요)
둘째, 2023-02-30과 같이 유효하지 않은 날짜를 입력했을 때:
셋째, start_date=2023-01-31&end_date=2023-01-01처럼 유효하지 않은 날짜 구간을 입력했을 때:
요약 및 다음 단계#
이 두 편의 글에서 우리는 FilterSchema의 효과적인 사용법을 소개하고, 다중 필드 조회와 날짜 필터링을 완성했어요. 그리고 model_validator를 사용하여 데이터 검증을 강화하고 조회 로직의 정확성을 보장하는 방법도 보여드렸죠.
또한 각 단계의 용도와 효과를 독자들이 더 잘 이해할 수 있도록 이러한 적용 시나리오의 실제 코드 예시도 살펴보았어요.
다음 장에서는 Django Ninja의 인증(Authentication) 메커니즘에 대해 알아보고, pytest를 사용하여 단위 테스트(Unit Testing)를 수행하는 방법을 소개해 드릴게요. 이 두 가지는 백엔드 개발에서 빼놓을 수 없는 필수 요소예요.