프로젝트 목표
회원가입/로그인 기능 구현. 웹 사이트에서 가장 기본이 되는 기능이므로 확실하게 알아두자.
프로젝트 구현 과정
모델링
회원가입에 필요한 정보는
- 이메일(또는 휴대전화번호)
- 이름(실제 사람 이름)
- 사용자 이름(인스타그램 내에서 사용하는 이름)
- 비밀번호
4가지 정보를 생각하고 모델링을 했다. 위 정보를 기반으로 models.py
를 작성했다.
from django.db import models
class User(models.Model):
email = models.CharField(max_length=300)
name = models.CharField(max_length=45)
nickname = models.CharField(max_length=45)
password = models.CharField(max_length=300)
class Meta:
db_table = 'users'
고려한 것
- 4가지 필드 모두
Charfield
로 생성 - 한 사람은 한 개의 계정만 가질 수 있다고 가정
❗️Pain points
- 실제로는 한 사람이 여러개의 계정을 가질 수 있다. 이 경우, 테이블을 두 개로 나누어 이름과 이메일 간 1:N 관계로 만들어야 하는지 의문이 생긴다.
- email은
EmailField
가 있었지만 몰랐다. 차이점을 알아봐야겠다. - 휴대전화번호로 가입을 하는 경우를 위해 phone컬럼도 추가하면 좋았을 것 같다.
Endpoint #1. 회원가입
고려한 것
- email validation은 정규표현식을 사용하여 이메일 형식에 맞지 않는 요청이거나 빈 값으로 요청이 오면 INVALID_EMAIL_ADDRESS를 리턴하도록 했다.
- password validation은 단순히 8자 이상이기만 하면 에러를 리턴하지 않았다.
- 각 validation기능을 수행하는 함수를
utils.py
에 따로 분리하여views.py
에서 import하여 사용하도록 구현했다. - 요청으로 들어온 email, nickname이 이미 데이터베이스에 존재한다면 각각 ALREADY_EXISTS_EMAIL, ALREADY_EXISTS_NICKNAME을 리턴한다.
- 각 에러를 클래스로 만들어 특정 조건에서 에러를
raise
시켜서except
구문으로 처리하도록 했다. 예외 처리 전략 중 EAFP를 따르도록 짰는데 내가 짠 코드가 이 전략에 맞는 건지 모르겠다.🤔 try~ except~ else
구문을 사용하여 예외가 발생하지 않으면else
이하에서 DB에 인서트하도록 구현했다.- 최대한 코딩 컨벤션을 지키려고 노력했다. 특히, import, 정렬 부분을 중심으로 고려했다.
❗️Pain points
- KEY_ERROR가 발생할 때 어떤 KEY때문에 발생하는지 알 수가 없다.
- 이메일 대신 휴대전화 번호로 가입을 할 수도 있는데 이 경우 email_validation에 의해 INVALID_EMAIL_ADDRESS를 리턴한다. 휴대전화번호 형식이 이메일 형식과 다르기 때문이다. 휴대전화 번호 형식에 해당하는 정규표현식을 추가하여 휴대전화번호도 가입이 가능하게 할 필요가 있다.
- password_validation에서 실제로는 영어 대문자, 특수문자를 적어도 1개는 포함하고 최대 길이도 제한이 있는 것이 일반적이므로 여기에도 정규표현식을 활용하는 것이 맞겠다는 생각이 들었다.
소스코드
1. 처음 짠 views.py
import json
import re
from django.http import JsonResponse
from django.views import View
from .models import User
from .my_exceptions import *
from .utils.py import *
class Signup(View):
def post(self, request):
data = json.loads(request.body)
try:
email = data['email']
name = data['name']
nickname = data['nickname']
password = data['password']
if not(Signup.email_check(email)):
raise InvalidEmail('INVALID_EMAIL_ADDRESS')
if not(Signup.password_check(password)):
raise InvalidPassword('INVALID_PASSWORD')
if Signup.duplicate_email_check(email):
raise AlreadyExistEmail('ALREADY_EXISTS_EMAIL')
if Signup.duplicate_nickname_check(nickname):
raise AlreadyExistNickname('ALREADY_EXISTS_NICKNAME')
except KeyError:
return JsonResponse({'MESSAGE':'KEY_ERROR'}, status=400)
except InvalidEmail as e:
return JsonResponse({'MESSAGE':f'{e}'}, status=400)
except InvalidPassword as e:
return JsonResponse({'MESSAGE':f'{e}'}, status=400)
except AlreadyExistEmail as e:
return JsonResponse({'MESSAGE':f'{e}'}, status=400)
except AlreadyExistNickname as e:
return JsonResponse({'MESSAGE':f'{e}'}, status=400)
else:
User.objects.create(
email = email,
name = name,
nickname = nickname,
password = password
)
return JsonResponse({'MESSAGE':'SUCCESS'}, status=200)
2. utils.py
def email_check(email):
return re.match('^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', email) != None
def password_check(password):
return len(password)>=8
def duplicate_email_check(email):
return User.objects.filter(email=email).exists()
def duplicate_nickname_check(nickname):
return User.objects.filter(nickname=nickname).exists()
3. my_exceptions.py
class InvalidEmail(Exception):
def __init__(self, msg):
super().__init__(msg)
class InvalidPassword(Exception):
def __init__(self, msg):
super().__init__(msg)
class AlreadyExistEmail(Exception):
def __init__(self, msg):
super().__init__(msg)
class AlreadyExistNickname(Exception):
def __init__(self, msg):
super().__init__(msg)
✅피드백 받은 것
raise
를 많이 사용하면 가독성이 떨어질 수 있다. 만약에 Exception을 사용할 거라면 django내에 있는 Exception을 사용하는 것이 더 좋아보인다. 피드백을 토대로django exception
에 있는ValidateError
를 사용하여 다시 구현했지만, 이번 과제의 의도는if
를 사용하여 바로JsonResponse
를 통해 바로 에러메세지를 리턴하는 것이라고 한다.try ~ except ~ else
에서else
를 굳이 사용할 필요가 없다.else
구문에서 데이터베이스에 인서트되도록 구현했는데 create될 때 또 다른 Exception이 발생할 수도 있으므로 그냥try
안에서 처리하는게 좋을 것 같다고 한다. 이 부분은 예외처리에 대해 더 공부해보고 언제 어떤 방식으로 예외처리를 할 지 알아봐야겠다.- 중복 체크하는 메소드는 validate와 성격이 다르기 때문에 그냥
views.py
안에서 작동하는게 나을듯 - 상수는 변수에 담아서 사용한다.
1차 수정 views.py
import json
from django.http import JsonResponse
from django.views import View
from django.core.exceptions import ValidateError
from .models import User
from .utils import validate_email, validate_password
class SignUp(View):
def post(self, request):
data = json.loads(request.body)
try:
email = data['email']
name = data['name']
nickname = data['nickname']
password = data['password']
validate_email(email)
validate_password(password)
if User.objects.filter(email=email).exists():
return JsonResponse({'MESSAGE':'ALREADY_EXISTS_EMAIL'}, status=400)
if User.objects.filter(nickname=nickname).exists():
return JsonResponse({'MESSAGE':'ALREADY_EXISTS_NICKNAME'}, status=400)
except KeyError:
return JsonResponse({'MESSAGE':'KEY_ERROR'}, status=400)
except ValidateError as e:
return JsonResponse({'MESSAGE':e.message}, status=400)
else:
User.objects.create(
email = email,
name = name,
nickname = nickname,
password = password
)
return JsonResponse({'MESSAGE':'SUCCESS'}, status=200)
2. utils.py
import re
from django.core.exceptions import ValidateError
def validate_email(email):
if re.match('^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', email) is not None:
raise ValidateError('INVALID_EMAIL_ADDRESS')
def validate_password(password):
MIN_LENGTH = 8
if len(password) >= MIN_LENGTH:
raise ValidateError('INVALID_PASSWORD')
최종 수정 views.py
import json
from django.http import JsonResponse
from django.views import View
from .models import User
from .utils import validate_email, validate_password
class SignUp(View):
def post(self, request):
data = json.loads(request.body)
try:
email = data['email']
name = data['name']
nickname = data['nickname']
password = data['password']
if not validate_email(email):
return JsonResponse({'MESSAGE':'INVALID_EMAIL_ADDRESS'}, status=400)
if not validate_password(password):
return JsonResponse({'MESSAGE':'INVALID_PASSWORD'}, status=400)
if User.objects.filter(email=email).exists():
return JsonResponse({'MESSAGE':'ALREADY_EXISTS_EMAIL'}, status=400)
if User.objects.filter(nickname=nickname).exists():
return JsonResponse({'MESSAGE':'ALREADY_EXISTS_NICKNAME'}, status=400)
User.objects.create(
email = email,
name = name,
nickname = nickname,
password = password
)
return JsonResponse({'MESSAGE':'SUCCESS'}, status=200)
except KeyError:
return JsonResponse({'MESSAGE':'KEY_ERROR'}, status=400)
utils.py
import re
def validate_email(email):
return re.match('^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', email) is not None
def validate_password(password):
MIN_LENGTH = 8
return len(password) >= MIN_LENGTH
Endpoint #2. 로그인
소스코드
class SingIn(View):
def post(self, request):
data = json.loads(request.body)
try:
email = data['email']
password = data['password']
if not User.objects.filter(email=email).exists():
return JsonResponse({'MESSAGE':'INVALID_USER'}, status=401)
if not User.objects.get(email=email).password == password:
return JsonResponse({'MESSAGE':'INVALID_USER'}, status=401)
return JsonResponse({'MESSAGE':'SUCCESS'}, status=200)
except KeyError:
return JsonResponse({'MESSAGE':'KEY_ERROR'}, status=400)
고려한 것
- 먼저 email이 DB에 있는지 확인하고 없으면 INVALID_USER를 리턴한다. DB에 해당 이메일이 있으면 그 다음에는 해당 이메일에 대응하는 패스워드와 요청으로 들어온 패스워드를 비교한다. 그 결과가 일치하면 SUCCESS를 리턴하고 불일치하면 다시 INVALID_USER를 리턴한다.
- 패스워드 암호화하여 저장할 때 decode를 해서 DB에 저장해야한다. decode를 안하고 저장하면 바이트자료형을 나타내는
b'~'
부분까지 함께 문자열로 변환되어 저장된다. 실제 암호화된 패스워드는~
부분이기 때문에 decode를 안하고 저장하면 다시 인코딩할 때b''
부분까지 함께 인코딩되기 때문에checkpw()
메소드를 사용했을 때 ValueError가 발생한다. - 유효한 ID와 비밀번호로 요청이 오면 로그인 성공을 알리기 위해 SUCCESS 메시지와 함께 access_token을 리턴한다. access_token을 생성할 때 사용하는 키값은
my_settings.py
에서 따로 import해서 사용한다.
❗️Pain points
- SignUp에서와 마찬가지인 부분으로 휴대전화번호로 로그인이 불가능하다.
# SignUp 메소드에서 패스워드 저장 부분 변경
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
User.objects.create(
email = email,
name = name,
nickname = nickname,
password = hashed_password
)
# SignIn 메소드에서 패스워드 확인 부분 변경
email = data['email']
password = data['password']
if not User.objects.filter(email=email).exists():
return JsonResponse({'MESSAGE':'INVALID_USER'}, status=401)
valid_password = User.objects.get(email=email).password.encode('utf-8')
if not bcrypt.checkpw(password.encode('utf-8'), valid_password):
return JsonResponse({'MESSAGE':'INVALID_USER'}, status=401)
✅피드백 받은 것
- INVALID_USER를 리턴할 때는 상태코드를 404를 보내는 것이 더 적절하다.
로그인 엔드포인트 최종
class SingIn(View):
def post(self, request):
data = json.loads(request.body)
try:
email = data['email']
password = data['password']
if not User.objects.filter(email=email).exists():
return JsonResponse({'MESSAGE':'INVALID_USER'}, status=404)
valid_password = User.objects.get(email=email).password.encode('utf-8')
if not bcrypt.checkpw(password.encode('utf-8'), valid_password):
return JsonResponse({'MESSAGE':'INVALID_USER'}, status=401)
access_token = jwt.encode({'email':email}, SECRET['secret'], algorithm = 'HS256')
return JsonResponse({'MESSAGE':'SUCCESS', 'ACCESS_TOKEN':access_token}, status=200)
except KeyError:
return JsonResponse({'MESSAGE':'KEY_ERROR'}, status=400)
배운것
- 예외 처리를 통해 요청에 따른 Error 캐칭하여 리턴
- bcrypt, jwt를 이용한 암호화, access_token 생성
- 코딩 컨벤션
'TIL' 카테고리의 다른 글
[TIL] #17. self에 대하여 (0) | 2021.04.11 |
---|---|
[TIL] #16. ORM (0) | 2021.04.08 |
[TIL] #15. Django-Introduction (0) | 2021.03.30 |
[TIL] #14. Framework vs Library (0) | 2021.03.28 |
[TIL] #13. __init__.py (0) | 2021.03.23 |