왓챠피디아 클론 WatchB 개발기: 유저 인증 (백엔드 1편)

mynghn·2022년 8월 10일
1
post-thumbnail

지난 포스팅 이후 백엔드가 필요한 프로젝트에 대해 고민하다가 WatchB(a대신 b...)라는 이름으로 평소 애용하는 서비스인 왓챠피디아를 클론해보기로 했다..!

선정 이유는 대략 다음과 같다.

  • 일단 계정이 필요하다.
    • 계정 별로 영화에 매긴 평점과 코멘트 저장이 필요.
    • 코멘트에 대한 좋아요까지 생각하면 유저 객체 사이의 상호작용을 구현하고 관리하기 위해 체계적인 데이터베이스 모델이 필요할 것.
  • 그 외에도 영화(+인물) 역시 별도의 모델로 관리할 필요가 있다.
  • 추천 서비스를 하기 위해 데이터베이스를 기반으로 복잡한 활용 로직이 요구된다.

컨텐츠 평점 & 추천 서비스 의 틀을 가지고 확장시켜보고 싶은 아이디어가 더 있지만 우선은 클론부터 시작을 해보기로 했고 현재 유저 인증 파트까지 구현을 마쳤다.

유저 인증 파트는 앞으로 어떤 웹 서비스를 만든다고 해도 공통적으로 들어가게 될 부분인만큼 꼼꼼하게 준비하고자 했는데 첫 프로젝트라 그런지 생각보다 시간이 오래 걸렸고 그만큼 배운 점도 많은 시간이었다. 하나씩 정리해보겠다.


🔧 개발 환경

백엔드 구축에는 Python/django를 활용했다.

패키지 매니저로는 전부터 사용하던 poetry를 이번에도 사용하기로 했다.
(의존성 관리와 프로젝트별 가상 환경 분리를 기본 pip/venv 보다 나은 커맨드라인 인터페이스와 성능으로 지원한다.)

주요 버전 명세는 다음과 같다.

  • Python 3.10
  • django 4.0
  • djangorestframework 3.13
  • djangorestframework-simplejwt 5.2

그리고 추후 재사용성을 고려해 유저 인증 관련 코드는 accounts라는 이름의 별도의 쟝고 앱을 생성해 그 안에서 작업하였다.

➕ 이번에 처음으로 gitmoji를 활용해보고 있다. 우선 작업 전부터 이모지에 따라 큰 분류로 나눠서 생각하고 접근하게 되고 그래서 효율적인 단위로 작업을 쪼갤 수 있게 된다. 무엇보다 커밋 하나하나가 더 즐거워지는 것이 아주 만족스럽다.

🙋 유저 모델 정의

먼저 유저 인증 시스템의 중심이 될 쟝고 모델로서의 유저를 정의해야 한다.

쟝고는 웹 개발 상황에서 공통적으로 발생하는 기본 작업들을 위해 contrib 패키지를 프레임워크에 포함해 지원하는데,
그 중 인증과 관련된 django.contrib.auth 패키지를 최대한 활용하면 인증 파트 쪽에서 효율적인 개발이 가능하다.

커스터마이징 없이 바로 사용할 수 있는 User 클래스도 이미 구현되어 있지만 WatchB의 구현 사항에 맞게 몇 가지 수정/확장이 필요하기 때문에 AbstractUser 클래스를 상속받아 커스텀 유저 클래스를 정의했다.

is_superuserdate_joined와 같은 기본 User 클래스의 필드 구현을 그대로 가져가고 싶기 때문에 AbstractBaseUser 대신 AbstractUser 클래스를 상속 받기로 결정했다. 그 결과 필요 없는 first_name이나 last_name 같은 필드도 같이 따라오긴 한다. 컴팩트한 모델 구현과 효율적인 개발 사이에서 취사선택하면 될 듯하다.

수정/확장 내역은 다음과 같다.

1. 이메일을 식별자로 사용

class User(AbstractUser):
	...
    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = ["username"]  # only for createsuperuser command
    ...

새롭게 정의하는 커스텀 유저 모델에서 USERNAME_FIELD 클래스 속성을 이메일 필드로 변경하면 된다. 이제 username은 중복 가능.
마지막으로 username 필드를 REQUIRED_FIELDS 클래스 속성에 추가해주는 작업까지 해주면 완료.

REQUIRED_FIELDS

  • createsuperuser 관리자 명령 시에 입력할 필드를 결정
  • USERNAME_FIELDpassword 필드는 자동으로 포함

2. 왓챠피디아 구현 사항에 맞게 유저 이름 최소 두 글자 조건 추가

class User(AbstractUser):
	...
    username = models.CharField(
        max_length=150,
        help_text="Required. 2 to 150 characters. Letters, digits and @/./+/-/_ only.",
        validators=[MinLengthValidator(limit_value=2), AbstractUser.username_validator],
    )
    ...

validators 인자에 MinLengthValidator 추가해서 username 필드를 재정의하는 방식으로 해결했다. 나머지 구현은 AbstractUser의 구현을 그대로 따른다.

3. 유저 프로필 필드 추가

class User(AbstractUser):
	...
    profile = models.TextField(blank=True)
    ...

4. 유저 프로필 이미지 & 백그라운드 이미지 필드 추가

class User(AbstractUser):
	...
    avatar = models.ImageField(blank=True, upload_to="account/avatar/%Y/%m/%d")
    background = models.ImageField(blank=True, upload_to="account/background/%Y/%m/%d")
    ...

ImageField를 활용하면 데이터베이스의 해당 필드 값으로는 이미지 파일의 주소가 들어가고 실제 파일의 저장은 별도로 관리된다. 미디어 파일이 저장될 경로를 upload_to 옵션을 통해 정의할 수 있는데 날짜 패턴을 활용해 디렉토리 구분이 가능하다. 상대경로의 형태로 일자별 디렉토리 구분을 정의.

5. 전체공개/친구공개/비공개 세 가지 옵션을 갖는 계정 공개 상태 필드 추가

class User(AbstractUser):
	...
    PUBLIC_OPTION = ("public", "전체공개")
    PRIVATE_OPTION = ("private", "친구공개")
    CLOSED_OPTION = ("closed", "비공개")
    VISIBILITY_CHOICES = [PUBLIC_OPTION, PRIVATE_OPTION, CLOSED_OPTION]
    visibility = models.CharField(
        max_length=7, choices=VISIBILITY_CHOICES, default=PUBLIC_OPTION[0]
    )
    ...

CharFieldchoices 속성을 활용해 정의했다. 기본값은 전체공개.

💁 모델 매니저 커스터마이징

쟝고 모델에는 해당 모델에 대응되는 실제 데이터베이스 테이블로의 쿼리 수행을 담당해주는 Manager 객체가 objects 라는 이름의 속성으로 기본적으로 주어진다.
ex) User.objects

그리고 이후 API 구현 과정에서 회원가입 API를 통해 유저 인스턴스를 생성했을 때 패스워드 암호화 처리가 되지 않는 문제가 있었는데, 유저 모델의 매니저를 수정하는 것으로 문제를 해결했다.
(자세한 이유는 👇아래에서 부연)

⚙️ API 구현

이제 유저 인증 관련 서버 로직을 담당할 API를 Django REST framework(DRF)를 활용해 구현할 차례. 아래에서 언급될 상세 사항들은 대부분 DRF에 대한 사항이다.

완전한 수준까진 아니지만 첫 구현부터 그래도 RESTful한 디자인을 적용하면서 가고자 노력했다. 그 결과 현재 구현된 API 엔드포인트는 총 여섯 가지.

RESTful한 설계의 /api/users/

실제 작업 과정에서 필요성이 생길 때마다 하나씩 추가해 현재 세 가지 엔드포인트 구현 완료

  1. POST /api/users/ → 회원가입
  2. GET /api/users/ → 조건에 맞는 유저 검색
  3. GET /api/users/{id}/ → 해당 유저 정보 조회

JWT 관련 처리를 담당하는 /api/auth/

JWT의 리소스로서의 정체성이 모호해 RESTful하지 않게 가기로 결정

  1. POST /api/auth/token-pair/obtain/ → access & refresh 토큰 발급
  2. POST /api/auth/token-pair/refresh/ → access & refresh 토큰 갱신
  3. POST /api/auth/refresh-token/expire/ → refresh 토큰 쿠키 삭제

👥 유저 리소스 API 컬렉션

class UserViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet):
    def get_serializer_class(self):
        ...

    def get_permissions(self):
        ...

    def get_queryset(self):
        ...

api/users/ URI 아래의 유저 리소스 API들은 최종적으로 DRF의 ViewSet 형태로 표현하였다. APIView 기반의 단일 클래스 기반 뷰 형태로 순서대로 구현하다가, 보다 컴팩트한 구현을 위해 마지막에 필요한 엔드포인트만 구현된 ViewSet의 형태로 통합하였다.

🔬 커스텀 Serializer 구현

DRF의 클래스 기반 뷰 중 데이터베이스 작업을 동반하는 것들은 기본적으로 GenericAPIView를 상속받아 동작하고 WatchB의 UserViewSet 역시 그렇다. (GenericAPIView를 상속 받은 GenericViewSet을 상속)

그리고 GenericAPIView는 데이터베이스 작업을 위해 DRF의 Serializer 클래스를 serializer_class 클래스 속성으로 지정해 활용한다.

DRF의 Serializer가 하는 기본적인 역할은 쟝고 모델 인스턴스, HTML 폼 인풋 등을 파이썬 네이티브 데이터 타입으로 변환해 JSON 등 포맷으로 직렬화(serialize)하거나, 그 반대 순서로 역직렬화(deserialize)하는 것이다.

그리고 GenericAPIView에서 실제로 Serializer를 활용하는 방식은 직접 API 뷰 클래스를 정의할 때 같이 상속 받아 사용하게 되는 액션별 Mixin들의 구현을 보면 알 수 있는데,
직렬화와 역직렬화를 기준으로 크게 두 가지이다.

  • POST/PATCH 요청을 처리할 땐 payload를 역직렬화해 유효성 검사 후 유저 인스턴스 생성/수정해 데이터베이스에 반영
  • DELETE 요청을 제외한 모든 경우에 대해 뷰 로직의 대상이 되는 모델 인스턴스를 직렬화해 HTTP 응답

따라서 UserViewSet

  • (POST/PATCH) 요청에서 (payload로) 받을 유저 모델 필드와
  • 응답으로 돌려줄 유저 모델 필드를

정의해둘 필요가 있었고,
세 가지 API마다 다른 스펙이 필요하다는 판단 하에 세 개의 커스텀 Serializer를 개별적으로 정의하기로 결정했다.

세 Serializer 모두 기본적으로 ModelSerializer 위에서 구현했고,
Meta 클래스에서 read_only 필드와 write_only 필드를 구분하는 방식으로 받을 필드뱉을 필드를 서로 다르게 운용할 수 있도록 했다.

# 회원가입 API Serializer 예시
from django.contrib.auth import get_user_model
from rest_framework.serializers import ModelSerializer

User = get_user_model()


class SignUpSerializer(ModelSerializer):
    class Meta:
        model = User
        fields = ["username", "email", "password", "id", "date_joined"]
        read_only_fields = ["id", "date_joined"]
        extra_kwargs = {"password": {"write_only": True}}

회원가입 API의 Serializer는,
email, username, password 필드를 받아 생성된 유저 인스턴스의 id, email, username, date_joined 필드를 보여준다.

유저 조회 API의 Serializer는,
POST 요청에 대해 사용될 일이 없기 때문에 받을 필드는 하나도 없이 모든 필드를 read_only로 정의해뒀다. 유저 정보를 나타내는 필드 목록을 미리 정의해두고 이를 모두 보여준다.

유저 검색 API의 Serializer는,
조회 API와 같이 GET 요청에만 대응하지만 검색을 위해 특수하게 요청의 쿼리 스트링에서 필드를 받아 처리하는 역할로도 사용한다.(👇아래에서 부연)
처리 결과로 보여주는 필드들은 조회 API와 같고 받을 필드는 그 중 == 조건으로 검색이 가능한 필드들로 구성했다.

👉 WatchB Serializer 전체 소스코드

✍️ 회원가입 API

  • CreateAPIView를 통해 개별 뷰로 구현하든
  • ViewSet 형태로 합쳐서 구현하든

모델 인스턴스 생성을 맡는 API는 DRF에서 결국 CreateModelMixincreate() 메소드를 거쳐 회원가입 처리(유저 생성)가 이루어지는데,
앞서 언급한 패스워드 암호화 처리가 createsuperuser 관리자 명령과 같은 상황에선 되는데 API를 통해 유저 인스턴스를 생성할 때에는 작동하지 않아 이를 뜯어봐야 할 필요가 있었다.

이 과정에서 쟝고와 DRF가 HTTP 요청을 받아 응답을 내놓기까지의 흐름이 꽤나 복잡해서 애를 먹었는데 내부 처리 순서를 상세하게 나열하면 다음과 같다.

  1. HTTP 요청 발생
  2. 쟝고 기본 미들웨어들을 거쳐 요청을 쟝고의 HttpRequest 객체로 표현해 다음 단계로 전달
  3. DRF의 미들웨어들을 거쳐 HttpRequest 객체를 DRF의 Request 객체로 변환해 전달
  4. 클래스 기반 뷰의 as_view()를 통해 생성된 호출 가능한 뷰 함수에 Request 객체를 넣어 호출
  5. 해당 뷰 함수 안에서 일련의 사전 작업 후 UserViewSet(혹은 CreateAPIView)의 dispatch(request) 호출
  6. HTTP 메소드 분류에 맞게 UserViewSet.post(request) 호출
  7. DRF의 HTTP 액션 분류에 맞게 CreateModelMixin.create(request) 호출
  8. CreateModelMixin.create 실행 과정에서
    serializer = UserViewSet.get_serializer(request) 선언을 통해 이번 API 요청 처리 과정에서 사용할 Serializer 객체 생성
  9. serializer.is_valid(raise_exception=True) 호출을 통해 요청 데이터의 유효성 검사
  10. 유효성 검사 통과하면, CreateModelMixin.perform_create(serializer) 호출
  11. perform_create 실행 과정에서 serializer.save() 호출
  12. save 실행 과정에서 serializer.create(serializer.validated_data) 호출
  13. create 실행 과정에서 serializer.Meta.model._default_manager.create(**serializer.data) 호출
  14. 이후 DRF의 Response 객체 리턴
  15. 역순으로 미들웨어들 다시 거쳐 HTTP 응답까지

위의 흐름에서 실제로 유저 인스턴스가 생성되고 데이터베이스에 써지는 순간은 13번 단계인 serializer.Meta.model._default_manager.create()가 불리는 순간이다.

모델 매니저의 create() 메소드는 쟝고 QuerySet APIcreate() 메소드를 뜻하는데, 이는

  1. 모델 클래스의 단순 생성자 호출을 통한 객체 생성과
  2. 이후 해당 객체의 save(force_insert=True) 호출

의 순서로 구현되어 있다.

따라서 기본 모델 매니저의 create() 메소드 구현 상 단순 생성자를 활용하기 때문에 유저의 패스워드 암호화 처리가 기본적으로는 작동하지 않는 것을 알아냈다.

이를 해결할 수 있는 방법은 크게 두 가지가 있었는데,

  1. UserViewSet.perform_create() 메소드, 혹은 Serializer.create() 메소드를 오버라이드해서 그 과정에서 암호화 처리
  2. UserManager.create() 메소드를 오버라이드해서 그 과정에서 암호화 처리

고민 끝에 모든 유저 인스턴스 생성 처리는 패스워드 암호화 처리가 동반되는 게 보다 정확한 구현이라는 판단 하에 문제 해결 방법으로 후자를 선택했고,

1번 방법으로 Serializer나 View에서만 고친다면 해당 클래스가 활용될 때에만 패스워드 암호화를 지원하게 됨

from django.contrib.auth.models import UserManager


class UserManager(UserManager):
	...
    def create(self, *args, **kwargs) -> User:
        return self.create_user(*args, **kwargs)

이미 구현되어 있는 UserManager 클래스를 상속 받아 활용했고 create()가 불리면 패스워드 암호화를 지원하는 인스턴스 생성 메소드인 create_user() 메소드로 요청을 넘기는 방식으로 오버라이드해 해결하였다.

추가로 작성해야 하는 코드도 인자를 그대로 넘기는 한 줄에 불과해서 작업량을 고려했을 때도 보다 효율적인 선택이 됐다.

📑 유저 정보 조회 API

프론트엔드에서 로그인 이후 username 같은 정보가 필요하게 될 때 사용하기 위해 만든 API.

GET /api/users/{id} HTTP 요청을 보내면 해당 id의 유저 인스턴스에 대해 앞서 Serializer에서 정의한 유용한 정보 필드들을 담은 응답을 보내준다.

권한 관련 설정(👇아래에서 부연)을 제외하면 DRF의 RetrieveModelMixin 기본 구현을 추가 수정 없이 그대로 사용한다.

🔎 유저 검색 API

유저 검색 API는 실질적으로는 프론트엔드의 회원가입 폼에서 이메일 중복 검사를 미리 수행하기 위해 만들게 됐다.

이를 RESTful한 설계 아래에서 해결하고자 일단 유저 컬렉션 리소스로의 GET 요청에 대한 API를 통해 구현하기로 했고,

GET /api/users/

여기서 모든 유저를 돌려주는 일반적인 구현 대신 HTTP 요청 시 쿼리 스트링에 검색 조건을 담으면 조건에 해당하는 유저 리스트를 돌려주는 형태로 API를 만들어 봤다.

이 경우 회원가입 시 이메일 중복 검사의 유즈 케이스라면,
쿼리 스트링에 해당 이메일을 담아 요청했을 때 빈 리스트가 응답으로 돌아온다면 검사 통과.

GET /api/users/?email={EMAIL} => []

유저 검색 API의 시나리오는 다음과 같다.

  1. 빈 쿼리 스트링으로 요청 → 모든 유저 응답
  2. 유효한 쿼리 스트링으로 검색 요청 → 조건에 맞는 유저 응답
  3. 유효하지 않은 쿼리 스트링으로 검색 요청 → HTTP 400 Bad Request

그리고 여기서 Serializer를 3번 시나리오를 처리하기 위해 사용하기로 결정했다.
따라서 유저 검색 API의 Serializer는

  • 기본적으로 응답을 직렬화하는 역할에 더해
  • POST 요청의 payload가 아닌 GET 요청의 쿼리 스트링을 역직렬화하고 유효성 검사하는 역할을 수행한다.

Serializer를 통해 확실히 하고자 하는 것은 세 가지였는데,

  1. 쿼리 스트링의 key들이 유효한 필드명일 것
    ex) 사전에 정의한 == 조건으로 검색이 가능한 필드들
  2. 쿼리 스트링의 value들이 key가 가리키는 필드에 맞게 유효한 형태의 값일 것
  3. 빈 쿼리 스트링을 허용할 것

여기서 2번 사항은 ModelSerializer를 상속 받아 각 필드들을 제대로만 정의하면 유효성 검사 시 기본적으로 처리해주는 부분이고, 3번 역시 required 조건인 필드만 없게 정의하면 유효성 검사에서 문제가 되지 않는다.

문제는 1번 사항이었는데,
유효성 검사를 위해 Serializer의 생성자에 data 키워드 인자로 Mapping 객체를 넘길 때,
기본 구현은 미리 정의한 필드명에 해당하지 않는 key-value 쌍은 유효성 검사 시 무시하는 것이다. 따라서 유효하지 않은 key를 쿼리 스트링에 넣어도 유효성 검사에선 문제가 없게 된다.

하지만 그런 경우 확실하게 Bad Request 처리를 해주고 싶었고,
유효성 검사 시 실행되는 역직렬화 메소드 이전에 커스텀한 쿼리 스트링 key 검사를 먼저 실행하는 방식으로 정의하지 않은 key를 갖는 쿼리 스트링이 들어왔을 때 유효성 검사에 실패하도록 추가 구현을 해줬다.

from collections import OrderedDict
from typing import Mapping

from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import ModelSerializer
from rest_framework.settings import api_settings

User = get_user_model()


class UserListSerializer(ModelSerializer):
	default_error_messages = {
        **ModelSerializer.default_error_messages,
        "undefined": _(
            "Query key not allowed. Expected one of {valid_keys}, but got {invalid_keys}."
        ),
    }
    ... 
	# 커스텀 쿼리 스트링 key 검사
    def validate_query_string(self, query_params: Mapping):
        if isinstance(query_params, Mapping):
            defined_query_keys = {
                fname for fname, field in self.fields.items() if not field.read_only
            }
            unexpected_query_keys = [
                qkey for qkey in query_params.keys() if qkey not in defined_query_keys
            ]
            if unexpected_query_keys:
                message = self.error_messages["undefined"].format(
                    valid_keys=defined_query_keys, invalid_keys=unexpected_query_keys
                )
                raise ValidationError(
                    {api_settings.NON_FIELD_ERRORS_KEY: [message]}, code="undefined"
                )
                
	# 역직렬화 메소드
    def to_internal_value(self, data: Mapping) -> OrderedDict:
        self.validate_query_string(query_params=data)
        return super().to_internal_value(data)

이 모든 동작은 뷰의 get_queryset() 메소드를 오버라이드함으로써,
검색 결과에 따라 뷰가 처리할 쿼리셋 자체를 동적으로 제한하는 방식으로 API의 로직에 적용시켰다.

from django.contrib.auth import get_user_model
from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin
from rest_framework.viewsets import GenericViewSet


User = get_user_model()


class UserViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet):
    ...
    def get_queryset(self):
        if self.action == "list":
            user_search_serializer = self.get_serializer(
                data=self.request.query_params.dict()
            )
            user_search_serializer.is_valid(raise_exception=True)
            return User.objects.filter(**user_search_serializer.validated_data)
        else:
            return User.objects.all()

먼저 사전 형태 쿼리 스트링을 data 키워드 인자로 Serializer에 인위적으로 넘겨 유효성 검사를 한다.

여기서 raise_exception=True이기 때문에 유효성 검사에 실패하면 요청 역시 실패

그리고 유효성 검사를 통과했을 시, 모델 매니저의 filter() 쿼리를 통해 조건에 해당하는 유저만 쿼리셋으로 반환한다.

빈 쿼리 스트링이 들어왔을 경우 Serializer의 validated_data가 빈 사전이 되기 때문에 User.objects.filter(**{})User.objects.all()과 같은 결과를 반환한다

🌐 ViewSet 통합과 권한 설정

위의 세 가지 API를 하나씩 구현한 뒤 보다 간결한 구현을 위해 하나의 ViewSet으로 통합하는 과정을 거쳤다.

그리고 기본적인 수준의 API이기에 뷰 로직은 DRF의 Mixin 구현을 그대로 따른다고 하면, API 구현의 대부분은 사실 Serializer 단에 정의되는 경우가 많기 때문에 ViewSet 통합 과정에서 해줄 일은 케이스 처리가 전부였다.

구분해야 할 건 세 가지였다.

  1. API마다 그에 맞는 Serializer 결정 → get_serializer_class
  2. API마다 그에 맞는 권한 결정 → get_permissions
  3. API마다 그에 맞는 쿼리셋 결정 → get_queryset
from django.contrib.auth import get_user_model
from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.viewsets import GenericViewSet

from .permissions import IsSelfOrAdmin
from .serializers import SignUpSerializer, UserDetailSerializer, UserListSerializer

User = get_user_model()


class UserViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet):
    def get_serializer_class(self):
        if self.action == "create" or (self.action == "metadata" and not self.detail):
            return SignUpSerializer
        elif self.action == "retrieve":
            return UserDetailSerializer
        elif self.action == "list":
            return UserListSerializer
        else:
            return Serializer

    def get_permissions(self):
        if self.action in ("create", "list"):
            return [AllowAny()]
        elif self.action == "retrieve":
            return [IsSelfOrAdmin()]
        else:
            return super().get_permissions()

	def get_queryset(self):
        if self.action == "list":
            user_search_serializer = self.get_serializer(
                data=self.request.query_params.dict()
            )
            user_search_serializer.is_valid(raise_exception=True)
            return User.objects.filter(**user_search_serializer.validated_data)
        else:
            return User.objects.all()

모든 분기 처리는 ViewSet의 self.action 속성을 이용해 앞단에서 URL 라우팅된 결과를 기준으로 진행했다.

# @urls.py
from rest_framework.routers import DefaultRouter

from . import views

router = DefaultRouter()
router.register("users", views.UserViewSet, basename="user")

위와 같이 Router를 활용하면 요청이 들어왔을 때 ^users/$^users/{pk}/$의 두 가지 컬렉션/아이템 URI 정규식 패턴에 대해 앞단의 URL 라우팅을 알아서 해준다.

쿼리셋 처리는 특수한 요구 사항이 있는 유저 검색 API의 self.action == "list" 케이스에 대해서만 특별히 처리를 해줬고, 나머지 케이스에 대해서는 특별한 필터링 없이 모든 유저 인스턴스를 쿼리셋으로 뒀다.

앞서 따로 언급하지 않았던 API별 권한의 경우,

  • 유저 정보 조회 API는 요청자가 관리자거나 해당 유저 본인인 경우를 의미하는 IsSelfOrAdmin 퍼미션 클래스를 직접 구현해 사용하였고
  • 나머지 두 API에 대해선 모든 사용자에게 열려있는 AllowAny
  • 마지막으로 OPTIONS 요청과 같은 경우 else에 걸려 DRF의 기본 설정 권한을 주는데 DjangoModelPermissionsOrAnonReadOnly를 사용 중이다.
# @permissions.py
class IsSelfOrAdmin(permissions.BasePermission):
    def has_object_permission(self, request, view, user_obj):
        return user_obj == request.user or request.user.is_superuser
# @settings.py
REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly"
    ],
    ...
}

🚫 CORS와 CSRF

WatchB와 같이 백엔드와 프론트엔드 서버를 별도의 origin(URL의 프로토콜+도메인+포트)으로 구성하게 되면 CORSCSRF를 신경 써야 하는데,
그래야 서버 origin이 다르더라도 프론트엔드로부터 백엔드로 API 요청을 자유롭게 할 수 있기 때문이다.

CORS (Cross Origin Resource Sharing)는,
받아오고자 하는 웹 리소스의 origin이 브라우저의 origin과 다를 때 이를 허용할지를 웹 리소스를 전송하는 서버가 HTTP 헤더를 활용해 브라우저 단에 설정하는 서버-브라우저 간의 통신 메커니즘이다.

CSRF (Cross-Site Request Forgery)는,
특정 사이트에 이미 인증이 되어 있는 유저의 브라우저 컨텍스트(ex. 쿠키)를 악용한다. 이후 유저가 해당 브라우저를 통해 이번에는 공격자의 사이트에 접속해 특정 액션(클릭 등)을 수행하면, 공격자가 미리 심어둔 웹 요청을 유저가 의도하지 않은 채 인증 상태로 서버에 보내게 되는 것이다.

CORS는 상용 브라우저들에서 이미 강제가 되어 있기 때문에 쟝고 서버는 이에 맞게 적절한 응답 헤더를 보내주면 되고,
CSRF의 경우 이를 방어하기 위한 보안 메커니즘이 쟝고에선 미들웨어로 기본 구현되어 있는데 별도의 프론트엔드 서버 도메인을 두기 위해서는 이에 대한 조치가 필요한 상황이다.

먼저 CORS를 위한 조치는 django-cors-headers 패키지를 이용하면 된다. 미들웨어를 추가해 모든 응답에 대해 적절한 CORS 관련 헤더를 넣어주는 방식이고, 쟝고 세팅 모듈에서 아래와 같이 허용할 프론트엔드 서버 origin을 설정해주면 된다.

# @settings.py
CORS_ALLOWED_ORIGINS = [
	...
    "http://localhost:3000",
    ...
]

그리고 CSRF의 경우 DRF를 사용한다면 사실 별도의 조치가 필요 없는데 이는 DRF의 APIView가 최종적으로 as_view()를 통해 뷰 함수를 만들 때 항상 csrf_exempt 처리를 해서 주기 때문이다.

DRF의 인증 방식으로 SessionAuthentication을 사용하는 경우에만 쟝고의 CSRF 인증을 추가로 진행시킨다. 쿠키에 세션 정보를 담아 인증하는 방식 때문으로 보인다.


여기까지만 썼는데도 벌써 내용이 너무 많아졌다. 😇
JWT 관련 API 컬렉션에 대한 내용은 다음 편에서 이어 하도록 하겠다.
TO BE CONTINUED...

0개의 댓글