[Django] Session-Based 회원가입/로그인 기능 구현

Saemi An·2025년 5월 26일
post-thumbnail

python -m Core.manage startapp Users

Core 디렉토리 하위로 Users 디렉토리 옮기기

Core/Users/app.py에서 name = 'Core.Users'로 수정

Core/Project/settings.py의 INSTALLED_APP에 'Core.Users.apps.UsersConfig', 추가
그냥 Users만 해줘도 잘 되던데 왜?

🐸 개요

🐍 장고의 기본 사용자 모델 User

장고 프레임워크에는 기본 사용자 모델인 User가 존재한다.
어드민에 들어가보면 나오는 것이 바로 User 모델;

User 모델에는 다음과 같은 필드들이 존재한다;

공식문서에 들어가 보면 각 필드에 대한 구체적인 설명을 확인할 수있다.

🐍 User 모델 활용

하지만 장고의 User 모델만으로 회원가입/로그인 기능을 구현하는 것은 큰 위험을 동반한다.
생각보다 많은 디폴트 기능과 접근 권한들이 해당 모델 설정을 기반으로 하기 때문이다.

그래서 보통 개발자가 따로 사용자 모델(이하 Profile 모델)을 만들고
개발자의 사용자 모델에 장고 User 모델의 인스턴스와 1:1 관계를 갖는 필드(이하 Profile.user 필드)를 만들어 사용하는 것이 일반적이라고 한다.

from django.db import models
from django.contrib.auth.models import User

class Profile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    # address, birthdate, created_at 등 부가 회원정보

id & password와 같은 사용자 정보는 User 모델에 저장하고
이외에 개발자가 필요로 하는 부가 정보는 Profile 모델에 저장하는 것이다.

이를 통해 장고의 사용자 인증 및 권한 부여 시스템을 가져다 쓰면서
비즈니스 로직에 부합하는 회원 관련 확장 기능들을 안전하게 추가할 수 있는 것이다.

🐍 회원가입/로그인 기능은 어떻게 구현되는가?

실제 코드를 짜보기에 앞서 User 모델을 활용하여 어떻게 회원가입 기능을 구현할 수 있는지 구조를 살펴보자;

  1. 사용자가 폼에서 id, password, birthdate, address 제출.
  2. views.py에서 폼을 저장하면 User 모델에 id 및 pw를 기반으로 인스턴스가 생성됨.
  3. User 인스턴스가 생성되면 models.py에서 post_save 시그널을 받아 Profile 모델의 인스턴스 자동 생성. birthdate와 address가 저장됨.

복잡해 보이지만 사실 기본적인 '폼 제출 - 유효성 검사 - 폼 저장 - 인스턴스 생성' 구조에서 크게 벗어나지 않는다.

튜토리얼을 따라가며 코드를 짜보자.
단, 아래의 튜토리얼은 우리가 원하는 기능 구현까지 매우 돌아돌아 가는 방식으로 구성되어 있다.

🐸 장고 어드민을 통한 회원 생성

🐍 Profile 모델 생성

from django.db import models
from django.contrib.auth.models import User
import uuid

class Profile(models.Model):
    class Meta:
        verbose_name_plural = '프로필'
        db_table = 'Profile'
        
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    name = models.CharField(max_length=30)
    birthdate = models.DateField()
    created_at = models.DateTimeField(auto_now_add=True)
    id = models.UUIDField(default=uuid.uuid4, unique=True, primary_key=True, editable=False)

    def __str__(self):
        return str(self.user.username)

user 필드는 장고 User 모델과 1:1 관계에 있는 필드이다. 아이디 (User.username) 및 패스워드(User.password)가 저장될 것이다.
User 모델에서 인스턴스가 삭제되면 Profile 모델의 인스턴스도 함께 삭제된다. --> 이거 vice versa? 혹은 반대?

name 필드는 사용자가 앱에서 불릴 이름이다.

이후 마이그레이션을 진행한 뒤 어드민에 Profile 모델도 등록해 줬다.

python -m Core.manage makemigrations Users
python -m Core.manage migrate

🐍 Profile 인스턴스 생성

Profile 모델의 인스턴스를 생성하기 위해서는 우선 User 모델 인스턴스가 존재해야 한다.
Profile.user 필드가 User 모델을 1:1 관계로 참조하고 있기 때문이다.

우선 장고 어드민에서 User 인스턴스를 생성해 준다;

유저 등록 첫 번째 페이지에서는 필수 입력값인 username과 password를 입력해야 한다.
두 번째 페이지에서는 Personal info, Permissions, Important dates를 설정해 주는데 이들은 모두 선택 입력이다.

아무것도 만지지 않고 유저 생성을 마치면 아무 권한 없이 그저 active 상태일 뿐인 유저가 생성된 것을 확인할 수 있다.

이후 Profile 인스턴스를 생성해 준다;

user 필드에서는 위에서 만든 User 모델의 인스턴스를 선택해 준다.
created_at필드는 자동생성이고 id 필드는 수정불가 설정이기 때문에 어드민에 보여지지 않는다.

이후 목록을 확인하면 새로운 프로필 인스턴스가 생성된 것을 확인할 수 있다.

🐍 시그널

장고 시그널이란?

추후에 회원가입시 쿠폰을 발행하는 기능을 넣을까 고민중이다.
연습을 위해 장고 시그널을 활용해보자.

from django.db.models.signals import post_save, post_delete

...(중략)

def update_profile(sender, instance, created, **kwargs):
    print(f'프로파일 {"생성" if created else "수정"}됨!')
    print(f'센더: {sender}')
    print(f'인스턴스: {instance}')

    print('인자로 뭐가 들어왔는가?')
    for k, v in kwargs.items():
        print(f'키: {k}', f'값: {v}')


def delete_user(sender, instance, **kwargs):
    print(f'유저 삭제됨')
    print(f'센더: {sender}')
    print(f'인스턴스: {instance}')

post_save.connect(update_profile, sender=Profile)   # 연결
post_delete.connect(delete_user, sender=Profile)   # 연결

update_profile()은 Profile 모델에서 인스턴스가 수정되었다는 시그널을 받았을 때 실행할 함수이다. 이 함수는 다음 네가지 인자를 받을 수 있다;

  • sender는 시그널을 보내는 모델을 의미한다.
  • instance는 시그널의 트리거가 되는 인스턴스를 의미한다.
  • created는 instance가 새로 생성되었는지 아닌지에 따라 T/F 값을 반환한다.
  • kwargs는 딕셔너리 형태의 파라미터이다.

kwargs에 대한 자세한 설명은 여기서 확인

post_save.connect(update_profile, sender=Profile) [무슨 이벤트]가 발생했을 때 [어느 모델에서] 시그널을 보내 [무엇을 것을 실행]시킬지를 정의하고 있다.

  • Profile 모델에서
  • save 이벤트가 발생한 직후에
  • update_profile 함수를 실행해라

터미널에 찍힌 프린트 결과를 보면 다음과 같다;

프로파일 수정됨!
센더: <class 'Core.Users.models.Profile'>
인스턴스: saemi
인자로 뭐가 들어왔는가?
키: signal 값: <django.db.models.signals.ModelSignal object at 0x10392bf50>
키: update_fields 값: None
키: raw 값: False
키: using 값: default

raw가 뭔지 아직 모르겠음

🐍 시그널 데코레이터

데코레이터를 사용하면 조금 더 직관적인 표현이 가능하다;

from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver   # 추가

@receiver(post_save, sender=Profile)
def update_profile(sender, instance, created, **kwargs):
    ...(생략)

@receiver(post_delete, sender=Profile)
def delete_user(sender, instance, **kwargs):
    ...(생략)

🐍 시그널을 활용한 Profile 인스턴스 자동생성

현재 상태에서 사용자를 추가하려면 두 단계를 거쳐야 한다: User 인스턴스 생성 > Profile 인스턴스 생성.
시그널을 활용하여 이를 한 단계로 단축시켜 보자;

@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
    if created:
        user = instance
        profile = Profile.objects.create(
            user = user,
            name = user.username,
            birthdate = None
        )

위 코드는 User 모델에서 새로운 인스턴스 생성시 if created:
Profile 모델에서 인스턴스를 생성하도록 한다. Profile.objects.create()

테스트 과정에서 에러가 나서 birthdate 필드에 null=True, blank=True를 추가한 후 마이그레이션을 진행 줬다.

과정은 단축 되었지만 만일 기본적인 UserCreationFrom()으로 회원가입을 받는다면 불편할 것 같다.
아래와 같은 단점이 예상된다;

  • 아이디 = 앱에서 불릴 이름
  • 생년월일을 받을 수 없음 (기본값 None)

🐍 시그널을 활용한 User 인스턴스 자동삭제

user = models.OneToOneField(User, on_delete=models.CASCADE)
Profile 모델의 user 필드에는 CASCADE 옵션이 있기 때문에
User 인스턴스 삭제시 관련된 Profile 인스턴스도 자동으로 삭제된다.

하지만 개발자가 어드민에서 Profile만 삭제하는 경우가 있을 수 있다.
이 경우 User 정보는 여전히 남아 있을 것이다.

이와 같은 경우를 방지하기 위해 Profile 인스턴스 삭제시 관련된 User 인스턴스도 삭제하는 시그널을 다음과 같이 정의할 수 있다;

@receiver(post_delete, sender=Profile)
def delete_user(sender, instance, **kwargs):
    user = instance.user
    user.delete()

🐍 모델과 시그널 분리

클린 코딩을 위해 models.py 파일에는 정의된 모델 코드만 두고 시그널들은 다른 파일에 옮겨보자!

/Users/signals.py 파일을 생성하고 시그널 관련 코드들을 옮겨준다. 파일 안에서 import도 진행해 준다.

이후 파일을 저장해 보면 터미널에서 아무런 반응이 없다.
장고가 signals.py 파일 안에 있는 시그널들을 인지하지 못하고 있다는.

장고에게 분리된 파일에 있는 시그널의 존재를 알리기 위해/Users/apps.py 에서 다음과 같은 코드를 추가해 준다;

from django.apps import AppConfig


class UsersConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'Core.Users'
    
    def ready(self):   # 추가됨
        import Core.Users.signals

이제 시그널이 정상작동 하는 것을 확인할 수 있다! 🥳

⚠️ app.py를 업데이트 했음에도 불구하고 터미널에 반응이 없는 경우
settings.py 파일에서 INSTALLED_APPS = [ ..., '프로젝트명', ...]과 같이 앱을 추가해준 경우이다.

이 경우 수정된 AppConfig를 장고가 인식하지 못한다!
INSTALLED_APPS = [ ..., 'Core.Users.apps.UsersConfig', ...]와 같이 적어 주어야 한다.



🐸 로그인/로그아웃

  • 프론트에서 로그인/로그아웃
  • 로그인(Authentification) 여부에 따라 프론트 접근 권한 부여(Authorization)

🐍 Session-Based 인증

장고는 기본적으로 Session-Based 인증 시스템을 제공한다.
장고는 인증된 사용자가 접근시 session id를 주고 이를 Sessions 테이블에 저장해 둔다.
사용자가 접근할 때마다 Sessions 테이블에서 session id가 있는지 찾고, 있으면 요청을 허락한다.

실제로 브라우저에서 개발자도구 > Application > Cookies > sessionid를 확인해볼 수 있다;

직접 sessionid를 삭제한 뒤 페이지를 새로고침 하면 로그아웃이 되고
다시 로그인을 해보면 새로운 session id가 부여된다.

장고 기본 DB(SQLite)에서는 Sessions 테이블을 확인하기 어렵지만,
PostgreSQL 등과 같은 외부 DB를 연동하여 PG Admin 등의 도구로 들어가 보면 Session 테이블을 직접 확인해볼 수 있다. 필드는 다음과 같다;

  • session_key: session id
  • session_data: 이건 뭐야?
  • expire_date: sesssion 만료일

개발자 도구에서 세션을 삭제하더라도 Sessions 테이블에서 삭제되지는 않는다.
데이터베이스에서 지속적으로 세션 데이터를 축적해 나간다.

따라서 엄청난 수의 유저가 수년에 걸쳐 시스템을 사용한다면 session-based 인증 방식은 비효율적이다 ?
세션 아이디는 암호화 안 되어 있음? 탈취되면 어떻게함?
refresh 주기는 어떻게됨?

🐍 로그인/로그아웃

장고에서 제공하는 login(), logout(), authenticate() 함수를 활용하여 간단하게 로그인/로그아웃 뷰를 만들 수 있다;

# views.py
from django.shortcuts import render, redirect
from django.contrib.auth import login, authenticate, logout

from django.contrib.auth.models import User

def login_user(request):
    if request.method == 'POST':
        id = request.POST['id']
        pw = request.POST['pw']

        try:
            user = User.objects.get(username=id)
        except:
            print('사용자가 존재하지 않습니다.')

        user = authenticate(request, username=id, password=pw)

        if user:
            login(request, user)
            return redirect('home')
        else:
            print('아이디와 비밀번호를 확인해 주세요.')

    return render(request, 'Users/login.html')

def logout_user(request):
    logout(request)
    return redirect('login')

(테스트용이기 때문에 유효성 검사 메세지는 그냥 터미널에 출력하고 있음)

  • 장고 내장 함수 login()logout()와 겹치는 이름의 뷰 함수를 피하기 위해 login_user()logout_user() 사용.
  • try / except 구문에서는 id 존재 여부 확인
  • if 구문에서는 id와 pw 일치 여부 확인

로그인 html을 다음과 같이 간단히 만들어준 뒤, urls.py에서 뷰 맵핑도 해줬다;

<form method="POST" action="{% url 'login' %}">
    {% csrf_token %}
    <input type="text" name="id" placeholder="이름">
    <input type="password" name="pw" placeholder="비밀번호">
    <button>로그인</button>
</form>

로그인을 하면 navbar에 있던 '로그인' 텍스트가 '로그아웃'으로 변하는 것을 확인할 수 있는데, 장고가 제공하는 is_authenticated속성을 활용하면 아주 쉽게 구현할 수 있다;

<nav class="nav">
    <div class="nav__left">
        <img src="{% static 'images/logo.svg' %}" alt="">
    </div>
    <ul class="nav__right">
        <li><a href="{% url 'home' %}">구움과자</a></li>
        <li>
            {% if request.user.is_authenticated %}
            <a href="{% url 'logout' %}">로그아웃</a>
            {% else %}
            <a href="{% url 'login' %}">로그인</a>
            {% endif %}
        </li>
    </ul>
</nav>
  • request는 뷰 함수에서 넘겨준 그 request 객체
  • request.user는 현재 요청을 보낸 사용자 객체(User object)
    • 로그인 O: 로그인한 사용자. 즉, 장고 User 모델의 인스턴스
    • 로그인 X: AnonymousUser

+) 추후에 request.user.email과 같은 형식으로 사용자 정보 출력도 가능하다!

🐍 로그인 사용자 접근제한

request.user.is_authenticated를 활용하여 이미 로그인을 한 사용자가 로그인 페이지에 접근하는 것을 막을 수 있다;

def login_user(request):

    if request.user.is_authenticated:   # 로그인 사용자 접근제한 추가
        return redirect('home')

    if request.method == 'POST':
       ...(생략)

특정 페이지 전체에 대한 접근 권한이 필요할 경우에는 데코레이터를 사용할 수도 있다;

from django.contrib.auth.decorators import login_required

@login_required(login_url="login")
def home(request):
       ...(생략)

뷰가 지저분해지는 것이 싫다면 urls.py에서 데코레이터를 몰아 넣을 수도 있다!

🐍 Toast(Flash) Messages

기존에 터미널에 출력하던 유효성검사 메세지를 토스트 메세지로 출력해보자;

from django.contrib import messages
def login_user(request):
	...(중략)
    messages.error(request, '아이디와 패스워드가 일치하지 않습니다.')
    return redirect('login')

🐸 회원가입

🐍 UserCreationForm() 사용


장고에서 기본 제공하는 UserCreationForm()을 활용하여 회원가입 페이지를 구현해 보았다.

views.py;

def signup(request):
    is_signup = True
    form = UserCreationForm()

    if request.method == 'POST':
        form = UserCreationForm(request.POST)
        if form.is_valid():
            # User 인스턴스 생성
            user = form.save(commit=False)
            user.save()
            
            # Profile 인스턴스 생성
            user_name = request.POST['name']
            user_birthdate = request.POST['birthdate']
            Profile.objects.create(
                user = user,
                name = user_name,
                birthdate = datetime.strptime(user_birthdate, '%Y-%m-%d').date()
            )

            login(request, user)
            messages.success(request, f'환영합니다, {user_name}님. 회원가입을 축하 드립니다!')
            return redirect('login')

    context = {
        'is_signup': is_signup,
        'form': form,
    }
    return render(request, 'Users/login.html', context)
  • is_signup 변수: 따로 signup.html 파일을 만들지 않고, is_signup 변수를 통해 login.html에서 다른 템플렛을 렌더링 하도록 했다.
  • UserCreationForm() 모델폼: UserCreationForm()은 장고 User 모델에 인스턴스를 생성하기 위한 모델폼이다.
  • user = form.save(commit=False): 편의를 위해 User 모델에 생성할 인스턴스를 user 변수에 담아 주었다.
  • Profile.objects.create(...): 기존 Users/signals.py에 있던 @receiver(post_save, sender=User) 데코레이터를 삭제한 후, 뷰에서 직접 Profile 모델의 인스턴스를 생성하도록 했다.
    • post_save 이벤트 발생할때 name이랑 birthdate는 함께 못 넘겨주나? 데코레이터 사용하고 싶은데..
  • login(request, user): 회원가입 이후 바로 로그인 된 상태로 넘어가도록 했다.

login.html;

...(생략)
{% load widget_tweaks %}

{% block toast %}
{% if messages %}
<ul class="toast">
    {% for message in messages %}
    <li{% if message.tags %} class="toast__{{ message.tags }}"{% endif %}>{{ message }}</li>
    {% endfor %}
</ul>
{% endif %}
{% endblock %}

{% block body %}
{% if is_signup %}
<h1>회원가입</h1>
<form method="POST" action="{% url 'signup' %}">
    {% csrf_token %}
    <div class="input_container">
        {{ form.username | attr:'placeholder: 아이디 (특수문자 불가)' }}
        <div class="input_error_container">{{ form.username.errors}}</div>
        
        {{ form.password1 | attr:'placeholder: 비밀번호 (문자와 숫자 조합 최소 8자)' }}
        <div class="input_error_container">{{ form.password1.errors}}</div>
        
        {{ form.password2 | attr:'placeholder: 비밀번호 확인' }}
        <div class="input_error_container">{{ form.password2.errors}}</div>

        <input type="text" name="name" placeholder="이름">
        <input type="date" name="birthdate">
    </div>
    <button type="submit">회원가입</button>
</form>

{% else %}
<h1>로그인</h1>
	...(생략)
{% endif %}
  • {% load widget_tweaks %}: 모델폼을 사용하니 커스텀이 어려워서 외부 라이브러리를 설치해 주었다. 폼 커스텀을 위해서는 일반적으로 forms.py에서 파이썬 코드로 폼을 수정하지만, 해당 라이브러리를 활용하면 html 파일에서 직접 태그의 속성을 수정할 수 있다. 특히 모델폼 사용시 매우 간단하게 class나 set-data를 추가할 수 있어 유용하다.
  • {% if is_signup %}: 뷰에서 넘겨받은 is_signup 변수를 조건문에 활용하여 하나의 html 파일에 회원가입 혹은 로그인 html을 렌더링 한다.
  • {{ form.username.errors}}: 폼 유효성 검사 메세지를 출력해 주었다. 기본형이 매우 못생겼기 때문에 따로 클래스를 지정해 주었다.
  • 이름과 생년월일: UserCreationForm()에는 없지만 Profile 인스턴스 생성시 필요한 두 개의 필드를 직접 추가해 주었다.

🐍 CustomUserCreationForm() 사용

그러나 사실 모델폼을 그대로 가져다 쓰는 것은 현실적으로 한계가 있다;

  • 유효성 검사 메세지 영어로 나옴
  • 못생김
    • class, placeholder 등 추가 필수 (django-widget-tweaks로 대체 가능)
    • date 타입 input 필드 플레이스 홀더(연도. 월. 일.)만 색깔이 검은색인게 너무 거슬림(django-widget-tweaks로 대체 불가)

따라서 forms.py에서 기존의 UserCreationForm()을 커스텀 할 수 있다;

from django import forms
from django.contrib.auth.models import User
from django.contrib.auth.forms import UserCreationForm


class CustomUserCreationForm(UserCreationForm):
    name = forms.TextInput(attrs={'placeholder' : '사용자 이름', 'class' : 'some-class'})
    birthdate = forms.DateField()

    class Meta:
        model = User
        fields = ['username', 'password1', 'password2']
        labels = {
            'username': '아이디',
            'password1': '비밀번호',
            'password2': '비밀번호 확인',
        }
        widgets = {
            'username' : forms.TextInput(attrs={'placeholder' : '아이디', 'class' : 'some-class'}),
            'password1' : forms.PasswordInput(attrs={'placeholder' : '비밀번호 (문자와 숫자 조합 최소 8자)', 'class' : 'some-class'}),
            'password2' : forms.PasswordInput(attrs={'placeholder' : '비밀번호 확인', 'class' : 'some-class'}),
        }
        
        def clean_name(self):
            name = self.cleaned_data.get('name')
            if name is None or name == "":
                raise forms.ValidationError("필수 입력값 입니다.")
            elif len(name) > 10:
                raise forms.ValidationError("공백포함 최대 10자까지 입력이 가능합니다.")
            return name
        
        ... (생략)

위와 같이 forms.py에서는 html 태그, 속성, 유효성 검사 메세지, 추가 필드 등의 커스텀이 가능하다.
위 코드는 작성중에 사용을 중단 했음으로 검증되지 않음(에러 발생 가능)

🐸 회원정보 수정

장고의 User 모델을 가져다가 쓰면 편리한 기능이 압도적으로 많다.
Session id를 자동 생성해주고,
로그인/로그아웃 기능을 제공하고,
비밀번호도 DB에 저장할 때 자동으로 해싱해 주는 등..

하지만 동시에 불편한 점도 있다.
아이디와 비밀번호는 장고의 User 모델에 저장되어 있는 반면 이외 회원정보는 Profile 모델에 분산 저장되어 있으니.. 회원가입 / 회원정보 수정시 폼 커스텀을 빡세게 해줘야 한다.
ModelForm에서는 다른 모델의 필드 (user.username)를 fields에 직접 쓸 수 없음이기 때문에 폼 커스텀이 참으로 복잡하다..

또한 회원정보 수정시 사용하는 UserChangeForm()는 비밀번호 변경의 용도로 사용할 수 없다.
(그리고 초기값 넣어서 렌터링시 해시된 암호를 보여준다. 이건 당연한거라 ok.)
비밀번호 변경을 위해서는 PasswordChangeForm()를 사용해야 한다.
즉, 마이페이지에 '개인정보 수정' 버튼 외에 '비밀번호 변경' 버튼이 따로 있어야 하고(UI가 지저분해 지는게 싫다..) 이를 위한 뷰를 또 만들어 줘야 한다.

장고 User 모델과 개발자의 Profile 모델이 분리되어 있을 때 데이터를 다루는 방법 두가지는 다음과 같다;
1️⃣ 단일화: 모델폼 커스텀 필드 추가 + 초기값 지정 + 저장 처리
이 방법은 모델폼 초기 설정이 매우 길고 복잡한 대신, 뷰나 html 파일이 깔끔해 진다는 장점이 있다.

2️⃣ 분산: 모델폼(커스텀 X) + 뷰에서 초기값 지정 및 분산 저장 처리 (비추천)
현재 내가 채택한 방법이다. 하지만 코드가 지저분하고 로직이 분산되어 있어 실제 포레포레에 회원가입 기능을 추가할 때 사용하지는 않을 것이다.

🐍 삽질


⚠️ 디버깅 - Profile' object has no attribute 'get'
form = ProfileForm(profile)
장고 폼의 첫 번째 인자는 입력 데이터이며, 보통 request.POST를 의미.
이는 폼에 입력값을 전달하는 것으로, 장고는 .get()을 통해 입력값에 접근하려고 시도.

하지만 profile = request.user.profile 일때 profile은 인스턴스임.

따라서 form = ProfileForm(instance=profile)와 같이 인자가 인스턴스임을 명시해 주어야함!


⚠️ 디버깅 - <ul class="errorlist"><li>date_joined<ul class="errorlist" id="id_date_joined_error"><li>This field is required.</li></ul></li></ul>

UserChangeForm()을 가져다가 {{ form.username }}만 렌더링하여 아이디를 수정했더니 위와 같은 에러가 발생했다.

date_joined는 필수 입력값이 아니다. 그럼에도 불구하고 에러가 난 이유는 뭘까?;
date_joined 필드는 auto_now_add=True로 자동 설정되는 필드라고 한다. 따라서 새로운 유저를 생성할 때에는 자동으로 값이 저장된다.
하지만 기존 인스턴스 수정시 UserChangeForm()에서 date_joined 필드를 바인딩하려고 시도하고, 폼에 해당 필드가 없었기 때문에 None값으로 처리되어 에러가 났다.

아오... 남이 만들어 놓은거 빌려 쓰기 힘드네


⚠️ 디버깅 - SetPasswordForm.__init__() missing 1 required positional argument: 'user'

SetPasswordForm()의 첫번째 인자는 무조건 user=인스턴스이다.
(공식문서에서 ctrl + F로 검색 했을 때 첫 번째 용례 부분..)

또한 SetPasswordForm()에는 old_password, new_password1, new_password2 필드가 있다.
이럴거면 그냥 처음부터 빡세게 커스텀 필드 만드는게 나았다


🐍 코드

결과적으로 1️⃣ 번도 2️⃣ 번도 아닌 방식(모델폼 상속 후 필드만 커스텀 + 저장은 뷰에서)으로 구현했다.

벌써 새벽 4시라 빨리 자고 싶어서 가장 간단하다고 생각되는 방법으로 구현은 했는데..

코드가 너무 길고 완전히 파편화 되어 있어 마음에 들지 않는다..

forms.py;

class ProfileForm(forms.ModelForm):
    class Meta:
        model = Profile
        fields = ['name', 'birthdate']
        widgets = {
            'name' : forms.TextInput(attrs={'placeholder' : '이름'}),
            'birthdate' : forms.DateInput(attrs={'type': 'date'})
        }

class CustomUserChangeForm(UserChangeForm):
    class Meta:
        model = User
        fields = ['username']

views.py;

@login_required(login_url='login')   # 비로그인 유저가 접근시 login 페이지로 이동
def edit_profile(request):
    profile = request.user.profile
    edit_profile = True
    form = ProfileForm(instance=profile)

    if request.method == 'POST':
        form = ProfileForm(request.POST, instance=profile)
        if form.is_valid():
            form.save()
            messages.success(request, f'회원정보가 성공적으로 변경 되었습니다.')
            return redirect('mypage')
    
    context = {
        'profile': profile,
        'edit_profile': edit_profile,
        'form': form,
    }
    return render(request, 'Users/mypage.html', context)

@login_required(login_url='login')
def change_id(request):
    profile = request.user.profile
    change_id = True
    form = CustomUserChangeForm(instance=request.user)

    if request.method == 'POST':
        form = CustomUserChangeForm(request.POST, instance=request.user)
        if form.is_valid():
            form.save()
            messages.success(request, f'아이디가 성공적으로 변경 되었습니다.')
            return redirect('mypage')
        else:
            print(form.errors)

    context = {
        'profile': profile,
        'change_id': change_id,
        'form': form,
    }
    return render(request, 'Users/mypage.html', context)

@login_required(login_url='login')
def change_pw(request):
    profile = request.user.profile
    change_pw = True
    form = PasswordChangeForm(user=request.user)

    if request.method == 'POST':
        form = PasswordChangeForm(user=request.user, data=request.POST)
        if form.is_valid():
            user = form.save()
            update_session_auth_hash(request, user)  # 세션 유지 (	비밀번호 바꿔도 로그아웃되지 않도록 함)
            messages.success(request, f'비밀번호가 성공적으로 변경 되었습니다.')
            return redirect('mypage')

    context = {
        'profile': profile,
        'change_pw': change_pw,
        'form': form,
    }
    return render(request, 'Users/mypage.html', context)

login.html;

{% if edit_profile %}
    <h1>{{ profile.name }}님, 회원정보를 수정하시겠어요?</h1>
    <form method="POST" action="{% url 'edit_profile' %}">
        {% csrf_token %}
        <div class="input_container">
            {{ form.name }}
            {{ form.birthdate }}
        </div>
        <button type="submit">수정하기</button>
    </form>

{% elif change_id %}
    <h1>{{ profile.name }}님, 아이디를 변경 하시겠어요?</h1>
    <form method="POST" action="{% url 'change_id' %}">
        {% csrf_token %}
        <div class="input_container">
            {{ form.username }}
        </div>
        <button type="submit">수정하기</button>
    </form>

{% elif change_pw %}
    <h1>{{ profile.name }}님, 비밀번호를 변경 하시겠어요?</h1>
    <form method="POST" action="{% url 'change_pw' %}">
        {% csrf_token %}
        <div class="input_container">
            {{ form.old_password | attr:'placeholder: 기존 비밀번호' }}
            {{ form.new_password1 | attr:'placeholder: 새로운 비밀번호 (문자와 숫자 조합 최소 8자)' }}
            {{ form.new_password2 | attr:'placeholder: 새로운 비밀번호 확인' }}
        </div>
        <button type="submit">수정하기</button>
    </form>

{% else %}
    <h1>{{ profile.name }}님, 안녕하세요.</h1>
    <ul>
        <li><strong>아이디</strong>: {{ profile.user.username }}</li>
        <li><strong>생년월일</strong>: {{ profile.birthdate }}</li>
        <li><strong>가입 날짜</strong>: {{ profile.created_at }}</li>
    </ul>
    <button type="button" onclick="location.href='{% url 'edit_profile' %}'">회원정보 수정</button>
    <button type="button" onclick="location.href='{% url 'change_id' %}'">아이디 변경</button>
    <button type="button" onclick="location.href='{% url 'change_pw' %}'">비밀번호 변경</button>
    <button>회원탈퇴</button>
{% endif %}

다 뜯어 고쳐서 포레포레에 적용 할때는 모델폼을 빡세게 리팩토링한 뒤 뷰를 깔끔하게 짜는 것을 목표로 해야겠다!!
괜찮아 이건 테스트용이니까!!!

그리고 비밀번호 변경시 자동 로그아웃 되지 않도록 하는 것이 중요하다!

🐸 회원탈퇴

Profile 인스턴스 삭제시 참조되는 1:1 관계의 User 인스턴스도 함께 삭제되는 시그널을 앞서 정의했었다.

따라서 다음과 같이 회원탈퇴 뷰 작성 후 html 파일과 urls.py를 연결만 해주면 된다;

def widthdraw(request):
    profile = Profile.objects.get(user=request.user)
    profile.delete()
    messages.success(request, f'회원 탈퇴가 성공적으로 진행 되었습니다.')
    return redirect('login')


기존 포레포레에서는 회원가입 기능이 없었다.

jwt 인증 토큰을 사용해 보기위해 간단히 만들어 보려 했던 장고 기본 회원가입/로그인 기능 구현이 이렇게나 길어져 버렸다..

다음 포스팅은 드디어 JWT 이다!

profile
하나씩 차근차근 천천히

0개의 댓글