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 기초를 제공하지 않습니다. 기초 지식은 각 공식 문서를 참고하세요.

44BITS 소식과 클라우드 뉴스를 전해드립니다. 지금 5,000명 이상의 구독자와 함께 하고 있습니다 📮

예시로 사용할 API

개인이 여러 투자 계좌를 관리하는 invertimo 앱에 기능을 추가하는 중입니다. 만들려는 API는 리액트 프론트엔드가 사용하고, 프론트엔드 앱의 요구에 맞춰 수정해 갑니다.

다음과 같은 요건을 충족하고자 DRF의 ViewSet을 사용하고 있습니다.

예시는 파이썬 3.8을 기반으로 타입 힌트 기능을 사용합니다. Django Rest Framework에 타입 검사를 접목하는 설정법을 알고 싶다면 이 글을 추천합니다.

레시피 순서

레시피들은 가장 일반적인 것부터 복잡한 형태 순으로 나열하였습니다. 문제 수준이 비슷한 것들은 함께 묶어 두었고요. 맥락과 함께 해결책, 사용하는 이유를 설명하겠습니다.

현재 사용자에게 해당하는 데이터만 보여주기

invertimo에서는 사용자마다 각자의 데이터를 관리합니다. 어떤 사용자가 다른 사용자의 데이터를 봐선 안 되겠죠.

동기

구현

쿼리셋을 필터링할 가장 좋은 위치는 get_queryset 메서드입니다. ModelViewSet에서 상속받았죠.

class AccountsViewSet(viewsets.ModelViewSet):
    permission_classes = [permissions.IsAuthenticated]
    serializer_class = AccountSerializer
    basename = "account"

    def get_queryset(self) -> QuerySet[models.Account]:
      assert isinstance(self.request.user, User)
      queryset = models.Account.objects.filter(user=self.request.user)
      ...
      return queryset

어떤 사용자인지는 self.request.user로 확인할 수 있습니다.

예상 가능한 타입을 줄이려고 사용한 assert isinstance(self.request.user, User) 구문은 빼도 됩니다. 여기서는 인증을 거친 사용자(permission_classes = [permissions. IsAuthenticated])임을 강제하고자 사용했습니다.

메서드마다 시리얼라이저 다르게 적용하기

DRF에서는 시리얼라이저를 통해 API를 직렬화(예. json으로) 또는 역직렬화(예. 파이썬 객체로)하기 쉽습니다.

기본으로 뷰셋 하나에는 시리얼라이저 클래스가 하나입니다. 뷰가 여럿 있더라도요.

이 특징이 평소에는 괜찮지만 여러 시리얼라이저 클래스를 적용해야 할 경우도 생기기 마련이죠. 다음 화면을 봅시다.

API 데이터의 형태

위쪽의 직렬화된 필드들에 비해 아래쪽에 있는 생성 폼에는 필드가 몇 개 뿐입니다.

동기

구현

class AccountsViewSet(viewsets.ModelViewSet):
    permission_classes = [permissions.IsAuthenticated]
    serializer_class = AccountSerializer
    basename = "account"

    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를 개발하면서 자주 마주치는 또다른 상황은 모델 인스턴스에 데이터를 추가하는 경우입니다. 이 글에서는 두 가지 해갤책을 제시합니다.

쿼리셋 애너테이션 방식

동기

구현

여기서는 positions_counttransactions_count를 추가했습니다.

class AccountsViewSet(viewsets.ModelViewSet):
    permission_classes = [permissions.IsAuthenticated]
    serializer_class = AccountSerializer
    pagination_class = LimitOffsetPagination
    basename = "account"

    def get_queryset(self) -> QuerySet[models.Account]:
        assert isinstance(self.request.user, User)
        queryset = models.Account.objects.filter(user=self.request.user).annotate(
            positions_count=Count("positions", distinct=True),
            transactions_count=Count("positions__transactions", distinct=True),
        )
        return queryset

이렇게 추가한 필드를 시리얼라이저에 정의합니다. 새 필드는 positions_counttransactions_count입니다.

class AccountSerializer(serializers.ModelSerializer[Account]):
  
    positions_count = serializers.IntegerField()
    transactions_count = serializers.IntegerField()
  
    class Meta:
        model = Account
        fields = [
            "id",
            "nickname",
            "description",
            "balance",
            "last_modified",
            "positions_count",
            "transactions_count",
        ]

이렇게 쿼리 애너테이션으로 필드를 추가하는 방식은 SQL 쿼리가 늘어나는 것을 방지하여 효과적입니다.

유용한 링크

시리얼라이저 메서드 방식

앞선 방식으로는 추가 필드를 표현하기 어려운 경우가 있습니다. 예를 들어 인스턴스의 메서드를 호출해야 값을 알 수 있는 경우처럼요. DRF의 SerializerMethodField를 사용하여 쉽게 해결할 수 있습니다.

동기

구현

다음은 values 필드를 추가하는 예시입니다.

class AccountWithValuesSerializer(serializers.ModelSerializer[Account]):

    positions_count = serializers.IntegerField()
    transactions_count = serializers.IntegerField()
    currency = CurrencyField()
    values = serializers.SerializerMethodField()

    class Meta:
        model = Account
        fields = [
            "id",
            "currency",
            "nickname",
            "description",
            "balance",
            "last_modified",
            "positions_count",
            "transactions_count",
            "values",
        ]

    def get_values(self, obj):
        from_date = self.context["from_date"]
        to_date = self.context["to_date"]

        return obj.value_history_per_position(from_date, to_date)

코드에서 self.context를 사용했는데 이 기법은 다음 레시피에서도 사용합니다.

시리얼라이저에 추가 데이터 보내기

동기

구현

다음은 ViewSet 코드 예시입니다.

    def get_serializer_context(self) ->  Dict[str, Any]:
        context: Dict[str, Any] = super().get_serializer_context()
        query = FromToDatesSerializer(data=self.request.query_params)
        context["request"] = self.request
        return context

이렇게 설정한 값을 시리얼라이저에서 사용할 수 있습니다.

request = self.context.get("request")

쿼리 파라미터에 시리얼라이저 사용하기

시리얼라이저는 JSON과 파이썬 객체를 서로 변환합니다. 유효성 검증 로직을 추가할 적당한 장소도 제공하죠. 쿼리 파라미터(URL 파라미터라고도 하는)의 데이터를 추출하고 검증하는 데에도 시리얼라이저를 사용할 수 있습니다.

동기

구현

시리얼라이저의 context에 데이터를 추가했던 이전의 기법도 함께 활용할 수 있습니다.

class FromToDatesSerializer(serializers.Serializer[Any]):
    from_date = serializers.DateField(required=False)
    to_date = serializers.DateField(required=False)

유용한 링크

class AccountsViewSet(viewsets.ModelViewSet):
    ...
    def get_serializer_context(self) ->  Dict[str, Any]:
        context: Dict[str, Any] = super().get_serializer_context()
        query = FromToDatesSerializer(data=self.request.query_params)
        context["request"] = self.request

        if query.is_valid(raise_exception=True):
            data = query.validated_data
            self.query_data = data
            context["from_date"] = self.query_data.get(
                "from_date",
                datetime.date.today() - datetime.timedelta(days=30),
            )
            context["to_date"] = self.query_data.get("to_date", datetime.date.today())
        return context

DB에는 정수로 저장되지만 API에서는 문자열로 표현하기(열거형 활용)

필드에 넣을 수 있는 값이 몇 가지 뿐이라면 열거형(enum)을 사용하는 편이 좋습니다. Django는 TextChoicesIntegerChoices, Choices를 제공합니다.

열거형 만들기는 매우 쉽습니다.

저는 개인적으로 IntegerChoices 필드를 선호합니다. 데이터베이스에서 용량을 적게 차지하면서도 문자열로 값을 표현할 수 있으니까요.

여기서는 통화 필드를 다음처럼 정의해봅시다.

class Currency(models.IntegerChoices):
    EUR = 1, _("EUR")
    GBP = 2, _("GBP")
    USD = 3, _("USD")
    GBX = 4, _("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):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    currency = models.IntegerField(choices=Currency.choices, default=Currency.EUR)
    nickname = models.CharField(max_length=200)
    description = models.TextField(blank=True)

통화로 선택 가능한 네 가지 값이 있고, 데이터베이스에 저장될 때는 효율적으로 정수만 저장됩니다.

API에서도 EUR 대신 값 1을 사용해야 하죠. 그런데 EUR을 나타내는 값으로 EUR을 사용하면 어떨까요? 데이터베이스에 어떤 값이 저장되는지도 숨길 수 있으니까요.

동기

구현

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를 나타내는 문자열을 열거형으로 바꿔야 합니다.
    currency = CurrencyField()

유용한 링크

폼에 입력하지 않고도 사용자에 대한 고유 값 검증하기

현재 접속한 사용자에 대해 객체를 생성하는 일은 흔합니다. 이걸 위해 폼에 user 필드를 추가해야 할까요? 해당 사용자의 이름(name)이 고유 값이어야 한다면 어떻게 해야 할까요?

다음 두 기법을 함께 사용합니다.

이 예시에서는 Account 모델에 usernickname 제약을 추가했습니다. (=사용자의 nickname은 고유 값이어야 합니다.)

    # 모델에서

    class Meta:
        unique_together = [["user", "nickname"]]

nickname 필드의 유효성을 검증하려고 validate_nickname 메서드를 오버라이드합니다. 이미 존재하는 값이면 serializers.ValidationError 예외를 일으킵니다.

   class Meta:
        model = Account
        fields = [
            "id",
            "currency",
            "nickname",
            "description",
        ]

    def validate_nickname(self, value):
        # user가 시리얼라이저에 들어 있다면 unique_together 제약이 
        # 자동으로 검증됩니다.
        # 하지만 그렇지 않다면 수동으로 검증해야 합니다.
        request = self.context.get("request")
        if request and hasattr(request, "user"):
            user = request.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):
    EUR = 1, _("EUR")
    GBP = 2, _("GBP")
    USD = 3, _("USD")
    GBX = 4, _("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):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    currency = models.IntegerField(choices=Currency.choices, default=Currency.EUR)
    nickname = models.CharField(max_length=200)
    description = models.TextField(blank=True)

    balance = models.DecimalField(max_digits=12, decimal_places=5, default=0)
    last_modified = models.DateTimeField(auto_now=True, null=True)

    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:
        unique_together = [["user", "nickname"]]

views.py:

class AccountsViewSet(viewsets.ModelViewSet):
    permission_classes = [permissions.IsAuthenticated]
    serializer_class = AccountSerializer
    pagination_class = LimitOffsetPagination
    basename = "account"

    def get_queryset(self) -> QuerySet[models.Account]:
        assert isinstance(self.request.user, User)
        queryset = models.Account.objects.filter(user=self.request.user).annotate(
            positions_count=Count("positions", distinct=True),
            transactions_count=Count("positions__transactions", distinct=True),
        )
        return queryset

    def get_serializer_context(self) -> Dict[str, Any]:
        context: Dict[str, Any] = super().get_serializer_context()
        context["request"] = self.request

        query = FromToDatesSerializer(data=self.request.query_params)

        if query.is_valid(raise_exception=True):
            data = query.validated_data
            self.query_data = data
            context["from_date"] = self.query_data.get(
                "from_date",
                datetime.date.today() - datetime.timedelta(days=30),
            )
            context["to_date"] = self.query_data.get("to_date", datetime.date.today())
        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):
        queryset = self.get_queryset()
        queryset = queryset.prefetch_related("positions__security")
        account = get_object_or_404(queryset, pk=pk)
        serializer = self.get_serializer(account, context=self.get_serializer_context())
        return Response(serializer.data)

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(
            data=request.data, context=self.get_serializer_context()
        )
        serializer.is_valid(raise_exception=True)
        assert isinstance(self.request.user, User)
        accounts.AccountRepository().create(
            user=self.request.user, **serializer.validated_data
        )
        headers = self.get_success_headers(serializer.data)
        return Response(
            serializer.data, status=status.HTTP_201_CREATED, headers=headers
        )

serializers.py :

class AccountSerializer(serializers.ModelSerializer[Account]):

    positions_count = serializers.IntegerField()
    transactions_count = serializers.IntegerField()
    currency = CurrencyField()

    class Meta:
        model = Account
        fields = [
            "id",
            "currency",
            "nickname",
            "description",
            "balance",
            "last_modified",
            "positions_count",
            "transactions_count",
        ]

class AccountEditSerializer(serializers.ModelSerializer[Account]):

    # Currency를 나타내는 문자열을 열거형으로 바꿔야 합니다.
    currency = CurrencyField()

    class Meta:
        model = Account
        fields = [
            "id",
            "currency",
            "nickname",
            "description",
        ]

    def validate_nickname(self, value):
        # user가 시리얼라이저에 들어 있다면 unique_together 제약이 
        # 자동으로 검증됩니다.
        # 하지만 그렇지 않다면 수동으로 검증해야 합니다.
        request = self.context.get("request")
        if request and hasattr(request, "user"):
            user = request.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]):

    positions_count = serializers.IntegerField()
    transactions_count = serializers.IntegerField()
    currency = CurrencyField()
    values = serializers.SerializerMethodField()

    class Meta:
        model = Account
        fields = [
            "id",
            "currency",
            "nickname",
            "description",
            "balance",
            "last_modified",
            "positions_count",
            "transactions_count",
            "values",
        ]

    def get_values(self, obj):
        from_date = self.context["from_date"]
        to_date = self.context["to_date"]

        return obj.value_history_per_position(from_date, to_date)

추천 문서

Django rest framework은 놀랍도록 잘 설계됐습니다. 적재적소에 기능을 추가하기도 쉽고요. 뭔가 일반적이지 않은 작동 방식을 구현해야 한다면, 스택오버플로를 뒤지는 대신 DRF의 소스코드를 저장소나 에디터에서 열어보세요. 즐거운 코딩을 응원합니다! 이 글이 도움이 됐다면 맘껏 공유하시고 트위터에서 를 팔로우해주세요.