Django Rest Framework 레시피
일러두기: 이 글은 Justyna님이 작성한 Django Rest Framework Recipes의 번역문입니다. Justyna님의 허락을 받아 번역하였습니다. 고맙습니다. Justyna님.
Note: This is translated version of the Django Rest Framework Recipes which is written by Justyna. Justyna wrote this useful article and was permitted to translate it. Thanks!
개요
앱 개발시 중 제가 애용하는 도구 중 하나는 DRF라고도 하는 Django Rest Framework입니다. DRF를 사용하면 파이썬/Django 기반 REST API를 쉽고 재밌게 개발할 수 있습니다. 설정하기 쉽고 확장성도 좋으며 시간을 많이 아껴줍니다.
Django Rest Framework의 공식 문서는 매우 방대합니다. 훌륭한 튜토리얼과 API 설명, 다양한 예시까지요. 하지만 실전이란 언제나 더 복잡하기 마련이기에 프레임워크를 여러분의 요구에 맞춰 수정할 일도 생깁니다.
이 글에서는 제 프로젝트에서 Django Rest Framework를 사용하면서 얻은 몇 가지 레시피를 공유하려 합니다. 이 레시피는 모두 뷰셋(ViewSet) - 연관성 있는 API 엔드포인트의 집합 - 하나와 관련되어 있습니다. 이 글 말미에서는 모든 레시피를 합쳐 둔 코드를 보여드리겠습니다.
이 글은 Django 기초나 Django Rest Framework 기초를 제공하지 않습니다. 기초 지식은 각 공식 문서를 참고하세요.
예시로 사용할 API
개인이 여러 투자 계좌를 관리하는 invertimo 앱에 기능을 추가하는 중입니다. 만들려는 API는 리액트 프론트엔드가 사용하고, 프론트엔드 앱의 요구에 맞춰 수정해 갑니다.
다음과 같은 요건을 충족하고자 DRF의 ViewSet
을 사용하고 있습니다.
-
/accounts/
는 계좌 모델의 목록(list)을 제공하는 엔드포인트입니다. 몇 가지 부가적인 필드도 제공합니다. -
/accounts/id/
는 계좌 하나의 세부 내역(get)을 제공하는 엔드포인트입니다.from_date
나to_date
같은 파라미터를 추가로 입력받을 수 있습니다. - 생성과 수정용 엔드포인트에서는 특정한 파라미터만 허용합니다.
- 일반적인 삭제용 엔드포인트도 존재합니다.
예시는 파이썬 3.8을 기반으로 타입 힌트 기능을 사용합니다. Django Rest Framework에 타입 검사를 접목하는 설정법을 알고 싶다면 이 글을 추천합니다.
레시피 순서
레시피들은 가장 일반적인 것부터 복잡한 형태 순으로 나열하였습니다. 문제 수준이 비슷한 것들은 함께 묶어 두었고요. 맥락과 함께 해결책, 사용하는 이유를 설명하겠습니다.
현재 사용자에게 해당하는 데이터만 보여주기
invertimo에서는 사용자마다 각자의 데이터를 관리합니다. 어떤 사용자가 다른 사용자의 데이터를 봐선 안 되겠죠.
동기
- 개인에게 속한 정보는 해당 사용자에게만 보인다.
구현
- 쿼리셋을 필터링한다.
쿼리셋을 필터링할 가장 좋은 위치는 get_queryset
메서드입니다. ModelViewSet
에서 상속받았죠.
class AccountsViewSet(viewsets.ModelViewSet):
= [permissions.IsAuthenticated]
permission_classes = AccountSerializer
serializer_class = "account"
basename
def get_queryset(self) -> QuerySet[models.Account]:
assert isinstance(self.request.user, User)
= models.Account.objects.filter(user=self.request.user)
queryset
...return queryset
어떤 사용자인지는 self.request.user
로 확인할 수 있습니다.
예상 가능한 타입을 줄이려고 사용한 assert isinstance(self.request.user, User)
구문은 빼도 됩니다. 여기서는 인증을 거친 사용자(permission_classes = [permissions. IsAuthenticated]
)임을 강제하고자 사용했습니다.
메서드마다 시리얼라이저 다르게 적용하기
DRF에서는 시리얼라이저를 통해 API를 직렬화(예. json으로) 또는 역직렬화(예. 파이썬 객체로)하기 쉽습니다.
기본으로 뷰셋 하나에는 시리얼라이저 클래스가 하나입니다. 뷰가 여럿 있더라도요.
이 특징이 평소에는 괜찮지만 여러 시리얼라이저 클래스를 적용해야 할 경우도 생기기 마련이죠. 다음 화면을 봅시다.
위쪽의 직렬화된 필드들에 비해 아래쪽에 있는 생성 폼에는 필드가 몇 개 뿐입니다.
동기
- 필드들을 활용하기 좋은, 읽기 전용 형태로 보여준다.
- 복잡한 로직을 거쳐 계산하는 필드 추가하기
구현
- 시리얼라이저를 바꿔 사용하는 메서드마다 재구현한다. (반복 작업 필요)
-
get_serializer_class
를 오버라이드한다. (추천!)
class AccountsViewSet(viewsets.ModelViewSet):
= [permissions.IsAuthenticated]
permission_classes = AccountSerializer
serializer_class = "account"
basename
def get_serializer_class(self):
if self.action in ("create", "update"):
return AccountEditSerializer
if self.action == "retrieve":
return AccountWithValuesSerializer
return AccountSerializer
여러분이 만든 시리얼라이저가 서로 비슷하다고 해봅시다. 어떤 필드를 읽기 전용으로 바꾸고 업데이트 뷰에서는 사용하지 않기로 하는 것처럼 말이죠. 이때는 시리얼라이저를 교체하는 대신 해당 필드를 읽기 전용으로 명시하면 그만입니다.
모델에 없는 필드를 간단하게(on the fly) 추가하기
API를 개발하면서 자주 마주치는 또다른 상황은 모델 인스턴스에 데이터를 추가하는 경우입니다. 이 글에서는 두 가지 해갤책을 제시합니다.
- 쿼리셋 애너테이션 사용하기
- 시리얼라이저 메서드 사용하기
쿼리셋 애너테이션 방식
동기
- SQL 쿼리 결과를 바탕으로 필드를 추가합니다. 예를 들면 결과물의 개수 같은 것입니다.
구현
- 쿼리셋에 애너테이션을 추가합니다.
- 새 필드를 시리얼라이저에도 추가합니다.
여기서는 positions_count
와 transactions_count
를 추가했습니다.
class AccountsViewSet(viewsets.ModelViewSet):
= [permissions.IsAuthenticated]
permission_classes = AccountSerializer
serializer_class = LimitOffsetPagination
pagination_class = "account"
basename
def get_queryset(self) -> QuerySet[models.Account]:
assert isinstance(self.request.user, User)
= models.Account.objects.filter(user=self.request.user).annotate(
queryset =Count("positions", distinct=True),
positions_count=Count("positions__transactions", distinct=True),
transactions_count
)return queryset
이렇게 추가한 필드를 시리얼라이저에 정의합니다. 새 필드는 positions_count
와 transactions_count
입니다.
class AccountSerializer(serializers.ModelSerializer[Account]):
= serializers.IntegerField()
positions_count = serializers.IntegerField()
transactions_count
class Meta:
= Account
model = [
fields "id",
"nickname",
"description",
"balance",
"last_modified",
"positions_count",
"transactions_count",
]
이렇게 쿼리 애너테이션으로 필드를 추가하는 방식은 SQL 쿼리가 늘어나는 것을 방지하여 효과적입니다.
유용한 링크
시리얼라이저 메서드 방식
앞선 방식으로는 추가 필드를 표현하기 어려운 경우가 있습니다. 예를 들어 인스턴스의 메서드를 호출해야 값을 알 수 있는 경우처럼요. DRF의 SerializerMethodField
를 사용하여 쉽게 해결할 수 있습니다.
동기
- 커스텀 로직을 실행해서 얻을 수 있는 데이터를 필드로 추가합니다.
구현
- 필드를
serializers.SerializerMethodField()
로 정의합니다. - 이 필드를
Meta
클래스의fields
목록에 추가합니다. -
get_필드이름
메서드를 정의합니다.
다음은 values
필드를 추가하는 예시입니다.
class AccountWithValuesSerializer(serializers.ModelSerializer[Account]):
= serializers.IntegerField()
positions_count = serializers.IntegerField()
transactions_count = CurrencyField()
currency = serializers.SerializerMethodField()
values
class Meta:
= Account
model = [
fields "id",
"currency",
"nickname",
"description",
"balance",
"last_modified",
"positions_count",
"transactions_count",
"values",
]
def get_values(self, obj):
= self.context["from_date"]
from_date = self.context["to_date"]
to_date
return obj.value_history_per_position(from_date, to_date)
코드에서 self.context
를 사용했는데 이 기법은 다음 레시피에서도 사용합니다.
시리얼라이저에 추가 데이터 보내기
동기
- 추가 필드에 사용할 추가 데이터 생성하기
- 유효성 검증 추가
구현
-
get_serializer_context
를 오버라이드 -
self.request
나self.request.user
(self.request.query_params
에 들어 있는)의 데이터 사용하기
다음은 ViewSet
코드 예시입니다.
def get_serializer_context(self) -> Dict[str, Any]:
str, Any] = super().get_serializer_context()
context: Dict[= FromToDatesSerializer(data=self.request.query_params)
query "request"] = self.request
context[return context
이렇게 설정한 값을 시리얼라이저에서 사용할 수 있습니다.
= self.context.get("request") request
쿼리 파라미터에 시리얼라이저 사용하기
시리얼라이저는 JSON과 파이썬 객체를 서로 변환합니다. 유효성 검증 로직을 추가할 적당한 장소도 제공하죠. 쿼리 파라미터(URL 파라미터라고도 하는)의 데이터를 추출하고 검증하는 데에도 시리얼라이저를 사용할 수 있습니다.
동기
-
ViewSet
에 쿼리 파라미터를 추가하고 검증하기
구현
- 쿼리 파라미터에 대응할 시리얼라이저를 선언합니다. (
ModelSerializer
를 상속하지 않는다는 점을 유념하세요.) - 이 시리얼라이저를 뷰에 사용합니다.
-
MySerializer(data=self.request.query_params)
코드로 초기화합니다. - 유효성을 검증하고 데이터를 추출합니다.
-
시리얼라이저의 context
에 데이터를 추가했던 이전의 기법도 함께 활용할 수 있습니다.
class FromToDatesSerializer(serializers.Serializer[Any]):
= serializers.DateField(required=False)
from_date = serializers.DateField(required=False) to_date
유용한 링크
class AccountsViewSet(viewsets.ModelViewSet):
...def get_serializer_context(self) -> Dict[str, Any]:
str, Any] = super().get_serializer_context()
context: Dict[= FromToDatesSerializer(data=self.request.query_params)
query "request"] = self.request
context[
if query.is_valid(raise_exception=True):
= query.validated_data
data self.query_data = data
"from_date"] = self.query_data.get(
context["from_date",
- datetime.timedelta(days=30),
datetime.date.today()
)"to_date"] = self.query_data.get("to_date", datetime.date.today())
context[return context
DB에는 정수로 저장되지만 API에서는 문자열로 표현하기(열거형 활용)
필드에 넣을 수 있는 값이 몇 가지 뿐이라면 열거형(enum)을 사용하는 편이 좋습니다. Django는 TextChoices
와 IntegerChoices
, Choices
를 제공합니다.
열거형 만들기는 매우 쉽습니다.
저는 개인적으로 IntegerChoices
필드를 선호합니다. 데이터베이스에서 용량을 적게 차지하면서도 문자열로 값을 표현할 수 있으니까요.
여기서는 통화 필드를 다음처럼 정의해봅시다.
class Currency(models.IntegerChoices):
= 1, _("EUR")
EUR = 2, _("GBP")
GBP = 3, _("USD")
USD = 4, _("GBX")
GBX
def currency_enum_from_string(currency: str) -> Currency:
try:
return Currency[currency]
except KeyError:
raise ValueError("Unsupported currency '%s'" % currency)
def currency_string_from_enum(currency: Currency) -> str:
return Currency(currency).label
class Account(models.Model):
= models.ForeignKey(User, on_delete=models.CASCADE)
user = models.IntegerField(choices=Currency.choices, default=Currency.EUR)
currency = models.CharField(max_length=200)
nickname = models.TextField(blank=True) description
통화로 선택 가능한 네 가지 값이 있고, 데이터베이스에 저장될 때는 효율적으로 정수만 저장됩니다.
API에서도 EUR
대신 값 1
을 사용해야 하죠. 그런데 EUR
을 나타내는 값으로 EUR
을 사용하면 어떨까요? 데이터베이스에 어떤 값이 저장되는지도 숨길 수 있으니까요.
동기
- 내부에 저장하는 값과 외부로 보여주는 값을 다르게 하고 싶습니다. 정수형 <-> 문자열처럼요.
구현
- 새 시리얼라이저 필드 클래스를 생성합니다. 이는 내외부 표현(representation)을 관리하는 데 적절한 위치입니다.
-
to_representation
메서드와to_internal_value
메서드를 정의합니다. - 새로 정의한 필드를 시리얼라이저에서 사용합니다.
class CurrencyField(serializers.IntegerField):
def to_representation(self, value):
return models.currency_string_from_enum(value)
def to_internal_value(self, value):
return models.currency_enum_from_string(value)
class AccountEditSerializer(serializers.ModelSerializer[Account]):
# Currency를 나타내는 문자열을 열거형으로 바꿔야 합니다.
= CurrencyField() currency
유용한 링크
폼에 입력하지 않고도 사용자에 대한 고유 값 검증하기
현재 접속한 사용자에 대해 객체를 생성하는 일은 흔합니다. 이걸 위해 폼에 user 필드를 추가해야 할까요? 해당 사용자의 이름(name)이 고유 값이어야 한다면 어떻게 해야 할까요?
다음 두 기법을 함께 사용합니다.
-
get_serializer_context
를 오버라이딩하여 시리얼라이저에 추가 데이터를 넘겨줍니다. -
validate_필드이름
메서드를 오버라이드하여 필드 유효성을 검증합니다.
이 예시에서는 Account 모델에 user
와 nickname
제약을 추가했습니다. (=사용자의 nickname
은 고유 값이어야 합니다.)
# 모델에서
class Meta:
= [["user", "nickname"]] unique_together
nickname
필드의 유효성을 검증하려고 validate_nickname
메서드를 오버라이드합니다. 이미 존재하는 값이면 serializers.ValidationError
예외를 일으킵니다.
class Meta:
= Account
model = [
fields "id",
"currency",
"nickname",
"description",
]
def validate_nickname(self, value):
# user가 시리얼라이저에 들어 있다면 unique_together 제약이
# 자동으로 검증됩니다.
# 하지만 그렇지 않다면 수동으로 검증해야 합니다.
= self.context.get("request")
request if request and hasattr(request, "user"):
= request.user
user if Account.objects.filter(user=user, nickname=value).count() > 0:
raise serializers.ValidationError(
f"User already has an account with name: '{value}'"
)return value
유용한 링크
이 모든 레시피를 담은 ViewSet
지금까지 작업한 거대한 결과물이 어떤 모습일지 궁금하시죠? 모든 레시피를 모아둔 코드는 다음과 같습니다. (테스트 코드는 작성했지만 여기엔 소개하지 않았습니다. 전체 코드가 궁금하다면 코드 저장소를 참고하세요.)
models.py
:
class Currency(models.IntegerChoices):
= 1, _("EUR")
EUR = 2, _("GBP")
GBP = 3, _("USD")
USD = 4, _("GBX")
GBX
def currency_enum_from_string(currency: str) -> Currency:
try:
return Currency[currency]
except KeyError:
raise ValueError("Unsupported currency '%s'" % currency)
def currency_string_from_enum(currency: Currency) -> str:
return Currency(currency).label
class Account(models.Model):
= models.ForeignKey(User, on_delete=models.CASCADE)
user = models.IntegerField(choices=Currency.choices, default=Currency.EUR)
currency = models.CharField(max_length=200)
nickname = models.TextField(blank=True)
description
= models.DecimalField(max_digits=12, decimal_places=5, default=0)
balance = models.DateTimeField(auto_now=True, null=True)
last_modified
def __str__(self):
return (
f"<Account user: {self.user}, nickname: '{self.nickname}', "
f"currency: {self.get_currency_display()}>"
)
def value_history_per_position(self, from_date, to_date):
= []
results for position in self.positions.all():
results.append(
(
position.pk,
position.value_history_in_account_currency(from_date, to_date),
)
)return results
class Meta:
= [["user", "nickname"]] unique_together
views.py
:
class AccountsViewSet(viewsets.ModelViewSet):
= [permissions.IsAuthenticated]
permission_classes = AccountSerializer
serializer_class = LimitOffsetPagination
pagination_class = "account"
basename
def get_queryset(self) -> QuerySet[models.Account]:
assert isinstance(self.request.user, User)
= models.Account.objects.filter(user=self.request.user).annotate(
queryset =Count("positions", distinct=True),
positions_count=Count("positions__transactions", distinct=True),
transactions_count
)return queryset
def get_serializer_context(self) -> Dict[str, Any]:
str, Any] = super().get_serializer_context()
context: Dict["request"] = self.request
context[
= FromToDatesSerializer(data=self.request.query_params)
query
if query.is_valid(raise_exception=True):
= query.validated_data
data self.query_data = data
"from_date"] = self.query_data.get(
context["from_date",
- datetime.timedelta(days=30),
datetime.date.today()
)"to_date"] = self.query_data.get("to_date", datetime.date.today())
context[return context
def get_serializer_class(
self,
-> Type[
)
Union[AccountEditSerializer, AccountWithValuesSerializer, AccountSerializer]
]:if self.action in ("create", "update"):
return AccountEditSerializer
if self.action == "retrieve":
return AccountWithValuesSerializer
return AccountSerializer
def retrieve(self, request, pk=None):
= self.get_queryset()
queryset = queryset.prefetch_related("positions__security")
queryset = get_object_or_404(queryset, pk=pk)
account = self.get_serializer(account, context=self.get_serializer_context())
serializer return Response(serializer.data)
def create(self, request, *args, **kwargs):
= self.get_serializer(
serializer =request.data, context=self.get_serializer_context()
data
)=True)
serializer.is_valid(raise_exceptionassert isinstance(self.request.user, User)
accounts.AccountRepository().create(=self.request.user, **serializer.validated_data
user
)= self.get_success_headers(serializer.data)
headers return Response(
=status.HTTP_201_CREATED, headers=headers
serializer.data, status )
serializers.py
:
class AccountSerializer(serializers.ModelSerializer[Account]):
= serializers.IntegerField()
positions_count = serializers.IntegerField()
transactions_count = CurrencyField()
currency
class Meta:
= Account
model = [
fields "id",
"currency",
"nickname",
"description",
"balance",
"last_modified",
"positions_count",
"transactions_count",
]
class AccountEditSerializer(serializers.ModelSerializer[Account]):
# Currency를 나타내는 문자열을 열거형으로 바꿔야 합니다.
= CurrencyField()
currency
class Meta:
= Account
model = [
fields "id",
"currency",
"nickname",
"description",
]
def validate_nickname(self, value):
# user가 시리얼라이저에 들어 있다면 unique_together 제약이
# 자동으로 검증됩니다.
# 하지만 그렇지 않다면 수동으로 검증해야 합니다.
= self.context.get("request")
request if request and hasattr(request, "user"):
= request.user
user if Account.objects.filter(user=user, nickname=value).count() > 0:
raise serializers.ValidationError(
f"User already has an account with name: '{value}'"
)return value
class AccountWithValuesSerializer(serializers.ModelSerializer[Account]):
= serializers.IntegerField()
positions_count = serializers.IntegerField()
transactions_count = CurrencyField()
currency = serializers.SerializerMethodField()
values
class Meta:
= Account
model = [
fields "id",
"currency",
"nickname",
"description",
"balance",
"last_modified",
"positions_count",
"transactions_count",
"values",
]
def get_values(self, obj):
= self.context["from_date"]
from_date = self.context["to_date"]
to_date
return obj.value_history_per_position(from_date, to_date)
추천 문서
Django rest framework은 놀랍도록 잘 설계됐습니다. 적재적소에 기능을 추가하기도 쉽고요. 뭔가 일반적이지 않은 작동 방식을 구현해야 한다면, 스택오버플로를 뒤지는 대신 DRF의 소스코드를 저장소나 에디터에서 열어보세요. 즐거운 코딩을 응원합니다! 이 글이 도움이 됐다면 맘껏 공유하시고 트위터에서 저를 팔로우해주세요.