본문 바로가기

TIL

[Project] Westagram #1

프로젝트 목표

회원가입/로그인 기능 구현. 웹 사이트에서 가장 기본이 되는 기능이므로 확실하게 알아두자.



프로젝트 구현 과정

모델링

회원가입에 필요한 정보는

  • 이메일(또는 휴대전화번호)
  • 이름(실제 사람 이름)
  • 사용자 이름(인스타그램 내에서 사용하는 이름)
  • 비밀번호

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