wafflestudio/seminar-2020

DRF ViewSet에서 put() 등의 함수를 선언하면서 Router를 이용하는 것에 대하여

Opened this issue · 2 comments

다음 글은 과제 1 수행 도중 DRF와 친해지며 제가 가진 의문과 그 해결 과정, 문제 제기를 포함하고 있습니다.
질문 내용만이 궁금하시다면 TL; DR로 이동하시면 됩니다.
또한 이 글은 단정적 어조로 작성되었으나 Django 입문자인 제가 작성했으므로 틀린 부분이 있을 확률이 매우 높다는 점을 밝힙니다. 오류를 발견하실 경우 아래 답변으로 지적해 주시면 감사하겠습니다.

Router

Router는 URL을 ViewSet의 각 함수에 연결해 주는 역할을 합니다.
예를 들어 과제 0에서 사용한 SimpleRouter{prefix}/api/v1/survey일 때
GET /api/v1/survey/list(self, request)에,
POST /api/v1/survey/create(self, request)에,
GET /api/v1/survey/{surveyresult_id}/retrieve(self, request, pk)
연결해 주는 등 CRUD API를 작성할 때 일반적으로 필요한 URL Routing을 제공합니다.


put()의 사용

그런데 과제 1에서는 SimpleRouter의 Route나 DynamicRoute가 연결해 주지 않는 PUT /api/v1/user/의 개발을 요구하고 있고, 구현 방법 중 하나는 아래와 같이 put() 함수를 선언하는 것입니다.

# waffle_backend/user/views.py
class UserViewSet(viewsets.GenericViewSet):
(...중략...)
    # PUT /api/v1/user/
    def put(self, request):
        if request.user.is_authenticated:
            return Response(self.get_serializer(request.user).data)
        else:
            return Response(status=status.HTTP_403_FORBIDDEN)

저는 waffle_backend/user/urls.py에서 /api/v1/user 이하의 URL 설정을 SimpleRouter에 위임했기 때문에 SimpleRouter가 URL에 대한 single source of truth가 되어야 한다고 생각했고, SimpleRouter에는 PUT /api/v1/user/put(self, request)로 연결해주어야 한다는 설정이 없기 때문에 이 요청은 405 Method Not Allowed를 반환해야 한다고 생각했습니다.

그러나 실제로 실행해 보면 PUT /api/v1/user/put(self, request)로 연결되고 로그인 상태에 따라 200 OK 또는 403 Forbidden이 정상적으로 반환됩니다.


Router, ViewSet, APIView의 작동 방식

이로 인해 Router를 수정하지 않고 ViewSet에 함수(put)를 하나 생성하는 것이 어떻게 URL의 전달 방식에 영향을 줄 수 있을까? 라는 고민을 시작했고, viewsets.py 소스 코드 등에서 답을 찾을 수 있었습니다.

Django에서 하나의 URI는 하나의 View에 연결됩니다. 우리가 사용하는 ViewSet은 View들의 집합이고, Router는 이 View들의 집합에 UserViewSet.as_view(actions)를 사용해서 View라는 Callable을 만들게 됩니다. 이때 라우터가 actions 파라미터에 어떤 HTTP 메소드를 어떤 함수에 연결할 지 전달해 줍니다. 그런데 여기서 주목할 것은 request의 method에 관계없이 같은 URI는 같은 View에 전달된다는 사실입니다. 결국 Router는 각 URI가 어떤 View에 전달될지만 지정해줄 뿐, HTTP 메소드는 View(정확하게는 GenericViewSet의 BaseClass 중 하나인 APIView)에서 자체적으로 처리하는 것입니다.

APIView는 이를 dispatch(self, request, *args, **kwargs)라는 함수를 이용하여 처리합니다.
dispatch()는 request.method.lower()라는 이름의 함수가 View 내에 정의되어 있는지 확인하고, 있다면 그 함수에 request를 전달하고, 그런 이름의 함수가 없다면 405 Method Not Allowed를 반환합니다. 이로 인해 View가 put()이라는 함수가 정의된 것을 확인하고 request를 전달한 것입니다.

결국 viewset.as_view(actions)가 하는 일은 아래와 같이 Router에 정의된 action(예를 들어 GET을 list 함수에 연결하라)대로 함수를 self.get = self.list로 연결하는 것입니다.

# rest_framework/viewsets.py  ViewSetMixin.as_view(cls, actions=None, **initkwargs) 중
for method, action in actions.items():
    handler = getattr(self, action)
    setattr(self, method, handler)

우려되는 부분

위의 내용을 바탕으로, 저는 ViewSet 내부에 put()이나 get()과 같은 HTTP 메소드 이름의 함수를 선언하는 것에 다음의 두 가지 문제가 있다고 생각합니다.

  1. Router의 작동에 의해 GET 요청이 일어났음에도 get() 함수가 실행되지 않을 수 있습니다.
    예를 들어 ViewSet에 get() 함수와 list() 함수를 모두 작성한 경우, as_view()의 작동에 의해 get()이 덮어 쓰이면서 GET {prefix}/ 요청을 받을 때 list() 함수가 실행됩니다. 이는 디버깅을 어렵게 할 수 있습니다.

  2. ViewSet에 선언한 put() 함수는 ViewSet이 연결되는 모든 URI에 대해 작동하여, 예상치 못한 결과를 낼 수 있습니다.
    위의 put()의 사용처럼 SimpleRouter를 이용하는 상황에서 PUT /api/v1/user/를 구현하기 위해 put(self, request)를 선언했다고 합시다. 여기까지는 별 문제가 없어 보입니다.
    이후에 다른 유저의 정보를 조회하는 기능을 위해 GET /api/v1/user/{username}/def retrieve(self, request, pk=None)를 이용하여 개발했다고 합시다.
    여기서 PUT /api/v1/user/{username}/은 API Specification에 없으므로, 405 Method Not Allowed를 반환해야 할 것입니다. 하지만 이 요청은 위에서 선언한 put에 put(self, request, pk)의 형태로 전달됩니다.
    put은 두 개의 파라미터를 받는데 pk라는 이름의 파라미터가 추가로 전달되었으므로 500 Internal Error가 발생할 것이고, def put(self, request, **kwargs)으로 정의했다면 처리는 되겠지만 의도한 동작은 아닐 것입니다.
    여기서 PUT /api/v1/user/{username}/이 put()으로 전달되는 이유는 SimpleRouter가 retrieve()가 선언된 것을 확인하고 /api/v1/user/{lookup}/에 연결되는 View를 UserViewSet.as_view(actions)로 생성하였고, View의 dispatch()가 클래스 내부에 put이 선언된 것을 확인하고 요청을 put()으로 전달한 것입니다.
    하지만 retrieve()가 선언되지 않았다면 Router에서 /api/v1/user/{lookup}/을 처리하는 함수가 없다고 판단하고 해당 경로에 연결될 View를 만들지 않습니다. 따라서 PUT /api/v1/user/{username}/은 404 Not Found를 반환할 것입니다.
    이처럼 put()은 다른 함수(retrieve)의 선언에 따라 예상치 못한 접근 지점을 만들어낼 수 있습니다.


TL; DR

  1. ViewSet과 Router를 이용하면 기본적으로 Router에 정의된 대로 요청이 ViewSet의 함수로 전달됩니다.
  2. ViewSet에 HTTP Method 이름의 함수 put(), get() 등을 정의하면 Router에서 연결해 주는 URI이지만 Router에서 연결해 주는 Method가 아닐 경우 작동합니다.

질문

  1. ViewSet에서 put(), get() 등을 정의하는 것이 위와 같은 Complex Behavior를 야기하는데, ViewSet에서 put(), get()을 정의하는 것이 DRF에서 의도한 문법이거나 일반적으로 사용하는 문법인가요?
  2. 현업 개발에서도 ViewSet에 HTTP 메소드 이름의 함수를 작성하나요? 만약 그렇다면 개발자 모두가 put() 함수가 언제 작동하고 언제 작동하지 않는지 잘 알고 있어서 크게 문제가 되지 않는 것일까요?

좋은 질문 감사합니다, 저도 2번째 세미나 및 질의 응답 시간에, put()으로 많이들 구현하신 것을 보면서 할 말이 여러가지 있는 뉘앙스로 뭔가 다음에 짚어야겠다고 언급한 적이 있었는데, 어느 정도 결이 같은 질문인 듯합니다. 일단 답변을 찬찬히 해야할 듯한데, 과제 1 관련해서 많은 분들이 get(), put()UserViewSet에서 정의해서 사용하고 있다는 점을 보고(실제로 저도 그런 방식도 가능하다, 라고 우선 몇몇 분의 과제 피드백 등에서도 남겼었구요), 저도 언젠가 정리해서 얘기드리려는 지점이 있었어서 반가워서 먼저 답글 남깁니다.

일단 질문에 대해 답만 짧게 하면,

  1. 그렇지 않습니다. ViewSet이 Django의 View를 가져오기에 HTTP method 이름의 함수를 그대로 받아 사용할 수는 있습니다.(#153 (comment) 에서도 관련 코드의 링크를 달아 언급한 적이 있습니다) 그러나 이런 방식은 DRF의 ViewSet에서 의도하는 방식이라고 보기 어렵습니다.

  2. 그렇지 않습니다. DRF의 APIView나 Django의 View 내부에서 주로 get(), put() 등 HTTP method 이름의 함수를 사용합니다.

일단 이에 대해 먼저 언급드리고 싶은 것은, 사실 이런 혼란이 야기된 것은 제 잘못도 크다는 것입니다. 결과적으로 이런저런 것들에 대해 다 얘기할 수 있어서 좋긴 한데, 애초에 뭔가 고민할 지점이 생겼던 이유 중 하나가, 과제 1에서 요구한 API 스펙이 RESTful하지 않았기 때문이라고 할 수 있습니다. 제가 0번째 세미나에서 RESTful API에 대해 가볍게 언급한 적이 있습니다. 실무에서도 항상 잘 지켜지는 것은 아니지만, 가급적 URI를 RESTful하게 만드는 것이 좋습니다.

https://meetup.toast.com/posts/92 등을 참고하면, 어떤 것이 RESTful한 URI인지 살펴볼 수 있는데, 이를 고려하여 생각해보면 과제 1에서처럼 내(User) 정보를 조회하기 위해서 GET /api/v1/user/, 내(User) 정보를 수정하기 위해서 PUT /api/v1/user/를 사용한다는 것이 어색하게 느껴질 것입니다. 실제로 GET /api/v1/user/ViewSet에서 구현할 때 SurveyResultSerializer에서처럼 list()를 사용하신 분들이 있었는데요, 이건 뭔가 여러 User 정보를 가져올 때 쓰는 느낌이니까 어색하다고 느끼셨거나, 또는 제가 피드백으로라도 이를 언급했었을 것입니다. RESTful한 API라면 내 정보를 가져오고 수정하는 URI는 /api/v1/user/{my_user_id}/ 거나, /api/v1/user/me/ 여야 할 것입니다. 때문에 일반화하기는 어려우나, UserViewSet 내에 내 정보를 수정하는 API를 개발하라고 하면 저의 경우, 아래와 같이 할 것입니다. ViewSet 문서에 있는 list(), retrieve()와 같은, update()를 이용한 것입니다.

    def update(self, request, pk=None):
        if pk != 'me':
            return Response(status=status.HTTP_403_FORBIDDEN)
        user = request.user
        ...

위와 같은 구현에서 'me'가 뭐지... 하실 수도 있는데, PUT /api/v1/user/{user_id}/에서 {user_id}me가 오는 것만을 강제했구나, 라고 생각하시면 될 것 같습니다.(여기에 이렇게 숫자가 아닌 것도 됩니다!) 그렇다고 엉뚱한 user_id를 진짜 넣어서 남의 정보를 수정하게 동작하거나 또는 그렇게 여겨지면 안 되니까 me가 아닌 경우 403 FORBIDDEN을 주려고 한 것이구요.

그러면 대체 과제 1에서 왜 이런 URI로 해당 API들을 구현하라고 한 것이냐? 일단 제 생각이 좀 짧았던 것 같습니다. 제 딴에는 다들 Django, DRF, MySQL 등 적응하실 게 워낙 많으니까(그리고 제가 아직 DRF에 대해서는 세미나 진행 시간에 상대적으로 설명 투자도 덜 했구요) RESTful을 좀 희생해서라도, 굳이 저렇게 me 같은 방식 등을 고민하지 않고 가볍게 구현하게 해드리려고 endpoint를 간단히 설정했으나, 오히려 이 점 때문에 더 어려워진 거 같기도 합니다. 그리고 이런 내용을 #120 에서 같이 언급드렸어도 좋았을 거 같은데, 타이밍을 좀 놓친 감이 있구요. 안 그래도 과제 2를 위한 기본 서버 코드에서는, 같은 기능을 하는 API들의 endpoint를 위에 제가 예시를 든 방식으로 구현하고, 이런 부분에 차이가 있다고 알려드리려고 하는 참이었습니다.

좋은 분석과 지적 감사드립니다. 지금 제 답변에는 말씀하신 '우려되는 부분'에 대해서는 더 구체적으로 응답하거나 설명을 덧붙이지 않았는데, 일단 질문 주신 분과 이슈를 보시는 분들의 혼란을 조금이라도 줄여드리고자 빠르게 답해보았습니다. 그 외 내용에 대해서는 추후 시간이 날 때 덧붙이겠습니다.