백엔드 관점에서 인스타그램의 회원가입
기능을 구현해보도록 하겠습니다. 기본적인 기능만 구현할 수 있지만 저는 최대한 실제 인스타그램과 유사하게 만들어보고 싶어서 도전해보았습니다.
우선 인스타그램 홈페이지에 들어가서 회원가입이 어떤 식으로 이루어지는지 파악을 해봐야겠죠? 직접 회원가입을 시도해보면서 어떤 형식의 이메일을 넣었을 때는 가능한지 등 여러 조건을 파악해보시길 추천드립니다. 수없이 많은 사이트에 회원가입을 해봤겠지만 백엔드 개발자 입장에서 생각해보면 좀 다를 것입니다.
제가 파악한 조건들을 아래에 설명해놓았습니다.
Mobile Number
, Email
, Full Name
, Username
, Password
입니다.Mobile Number
와 Email
은 둘 중 하나만 입력하면 됩니다. 나머지 정보는 꼭 입력되어야 합니다. 회원가입할 때 받은 데이터는 로그인할 때 어느 데이터를 사용하냐에 따라 조건이 달라질 수 있습니다. 이 내용은 Model
을 작성할 때 자세히 다루도록 하겠습니다.
Mobile Number
는 숫자로만 입력합니다. 중간에 -
등이 들어가면 안 됩니다.email
은 이메일 형식에 맞춰서 입력합니다. @
, .
이 들어가는 식이어야 합니다. username
에는 소문자
, 숫자
, _
, .
이 들어갈 수 있는데, 특히 소문자
가 하나 이상 들어가야 합니다. (사실 _
이나 .
으로 끝나면 안 되는 등 세세한 조건이 있지만 여기서 다루지는 않겠습니다. 정규식을 깊게 들어가야 하기 때문입니다.) password
는 8자리 이상이어야 합니다.(이미 westagram
이라는 프로젝트가 생성되어 있는 상황입니다.)
기능 분석이 완료가 되었으니 회원정보를 관리할 수 있는 user
라는 앱을 만들도록 하겠습니다.
python manage.py startapp user
settings.py
의 INSTALLED_APPS
에 생성한 앱을 등록합니다.
# westagram/settings.py
INSTALLED_APPS = [
# 'django.contrib.admin',
# 'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders',
'user', ---> 입력!
]
# user/models.py
from django.db import models
class User(models.Model):
email = models.CharField(max_length=100, unique=True, null=True)
mobile_number = models.CharField(max_length=100, unique=True, null=True)
full_name = models.CharField(max_length=100)
username = models.CharField(max_length=100, unique=True)
password = models.CharField(max_length=300)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'users'
Q. email
, mobile_number
, username
에 unique=True
를 지정한 이유는?
로그인할 때 필요한 정보를 살짝 들여다 봅시다.
email
, mobile_number
, username
이 세 가지 정보 중 하나를 id
로 사용할 수 있습니다. 각각의 값은 중복된 값이 없어야(unique=True
이어야) id
로서 역할을 할 수 있겠지요.
Q. email
, mobile_number
에 null=True
를 지정한 이유는?
회원가입 시 email
와 mobile_number
중에 하나의 정보만 받을 수 있습니다. 당연히 각각의 값은 들어오지 않을 수 있으므로 null=True
지정해주었습니다.
Q. password
에 max_length=300
을 지정한 이유는?
비밀번호는 이후에 암호화 과정을 거치면서 DB에 저장되는 값이 길어질 수 있습니다. 넉넉히 max_length=300
을 지정해주었습니다.
Q.created_at
, updated_at
필드를 작성한 이유는?
보통 회원가입을 관리할때에는 시간정보가 중요합니다. created_at에는 처음 생성된 시점, updated_at에는 업데이트된 시점을 기록하는데, 날짜와 시간을 가져오는 필드인 DateTimeField
를 이용합니다. auto_now_add = True
를 넣어주면 처음 생성(추가)된 시점을 자동으로 기록해주고, auto_now = True
를 넣어주면 필드가 업데이트 될 때마다 그 시점을 기록해줍니다.
Model
작성이 완료되었으면 makemigrations
로 migration 파일을 만들어줍니다.
manage.py
가 위치한 디렉토리(루트 디렉토리)에서 아래의 명령어를 입력합니다.
python manage.py makemigrations user
makemigrations
뒤에 마이그레이션을 할 앱(user
)을 지정해주는게 좋은데, 만약 앱 모델 간에 참조 관계가 있는 경우 순서가 중요할 때가 있기 때문입니다.
migration 파일이 생성이 되었으면 migrate
명령어로 변경된 사항을 실제 DB에 적용할 수 있습니다.
하지만, python manage.py sqlmigrate user 0001
명령어로 실제로 어떤 sql문이 적용되는지 확인하는 습관을 가지는 게 좋습니다.
확인 했으면 migrate
로 실제 DB에 변경 사항을 적용해봅시다.
python manage.py migrate user
제가 작성한 회원가입 뷰를 보여드리기 전에 어떤 방식으로 작성했는지 설명드리겠습니다. 크게 다섯 부분으로 나눌 수 있습니다.
data = json.loads(request.body)
회원가입에 필요한 정보는 Json
형식으로 request의 body에 담겨서 옵니다. 그걸 json.loads()
로 장고에서 사용할 수 있도록 파이썬 딕셔너리 형식으로 바꾸어 data라는 변수에 담습니다.
하지만 항상 우리에게 필요한 모든 정보가 전달되지 않을 수도 있습니다.
email = data['email']
예를 들어 data['email']
로 이메일 정보를 가져오려고, email
이 request body에 없다면?! 이 코드는KeyError
를 뿜어내고 로직은 중단됩니다.
따라서 KeyError
를 처리하기 위해서는
try:
email = data['email]
except KeyError:
return JsonResponse({'message':'KEY_ERROR'}, status=400)
이런 식으로 try, except
를 사용하는 방법이 있을 수 있겠으나 저는 다른 방법을 사용하였습니다.
바로 딕셔너리의 get
메소드를 이용하는 방법입니다.
email = data.get('email', None)
get
메소드는 딕셔너리.get(키, 기본값)
형식으로 딕셔너리 안에 찾으려는 Key 값이 없을 경우 기본값을 대신 가져옵니다. 위 예시에서는 data
에 email
이라는 키가 없으면 에러 대신 None
을 가져옵니다.
이제 에러 발생은 막았으니, KeyErorr
가 발생했다는 것을 프론트 쪽에 알려야겠지요? if문
을 사용합니다.
if not email:
return JsonResponse({'message':'KEY_ERROR'}, status=400)
email
이 없으면 None
을 반환하는데, 파이썬에서 None
은 False
입니다. if는 조건식이 True
일 때 아래 코드를 실행하므로, email
에 not
을 붙여 True
로 만들어 줍니다.
딕셔너리의
get
메소드와if not
을 이용해KeyError
를 처리합니다!
저는 JsonDecodeError
받아야 할 정보들이 있는데, 아무런 정보도 전달되지 않았을 때 발생하는 에러라고 정의하였습니다. json.loads()
로 Json 형식으로 받은 정보를 decode하는데, decode할 값이 없으니 에러가 발생하는 것입니다.
예를 들어 httpie를 이용하여
http -v POST 127.0.0.1:8000/user/signup
이렇게 보내면 JsonDecodeError
가 발생합니다.
받을 정보가 있는 POST
에서 발생하므로, GET
에서는 따로 처리를 안 해도 괜찮습니다.
정규표현식을 사용하여 Validation
조건을 작성하였습니다. 장고에서는 EmailValidator
등 내장된 Validator가 있습니다만, 장고 이외에 어떤 툴을 사용하더라도 사용할 수 있는 정규표현식을 익히는 것이 중요합니다.
(제가 정규식을 익힌 사이트는 파이썬 코딩 도장과 연오의 파이썬이라는 곳입니다.)
email_pattern = re.compile('^[^@]+@[^@]+\.[^@]+$')
mobile_number_pattern = re.compile('^[0-9]{1,15}$')
username_pattern = re.compile('^(?=.*[a-z])[a-z0-9_.]+$')
비밀번호는 8자리 이상이기만 하면 되므로 상수를 사용하였습니다. 상수는 코드 중간에 선언하는 것보다 함수 바깥 혹은 임포트 아래에 선언해주는 것이 좋습니다.
from user.models import User
PASSWORD_MINIMUM_LENGTH = 8
class SingUpView(View):
이제 if문
으로 프론트 쪽에 Validation Error가 발생했다는 것을 알려야 합니다. 사실 정규표현식 등으로 직접 만든 Validator의 경우 실제로 ValidationError
가 발생하지는 않습니다. 장고에 내장된 EmailValidator
등을 사용할 때 나는 에러이죠. 그럼에도 불구하고 우리는 입력된 값이 우리가 만든 Validation
조건에 맞지 않다는 것을 알려주어야 합니다.
if email:
if not re.match(email_pattern, email):
return JsonResponse({'message':'EMAIL_VALIDATION_ERROR'}, status=400)
if len(data['password']) < PASSWORD_MINIMUM_LENGTH:
return JsonResponse({'message':'PASSWORD_VALIDATION_ERROR'}, status=400)
자주 쓰이는 Validation이라면 함수로 따로 만들어서 사용할 수도 있습니다.
위에서 언급했듯이 로그인을 할 때 id
로 email
, mobile_number
, username
중에 하나를 받을 수 있습니다. 따라서 이미 DB에 존재하는 값을 중복해서 받을 수 없습니다.
중복 값 체크는 보통 filter()
와 exists()
를 이용합니다.
if User.objects.filter(email = data.get('email', 1)).exists():
return JsonResponse({'message':'ALREADY_EXISTS'}, status=409)
만약 전달된 email
정보가 있다면, 동일한 값을 filter()
로 DB에서 찾아내는데, 만약 그 값이 존재한다면 exists()
로 True
를 반환합니다.
만약 전달된 email
정보가 없다면, email = data.get('email', 1)
에 의해 email
은 1
이 됩니다. 왜 None
이 아니라 1
인지 의문이 들 것입니다.
그 이유는 바로 email
에는 null
값이 존재할 수 있기 때문입니다! 만약 email
이 None
이라면 기존 DB에 등록되어 있던 null
이 같은 값이라고 취급되어 중복값이 존재하는 것으로 처리됩니다. email
에 아무런 값이 전달되지 않았는데 이미 존재하는 정보라고 처리되면 안 되겠죠? 그러니 None
대신 임의의 값 1
을 넣어주었습니다.
자 여기까지 email
이 이미 존재하는지 확인하는 로직을 작성해보았습니다. 그럼 mobile_number
, username
의 경우도 새로운 if문
으로 이어서 작성하면 되겠죠?
물론~ 그렇게 해도 되겠지만 Q 객체
를 사용하면 코드의 길이를 확 줄일 수 있습니다! 한 번 사용해보도록 합시다.
if User.objects.filter(
Q(email = data.get('email', 1)) |
Q(mobile_number = data.get('mobile_number', 1)) |
Q(username = data['username'])
).exists():
return JsonResponse({'message':'ALREADY_EXISTS'}, status=409)
Q객체를 통해 OR 조건을 사용했습니다. 세 가지 정보 중 하나만 들어오기 때문에 OR 조건입니다.
User.objects.create(
email = email,
mobile_number = mobile_number,
full_name = full_name,
username = username,
password = password
)
return JsonResponse({'message':'SUCCESS'}, status=201)
이제 실제로 회원정보를 생성할 차례입니다! create()
메소드로 회원정보를 생성하였고, 모든 검사를 마쳤기 때문에 각각의 필드에는 최초 변수에 담은 데이터로 연결하면 됩니다.
# user/views.py
import json
import re
from json.decoder import JSONDecodeError
from django.http import JsonResponse
from django.views import View
from django.db.models import Q
from user.models import User
PASSWORD_MINIMUM_LENGTH = 8
class SingUpView(View):
def post(self, request):
try:
data = json.loads(request.body)
email = data.get('email', None)
mobile_number = data.get('mobile_number', None)
full_name = data.get('full_name', None)
username = data.get('username', None)
password = data.get('password', None)
email_pattern = re.compile('[^@]+@[^@]+\.[^@]+')
mobile_number_pattern = re.compile('^[0-9]{1,15}$')
username_pattern = re.compile('^(?=.*[a-z])[a-z0-9_.]+$')
if not (
(email or mobile_number)
and full_name
and username
and password
):
return JsonResponse({'message':'KEY_ERROR'}, status=400)
if email:
if not re.match(email_pattern, email):
return JsonResponse({'message':'EMAIL_VALIDATION_ERROR'}, status=400)
if mobile_number:
if not re.match(mobile_number_pattern, mobile_number):
return JsonResponse({'message':'MOBILE_NUMBER_VALIDATION_ERROR'}, status=400)
if not re.match(username_pattern, username):
return JsonResponse({'message':'USERNAME_VALIDATION_ERROR'}, status=400)
if len(data['password']) < PASSWORD_MINIMUM_LENGTH:
return JsonResponse({'message':'PASSWORD_VALIDATION_ERROR'}, status=400)
if User.objects.filter(
Q(email = data.get('email', 1)) |
Q(mobile_number = data.get('mobile_number', 1)) |
Q(username = data['username'])
).exists():
return JsonResponse({'message':'ALREADY_EXISTS'}, status=409)
User.objects.create(
email = email,
mobile_number = mobile_number,
full_name = full_name,
username = username,
password = password
)
return JsonResponse({'message':'SUCCESS'}, status=201)
except JSONDecodeError:
return JsonResponse({'message':'JSON_DECODE_ERROR'}, status=400)
# westagram/urls.py
from django.urls import path, include
urlpatterns = [
path('user', include('user.urls')),
]
# user/urls.py
from django.urls import path
from .views import SingUpView
urlpatterns = [
path('/signup', SingUpView.as_view()),
]
다음 시간에는 인스타그램 로그인 기능을 구현하도록 하겠습니다.