본 포스팅은 포레포레 고도화 과정에서 django_rest_framework와 JWT 토큰 적용을 위해 테스트 목적으로 작석 되었습니다.
테스트 과정에서 기존에 없었던 사용자 회원가입/로그인 기능이 필수적으로 요구되었고,
이를 위해 우선적으로 장고 기본 회원가입/로그인 기능(Session-Based)을 구현 하였습니다. (해당 과정을 담은 포스팅은 여기서 확인)
따라서 이 포스팅은 Session-Based Login 기능 구현 과정에서 사용한 User, Profile 모델을 기반으로 하며, 다음 주제를 다루고 있습니다;
mkdir Fore
cd Fore
python -m venv fore
source fore/bin/ activate
pip install django
django-admin startproject Project .
mkdir Core
Fore 디렉토리에서 가상환경 생성 & 활성화한 뒤
Django 패키지 다운로드 & 프로젝트 생성해 준다.
이후 some/path/to/Fore(이하 some/path/to 생략) 위치에서 VSCode를 열어준다.
앞으로 장고 앱을 실행하는 코드는 모두 /Fore/Core 디렉토리에 모아둘 것이기 때문에
/Fore/Core 디렉토리에 Project 디렉토리 및 manage.py 파일을 옮겨 주었다.
즉, 이제 실질적인 프로젝트 루트 디렉토리는 /Fore/Core 로 간주한다.
이외의 파일들은 모두 /Fore/에 위치시킬 것이다.
프로젝트 트리는 다음과 같다;
├── Core
│ ├── manage.py
│ └── Project
│ ├── __init__.py
│ ├── asgi.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
└── fore
├── bin
├── include
├── lib
└── pyvenv.cfg
tree -L 2와 같이 트리의 깊이를 제한할 수 있다.
-d: 디렉토리만 보여주는 옵션
-a: 숨김파일도 모두 보여주는 옵션
Core라는 디렉토리 안으로 모든 장고 파일을 옮겨 주었음으로
다음과 같이 변경사항을 반영해 준다;
BASE_DIR은 그대로 유지한다(추후 세팅파일을 분리할 경우 수정 필요).ROOT_URLCONF = "Core.Project.urls"WSGI_APPLICATION = "Core.Project.wsgi.application"os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Core.Project.settings")os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Core.Project.settings")/Fore 위치에서 python manage.py runserver 명령어를 실행하면 다음과 같은 에러가 뜬다;

터미널은 현재 디렉토리(.) 기준으로 manage.py를 찾는데, 현재 해당 파일은 Core/manage.py로 이동했기 때문이다.
이를 위한 해결법은 다음과 같다;
방법 1️⃣ - 비추천
cd Core
python manage.py runserver
방법 2️⃣ - 비추천
python Core/manage.py runserver
PYTHONPATH가 현재 위치.를 기준으로 /Core/manage.py 파일을 찾아 실행한다.
이는 단순히 Core 디렉토리 안의 manage.py 파일을 찾아 실행하는 방법으로, manage.py를 Core 패키지 안의 모듈로서 인식하지는 않는다.
방법 3️⃣ - 추천
python -m Core.manage runserver
-m 옵션으로 현재 디렉토리를 PYTHONPATH에 일시적으로 추가하여
manage.py 파일을 (독립적인 파일로 인식하지 않고)/Core/manage.py로 인식하여
Core 패키지 안의 manage.py 모듈처럼 실행하는 방법.
즉, -m은현재 디렉토리 기준으로 패키지 시스템 속에서 모듈을 찾도록 도와주는 옵션이다.
(모듈 = 파이썬 파일 하나)
# test.py
def test():
print("Hello, World!")
if __name__ == "__main__":
test()
일반적으로 (상대경로로 지정된 import 없이 독립적이며 간단한)파이썬 파일을 실행시킬 때,
파이썬 파일이 있는 위치로 직접 이동하여 python test.py 커맨드를 실행킨다.
이는 파일 경로를 직접 지정해서 스크립트처럼 실행하는 방법으로,
실행 시 __name__ == "__main__" 조건이 True가 되어 test()가 실행된다.
하지만 파이썬 파일을 모듈로 취급하여 실행시킬 때에는 python -m Core.manage runserver와 같은 커맨드를 통해 manage.py를 실행 시키게 되는데,
(이때에도 마찬가지로 __name__ == "__main__" 조건이 True가 되어 manage.py 파일이 실행 되지만)
-m 옵션을 사용하게 되면 파이썬은 모듈 검색 규칙(import와 동일한 방식)으로 실행 파일을 찾게 된다.
이는 단순히 manage.py파일을 실행하는 게 아니라, manage.py파일이 속한 패키지/모듈 시스템 속에서 그 파일을 찾아서 실행하는 것이다.
python hello.py: 현재 디렉토리를 기준으로 독립적인 hello.py 파일을 스크립트 처럼 실행
python -m Core.manage runserver: PYTHONPATH 기준으로 Core/manage.py 모듈을 찾음
따라서 상대경로 import나 패키지를 참조하고 있는 경우, 실행 모듈처럼 실행해야 불필요한 에러 없이 앱을 실행시킬 수 있다.
(이거 두번째로 정리한거! 기억하기!)
다시 본 내용으로 돌아가서,
매번 개발서버 실행을 위해 긴 명령어를 치기 귀찮으니
/Fore 위치에 Makefile을 만들고 다음과 같이 적어준다;
.PHONY: run
run:
python -m Core.manage runserver
이외에도 .gitignore 파일도 작성해 줬다.
make 커맨드를 사용해 개발서버를 실행시켜보면 다음과 같이 잘 장고 개발서버가 잘 돌아가는 것을 확인할 수 있다;

cd Core
python -m Core.manage startapp Cookie
이후 /Core/Cookie/apps.py에서 name='Core.cookie'로 바꿔준 후,
/Core/Project/settings.py의 INSTALLED_APP 부분에 앱 이름과 똑같이 'Core.Cookie'를 추가해 준다.
프로젝트 및 모든 앱들이 Core 디렉토리에 위치할 것이기 때문에 위의 설정을 꼭 적용해 줘야 한다. (안하면 ModuleNotFoundError가 발생한다.)
이후 포레포레에서 쓰던 Cookie 모델들을 가져와 migration을 진행해 줬다.
1️⃣ Cookie 모델에 ImageField가 있기 때문에 필수 패키지를 다운받아 줬다; pip install pillow
2️⃣ /Core/Project/settings.py에서 MEDIA_URL과 MEDIA_ROOT를 추가해 주었다;
MEDIA_ROOT 은 사진 등의 미디어 파일을 업로드할 때 해당 파일들이 저장될 위치이다.
반면 MEDIA_URL 은 사용자가 브라우저에서 미디어 파일에 접근할 때 사용되는 URL 경로이다.
3️⃣ /Core/Project/urls.py에서 다음과 같은 코드도 추가해 줬다;
from django.conf import settings
from django.conf.urls.static import static
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
위 코드는 개발환경에서 장고가 static 파일들을 제공하기 위한 설정이다.
이미지가 네다섯개만 되어도 로딩이 느리다.
운영 환경에서는 Nginx나 S3 Bucket 등을 사용하여 최적화를 진행해줘야 하며,
업로드 파일의 최대 크기 설정 등이 필요하다.
DRF는 Restful API 구현을 도와주는 장고 패키지 이다.
(API 테스트를 위해 Cookie 앱의 모델에서 여섯개 정도 인스턴스를 만들어 주었다.)
Fore/Core/ 위치에 API 디렉토리를 생성한다.
Cookie 앱과 유사하게 API도 하나의 장고 앱처럼 다루는 것 같다.
기존 프로젝트에서는 views.py에 api와 뷰 함수들이 섞여 있어 혼잡했었는데, 이렇게 API들만 따로 모아두면 아주 관리가 편할 것 같다!
이후 API 디렉토리 하위에 다음 파일들을 생성해 준다.
__init__.py: 장고가 API 디렉토리를 모듈로서 인식하도록 한다.
views.py: get_routes() 함수는 API 문서와 같은 역할을 한다. 앞으로 프로젝트에 생성할 모든 API들의 목록을 이곳에 적어둔다.
from django.http import JsonResponse
def get_routes(request):
routes = [
{'GET': '/api/cookies'},
{'GET': '/api/cookies/id'},
{'POST': '/api/cookies/id'},
]
return JsonResponse(routes, safe=False)
urls.py: api 라우팅을 설정해 준다.
from django.urls import path
from . import views
urlpatterns = [
path('', views.get_routes)
]
그리고 Fore/Core/Project/urls.py에서 API 폴더의 urls.py를 연결해 준다;
urlpatterns = [
...(생략)
path('api/', include('Core.API.urls')),
]
주의할 점은 API 디렉토리 역시 Core 하위에 있기 때문에 루트 디렉토리(Fore)를 기준으로 전체 경로를 Core.API.urls와 같이 적어주어야 한다는 것이다.
또한 views.py의 get_routes() 함수에서 JsonResponse 리턴시 두번째 인자로 꼭 safe=False를 적어주어야 한다.
이는 JsonResponse가 기본적으로 딕셔너리만 수용함과 동시에 safe=True를 기본값으로 갖는데, 여기서는 리스트를 넘기고 있기 때문이다.
safe=False를 해주지 않았을 경우 에러;

이후 개발서버를 실행하여 http://127.0.0.1:8000/api/를 확인하면 아래와 같이 앞으로 만들 API의 목록을 확인할 수 있다;

DRF는 장고 프레임워크 위에서 API, Authentification, Serialization 등의 기능을 제공해 준다.
이를 사용하기 위해 패키지를 설치해준 뒤; pip install djangorestframework
Fore/Core/Project/settings.py의 INSTALLED_APP에 'rest_framework'를 추가해 준다.
이후 위에서 작성했던 views.py를 다음과 같이 수정해 준다;
from rest_framework.decorators import api_view # 추가
from rest_framework.response import Response # 추가
# 모든 API 리스트
@api_view(['GET']) # 데코레이터 추가
def get_routes(request):
routes = [
{'GET': '/api/cookies'},
{'GET': '/api/cookies/id'},
{'POST': '/api/cookies/id'},
# {'POST': '/api/users/token'},
# {'POST': '/api/users/token/regfresh'},
]
return Response(routes) # 변경
get_routes() 함수에 데코레이터를 추가해 줌으로써 기존의 safe=False가 필요없어지게 되었다.
(참고로 DRF에는 클래스 기반 뷰와 함수 기반 뷰가 있는데, 해당 튜토리얼에서는 후자를 사용한다.
클래스 기반 뷰는 위키독스에서 튜토리얼을 확인할 수 있다.)
이후 개발서버를 실행시켜 다시 아까의 주소로 들어가보면 DRF 뷰를 확인할 수 있다!

Serializersf란? 왜 필요한지?
Fore/Core/API/serializers.py 파일 생성 후 다음과 같이 모델 시리얼라이저를 만들어 준다;
(공식 문서에는 다양한 시리얼라이저가 존재한다. 직접 파이썬 데이터를 시리얼라이징 할수도 있다)
from rest_framework import serializers
from Core.Cookie.models import Products, Cookies
class ProductsSerializer(serializers.ModelSerializer):
class Meta:
model = Products
fields = '__all__'
class CookiesSerializer(serializers.ModelSerializer):
class Meta:
model = Cookies
fields = '__all__'
이후 Fore/Core/API/views.py에서 다음 코드를 추가해 준다;
...(생략)
from .serializers import ProductsSerializer, CookiesSerializer
from Core.Cookie.models import Products, Cookies
...(생략)
# 모든 구움과자 상품 가져오기
@api_view(['GET'])
def get_products(request):
products = Products.objects.all()
serializer = ProductsSerializer(products, many=True)
return Response(serializer.data)
# 모든 구움과자 가져오기
@api_view(['GET'])
def get_cookies(request):
cookies = Cookies.objects.all().order_by('index')
serializer = CookiesSerializer(cookies, many=True)
return Response(serializer.data)
get_products() 함수는 get_cookies() 함수는 모든 장고 쿼리셋으로 인스턴스들을
각자의 시리얼라이저의 인자로 넘겨주어
파이썬 객체를 JSON 형식으로 변환하여 리턴한다.
그리고 이때 쿼리셋이 여러 인스턴스를 반환하고 있기 때문에 many=True 설정을 해주고 있다.
브라우저에서 확인해보면 다음과 같다;

이전 프로젝트에서는 이런 라이브러리가 있는지 모르고 api 관련해서 디버깅을 할때마다 모든 script를 주석처리하고 계속 fetch() 요청을 보냈었다.
Cookie 모델에 존재하는 모든 인스턴스들이 잘 출력되는 것을 볼 수 있다.
하지만 다른 모델을 참조하고 있는 필드의 경우 실제값이 아니라 PK가 뜨고 있다.
Foreign Key나 OneToOneField로 정의된 필드에서 실제 객체를 가져오지 않고 참조 아이디만 보여주고 있는 것이다.
Foreign Key, OneToOneField 등의 참조필드에서 실제 오브젝트를 가져오기 위해서는 두 개의 시리얼라이저 클래스를 연결해야 한다;
from rest_framework import serializers
from Core.Cookie.models import SalesCookie, Products, Cookies
class SalesCookieSerializer(serializers.ModelSerializer): # 추가
class Meta:
model = SalesCookie
fields = ['is_on_sale']
class ProductsSerializer(serializers.ModelSerializer):
class Meta:
model = Products
fields = '__all__'
class CookiesSerializer(serializers.ModelSerializer):
sale = SalesCookieSerializer(many=False) # 추가
product = ProductsSerializer(many=False) # 추가
class Meta:
model = Cookies
fields = '__all__'
SalesCookieSerializer 클래스를 생성한 후,
CookiesSerializer에서 sale 필드를 시리얼라이저를 사용해 정의해 주었다.

http://127.0.0.1:8000/api/cookies/ 에서 확인해 보면 참조 필드의 실제 객체를 잘 가져오고 있는 것을 확인할 수 있다!
SerializerMethodField() 를 활용하면 모델에 존재하지 않는 필드를 커스텀 로직을 통해 직렬화 응답에 추가할 수 있다.
활용 예시는 다음과 같다;
1. 역참조 관계의 데이터를 추가
2. 필터링된 관계 데이터 추가 (상품 API에서 최고 평점 리뷰를 추가)
3. 계산된 필드 추가 (학생 모델에서 해당 학생의 평균 성적 추가)
4. 요청에 따라 조건적으로 값을 변경 (현재 로그인한 사용자가 좋아요를 눌렀는지 추가)
단, 다음과 같은 조건이 있다;
get_fieldname()와 같이 정의해 주어야 한다.활용 예시 1번을 탐구해보자.
다음은 models.py에 정의된 Times와 Pickups 모델이다;
class Times(models.Model):
name = models.CharField(max_length=50)
start = models.PositiveIntegerField(validators=[MinValueValidator(10), MaxValueValidator(19)])
end = models.PositiveIntegerField(validators=[MinValueValidator(10), MaxValueValidator(19)])
interval = models.IntegerField(choices=Interval.choices)
selected = models.BooleanField(default=False)
class Pickups(models.Model):
str_time = models.CharField(max_length=5, validators=[MinLengthValidator(5), MaxLengthValidator(5)])
reserved = models.BooleanField(default=False)
time = models.ForeignKey(Times, on_delete=models.PROTECT)
생략 되었지만 @receiver(post_save, sender=Times)가 존재하며,
Times 모델의 인스턴스가 selected=True로 변경되면
Pikcups 모델에 해당 (Times 인스턴스를 참조하는)인스턴스들이 생성되는 구조이다.
예를 들어 start=12, end=13인 Times, interval=30인 인스턴스가 selected=True로 생성되면
Pickups 모델에서 str_time이 각각 '12:00', '12:30', '13:00'인 인스턴스 세개가 자동으로 생성된다.
Times 모델에 Pikcups와 관련된 필드는 없지만
이를 Times API에 추가하고 싶다면
TimesSerializer는 다음과 같이 정의될 수 있다;
class PickupsSerializer(serializers.ModelSerializer): # 순서 중요
class Meta:
model = Pickups
fields = '__all__'
class TimesSerializer(serializers.ModelSerializer):
created_pickups = serializers.SerializerMethodField()
class Meta:
model = Times
fields = '__all__'
def get_created_pickups(self, obj): # 함수 이름 규칙, 두개의 인자
time = obj.pickups_set.all() # 역참조
serializer = PickupsSerializer(time, many=True) # 직렬화
return serializer.data
Pickups 모델을 역참조 하여 커스텀 함수(get_created_pickups)로 created_pickups라는 필드를 API에 추가해 주었다.
PickupsSerializer(..., many=True)에서는 여개의 Pickups 인스턴스들을 직렬화 함으로 리스트나 쿼리셋과 같은 iterable이어야 한다.http://127.0.0.1:8000/api/times/를 확인해 보면 다음과 같이 역참조된 created_pickups 필드가 API에 추가 되었으며,
전체 객체들을 잘 가져고오 있는 것을 확인할 수 있다;

Postman은 단일 인터페이스에서 API 요청, url쿼리 테스트, 인증 요청 테스트 등을 가능하게 해준다.
이를 사용하기 위해 아래와 같이 초기 설정을 진행해 줬다;

사용자 인증을 위해 JWT를 활용해 보자.
장고는 기본적으로 Session-Based 인증을 제공한다.
패키지 설치; pip install djangorestframework-simplejwt
settings.py에서 INSATLLED_APPS와 MIDDLEWARE 사이에 추가;
(장고에게 어떻게 API Authentication을 핸들링 할 것인지 알려주기 위함)
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
)
}
API/urls.py에 추가;
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
...
path('users/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), # JSON 웹 토큰 생성
path('users/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), # JSON 웹 토큰 리프레시
...
]
브라우저에서 http://127.0.0.1:8000/api/users/token/로 이동;

이후 만들어 놨던 id & pw를 입력하면 다음과 같이 access 및 refresh 토큰이 발행된 것을 확인할 수 있다;

(기존에 장고의 기본 Session-Based을 기반으로 회원가입 기능을 여기서 만들어 놨었다. 기존에 로그인을 위한 모델이 존재하지 않으면 해당 튜토리얼은 진행할 수 없음을 명확히 해둔다.)
위 사진 속 "access" 키값을 jwt.io에 붙여 넣으면 실제로 valid한 토큰이 발행된 것을 확인할 수 있다;

POSTMAN 앱에서도 동일한 확인 작업을 진행할 수 있다;

이전 포스팅에서 @login_required(login_url='login') 를 사용해 로그인 유저와 비로그인 유저의 접근 권한을 나눈 적이 있다. 이는 Session-Based 로그인 기능 구현에 있어서 장고가 제공하는 기본 데코레이터를 뷰에 직접 사용해 뷰 접근 권한 나눈 방식 이었다.
이를 JWT가 제공하는 Authentication으로 변경해보자!
API/views.py에 permission_classes와 IsAuthenticated, 그리고 @permission_classes([IsAuthenticated])를 추가해 준다;
from rest_framework.decorators import api_view, permission_classes # 추가
from rest_framework.permissions import IsAuthenticated, IsAdminUser # 추가
@api_view(['GET'])
@permission_classes([IsAuthenticated]) # 추가
def get_cookie(request, pk):
...(생략)
return Response(serializer.data)
(참고로 IsAdminUser 은 어드민 유저에게만 접근을 허용해 준다.)
이후 브라우저에서 http://127.0.0.1:8000/detail/1를 들어가 보면 예상과 달리 잘 접속이 된다. 왜지? 아마 API 앱에서 시리얼라이즈를 만들기는 했지만 COOKIE 앱에 적용을 안해서?
이후 get_cookie 뷰와 연결된 url을 브라우저에 입력해서 들어가 보면 기존에는 다음과 같이 잘 나오던 것이;

이렇게 막힌 것을 확인할 수 있다;

로그인 인증 토큰이 필요해진 것이다.
하지만 아쉽게도 Django REST framework UI에서는 로그인 인증 토큰과 함께 GET 요청을 보낼 수 없다.
이를 위해 POSTMAN으로 이동해 access Token 정보와 함께 GET 요청을 보내준다;

Authorization 값을 Bearer 엑세스토큰 형식으로 넣어주어야 한다. bearer의 b를 대문자 B로, 토큰과 Bearer 사이에 스페이스 한칸을 잊지 말자.Authorization 탭에서 JWT Bearer로 하면 안되던데 왜지?
토큰 이름이나 만료 시간 등을 커스텀 할 수도 있다.
공식문서 > Settings 탭에 가보면 나오는 예제 코드를 다음과 같이 내 Project/settings.py에 복붙해 준다;
REST_FRAMEWORK = { ... }
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=10),
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
"UPDATE_LAST_LOGIN": False,
"ALGORITHM": "HS256",
"VERIFYING_KEY": "",
"AUDIENCE": None,
"ISSUER": None,
"JSON_ENCODER": None,
"JWK_URL": None,
"LEEWAY": 0,
"AUTH_HEADER_TYPES": ("Bearer",),
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "user_id",
"USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
"AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
"TOKEN_TYPE_CLAIM": "token_type",
"TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
"JTI_CLAIM": "jti",
"SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
"SLIDING_TOKEN_LIFETIME": timedelta(minutes=5),
"SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
"TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer",
"TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer",
"TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer",
"TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer",
"SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer",
"SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer",
}
override하지 않을 라인들은 지워주고 ACCESS_TOKEN_LIFETIME 도 10분으로 바꿔줬다.
그래서 이걸 실제 비즈니스 로직 및 뷰에 어떻게 적용하지? HTML 파일에서 Endpoint로 사용할 수 있다는건 예상이 되는데, 장고로 프론트 html 렌더링하면 GET은 필요 없어지지 않나?
CORS(Cross-Origin Resources Sharing)이란 '출처(origin, 서버)가 다른 곳에서 온 리소스 요청을 어떻게 허용할 것인지'에 대한 브라우저의 보안 정책이다.
출처란?
프로토콜 + 도메인 + 포트를 조합한 값으로, 아래 url은 모두 다른 출처를 갖는다;
1. http://localhost:8000
2. http://127.0.0.1:8000
3. https://mydomain.com
4. file:///some/path/to/test.html
만일 2에서 현재 개발서버를 실행시키고 있는데 1, 3, 4에서 요청이 온다면
브라우저는 기본적으로 해당 요청을 '보호 위반'으로 판단하고 CORS error를 내버린다.
Access to fetch at 'http://127.0.0.1:8000/api/products/' from origin 'null'
has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
개발자 도구에서 보통 위와 같은 응답을 받을 수 있는데, 이는요청은 갔지만 응답에 'Access-Control-Allow-Origin' 헤더가 없어서 브라우저가 응답을 차단했다는 뜻이다.
따라서 프론트와 백엔드의 출처가 다르다면(각기 다른 서버에 소스코드를 올려놓는다면) 백엔드 쪽에서 프론트 서버의 출처에 대해 CORS 허용 설정을 해줘야 한다.
Bucky 튜토리얼을 보니 보통 실무에서는 프론트를 따로 배포하고 S3 Bucket 등을 사용해 CDN 연결을 해놓던데 CORS 설정은 필수인것 같다.
하지만 일단 포레포레 고도화 단계에서는 프론트/백엔드 서버를 나눌 예정이 없기 때문에 과연 필요할까 싶기도..
즉, CORS는 악의적인 웹사이트가 사용자의 인증 정보를 악용하여 다른 사이트에 요청을 보내는 걸 막기 위한 보안 장치이다. 만일 해커가 내 브라우저에서 몰래 은행 API를 호출하면 안되니까 말이다.
포레포레에서는 비회원도 구매가 가능한데 그럼 어떻게 API 인증을 하지?!

실제로 CORS error를 발생시켜 보았다.
개발서버를 열어두고, 컴퓨터 아무 위치에서 html 파일을 만들고 JavaScript 코드로 fetch() 요청을 보내보니 위와 같은 에러가 발생했다.
일단 장고에서 CORS 설정을 할 수 있도록 외부 라이브러리를 설치해 준다;pip install django-cors-headers
이후 settings.py에 추가;
INSTALLED_APPS = { ..., 'corsheaders', ...}
MIDDLEWARE = ['corsheaders.middleware.CorsMiddleware', ...]
CORS_ALLOWED_ALL_ORIGINS = True
CORS_ALLOW_METHODS = ('GET', 'POST')
CORS_ALLOWED_ALL_ORIGINS = True도 상관 없음CORS_ALLOWED_ORIGINS = ['http://mydomain.com', ...] 와 같이 CORS 요청을 허용할 출처 목록을 적어줘야함.(더 상세한 내용은 공식문서에서 찾아볼 수 있다)
Access to fetch at 'http://127.0.0.1:8000/api/products/' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
CORS_ALLOWED_ALL_ORIGINS = True 를 해줬음에도 불구하고 위의 에러가 발생했다.
테스트 목적으로 file:///Users/saemi/src/ForeFront/list.html 와 같이 HTML 파일을 그대로 열었기 때문이었다.
django-cors-headers는 보안상의 이유로 null origin은 명시적으로 허용하지 않는다고 한다.
따라서 오직 개발서버에서 테스트 목적으로 코드를 짰기 때문에 명시적으로 null orgin을 허용한다고 추가해 줬다; CORS_ALLOWED_ORIGINS = [..., 'null', ...]
잘 된다!

html;
<!DOCTYPE html>
<html lang="en">
<head>
... (생략)
<script src="main.js"></script>
</head>
<body>
<h1>상품 목록</h1>
<div id="wrapper"></div>
</body>
</html>
js;
let api_get_products = 'http://127.0.0.1:8000/api/products/';
let getProducts = () => {
fetch(api_get_products)
.then(response => response.json()) // promise (?)
.then(data => {
build_list(data);
})
};
let build_list = (products) => {
const Wrapper = document.getElementById('wrapper');
for (i=0; i<products.length; i++) {
let elem = products[i]
let projectLi = `
<li>${elem.name} | ${elem.price}</li>
`
Wrapper.innerHTML += projectLi
}
};
getProducts();
앞서 구현해둔 url을 api_get_products 변수에 담고,
fetch(api_get_products) 함수의 인자로 넘겨준 뒤,
build_list(products)를 호출하여 GET 요청으로 넘겨받은 값을 html 파일에 붙여 주었다.

장고 서버와 다른 출처에서도 GET API를 통해 동적으로 상품 목록이 그려지는 것을 확인할 수 있었다.
프론트를 백엔드 서버와 완전히 분리 시킨 뒤
프론트에서 GET API를 호출하여 상품 목록을 그려내고
프론트에서 POST API를 호출하고 토큰을 적용하여 상품 가격을 변경해 보자.
이를 위해 장고 서버를 실행시킨 상태에서
장고 서버 소스코드 밖의 다른 위치에서 html, js 파일을 브라우저에 띄운 상태로 진행한다.
(위에서 CORS 에러를 다룰 때 취했던 방식에 이어진다.)

main.html;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프론트</title>
<link rel="stylesheet" href="main.css">
<script src="main.js"></script>
</head>
<body>
<h1>상품 목록</h1>
<div id="wrapper"></div>
</body>
</html>
main.js;
let api_get_products = 'http://127.0.0.1:8000/api/products/';
let getProducts = () => {
fetch(api_get_products)
.then(response => response.json()) // promise (?)
.then(data => {
build_list(data);
})
};
let build_list = (products) => {
const Wrapper = document.getElementById('wrapper');
for (i=0; i<products.length; i++) {
let elem = products[i]
let projectLi = `
<li>
${elem.name} | <span id=price_${elem.id}>${elem.price.toLocaleString()}</span>원
<button onclick="changePrice(${true}, ${elem.id})">➕</button>
<button onclick="changePrice(${false}, ${elem.id})">➖</button>
</li>
`
Wrapper.innerHTML += projectLi
}
};
getProducts();
main.html 파일을 로딩함과 동시에 fetch() 함수로 GET 요청을 보냈다.
이후 응답받은 데이터를 사용해 동적으로 상품 목록 html을 그려냈다.
(이후 상품 가격 변경 POST 요청을 하기 위해 상품 목록 html에 여러가지 정보를 함께 담아두었다.)
'http://127.0.0.1:8000/api/products/'는 해당 포스팅 초기에 만들어 두었던 장고 API 앱의 get_products() 함수에 해당한다.
어드민 유저만 ➕➖ 버튼을 눌러 상품 가격을 변경할 수 있도록 POST 요청을 보내보자.
하지만 당장은 POST 요청 자체에 집중하기 위해 어드민 유저 토큰은 수동으로 받아 복붙해 넣었다. (아래에서 자동으로 함)
Django - API/views.py;
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from rest_framework import status
@api_view(['POST'])
@permission_classes([IsAdminUser])
def change_price(request, pk):
product = Products.objects.get(id=pk)
if request.data.get('up'):
product.price += 300
product.save()
return Response({'price': f'{product.price}'}, status=status.HTTP_200_OK)
else:
product.price -= 300
product.save()
return Response({'price': f'{product.price}'}, status=status.HTTP_200_OK)
DRF가 제공하는 데코레이터를 활용하여 해당 POST 요청의 권한을 AdminUser로 제한했다.
Front - main.js;
function changePrice(behavior, pk) {
let token = '어드민 유저 JWT 토큰 직접 복붙';
let url = `http://127.0.0.1:8000/api/products/change_price/${pk}`;
fetch(`${url}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({'up': behavior})
})
.then(response => response.json())
.then(data => {
document.getElementById(`price_${pk}`).innerText = data.price;
})
}
GET 요청으로 상품 목록을 그려내는 과정에서 ➕➖ 버튼에 onclick 속성을 줬었다.
onclick 요청으로 불려질changePrice(behavoir, pk, price) 함수에서
behavior 은 true일때 상품 가격을 300원 인상, false일때 300원 인하 한다.
pk 는 가격변경이 있을 상품을 구분하는 용도이다.
앞서 서술했듯 변수 url에는 수동으로 받아놓은 어드민 유저의 토큰을 담아주고, fetch() 함수의 첫 번째 인자로 넘겨준다.
fetch() 함수의 두 번째 인자로 해당 요청이 POST임을 명확히 해준다.
then()에서는 POST 요청에 대한 응답을 json 형식으로 바꿔준 뒤 변경된 상품 가격을 업데이트해준다.
버튼을 누르면 어드민 인증 토큰이 만료되기 전까지 가격이 잘 변경되는 것을 확인할 수 있다.
(DRF를 활용한 GET/POST API 동작 테스트에 목적을 둔 코드라 따로 예외처리를 하지는 않았다.)
어드민 계정으로 로그인한 유저만 상품 가격을 변경할 수 있다.
어드민 유저임을 인증하는 JWT 토큰을 수동으로 복붙해줄 필요가 없도록 해보자.
이를 위해서는 우선 프론트에서 로그인/로그아웃 기능을 구현해야 한다.
<!--login.html-->
<!DOCTYPE html>
<html lang="en">
<head>
...(생략)
<script src="login.js" defer></script>
</head>
<body>
<h1>로그인</h1>
<form id="login-form">
...(생략)
</form>
</body>
</html>
// login.js
let form = document.getElementById('login-form');
console.log(form);
form.addEventListener('submit', (e) => {
e.preventDefault();
console.log('로그인 버튼 클릭됨!');
})
위와 같은 구조에서 Cannot read properties of null (reading 'addEventListener') 에러가 났다.
이는 DOM이 준비되기 전에 DOM 요소에 접근하려고 할 때 발생하는 에러라고 한다.
+) 그럼 돔이 준비되는 순서는 어떻게됨? 후술
문제 해결을 위해 다음 세가지 방법이 존재한다;
1️⃣ script 태그를 body 태그의 최하단에 위치시키기
<body>
<!-- ...(생략) -->
<form id="login-form">...(생략)</form>
<script src="login.js"></script>
</body>
예전에 가장 많이 사용하던 방식이라 한다. html 파일이 렌더링된 후 js 코드들이 실행 준비 상태에 들어서니 해당 문제가 발생하지 않는다.
2️⃣ DOMContentLoaded 이벤트 활용
document.addEventListener('DOMContentLoaded', () => {
let form = document.getElementById('login-form');
console.log(form); // 이제 null 아님
form.addEventListener('submit', (e) => {
e.preventDefault();
...(생략)
});
});
DOM이 완전히 로드된 후 실행되도록 한다.
3️⃣ defer 속성 사용
<script src="login.js" defer></script>
스크림트 태그에 defer 속성을 추가하면, HTML 파싱이 끝난 후 스크립트가 실행되도록 한다고 한다.
브라우저는 HTML을 위에서 아래로 한 줄씩 읽으면서 파싱한다.
따라서 헤더 태그 안에서 스크립트 태그를 만나면 브라우저는 HTML 파싱을 멈추고 스크립트 로딩 및 실행을 기다린다.
현재 에러의 경우 DOM 요소가 아직 없는 상태에서 js를 실행했고, 따라서 null이 뜨게 되었다.
하지만 defer 속성을 붙이게 되면 스크립트는 백그라운드에서 로드되고 HTML 파싱이 중단되지 않는다. HTML 파싱이 완전히 끝난 뒤 스크립트가 실행되도록 한다.
다시 토큰 사용을 위한 프론트 로그인 기능 구현으로 돌아가 보자.
로그인 페이지를 만들기 위해 새로 login.html과 login.js 파일을 생성해 주었다.
login.html;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프론트</title>
<link rel="stylesheet" href="main.css">
<script src="login.js" defer></script>
</head>
<body>
<h1>로그인</h1>
<form id="login-form">
<input type="text" name="id" placeholder="아이디">
<input type="password" name="pw" placeholder="패스워드">
<button type="submit">로그인</button>
</form>
</body>
</html>
login.js;
let form = document.getElementById('login-form');
form.addEventListener('submit', (e) => {
e.preventDefault(); // submit시 기본 동작인 페이지 리프레시를 막아줌
// POST 요청시 보낼 데이터 포메팅
let formData = {
'username': form.id.value,
'password': form.pw.value
}
// 토큰 발행 POST 요청
const URL = 'http://127.0.0.1:8000/api/users/token/'
fetch(URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
// 로컬스토리지에 토큰 저장
if (data.access) {
localStorage.setItem('token', data.access);
window.location = 'file:///Users/saemi/src/ForeFront/list.html';
} else {
// {detail: 'No active account found with the given credentials'}
alert(data.detail);
}
})
})
login 페이지에서 유효한 아이디 및 패스워드를 입력하면
변수 formData에 아이디 및 패스워드를 포매팅 하여 담아두고
fetch() 함수에서 POST 요청을 보낼 때 body에 담아 넘겨준다.
이후 응답으로 access 키에 토큰이 담겨오면 사용자의 localStorage에 저장한 후 상품 목록 페이지로 이동한다.
main.js;
let loginBtn = document.getElementById('login-btn');
let logoutBtn = document.getElementById('logout-btn');
let token = localStorage.getItem('token');
if (token) {
loginBtn.remove();
} else {
logoutBtn.remove();
}
logoutBtn.addEventListener('click', (e) => {
e.preventDefault();
localStorage.removeItem('token');
window.location = 'file:///Users/saemi/src/ForeFront/login.html';
})
상품 목록 페이지에서는 토큰이 존재하면 '로그아웃' 버튼을 보여주고
토큰이 없으면 '로그인' 버튼을 보여준다.
또한 로그아웃 버튼 클릭시 localStorage에서 토큰을 삭제하여 로그아웃을 진행한다.
기존에 상품 가격변경을 위한 fetch() 요청에서 let token = '어드민 유저 JWT 토큰 직접 복붙'; 부분을 let token = localStorage.getItem('token'); 로 바꿔주면 다음과 같이 잘 작동하는 것을 확인할 수 있다!

사실 포레포레 고도화 과정에서 Frontend와 Backend를 구분하여 따로 배포할 생각은 없었다.
어디까지나 백엔드 입장에서 그 편의를 위해 Django Template 사용을 유지할 생각이었다.
하지만 DRF 활용성을 늘리고 모든 API를 한 곳에서 관리하려면 프론트를 아예 분리하여 배포해야 한다고 느꼈다.
동시에 아예 각잡고 프론트/백엔드를 할거면 바닐라JS를 사용하는 것보다 리액트같은 프레임워크를 사용해볼까 하는 생각도 들었고..
그럼에도 불구하고 결론적으로 프론트/백엔드는 분리하지 않을 것이다.
시간 관계상 본래의 목적(취업..ㅎ)에 집중하여 백엔드 입장에서 고도화를 진행하고자 한다.