본문 바로가기

Django

Django 회원가입, 로그인 엔드포인트

인스타그램의 기능을 한 가지씩 만들어보는 westagram 프로젝트를 진행중이다. 첫 단계는 회원가입과 로그인이다.

원래 회원가입 엔드포인트와 로그인 엔드포인트 블로그 글을 따로 쓰려 했는데, 에러 처리 면에서 전자가 후자를 포괄한다고 볼 수 있어서 글을 수정했다.(만들면서 쓸 걸 나중에 쓰니까 번거롭다...)

회원가입

조건

필수 사항: email, password

email에는 '@'와 '.'가 반드시 포함되어야 함.

password는 8자 이상.

 

user/models.py

from django.db  import models

class User(models.Model):
    email    = models.CharField(max_length=30)
    password = models.CharField(max_length=18)
    account  = models.CharField(max_length=12, null=True)
    phone_number = models.CharField(max_length=12, null=True)
    created_at   = models.DateTimeField(auto_now_add=True)
    updated_at   = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'users'

email과 password 길이가 짧은 것 같다. 나중에 늘려야 겠다.

처음엔 email, password만 넣어 만들었다가 리뷰받고 수정했다. 이 과정에서의 migration 에러는 따로 글을 쓸 예정. 비슷한 에러를 전에도 겪긴 했지만 DateTimeField 연관은 처음이었다.

 

리뷰 내용

- 당장 쓰이지 않는 column이더라도 조금 더 미래지향적으로 생각해서 반영해두기.

- 보통 회원가입을 관리할 땐 시간정보가 중요. DateTimeField인 created_at와 updated_at.

 

user/views.py

import json

from django.http    import JsonResponse
from django.views   import View

from user.models    import User

class SignupView(View):
    def post(self, request):
        try:
            data  = json.loads(request.body)
        
            if len(data['email']) > 30 or len(data['password']) > 18:
                return JsonResponse({'MESSAGE' : 'DATA_TOO_LONG'}, status=400)
                
            if '@' not in data['email'] or '.' not in data['email']:
                return JsonResponse({'MESSAGE' : 'INVALID_EMAIL'}, status=400)

            if User.objects.filter(email=data['email']).exists():
                return JsonResponse({'MESSAGE' : 'EMAIL_ALREADY_EXIST'}, status=409)

            if len(data['password']) < 8:
                return JsonResponse({'MESSAGE' : 'INVALID_PASSWORD'}, status=400)

            email = User.objects.create(
                email    = data['email'],
                password = data['password']
            )

        except json.decoder.JSONDecodeError:
            return JsonResponse({'MESSAGE' : 'REQUEST_WITHOUT_DATA'}, status=400)

        except KeyError:
            return JsonResponse({'MESSAGE' : 'KEY_ERROR'}, status=400)

        return JsonResponse({'MESSAGE' : 'SUCCESS'}, status=201)

KeyError를 처음엔 if문으로 핸들링했는데(틀린 거였음), 테스트를 했다면 바로 틀렸다는 걸 알 수 있었다. 없는 key를 찾으니까 if문에서 이미 KeyError가 나니까. 여러가지를 테스트하다보니 이거 하나를 빼먹어서 그대로 제출해버렸다. 이걸 포함한 여러가지를 리뷰 받고 수정했다.

 

리뷰 내용

- try-except는 필요한 구간에 선언하는 것 보다는 로직 상단에 위치하는게 좋다. 코드 전체에 걸고 로직에서 나는 except만 종류별로 하단에 선언하기.

- 없는 key를 직접 호출해서 나는 KeyError는 exception handle하거나 다른 방식으로 dictionary 값을 겟해야 됨.

- 길이 관련의 케이스는 exception으로 처리하기보다 입력받으려는 필드의 길이 제한을 최소 요건보다 더 많이 주거나 입력 전에 if문으로 검사하는 게 더 좋을듯.

- max_length 초과의 케이스는 기본적으로 프론트/백 둘다 책임이 있음.

- 중복 문제의 status code는 409(conflict).

- 안 쓰게 된 import는 제거하기.

 

회원가입시 email과 password를 body에 포함해 http 요청을 보내면

1. models.py에 지정해둔 max_length보다 긴 데이터일 때

2. '@'와 '.'이 없어서 유효한 email이 아닐 때

3. 이미 가입된 email일 때 -> 409(Conflict)

4. password가 최소 길이보다 짧을 때

5. body에 아무것도 안 넣고 요청했을 때

6. email이나 password를 key로 넣지 않고 요청했을 때

이렇게 여섯 가지로 에러 핸들링을 했다. 3번을 제외한 나머지는 400(Bad Request)로 처리했다.

 

요청과 응답 스크린샷

더보기

정상적인 요청을 보냈을 때

에러

1. models.py에 지정해둔 max_length보다 긴 데이터일 때

2. '@'와 '.'이 없어서 유효한 email이 아닐 때

 3. 이미 가입된 email일 때

 4. password가 최소 길이보다 짧을 때

5. body에 아무것도 안 넣고 요청했을 때

6. email이나 password를 key로 넣지 않고 요청했을 때

 

시도

1. EmailField

장고에 있는 수많은 필드 중엔 EmailField가 있다. 공식 문서를 보면 EmailValidator를 사용해서 유효한 email인지 확인하는 CharField(max_length=254)라고 써 있다.

EmailValidator는 domain_regex attribute(이름만 봐도 정규식으로 domain 유효성 검사하게 생겼다)를 통해 '@' 뒤에 있는 걸 검사하는데, 거기에 '.'가 없으면 invalid다. 예외 domain은 whitelist에 넣어놔야 한다. localhost는 whitelist에 기본적으로 포함되어 있다.

Validator의 코드를 보니까 정규식 예시 노다지다... 좋은데 머리 아프다. grouping을 중첩할 수 있네.

위 링크 페이지에서 domain_regex로 검색하면 첫번째,

domain_regex = _lazy_re_compile(
        # max length for domain name labels is 63 characters per RFC 1034
        r'((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+)(?:[A-Z0-9-]{2,63}(?<!-))\Z',
        re.IGNORECASE)

대충 보면 ㅁㅁㅁ.ㅇㅇ 형태다. 63자가 최대고 그 중 최소 2자는 web-address suffix에 할당되어 있다. kr 같은 거.

여긴 '@'에 대한 건 없다.

그리고 domain_regex로 검색하고 나온 두번째,

def validate_domain_part(self, domain_part):
        if self.domain_regex.match(domain_part):
            return True

domain_part? 이건 언제 뭘 할당한 건지 보니까 바로 위 __call__에 있다.

def __call__(self, value):
        if not value or '@' not in value:
            raise ValidationError(self.message, code=self.code)

        user_part, domain_part = value.rsplit('@', 1)
        (후략)

주어진 email을 '@'를 기준으로 나누어 뒷부분인 domain_part만 domain_regex로 검사한다.

그럼 EmailField를 쓰면 내가 if문으로 '@'와 '.'를 검사하지 않아도 되고 ValidationError만 핸들링하면 되겠네?

 

2. custom validator

password도 validation 판단 해야 되는데, email 같이 통용되는 규칙(유효 domain)만 쓰는 게 아니라 우리가 원하는 조건(8자 이상)을 줘야 된다. if문 처리하는 게 가장 쉬울 건데 욕심을 좀 내서 validator를 직접 만들고 싶었다. 일단 email로 도전을 해보기로 했다. 그러다 실패해서 email도 password도 validation 판단하는 걸 일단 if문으로 만들어 PR 넣었다.

다시 도전할 거라 1차 실패 요인을 짧게 줄이자면(그래도 많다),

- 내가 만든 정규식이 유효한지 확신이 없는데 일단 넣었음

- validator의 문법을 파악하지 않고 일단 만듦. 함수로 만들어 models.py 상단에 넣었었는데, 공식 문서를 다시 보니 EmailValidator는 class임.

- 기존에 있는 EmailValidor의 에러 핸들링을 충분히 관찰하지 않았음. 위에 코드박스로 넣은 __call__의 후략 부분에 user_part도 validate한지 확인하는 코드가 있었음.

- 안 되니까 구글링을 했는데 custom validatorsms modelForm인 경우에만 자동 적용된다는 글들을 봄. (덧붙임: 전의 상실해서 일단 중단하긴 했는데 modelForm을 쓰지 않는 내 케이스엔 정말 안 되는 건지 검증 해봐야 된다. 구글링했을 때 modelForm 쓴 예시만 나오고 있지만......)

결론 - 이미 있는 EmailValidator을 overriding하든 복사하든 하고 점진적으로 수정해가며 봐야 됐는데, custom한 걸로 단번에 채워 뭐가 문제인지 판단하기 어려움. 그냥 내가 바닥에 압정 쫙 깔은 짓이었다고 보면 될 듯.

 

3. http error

if문으로 email에 '@'와 '.'이 반드시 포함되게 만들었다. 그럼 http 요청을 보낼 때 '@.'만 보내면 어떻게 될지 해보았다.

이건 뭐냐

터미널에 아무 것도 안 떴다. 이 말인즉슨 요청 자체가 실패했다는 거 같은데, 애시당초 왜 "email=@."라고 인식할까? email은 key인데? 왜 '@.'을 분리된 문자열로 인식하지 않고 directory를 찾는지 모르겠다. 구글링을 해도 같은 이름의 다른 에러에 대한 정보만 잔뜩 나온다.

http 요청 자체가 실패한 거라면 이건 백엔드까지 도달하지 않은 거니까 어떻게 처리를 할 수 없지 않나... 모르겠다.


로그인

회원가입 엔드포인트를 거치고 나니 로그인 엔드포인트는 좀 껌같다. 왜냐면 아직 email과 password로만 로그인하도록 해놨고 발생한 에러는 다 회원가입 엔드포인트 만들 때 겪은 거라서...(이렇게 써놓고 리뷰받을 때 털리는 거 아닐까?)

 

user/urls.py

from django.urls import path

from user.views  import SignupView, LoginView

urlpatterns = [
    path('/signup', SignupView.as_view()),
    path('/login', LoginView.as_view())
]

아래 있는 /login으로 http 요청을 보낼 것이다.

 

user/views.py 중 LoginView 부분

class LoginView(View):
    def post(self, request):
        try:
            data       = json.loads(request.body)
            login_user = User.objects.filter(email=data['email'])

            if not login_user.exists() or login_user[0].password != data['password']:
                return JsonResponse({'MESSAGE' : 'INVALID_USER'}, status=401)

        except KeyError:
            return JsonResponse({'MESSAGE' : 'KEY_ERROR'}, status=400)

        return JsonResponse({'MESSAGE' : 'SUCCESS'}, status=200)

로그인시 email과 password를 body에 포함해 http 요청을 보내면

1. 해당 email에 해당하는 데이터가 없거나 password가 틀렸을 때 -> 401(Unauthorized)

2. email 또는 password를 입력하지 않았을 때 -> 400(Bad Request)

이렇게 두가지로 에러 핸들링을 했다.

 

요청과 응답 스크린샷

더보기

정상적인 요청을 보냈을 때

에러

1. 해당 email에 해당하는 데이터가 없거나 password가 틀렸을 때

2. email 또는 password를 입력하지 않았을 때

 

성공을 포함한 세가지 경우에 서버 터미널에서는 아래와 같이 나온다.

Unauthorized: /user/login 이런 식으로 나오는 게 어떤 걸 인지해 출력되는 건지 궁금해서 코드를 좀 건드려보았다.

return하는 JsonResponse 안에 들어가는 status code를 바꾸니 그에 따라 바뀌었다.