25/10/14 장고

344th·2025년 12월 11일

AWS AI

목록 보기
22/48

회원가입 구현

회원가입 링크 추가

templates\common\login.html

...
<div class="container my-3">
    <div class="row">
        <div class="col-4">
            <h4>로그인</h4>
        </div>
        <div class="col-8 text-right">
            <span>
                또는 <a href="{% url 'common:signup' %}">계정을 만드세요</a>
            </span>
        </div>
    </div>
    <form method="post" action="{% url 'common:login' %}">
    ...

회원가입 url 매핑 추가

common\urls.py

...
from . import views
...
urlpatterns = [
    ...
    path('signup/', views.signup, name='signup'),
]
...

회원가입에 사용할 폼 생성

common\forms.py 생성

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

class UserForm(UserCreationForm):
    email = forms.EmailField(label="이메일")
    
    class Meta:
        model = User
        fields = ("username", "email")
  • Meta 클래스 Python 웹 프레임워크인 장고(Django)는 모델이라는 추상화된 클래스를 사용하여 데이터베이스에 테이블을 정의합니다. 이 모델은 models.Model을 계승시킨 클래스로서 작성합니다만, 이 클래스내에는 Meta(메타) 라고 하는 내부 클래스를 배치할 수가 있습니다. 모델의 클래스내에 Meta 클래스를 정의하는 것으로 Django 의 그 모델의 취급 방법을 변경하는 것이 가능합니다. 또, 모델에 한정하지 않고 Meta 클래스는 form등에도 설정할 수 있습니다.
  • UserCreationForm
    속성명설명
    username사용자이름
    password1비밀번호1
    password2비밀번호2 (비밀번호1을 제대로 입력했는지 대조하기 위한 값)
    email 속성을 추가해서 사용

회원가입을 위한 signup 함수 정의

common\views.py

from django.contrib.auth import authenticate, login
from django.shortcuts import render, redirect
from common.forms import UserForm

# Create your views here.
def signup(request):
    """
    회원가입
    """
    if request.method == "POST":
        form = UserForm(request.POST)
        if form.is_valid():
            form.save()
            username = form.cleaned_data.get('username')
            raw_password = form.cleaned_data.get("password1")
            user = authenticate(username=username, password=raw_password)
            login(request, user)
            return redirect('index')
    else:
        form = UserForm()
    return render(request, 'common/signup.html', {'form': form})
  • request.POST : django.http.request.QueryDict 객체
    • 내용: 클라이언트가 POST 방식으로 보낸 요청 바디의 폼 데이터가 키-값(문자열) 쌍으로 들어있음
      • (예: {'username': 'alice', 'password1': '...','password2':'...'} 등)
    • 특징: dict처럼 다루지만 불변(immutable) 취급이라 직접 수정은 못 하고 필요하면 request.POST.copy()로 복사해서 수정함
    • request.POSTUserForm 에 무엇을 전달?
      • UserForm(request.POST) 에 넘기면, 이 데이터로 폼이 “바운드(bound)” 됨
      • 바운드된 폼은 is_valid() 호출 시, 각 필드(username, password 등) 에 값을 채워 넣고 유효성 검사를 수행할 수 있게 된다
      • 엄밀히 말해 “response body” 가 아니라 “request body(요청 바디)”
  • 왜 그냥 data 가 아니라 cleaned_data에서 값을 가져오는가?
    • form.is_valid() 가 통과되면, 폼은 원시 입력값을 검증/정규화 하여 cleaned_data 에 담는다
      • 예: 공백 트리밍, 타입 변환, 비밀번호 확인 일치, 커스텀 검증 등.
    • 보안/정합성: request.POST 는 검증 전 사용자 입력 그대로라 신뢰할 수 없음
    • 반면 cleaned_data 는 검증(Validation) + 정제(Cleaning) 를 거친 안전한 값
    • 그래서 username, password1 등은 form.cleaned_data[...] 로 꺼낸다.
  • authenticate()
    • 시그니처(주요 케이스): authenticate(request=None, username=None, password=None, **credentials) (백엔드에 따라 이메일 등 다른 자격으로도 인증 가능)
    • 역할: 설정된 AUTHENTICATION_BACKENDS 를 순회하며 자격 증명이 맞는지 확인.
    • 반환값:
      • 성공 시: User 인스턴스 (그리고 user.backend 속성에 사용된 백엔드 경로가 세팅됨)
      • 실패 시: None
    • 비고: request 를 넘기면 일부 백엔드에서 추가 컨텍스트(IP, 세션 등)를 활용 가능.
  • login()
    • 시그니처: login(request, user, backend=None)
    • 역할: 인증된 사용자를 세션에 로그인 상태로 저장합니다.
      • 세션에 사용자 ID 저장
      • request.user 가 해당 유저로 설정
      • 필요 시(특히 비밀번호 인증 직후) 세션 로테이션(보안)
    • 반환값: None
    • 주의: authenticate() 로 받은 user 는 보통 user.backend 가 설정되어 있어 backend 인자를 별도로 줄 필요가 없습니다.
  • redirect(’index’) 는 해당 views.py 파일의 index 함수로 redirect 되는 것을 의미하는가?
    • URL 패턴 이름이 'index' 인 경로로 리다이렉트
    • 이 이름은 urls.pyname='index' 와 매칭
    • 해당 뷰 함수가 같은 파일이든, 다른 앱이든 상관없고, 이름만 맞으면 그 URL로 이동
  • else: 뒤의 get 요청에서 왜 UserForm 객체를 필요로 하는가? 해당 UserForm 객체는 비어있는가? 만약 비어있지 않다면 무슨 정보를 담고 있는가? 비어있다면 왜 빈 객체를 필요로 하는가?
    • 이유: 처음 회원가입 페이지를 표시하려면 입력 필드가 렌더링되어야 함. 템플릿에서 {{ form }} 로 필드/라벨/헬프텍스트/에러 등을 출력하므로 폼 인스턴스가 필요
    • 이 코드의 흐름:
      • POST & 유효성 실패 시: form = UserForm(request.POST) 로 바운드된 폼이 그대로 render(...) 에 전달되어 사용자 입력 + 에러 메시지가 화면에 표시됨
      • GET (처음 진입): form = UserForm() 으로 언바운드(비어있는) 폼을 만들어 빈 입력창을 렌더링
    • 비어있는가?
      • GET 분기에서 생성한 UserForm() 은 데이터가 없어 “언바운드” 상태(필드 정의/위젯/라벨/헬프텍스트 등 메타 정보는 포함).
      • 필요하면 UserForm(initial={'username': 'guest'}) 처럼 초기값을 줄 수도 있다

회원가입 템플릿 생성

templates\common\signup.html 생성

{% extends "base.html" %}
{% block content %}
<div class="container my-3">
    <div class="row my-3">
        <div class="col-4">
            <h4>회원가입</h4>
        </div>
        <div class="col-8 text-right">
            <span>
                또는 <a href="{% url 'common:login' %}">로그인 하세요.</a>
            </span>
        </div>
    </div>
    <form method="post" class="post-form">
        {% csrf_token %}
        <div class="form-group">
            <label for="username">사용자 이름</label>
            <input type="text" class="form-control" name="username" id="username"
                   value="{{ form.username.value|default_if_none:'' }}">
        </div>
        <div class="form-group">
            <label for="password1">비밀번호</label>
            <input type="password" class="form-control" name="password1" id="password1"
                   value="{{ form.password1.value|default_if_none:'' }}">
        </div>
        <div class="form-group">
            <label for="password2">비밀번호 확인</label>
            <input type="password" class="form-control" name="password2" id="password2"
                   value="{{ form.password2.value|default_if_none:'' }}">
        </div>
        <div class="form-group">
            <label for="email">이메일</label>
            <input type="text" class="form-control" name="email" id="email"
                   value="{{ form.email.value|default_if_none:'' }}">
        </div>
        <button type="submit" class="btn btn-primary">생성하기</button>
    </form>
</div>
{% endblock %}

CustomUser 생성

django.contrib.auth.models.User의 기본 필드는 아래와 같음

이미 완성된 고정(User) 모델이며 Django 내부 인증 시스템에서 바로 사용할 수 있지만,

구조를 직접 바꾸거나 필드를 추가할 수 없음

필드명타입설명
idAutoField기본 키 (자동 생성)
passwordCharField해시된 비밀번호 저장
last_loginDateTimeField마지막 로그인 시각
is_superuserBooleanField관리자 권한 여부
usernameCharField사용자 이름 (기본 로그인 ID)
first_nameCharField이름(이름 부분)
last_nameCharField성(성 부분)
emailEmailField이메일 주소
is_staffBooleanField관리자 페이지 접근 가능 여부
is_activeBooleanField계정 활성화 여부
date_joinedDateTimeField가입 일자

이 모델은 장고 내부적으로 완전히 고정되어 있어서, 원본 User를 수정하려면 "완전히 새 모델을 만들어야" 함

AbstractUser (상속 가능한 부모 클래스)

기본 User의 모든 필드를 그대로 상속받되,  추가 필드를 마음대로 덧붙일 수 있게 설계된 "추상 클래스"

from django.contrib.auth.models import AbstractUser

from django.db import models

class CustomUser(AbstractUser):

phone = models.CharField(max_length=20, blank=True)

address = models.CharField(max_length=255, blank=True)

nickname = models.CharField(max_length=30, blank=True)


이렇게 하면 결과적으로 User + 추가 필드들이 모두 포함된 완성형 유저 모델이 됨

예를 들어 이런 상황이면 AbstractUser가 필수

  • 전화번호로 로그인하고 싶은 경우
  • 유저마다 닉네임, 프로필 이미지, 생일을 저장하고 싶은 경우
  • 회원 등급(level)을 구분하고 싶은 경우

이런 경우엔 AbstractUser를 상속한 CustomUser를 만들어야 함


AbstractBaseUser와의 차이

  • AbstractUser: Django 기본 User의 구조를 유지하면서 확장 가능
  • AbstractBaseUser: 완전히 커스텀 로그인 로직까지 직접 구현해야 하는 저수준 클래스

즉,

필드만 추가하고 싶다면 → AbstractUser

인증 로직도 직접 만들고 싶다면 → AbstractBaseUser

AbstractUser 클래스 상속하여 CustomUser 생성

common\models.py

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

# Create your models here.
class CustomUser(AbstractUser):
    phone = models.CharField(max_length=20, blank=True)
    address = models.CharField(max_length=255, blank=True)
    nickname = models.CharField(max_length=30, blank=True)

    def __str__(self):
        return self.username

추가할 필드들 선언 후 추가

common\forms.py

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

class UserForm(UserCreationForm):
    email = forms.EmailField(label="이메일")
    phone = forms.CharField(required=False, label="전화번호")
    address = forms.CharField(required=False, label="주소")
    nickname = forms.CharField(required=False, label="닉네임")

    class Meta:
        model = CustomUser
        fields = ("username", "email", "phone", "address", "nickname")

추가한 필드 html에도 추가

templates\common\signup.html

{% extends "base.html" %}
{% block content %}
<div class="container my-3">
    <div class="row my-3">
        <div class="col-4">
            <h4>회원가입</h4>
        </div>
        <div class="col-8 text-right">
            <span>
                또는 <a href="{% url 'common:login' %}">로그인 하세요.</a>
            </span>
        </div>
    </div>
    <form method="post" class="post-form">
        {% csrf_token %}
        {% include "form_errors.html" %}
        <div class="form-group">
            <label for="username">사용자 이름</label>
            <input type="text" class="form-control" name="username" id="username"
                   value="{{ form.username.value|default_if_none:'' }}">
        </div>
        <div class="form-group">
            <label for="password1">비밀번호</label>
            <input type="password" class="form-control" name="password1" id="password1"
                   value="{{ form.password1.value|default_if_none:'' }}">
        </div>
        <div class="form-group">
            <label for="password2">비밀번호 확인</label>
            <input type="password" class="form-control" name="password2" id="password2"
                   value="{{ form.password2.value|default_if_none:'' }}">
        </div>
        <div class="form-group">
            <label for="email">이메일</label>
            <input type="text" class="form-control" name="email" id="email"
                   value="{{ form.email.value|default_if_none:'' }}">
        </div>
        <div class="form-group">
            <label for="phone">전화번호</label>
            <input type="tel" class="form-control" name="phone" id="phone"
                   value="{{ form.phone.value|default_if_none:'' }}">
        </div>
        <div class="form-group">
            <label for="address">주소</label>
            <input type="text" class="form-control" name="address" id="address"
                   value="{{ form.address.value|default_if_none:'' }}">
        </div>
        <div class="form-group">
            <label for="nickname">닉네임</label>
            <input type="text" class="form-control" name="nickname" id="nickname"
                   value="{{ form.nickname.value|default_if_none:'' }}">
        </div>
        <button type="submit" class="btn btn-primary">생성하기</button>
    </form>
</div>
{% endblock %}

Admin 페이지에서 생성된 사용자 확인 가능하도록 설정

pybo\admin.py

from django.contrib import admin
from .models import Question
from .models import Answer

# Register your models here.
class QuestionAdmin(admin.ModelAdmin):
    search_fields = ['subject']
admin.site.register(Question, QuestionAdmin)
admin.site.register(Answer)

migration 충돌 해결

config\settings.py

AUTH_USER_MODEL = 'common.CustomUser'

migrations 폴더에서 init.py 빼고 다 삭제

python manage.py makemigrations
python manage.py migrate

모델에 글쓴이 추가

모델 수정

pybo\models.py

...
from common.models import CustomUser

class Question(models.Model):
    author = models.ForeignKey(CustomUser, on_delete=models.CASCADE)
    ...

class Answer(models.Model):
    author = models.ForeignKey(CustomUser, on_delete=models.CASCADE)
    ...

마이그레이션

python [manage.py](http://manage.py) makemigrations 실행

It is impossible to add a non-nullable field 'author' to question without specifying a default. This is because the database needs something to populate existing rows.
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Quit and manually define a default value in models.py.
Select an option:

해당 메세지 출력

author 필드를 추가하면 이미 등록되어 있던 게시물에 author 필드에 해당되는 값이 저장되어야 하는데, 장고는 author 필드에 어떤 값을 넣어야 하는지 모르기 때문

그래서 장고가 기존에 저장된 Question, Answer 모델 데이터에는 author 필드값으로 어떤 값을 저장해야 하는지 묻는 것

1 → author 필드를 null 로 설정

2 → author 필드 값에 강제로 임의 계정 정보를 추가

1 선택

python [manage.py](http://manage.py) migrate 실행

author 필드 적용

답변 등록, 질문 등록 함수 수정

pybo\views.py

def answer_create(request, question_id):
    ...
        if form.is_valid():
            answer = form.save(commit=False)
            answer.author = request.user
	  ...

def question_create(request):
    ...
        if form.is_valid():
            question = form.save(commit=False)
            question.author = request.user
    ...

로그인이 필요한 함수 설정

하지만 로그아웃 상태에서 질문 또는 답변을 등록하면 다음과 같은 ValueError가 발생한다.

이 오류는 request.user가 User 객체가 아닌 AnonymousUser 객체라서 발생한 것이다. 조금 더 자세히 설명하자면 request.user에는 로그아웃 상태이면 AnonymousUser 객체가, 로그인 상태이면 User 객체가 들어있는데, 앞에서 우리는 author 속성을 정의할 때 User를 이용하도록 했다. 그래서 answer.author = request.user에서 User 대신 AnonymousUser가 대입되어 오류가 발생한 것이다.

이 문제를 해결하려면 request.user를 사용하는 함수에 @login_required 애너테이션을 사용해야 한다. @login_required 애너테이션이 붙은 함수는 로그인이 필요한 함수를 의미한다.

로그인이 필요한 함수에 @login_required 애노테이션 적용

pybo\views.py

from django.contrib.auth.decorators import login_required
...

@login_required(login_url='common:login')
def answer_create(request, question_id):
	...
@login_required(login_url='common:login')
def question_create(request):
	...

answer_create 함수와 question_create 함수는 함수내에서 request.user를 사용하므로 로그인이 필요한 함수이다. 따라서 위와 같이 @login_required 어노테이션을 사용해야 한다.

로그아웃 상태에서 @login_required 어노테이션이 적용된 함수가 호출되면 자동으로 로그인 화면으로 이동하게 된다. @login_required 어노테이션은 login_url='common:login' 처럼 로그인 URL을 지정할 수 있다.

이렇게 수정한후 로그아웃 상태에서 질문을 등록하거나 답변을 등록하면 자동으로 로그인 화면으로 이동되는 것을 확인할 수 있을 것이다.

next

그런데 로그아웃 상태에서 '질문 등록하기'를 눌러 로그인 화면으로 전환된 상태에서 웹 브라우저 주소창의 URL을 보면 next 파라미터가 있을 것이다.

이는 로그인 성공 후 next 파라미터에 있는 URL로 페이지를 이동하겠다는 의미이다. 그런데 지금은 그렇게 되고 있지 않다. 로그인 후 next 파라미터에 있는 URL로 페이지를 이동하려면 로그인 템플릿에 다음과 같이 hidden 타입의 next 항목을 추가해야 한다.

로그인 템플릿에 hidden 항목 추가하여 next 파라미터 활용

templates\common\login.html

(... 생략 ...)
<form method="post" action="{% url 'common:login' %}">
    {% csrf_token %}
    <input type="hidden" name="next" value="{{ next }}">  <!-- 로그인 성공후 이동되는 URL -->
    {% include "form_errors.html" %}
(... 생략 ...)

로그아웃 상태에서 아예 글을 작성할 수 없게 하기

templates\pybo\question_detail.html

...
<div class="form-group">
    <textarea name="content" id="content" 
    {% if not user.is_authenticated  %}disabled{% endif %}
    class="form-control" rows="10"></textarea>
</div>
<input type="submit" value="답변 등록" class="btn btn-primary">
...

글쓴이 표시

질문 목록 화면에 글쓴이 표시

질문 목록 템플릿 수정

templates\pybo\question_list.html

<tr class="text-center">
                <td>
                    ...
                </td>
                <td class="text-left">
                    <a href="{% url 'pybo:detail' question.id %}">
                        {{ question.subject }}
                    </a>
                    {% if question.answer_set.count > 0 %}
                    <span class="text-danger small mx-2">{{ question.answer_set.count }}</span>
                    {% endif %}
                </td>
                <td>{{ question.author.username }}</td> <!-- 글쓴이 추가 -->
                <td>{{ qustion.create_date }}</td>
            </tr>

질문 상세 화면에 글쓴이 표시

질문 상세 템플릿 수정

templates\pybo\question_detail.html

ed!(... 생략 ...)
<!-- 질문 -->
<h2 class="border-bottom py-2">{{ question.subject }}</h2>
<div class="card-body">
    <div class="card-text" style="white-space: pre-line;">{{ question.content }}</div>
    <div class="d-flex justify-content-end">
        <div class="badge bg-light text-dark p-2 text-start">
            <div class="mb-2">{{ question.author.username }}</div>
            <div>{{ question.create_date }}</div>
        </div>
    </div>
</div>
(... 생략 ...)

게시물 수정&삭제 기능 추가

모델 수정

Question, Answer 모델에 modify_date 필드 추가

modify_date = models.DateTimeField(null=True, blank=True)

form.is_valid() 를 통한 입력 폼 데이터 유효성 검사 시 값이 없어도 되도록

  • null=True
  • blank=True

makemigrations, migrate

질문 수정 기능 추가

질문 수정 버튼 추가

templates\pybo\question_detail.html

<!-- 질문 내용 -->
<div class="card my-3">
    <div class="card-body">
        <div class="card-text" style="white-space: pre-line;">
            {{ question.content }}
        </div>
        <div class="d-flex justify-content-end">
            <div class="badge badge-light p-2 text-left">
                <div class="mb-2">{{ question.author.username }}</div>
                <div>{{ question.create_date }}</div>
            </div>
        </div>
        {% if request.user == question.author %}
        <div class="my-3">
            <a href="{% url 'pybo:question_modify' question.id %}" class="btn btn-sm btn-outline-secondary">수정</a>
        </div>
        {% endif %}
    </div>
</div>

질문 수정 버튼의 url 매핑 추가

pybo\urls.py

urlpatterns = [
    ...
    path('question/modify/<int:question_id>/', views.question_modify, name='question_modify'),
]

질문 수정 함수 추가

pybo\views.py

from django.contrib import messages
...
@login_required(login_url='common:login')
def question_modify(request, question_id):
    """
    pybo 질문 수정
    """
    question = get_object_or_404(Question, pk=question_id)

    if request.user != question.author:
        messages.error(request, '수정권한이 없습니다')
        return redirect('pybo:detail', question_id=question_id)

    if request.method == 'POST':
        form = QuestionForm(request.POST, instance=question)
        if form.is_valid():
            question = form.save(commit=False)
            question.author = request.user
            question.modify_date = timezone.now()
            question.save()
            return redirect('pybo:detail', question_id=question_id)
    else:
        form = QuestionForm(instance=question)
    context = {'form': form}
    return render(request, 'pybo/question_form.html', context)

질문 삭제 기능 추가

질문 삭제 버튼 추가

templates\pybo\question_detail.html

{% if request.user == question.author %}
<div class="my-3">
    <a href="{% url 'pybo:question_modify' question.id %}" class="btn btn-sm btn-outline-secondary">수정</a>
    <a href="#" class="delete btn btn-sm btn-outline-secondary"
        data-uri="{% url 'pybo:question_delete' question.id %}">삭제</a>
</div>
{% endif %}

질문 삭제 버튼에 jQuery 사용

jQuery 실행을 위해 base.html 파일 수정

templates\base.html

...
    <script src="{% static 'jquery-3.7.1.min.js' %}"></script>
    <script src="{% static 'bootstrap.min.js' %}"></script>
    <!-- 자바스크립트 start -->
    {% block script %}
    {% endblock %}
    <!-- 자바스크립트 end -->
</body>

자바 스크립트 블록 삽입 가능

질문 템플릿에 삭제 알림 창 기능 추가(jQuery 이용)

templates\pybo\question_detail.html

...(맨끝)
{% block script %}
<script type="text/javascript">
    $(document).ready(function(){
        $(".delete").on('click', function() {
            if(confirm("정말로 삭제하시겠습니까?")) {
                location.href = $(this).data('uri');
            }
        });
    });
</script>
{% endblock %}

질문 삭제 url 매핑 추가

pybo\urls.py

path('question/delete/<int:question_id>/', views.question_delete, name='question_delete'),

질문 삭제 함수 추가

pybo\views.py

@login_required(login_url='common:login')
def question_delete(request, question_id):
    """
    질문 삭제
    """
    question = get_object_or_404(Question, pk=question_id)
    
    if request.user != question.author:
        messages.error(request, '삭제권한이 없습니다')
        return redirect('pybo:detail', question_id=question_id)
    question.delete()
    return redirect('pybo:index')
  • messages.error(request, '삭제권한이 없습니다')
    • 왜 request 객체를 인자로 필요로 하는가? Django의 메시지 시스템(django.contrib.messages)은 “사용자에게 일시적인 알림 메시지(예: 성공/에러 메시지)”요청(request) 단위로 저장하는 구조 즉,

      "현재 요청을 처리하는 사용자에게만 보여줄 임시 메시지"

      를 저장 그래서 메시지를 저장하려면 “이 요청이 누구의 요청인지” 알아야 하고, 그 정보를 request 객체로 전달해야 함
profile
새싹 개발자

0개의 댓글