>>>> 유튜브 강의목록
소스 : https://github.com/braverokmc79/python_django_instagram
1. cookiecutter를 활용한 프로젝트 생성
자세한 설명 : 쿠키커터로(cookiecutter ) 파이썬 장고 프로젝트 시작하기
1. 프로젝트 개요
이 강의에서는 Django 프레임워크를 사용하여 인스타그램을 직접 만들어보며 웹 개발 실력을 키우는 것을 목표로 합니다. 프로젝트 생성에는 Cookiecutter를 활용하여 보다 효율적인 디렉토리 구조와 기본 설정을 구성합니다.
Cookiecutter는 템플릿을 기반으로 프로젝트를 자동 생성하는 도구로, 반복적인 작업을 줄이고 표준화된 프로젝트 구조를 제공합니다. 이를 활용하면 프로젝트 설정 시간을 줄이고 협업 시 코드 일관성을 유지할 수 있습니다.
2. 가상 환경 설정
프로젝트를 생성하기 전, Python 가상 환경을 설정해야 합니다. 가상 환경을 사용하면 프로젝트별로 독립적인 패키지 관리를 할 수 있어, 패키지 충돌을 방지하고 유지보수를 쉽게 할 수 있습니다.
가상 환경 생성 및 활성화
python -m venv venv # 가상 환경 생성 source venv/bin/activate # macOS/Linux에서 활성화 venv\Scripts\activate # Windows에서 활성화
가상 환경이 활성화되면 (venv) 프롬프트가 표시되며, 이 상태에서 패키지를 설치하고 프로젝트를 진행하면 됩니다.
3. Cookiecutter를 활용한 프로젝트 생성
Cookiecutter 설치
pip install django pip install cookiecutter
설치가 완료되면 Cookiecutter를 사용하여 Django 프로젝트를 생성할 수 있습니다.
프로젝트 생성
cookiecutter https://github.com/cookiecutter/cookiecutter-django.git
이후 터미널에서 다양한 설정을 입력하며 프로젝트를 구성합니다:
- 프로젝트 이름 입력 (예: my_instagram)
- 작성자 및 라이선스(MIT) 선택
- 운영 체제 선택 (Windows의 경우 'Y', macOS/Linux는 'N')
- 데이터베이스(PostgreSQL 11.3) 설정
- 기본 설정 적용 (Debug Mode, 도커 사용 여부 등)
설정이 완료되면 Cookiecutter가 기본 프로젝트 구조를 자동으로 생성합니다.
4. 프로젝트 구조 살펴보기
Cookiecutter를 활용하면 일반적인 Django 프로젝트보다 더 체계적이고 확장 가능한 구조를 제공합니다.
django_instagram/ ├── config/ # 설정 파일 관리 (settings, wsgi, asgi 등) ├── apps/ # Django 앱 관리 (각 앱별로 나누어 관리) ====> 여기서는 django_instagram ├── docs/ # 문서화 관련 파일 ├── locale/ # 다국어 지원 파일 ├── requirements/ # 필요한 패키지 목록 관리 ├── static/ # 정적 파일(css, js, images) 저장 ├── templates/ # HTML 템플릿 파일 저장 ├── manage.py # Django 프로젝트 관리 스크립트
이러한 구조는 유지보수와 협업에 용이하며, 확장성 있는 애플리케이션을 개발하는 데 도움이 됩니다.
5. GitHub 연동
- GitHub에 로그인하여 새로운 저장소 생성
- 로컬 프로젝트를 Git에 추가하고 초기화
git init git add . git commit -m "Initial commit"
- 원격 저장소 연결
git remote add origin https://github.com/사용자명/my_instagram.git git branch -M main git push -u origin main
이 과정이 완료되면 프로젝트가 GitHub 저장소에 업로드되며, 이후 팀원들과 협업하거나 소스를 관리하는 데 유용합니다.
2. PostgreSQL 설치, django-db 연결
postsql 연동 방법은 다음 참조 : https://macaronics.net/m04/python/view/2362
Django Cookiecutter 프로젝트에서 한글 설정 및 SQLite3 연동 방법
1. 한글 설정
Django Cookiecutter 프로젝트에서 한글을 기본 언어로 설정하려면 config/settings/local.py 파일을 수정하고, 필요한 번역 파일을 생성해야 합니다.
1.1. LANGUAGE_CODE 변경
# config/settings/local.py LANGUAGE_CODE = 'ko'
1.2. TIME_ZONE 변경
# config/settings/local.py TIME_ZONE = 'Asia/Seoul' USE_I18N = True USE_L10N = True USE_TZ = True
1.3. 한글 번역 파일 생성 및 설치
한글 번역 파일(locale/ko/LC_MESSAGES/django.po)이 없는 경우 생성해야 합니다.
# locale 디렉터리에서 한글 번역 폴더 생성 mkdir -p locale/ko/LC_MESSAGES # Django 번역 메시지 파일 생성 django-admin makemessages -l ko
이후 locale/ko/LC_MESSAGES/django.po 파일을 편집하여 필요한 번역을 추가하고 컴파일합니다.
# 번역 파일 컴파일 django-admin compilemessages
이제 한글 설정이 적용됩니다.
2. SQLite3 연동
기본적으로 Cookiecutter Django 프로젝트는 PostgreSQL을 사용하도록 설정되어 있습니다. 이를 SQLite3로 변경하려면 config/settings/local.py에서 데이터베이스 설정을 수정해야 합니다.
2.1. DATABASES 설정 변경
# config/settings/local.py DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', } }
라이브러리 설치 pip install -r requirements/local.txt 설치 확인 python -c "import environ; print(environ.__version__)"
정상적으로 설치가 안된다면 다음 사이틀 참조해서 개별설치 : https://macaronics.net/m04/python/view/2362
. env 파일 만들기
2.2. 마이그레이션 적용
python manage.py makemigrations python manage.py migrate
이제 SQLite3가 정상적으로 연동됩니다.
3. 한글 폰트 적용 (선택 사항)
템플릿에서 한글이 정상적으로 표시되지 않는 경우, 한글 폰트를 추가로 설정할 수도 있습니다.
<style> body { font-family: 'Noto Sans KR', sans-serif; } </style>
위 설정을 적용하면 Django Cookiecutter 프로젝트에서 한글이 정상적으로 표시되며, SQLite3를 사용할 수 있습니다.
3. 데이터 모델(테이블) 만들기, db 설명
1. 프로젝트 개요
Django 기반으로 Instagram과 유사한 웹 애플리케이션을 구축하는 프로젝트입니다. Cookiecutter를 사용하여 프로젝트의 기본 구조를 생성하고, 사용자(User), 게시물(Post), 댓글(Comment) 모델을 정의합니다.
2. 사용자 모델 (User)
기본 Django AbstractUser 모델을 확장하여 사용자 관련 필드를 추가합니다.
from django.contrib.auth.models import AbstractUser from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ class User(AbstractUser): GENDER_CHOICES = ( ('M', _('남성')), ('F', _('여성')), ('C', _('사용자 지정')), ) # Django의 기본 first_name, last_name 필드를 제거 #first_name = None #last_name = None name = models.CharField(_("사용자 이름"), blank=True, max_length=255) user_name = models.CharField(_("아이디"), blank=True, max_length=255) profile_photo = models.ImageField(_("프로필 사진"), blank=True, upload_to="profile_pics/") #파일 경로 지정 website = models.URLField(_("웹사이트"), blank=True, max_length=255) bio = models.TextField(_("소개"), blank=True) email = models.EmailField(_("이메일"), blank=False) phone_number = models.CharField(_("전화번호"), blank=True, max_length=20) gender = models.CharField(_("성별"), blank=True, choices=GENDER_CHOICES, max_length=5) # ✅ ManyToManyField에 blank=True 추가 #A가 B를 팔로우한다고 해서, B가 A를 자동으로 팔로우하는 것은 아닙니다. 이런 경우 symmetrical=False를 설정해야 합니다. followers = models.ManyToManyField( "self", verbose_name=_("팔로워"), symmetrical=False, related_name="user_followers", blank=True ) following = models.ManyToManyField( "self", verbose_name=_("팔로잉"), symmetrical=False, related_name="user_following", blank=True ) def get_absolute_url(self) -> str: """사용자의 상세 페이지 URL 반환""" return reverse("users:detail", kwargs={"username": self.username})
주요 기능:
- ManyToManyField를 사용하여 팔로잉/팔로워 기능 구현
- get_absolute_url 메서드를 통해 사용자 상세 페이지 URL 반환
3. 공통 모델 (TimeStampedModel)
모든 모델에서 공통적으로 사용할 created_at, updated_at 필드를 정의합니다.
# 공통 등록일과 수정일 class TimeStampedModel(models.Model): created_at = models.DateTimeField(_("생성일"), auto_now_add=True) updated_at = models.DateTimeField(_("수정일"), auto_now=True) class Meta: abstract = True
주요 기능:
- 모델별 생성일 및 수정일 자동 기록
- abstract = True 설정으로 단독 테이블이 아닌 상속용으로 사용됨
4. 게시물 모델 (Post)
게시글을 저장하는 Post 모델을 정의합니다.
#게시글 class Post(TimeStampedModel): author = models.ForeignKey( user_models.User, null=True, #여기서 null 은 데이터 베이스에 관련된 null 허용 여부 on_delete=models.CASCADE, related_name='post_author', verbose_name=_("작성자") ) image = models.ImageField(_("이미지"), upload_to="posts/", blank=False) caption = models.TextField(_("내용"), blank=False)#여기서 blank 유효성 검사를 위한 허용여부 image_likes = models.ManyToManyField( user_models.User, blank=True, related_name='post_image_likes', verbose_name=_("좋아요") ) """ 1)가독성 향상: 관리 화면(admin)에서 모델 객체가 기본적으로 Post object (1) 또는 Comment object (1)처럼 보일 수 있는데, __str__을 정의하면 더 읽기 쉬운 형태로 나타납니다. 2)디버깅 편의성 : 객체를 디버깅하거나 로깅할 때, 객체의 의미 있는 정보를 바로 확인할 수 있습니다. """ def __str__(self): return f"{self.author} : {self.caption}" class Meta: verbose_name = _("게시물") verbose_name_plural = _("게시물들") ordering = ['-created_at'] # 최신 게시물이 먼저 오도록 기본 정렬
주요 기능:
- ForeignKey를 사용하여 사용자 모델과 연결 (게시글 작성자)
- ManyToManyField로 좋아요 기능 구현
- ordering = ['-created_at'] 설정으로 최신 게시물 우선 정렬
5. 댓글 모델 (Comment)
게시글에 대한 댓글을 저장하는 Comment 모델을 정의합니다.
#댓글 class Comment(TimeStampedModel): author = models.ForeignKey( user_models.User, null=True, on_delete=models.CASCADE, related_name='comment_author', verbose_name=_("작성자") ) post = models.ForeignKey( Post, null=True, on_delete=models.CASCADE, related_name='comment_post', verbose_name=_("게시물") ) contents = models.TextField(_("내용"), blank=True) def __str__(self): return f"{self.author} : {self.contents[:20]}" # 댓글 미리보기 (20자까지) class Meta: verbose_name = _("댓글") verbose_name_plural = _("댓글들") ordering = ['-created_at'] # 최신 댓글이 먼저 오도록 설정
주요 기능:
- ForeignKey를 사용하여 User 및 Post 모델과 연결
- ordering = ['-created_at'] 설정으로 최신 댓글 우선 정렬
6. 데이터베이스 관계 정리
- User ↔ User (Many-to-Many): followers와 following을 통해 팔로우 기능 구현
- User ↔ Post (One-to-Many): 한 사용자가 여러 개의 게시글을 작성 가능
- User ↔ Comment (One-to-Many): 한 사용자가 여러 개의 댓글을 작성 가능
- Post ↔ Comment (One-to-Many): 한 게시글에 여러 개의 댓글 가능
- Post ↔ Like (Many-to-Many): 여러 사용자가 하나의 게시물을 좋아요 할 수 있음
7. 데이터베이스 적용 절차
- 모델 정의 후 마이그레이션 파일 생성:
python manage.py makemigrations
- 데이터베이스에 반영:
python manage.py makemigrations
※ makemigrations 또는 migrate 오류시
# 기존 마이그레이션 파일 삭제 (주의: 데이터베이스에 적용된 변경사항을 잃을 수 있음) rm -rf app/migrations/* 여기서는 users/migrations 디렉토리 삭제 posts/migrations 삭제 한다. rm db.sqlite3 # SQLite 사용 시 (MySQL, PostgreSQL이면 삭제 X) # 마이그레이션 다시 생성 python manage.py makemigrations python manage.py migrate
sqlite3 으로 할경우 오류가 발생하는 경우가 있는데, postsql, 또는 다른 DB 를 설정후 테스트 해봐야 한다.
※ python manage.py makemigrations 을 실행 했으나 migrations 디렉토리가 생성 안될 경우
1) setting/base.py 에서 INSTALLED_APPS 에 등록되어 있는지 확인한다.
여기서는 다음과 같이 등록이 되어 있어야 한다.
LOCAL_APPS = [ "django_instagram.users", # Your stuff: custom apps go here "django_instagram.posts", ]
2) ( python manage.py makemigrations 모델명 ) 으로 앱명으로 실행한다.
python manage.py makemigrations users python manage.py makemigrations posts
※ Sqlite3 에서 migrate 실행시 오류 다음과 같은 오류 발생시
django.db.utils.OperationalError: no such table: django_site id seq
settings/base.py 파일에서 django.contrib.sites 부분을 주석 처리 한다.
DJANGO_APPS = [ "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", #"django.contrib.sites", "django.contrib.messages", "django.contrib.staticfiles", # "django.contrib.humanize", # Handy template tags "django.contrib.admin", "django.forms", ]
django.contrib.sites를 비활성화하면 사이트 프레임워크를 사용하는 기능이 영향을 받을 수 있습니다. 하지만 사이트 프레임워크는 기본적으로 선택적이므로, 비활성화해도 대부분의 기능은 정상적으로 작동합니다.
django.contrib.sites의 주요 역할:
- 사이트 관련 데이터 관리: 여러 사이트를 운영하는 경우 각 사이트에 대해 도메인 이름(domain), 사이트 이름(name) 등을 관리합니다. 예를 들어, 다중 도메인 환경에서는 각 사이트에 맞는 정보를 django_site 테이블에서 관리합니다.
- SITE_ID: Django 프로젝트에서 SITE_ID를 통해 현재 활성화된 사이트를 식별합니다.
django.contrib.sites 비활성화 후 발생할 수 있는 영향:
- 다중 사이트 설정이 필요한 경우: django.contrib.sites가 활성화되어 있지 않으면, SITE_ID를 설정할 수 없고, 특정 도메인에 맞는 설정을 다룰 수 없습니다.
- 템플릿에서 site 관련 기능을 사용하는 경우: django.contrib.sites를 사용하여 템플릿 내에서 도메인 이름이나 사이트 이름을 출력하는 코드가 있다면, 이 코드에서 오류가 발생할 수 있습니다.
sites 앱 비활성화 후 사용할 수 있는 방법:
- 단일 사이트 환경에서는 SITE_ID를 설정한 후, sites 앱을 비활성화해도 대부분의 기능은 문제가 없습니다.
- 예시: SITE_ID = 1
- 다중 사이트 설정이 필요 없다면 앱을 비활성화해도 사이트의 기능에 큰 영향을 미치지 않습니다.
- Site 객체에 의존하는 기능이 없다면 앱을 비활성화해도 문제가 없습니다.
결론
사이트 프레임워크가 꼭 필요한 기능이 아니라면, django.contrib.sites를 비활성화해도 일반적인 사이트 운영에는 큰 문제가 없습니다. 다만, SITE_ID와 관련된 설정이 중요한 경우에는 비활성화 시 오류가 발생할 수 있습니다.==>개발용 sqlite3 을 사용하지 말고 다른 DB 사용 할것
8. 추가 고려사항
- 프로필 사진 업로드: MEDIA_URL 및 MEDIA_ROOT 설정 필요
- 좋아요 기능 구현: ManyToManyField 사용으로 간단하게 구현 가능
4. 로그인, url-view-template 연결
Django로 만드는 Instagram 프로젝트 (Cookiecutter 활용)
1. 프로젝트 개요
이 프로젝트는 Django의 Cookiecutter 템플릿을 활용하여 Instagram과 유사한 기능을 구현하는 프로젝트입니다. 이번 강의에서는 로그인 기능을 추가하고, URL-View-Template을 연결하는 방법을 설명합니다.
2. 프로젝트 설정
프로젝트 디렉토리 구조
django_instagram/ │── config/ │ ├── settings/ │ │ ├── base.py # 기본 설정 │ │ ├── ... │ ├── urls.py # URL 설정 │ ├── ... │── django_instagram/ │ ├── users/ │ │ ├── urls.py # 유저 관련 URL 설정 │ │ ├── views.py # 유저 관련 뷰 함수 │ ├── posts/ │ │ ├── urls.py # 게시물 관련 URL 설정 │ │ ├── views.py # 게시물 관련 뷰 함수 │ ├── templates/ │ │ ├── users/ │ │ │ ├── main.html # 메인 페이지 │ │ ├── posts/ │ │ │ ├── index.html # 게시물 페이지 │ │ ├── pages/ │ │ │ ├── about.html # About 페이지
3. URL 설정
config/urls.py
# ruff: noqa from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path from django.views.generic import TemplateView urlpatterns = [ path("about/", TemplateView.as_view(template_name="pages/about.html"), name="about"), path(settings.ADMIN_URL, admin.site.urls), path("users/", include("users.urls", namespace="users")), path("accounts/", include("allauth.urls")), path("posts/", include("posts.urls", namespace="posts")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
users/urls.py
from django.urls import path from . import views app_name = "users" urlpatterns = [ path('main/', views.main, name="main") ]
posts/urls.py
from django.urls import path from . import views app_name = "posts" urlpatterns = [ path('', views.index, name="index") ]
4. View 및 Template 설정
templates/users/main.html
<script src="https://unpkg.com/@tailwindcss/browser@4"></script> <div class="w-full flex flex-col items-center justify-center min-h-screen bg-gray-100"> <!-- 로그인 컨테이너 --> <div class="w-full bg-white p-8 rounded-lg shadow-md w-full max-w-md text-center"> <h1 class="text-2xl font-bold text-gray-800 mb-6">로그인</h1> <form action="{% url 'users:main' %}" method="post" class="space-y-4"> {% csrf_token %} <!-- 사용자 이름 필드 --> <div> <input type="text" name="username" placeholder="사용자이름(아이디)" required class="w-full p-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> </div> <!-- 비밀번호 필드 --> <div> <input type="password" name="password" placeholder="비밀번호" required class="w-full p-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> </div> {% if error_message %} <!-- 에러 메시지 --> <div class="w-full text-left items-center"> <div class="!text-red-500 col-span-7 text-sm mt-1 ">{{ error_message }}</div> </div> {% endif %} <!-- 로그인 버튼 --> <button type="submit" class="w-full py-3 bg-blue-500 text-white text-lg rounded-lg hover:bg-blue-600 transition-colors"> 로그인 </button> </form> </div> <!-- 회원가입 링크 --> <div class="mt-6"> <a href="{% url 'users:signup' %}" class="text-sm text-blue-500 hover:underline"> 회원가입 </a> </div> </div>
templates/posts/index.html
여기는 posts index 화면
5. 앱 등록 (settings/base.py)
LOCAL_APPS = [ "users", "posts", ]
6. 슈퍼유저 생성
django_instagram> python manage.py createsuperuser
- 경고 메시지: URL namespace 'users' isn't unique. You may not be able to reverse all URLs in this namespace
- 사용자 입력:
- Username: admin
- 이메일: admin@gmail.com
- 비밀번호: 최소 8자리 입력 필요
- 비밀번호 검증 통과하지 못할 경우 y 입력 후 강제 생성
7. 로그인 기능 구현
- 사용자가 main.html에서 아이디와 비밀번호 입력 후 로그인 요청을 보냄 (POST 방식)
- Django의 authenticate 및 login 기능을 활용하여 사용자 인증 처리
- 로그인 성공 시 posts:index 페이지로 리다이렉트, 실패 시 에러 메시지 출력
- csrf_token을 사용하여 보안 강화
8. 테스트 및 주의 사항
- csrf_token이 없으면 로그인 요청이 차단됨
- users URL namespace 중복 문제를 해결하기 위해 설정을 재확인 필요
5. 장고 폼 이란?
1. 장고 폼(Form)이란?
장고 폼은 사용자가 입력한 데이터를 쉽게 처리할 수 있도록 도와주는 기능입니다. HTML의 <form> 태그와 달리, 데이터 검증 및 보안 기능을 기본적으로 제공합니다. 장고 폼을 사용하면 코드의 양을 줄이고, 보다 안전한 입력 처리가 가능합니다.
1.1 HTML 폼과의 차이점
- HTML 폼은 단순히 데이터를 입력받고 서버로 전송하는 역할을 합니다.
- 장고 폼은 데이터 검증, 보안, 입력 처리 기능을 자동으로 제공합니다.
- 별도의 HTML 폼을 작성하지 않고도 데이터 처리가 가능합니다.
2. 장고 폼의 필요성
웹 애플리케이션에서 폼을 직접 구현하면 여러 가지 어려움이 발생할 수 있습니다.
- 다양한 입력 타입의 데이터 검증이 필요합니다.
- 클라이언트 측과 서버 측에서 데이터 유효성 검사가 필요합니다.
- 입력된 데이터를 서버에서 다시 처리하는 과정이 복잡할 수 있습니다.
- 폼 개수가 많아지면 유지보수가 어려워집니다.
장고 폼을 사용하면 이러한 문제를 간단히 해결할 수 있습니다.
3. 장고 폼의 기본 구조
3.1 폼 클래스 생성
장고에서는 forms.py 파일을 생성하여 폼을 정의할 수 있습니다.
from django import forms class NameForm(forms.Form): name = forms.CharField(max_length=100)
위 코드에서 CharField는 HTML의 <input type="text"> 필드와 대응됩니다.
3.2 폼을 뷰(View)에서 사용하기
from django.shortcuts import render from .forms import NameForm def name_view(request): if request.method == "POST": form = NameForm(request.POST) if form.is_valid(): cleaned_data = form.cleaned_data["name"] return render(request, "thanks.html", {"name": cleaned_data}) else: form = NameForm() return render(request, "name_form.html", {"form": form})
3.3 템플릿에서 폼 사용하기
템플릿에서 폼을 렌더링할 때는 {{ form }}을 사용하면 됩니다.
<form method="post"> {% csrf_token %} {{ form.as_p }} <button type="submit">제출</button> </form>
- {{ form.as_p }}: 폼을 <p> 태그로 감싸서 렌더링합니다.
- csrf_token: 보안상의 이유로 반드시 포함해야 합니다.
4. 장고 폼의 핵심 기능
4.1 데이터 유효성 검사
장고 폼은 사용자가 입력한 데이터를 자동으로 검증합니다.
if form.is_valid(): cleaned_data = form.cleaned_data["name"]
- is_valid(): 데이터 검증을 수행합니다.
- cleaned_data: 검증된 데이터를 안전하게 사용하도록 변환합니다.
4.2 오류 메시지 처리
사용자가 올바르지 않은 데이터를 입력하면 오류 메시지가 자동으로 표시됩니다.
{% for field in form %} <div> {{ field.label_tag }} {{ field }} {{ field.errors }} </div> {% endfor %}
5. 장고 폼을 사용해야 하는 이유
- 코드의 간결화: 폼을 직접 구현할 필요 없이, 필요한 기능을 자동으로 제공받을 수 있습니다.
- 보안 강화: CSRF 공격 방어 및 데이터 유효성 검사를 기본적으로 제공합니다.
- 재사용성: 여러 곳에서 동일한 폼을 재사용할 수 있습니다.
- 유지보수 용이: 폼 필드 추가 및 수정이 용이합니다.
6. 정리
- 장고 폼을 사용하면 웹 애플리케이션의 입력 처리가 간단하고 안전해집니다.
- forms.py에서 폼을 정의하고, 뷰(View)에서 처리하며, 템플릿에서 렌더링하여 사용합니다.
- is_valid(), cleaned_data와 같은 기능을 활용하여 데이터 유효성 검사를 수행합니다.
- 템플릿에서는 {{ form.as_p }} 또는 {{ field.errors }}를 활용하여 폼을 표시하고 검증 메시지를 출력할 수 있습니다.
6. 회원가입, form 사용
1. 회원가입 폼 (SignUpForm)
Django의 UserCreationForm을 상속하여 사용자 회원가입 폼을 구현하였습니다. Meta 클래스를 통해 User 모델과 연결하고, 필요한 필드를 정의하였습니다.
- password1, password2 필드를 활용하여 비밀번호 입력 및 확인 기능을 추가
- widgets 속성을 사용하여 Tailwind CSS 스타일을 적용
- clean 메서드를 오버라이딩하여 비밀번호 일치 여부를 검증
회원가입 폼 코드
from allauth.account.forms import SignupForm from allauth.socialaccount.forms import SignupForm as SocialSignupForm from django.contrib.auth import forms as admin_forms from django.utils.translation import gettext_lazy as _ from django import forms as django_forms from .models import User from django.contrib.auth.forms import UserCreationForm from django.contrib.auth import get_user_model class UserAdminChangeForm(admin_forms.UserChangeForm): class Meta(admin_forms.UserChangeForm.Meta): # type: ignore[name-defined] model = User class UserAdminCreationForm(admin_forms.UserCreationForm): """ Form for User Creation in the Admin Area. To change user signup, see UserSignupForm and UserSocialSignupForm. """ class Meta(admin_forms.UserCreationForm.Meta): # type: ignore[name-defined] model = User error_messages = { "username": {"unique": _("This username has already been taken.")}, } class UserSignupForm(SignupForm): """ Form that will be rendered on a user sign up section/screen. Default fields will be added automatically. Check UserSocialSignupForm for accounts created from social. """ class UserSocialSignupForm(SocialSignupForm): """ Renders the form when user has signed up using social accounts. Default fields will be added automatically. See UserSignupForm otherwise. """ # 회원 가입폼 추가 #강의와 다르게 django_forms.ModelForm 이 아니라 password1, password2 사용을 위해, UserCreationForm을 상속받아서 사용 class SignUpForm(UserCreationForm): class Meta: model = User classStyle = """w-full p-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500""" fileInputStyle=""" file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer """ # UserCreationForm의 비밀번호 필드를 포함시키고, 추가적인 필드를 포함 fields = ("email", "name", "username", "profile_photo", "website", "bio", "gender", "password1", "password2") # 위젯 설정 widgets = { 'email': django_forms.EmailInput(attrs={'required': 'required', 'class': classStyle}), 'name': django_forms.TextInput(attrs={'required': 'required', 'class': classStyle}), 'username': django_forms.TextInput(attrs={'required': 'required', 'class': classStyle}), 'profile_photo': django_forms.ClearableFileInput(attrs={'class': classStyle + fileInputStyle }), 'website': django_forms.URLInput(attrs={'class': classStyle}), 'bio': django_forms.Textarea(attrs={'class': classStyle}), 'gender': django_forms.Select(attrs={'class': classStyle, 'required': 'required'}), } labels = { 'email': '이메일', 'name': '성명', 'username': '사용자이름(아이디)', 'profile_photo': '프로필 사진', 'website': '웹사이트', 'bio': '소개', 'gender': '성별', 'password1': '비밀번호', 'password2': '비밀번호 확인', } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # password1, password2 위젯 스타일 적용 self.fields['password1'].widget.attrs.update({'class': self.Meta.classStyle, 'required': 'required'}) self.fields['password2'].widget.attrs.update({'class': self.Meta.classStyle, 'required': 'required'}) # 추가적으로 커스터마이징할 수 있는 방법: # 비밀번호 확인 로직을 커스터마이징하고 싶으면, `clean` 메서드를 오버라이드 할 수 있습니다. def clean(self): cleaned_data = super().clean() password1 = cleaned_data.get("password1") password2 = cleaned_data.get("password2") # 비밀번호 확인이 일치하지 않으면 에러를 발생시킬 수 있습니다. if password1 != password2: raise django_forms.ValidationError("비밀번호가 일치하지 않습니다.") return cleaned_data
2. URL 설정
users/urls.py에서 회원가입 URL을 signup 뷰와 연결합니다.
from django.urls import path from . import views app_name = "users" urlpatterns = [ path('', views.main, name="main"), path('signup/', views.signup, name="signup") ]
3. 회원가입 뷰 (signup view)
뷰 함수에서는 SignUpForm을 사용하여 GET 요청 시 폼을 렌더링하고, POST 요청 시 폼을 검증하여 사용자 정보를 저장합니다. 성공적으로 회원가입이 완료되면 자동으로 로그인한 후 posts:index 페이지로 리다이렉트합니다.
from django.shortcuts import render from django.urls import reverse from django.contrib.auth import authenticate, login from django.http import HttpResponseRedirect from .forms import SignUpForm def signup(request): if request.method == "GET": form = SignUpForm() return render(request, 'users/signup.html', {'form': form}) elif request.method == "POST": form = SignUpForm(request.POST, request.FILES) if form.is_valid(): user = form.save(commit=False) user.set_password(form.cleaned_data['password1']) user.save() username = form.cleaned_data['username'] password = form.cleaned_data['password1'] user = authenticate(request, username=username, password=password) if user is not None: login(request, user) return HttpResponseRedirect(reverse('posts:index')) return render(request, 'users/signup.html', {'form': form})
4. 회원가입 템플릿 (SignUp HTML)과 Tailwind CSS 적용
Tailwind CSS를 활용하여 회원가입 페이지의 스타일을 개선하였습니다.
<script src="https://unpkg.com/@tailwindcss/browser@4"></script> <div class="w-full flex flex-col items-center justify-center min-h-screen bg-gray-100"> <div class="w-full bg-white p-8 rounded-lg shadow-md max-w-lg md:max-w-2xl text-center"> <h1 class="text-2xl font-bold text-gray-800 mb-6">회원가입</h1> <form action="{% url 'users:signup' %}" method="post" class="space-y-4" enctype="multipart/form-data"> {% csrf_token %} {% for field in form %} <div class="flex flex-col text-left"> <div class="grid grid-cols-10 gap-2 items-center"> <label class="col-span-3 text-gray-700 font-medium"> {{ field.label_tag }} </label> <div class="col-span-7"> {{ field }} </div> </div> <div class="grid grid-cols-10 gap-2 items-center"> <div class="col-span-3"></div> <div class="!text-red-500 col-span-7 text-sm mt-1 ">{{ field.errors }}</div> </div> </div> {% endfor %} <button type="submit" class="w-full py-3 bg-blue-500 text-white text-lg rounded-lg hover:bg-blue-600 transition-colors cursor-pointer"> 회원가입 </button> </form> <div class="mt-6 text-sm"> 이미 계정이 있으신가요? <a href="{% url 'users:main' %}" class="text-blue-600 hover:underline">로그인</a> </div> </div> </div>
5. 결론
Django의 UserCreationForm을 활용하여 회원가입을 구현하고, Tailwind CSS를 적용하여 직관적인 UI를 제공하였습니다.
7. 회원가입/로그인 디자인, 템플릿
1. 개요
공통 템플릿 구성 방법
2. 공통 템플릿 (Base Template)
로그인 및 회원가입 페이지에서 반복되는 요소들을 하나의 base.html 템플릿으로 정의하여 코드 중복을 줄입니다.
주요 내용
- {% block title %}을 활용하여 페이지별 제목 설정 가능
- {% block content %}를 사용하여 개별 페이지마다 다른 내용을 삽입 가능
- <footer>를 {% include %} 태그로 별도 파일로 분리하여 관리
- Tailwind CSS를 적용하여 반응형 디자인 지원
코드 예시 (base.html)
{% load static %} <!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{% block title %}{% endblock %}</title> <meta name="description" content="장고-인스타그램"> <meta name="keywords" content="HTML, JavaScript, Python, Tailwind CSS"> <meta name="author" content="macaronics.net"> <link href="{% static 'css/users/main.css' %}" rel="stylesheet"> </head> <body> <div> {% block content %} content {% endblock %} </div> {% include "users/footer.html" %} </body> </html>
3. 로그인 페이지 (Login Page)
로그인 페이지에서는 사용자 이름(ID)과 비밀번호 입력 필드를 제공하며, Django의 {% csrf_token %}을 사용하여 보안성을 높입니다.
주요 내용
- form 태그를 사용하여 로그인 요청을 처리
- input 필드에 focus:ring을 적용하여 사용자 경험 향상
- 에러 메시지 출력 기능 추가
코드 예시 (login.html)
{% extends "users/base.html" %} {% block title %}장고 인스타그램 | 로그인{% endblock title %} {% block content %} <div class="w-full flex flex-col items-center justify-center min-h-screen bg-gray-100"> <div class="w-full bg-white p-8 rounded-lg shadow-md max-w-md text-center"> <form action="{% url 'users:main' %}" method="post" class="space-y-4"> {% csrf_token %} <input type="text" name="username" placeholder="사용자이름(아이디)" required class="w-full p-3 border rounded-lg"> <input type="password" name="password" placeholder="비밀번호" required class="w-full p-3 border rounded-lg"> {% if error_message %} <div class="text-red-500 text-sm mt-1">{{ error_message }}</div> {% endif %} <button type="submit" class="w-full py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600">로그인</button> </form> <div class="mt-6"> <a href="{% url 'users:signup' %}" class="text-blue-500 hover:underline">회원가입</a> </div> </div> </div> {% endblock content %}
4. 회원가입 페이지 (Signup Page)
회원가입 페이지에서는 사용자 정보를 입력받는 폼을 표시하며, Django의 forms를 활용하여 필드 및 에러 메시지를 처리합니다.
주요 내용
- {% for field in form %}을 활용하여 동적으로 폼 필드 생성
- 에러 메시지 출력 기능 추가
- multipart/form-data 설정을 통해 파일 업로드 가능
코드 예시 (signup.html)
{% extends "users/base.html" %} {% block title %}장고 인스타그램 | 회원가입{% endblock title %} {% block content %} <div class="w-full flex flex-col items-center justify-center min-h-screen bg-gray-100"> <div class="w-full bg-white p-8 rounded-lg shadow-md max-w-lg text-center"> <h1 class="text-xl font-bold text-gray-800 mb-6">친구들의 사진과 동영상을 보려면 가입하세요.</h1> <form action="{% url 'users:signup' %}" method="post" enctype="multipart/form-data" class="space-y-4"> {% csrf_token %} {% for field in form %} <div class="flex flex-col"> <div class="grid grid-cols-12 gap-2 items-center"> <span class="col-span-2"></span> <div class="col-span-8">{{ field }}</div> </div> <div class="grid grid-cols-12 gap-2 items-center"> <div class="col-span-2"></div> <div class="text-red-500 col-span-7 text-sm mt-1">{{ field.errors }}</div> </div> </div> {% endfor %} <button type="submit" class="w-8/12 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600">회원가입</button> </form> <div class="mt-6 text-sm"> 이미 계정이 있으신가요? <a href="{% url 'users:main' %}" class="text-blue-600 hover:underline">로그인</a> </div> </div> </div> {% endblock content %}
5. 푸터 템플릿 (Footer Template)
공통적으로 사용되는 푸터를 별도의 HTML 파일로 관리합니다.
코드 예시 (footer.html)
<footer class="w-full h-12 flex text-center items-center justify-center"> macaronics.net </footer>
6. 강의 핵심 정리
Base Template (base.html)
- {% block %}을 활용하여 공통 레이아웃 관리
- {% include %}를 사용하여 푸터 등의 공통 요소 분리
로그인/회원가입 페이지
- {% extends %}를 사용하여 base.html을 상속
- {% csrf_token %}으로 보안 강화
- {% for field in form %}을 활용하여 동적 폼 렌더링
CSS 적용
- Tailwind CSS를 활용하여 스타일링
- {% static %}을 이용해 외부 CSS 파일 로드
8. 포스트 기능명세, 헤더UI
1. 프로젝트 개요
Django와 쿠키커터(cookiecutter)를 이용하여 Instagram tailwind 로 헤더 UI 만들기
2. 템플릿 구조
2.1 base.html
프로젝트의 기본 템플릿으로, 공통적인 레이아웃을 정의합니다. 모든 페이지는 이를 확장하여 사용합니다.
{% load static %} <!DOCTYPE html> <html lang="ko"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"/> <meta http-equiv="X-UA-Compatible" content="ie=edge" /> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@500&display=swap" rel="stylesheet"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"> <script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp,container-queries"></script> <link rel="stylesheet" href="{% static 'css/posts/main.css' %}" /> {% block staticBlock %}{% endblock staticBlock %} <title>{% block title %}{% endblock title %} | Djangogram</title> </head> <body class="bg-gray-50"> <div class="header"> {% include "posts/header.html" %} </div> <div class="content"> {% block content %} {% endblock content %} </div> </body> </html>
설명:
- {% load static %}: 정적 파일(css, js, 이미지 등)을 로드합니다.
- {% block title %}...{% endblock title %}: 페이지별 타이틀을 정의할 수 있도록 블록을 설정합니다.
- {% include "posts/header.html" %}: header.html 파일을 포함하여 네비게이션 바를 추가합니다.
- {% block content %}...{% endblock content %}: 개별 페이지에서 내용을 삽입할 수 있도록 블록을 제공합니다.
2.2 index.html
메인 페이지를 위한 템플릿입니다.
{% extends "posts/base.html" %} {% block title %}post 메인{% endblock title %} {% block content %} <!-- 컨텐츠 영역 --> {% endblock content %}
설명:
- {% extends "posts/base.html" %}: base.html을 상속받아 공통 레이아웃을 사용합니다.
- {% block content %}...{% endblock content %}: 메인 페이지에서 표시할 내용을 추가할 수 있는 블록입니다.
2.3 header.html
상단 네비게이션 바를 구성하는 템플릿입니다.
{% load static %} <header class="bg-white py-2 px-4 shadow-md sticky top-0 z-50 flex items-center justify-center"> <div class="flex justify-between items-center w-full max-w-5xl"> <div class="cursor-pointer"> <a href="{% url 'posts:index' %}"> <img src="{% static 'images/instagram.png' %}" alt="instagram" class="h-10"> </a> </div> <div class="flex-1 mx-5 relative hidden md:block"> <input type="search" placeholder="검색..." id="q" name="q" class="w-full px-4 py-2 text-sm border border-gray-300 rounded-full shadow-sm"> </div> <div class="flex gap-5 items-center"> <div class="cursor-pointer hover:scale-110"> <a href="#"><i class="fa fa-plus-circle text-gray-600 text-2xl"></i></a> </div> <div class="cursor-pointer hover:scale-110"> <a href="#"><i class="fa fa-user text-gray-600 text-2xl"></i></a> </div> <div class="cursor-pointer hover:scale-110"> <a href="#"><i class="fa fa-sign-out text-gray-600 text-2xl"></i></a> </div> </div> </div> </header>
설명:
- 네비게이션 바를 고정(sticky top-0)하여 스크롤 시에도 유지되도록 설정합니다.
- 검색 입력 필드를 중앙에 배치하고, 반응형 디자인을 적용합니다.
- 아이콘(글쓰기, 프로필, 로그아웃)을 포함하여 사용자 인터페이스를 구성합니다.
3. CSS 스타일링
header.css를 이용하여 헤더의 스타일을 지정합니다.
header { background-color: #fff; padding: 10px 20px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); position: sticky; top: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; }
설명:
- 헤더의 배경을 흰색(background-color: #fff)으로 설정하고, 그림자를 추가하여 구분감을 줍니다.
- position: sticky를 사용하여 상단에 고정되도록 설정합니다.
9. 포스트 생성 API 작업
1. 프로젝트 개요
Django를 이용하여 Instagram과 유사한 기능을 구현하는 프로젝트로, 사용자가 게시물을 생성하고 업로드할 수 있도록 합니다. 본 정리는 cookiecutter를 이용하여 프로젝트를 생성하고, 게시물 생성 기능을 구현하는 과정을 설명합니다.
2. URL 설정
Django에서는 urls.py를 이용하여 특정 URL 패턴에 대해 실행할 뷰를 정의할 수 있습니다.
from django.urls import path from . import views app_name = "posts" urlpatterns = [ path('', views.index, name="index"), # 메인 페이지 path('create/', views.post_create, name='post_create') # 게시물 생성 페이지 ]
위 코드에서 post_create URL이 views.post_create 뷰와 연결되어 있으며, 이를 통해 게시물 생성 페이지로 이동할 수 있습니다.
3. 게시물 생성 버튼 (템플릿 코드)
아래 HTML 코드에서 게시물 생성 버튼을 클릭하면 post_create 페이지로 이동합니다.
<!-- 게시물 생성 버튼 --> <div class="cursor-pointer transform transition-transform duration-300 hover:scale-110"> <a href="{% url 'posts:post_create' %}"> <i class="fa fa-plus-circle text-gray-600 hover:text-blue-500 text-2xl"></i> </a> </div>
위 버튼을 클릭하면 {% url 'posts:post_create' %}을 통해 post_create URL로 이동하게 됩니다.
4. 게시물 생성 페이지 템플릿
게시물 생성 페이지의 HTML 템플릿 코드입니다. 사용자는 입력 폼을 통해 게시물 내용을 작성하고 사진을 업로드할 수 있습니다.
{% extends "posts/base.html" %} {% load static %} {% block title %} 포스트 생성 {% endblock title %} {% block content %} <div class="flex justify-center items-center min-h-screen bg-gray-100"> <div class="w-3/4 aspect-[4/3] bg-white rounded-lg shadow-md p-8"> <h1 class="text-2xl font-bold text-gray-800 mb-6 text-center">게시글 등록</h1> <!-- 게시물 등록 폼 --> <form action="{% url 'posts:post_create' %}" method="post" enctype="multipart/form-data" class="space-y-6 w-4/6 mx-auto"> {% csrf_token %} <!-- CSRF 보안 토큰 --> <div> <label for="id_caption" class="block text-sm font-medium text-gray-700 mb-1">내용</label> <textarea id="id_caption" name="caption" class="w-full border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500" rows="12" required></textarea> </div> <div> <label for="id_image" class="block text-sm font-medium text-gray-700 mb-1">사진</label> <input required id="id_image" type="file" name="image" class="w-full file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-indigo-600 file:text-white file:font-medium file:cursor-pointer file:hover:bg-indigo-500" /> </div> <button type="submit" class="w-full bg-indigo-600 text-white font-semibold rounded-lg py-3 text-center shadow-md hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">등록하기</button> </form> </div> </div> {% endblock content %}
5. 게시물 생성 뷰
Django의 views.py에서 GET 요청 시 post_create.html을 렌더링하고, POST 요청 시 게시물을 저장하는 기능을 구현합니다.
from django.shortcuts import render, get_object_or_404, redirect from django_instagram.users.models import User as user_model from . import models # 메인 페이지 def index(request): return render(request, 'posts/index.html') # 게시물 생성 뷰 def post_create(request): if request.method == "GET": return render(request, 'posts/post_create.html') elif request.method == "POST": if request.user.is_authenticated: user = get_object_or_404(user_model, pk=request.user.id) image = request.FILES['image'] # 업로드된 이미지 파일 caption = request.POST['caption'] # 입력된 게시물 내용 # 새 게시물 생성 new_post = models.Post.objects.create( author=user, image=image, caption=caption, ) new_post.save() return redirect('posts:index') # 게시물 등록 후 메인 페이지로 이동 else: return redirect('users:login') # 로그인하지 않은 경우 로그인 페이지로 이동
6. 게시물 생성 API 흐름
- 사용자가 로그인 후 게시물 생성 버튼 클릭 → post_create URL로 이동
- GET 요청 처리 → post_create.html 템플릿을 렌더링하여 폼 제공
- 사용자가 폼 입력 후 제출 → POST 요청 전송
- 서버에서 인증 확인 후 데이터 저장
- 사용자가 로그인했는지 확인
- request.FILES에서 이미지 파일을 가져와 저장
- request.POST에서 텍스트 내용을 가져와 저장
- Post.objects.create()로 새 게시물 생성 후 데이터베이스 저장
- 게시물 저장 후 피드 페이지로 이동
10. 유닛 테스트 - 포스트 생성 API, GET 요청
1. 개요
Django 프로젝트에서 API 로직을 테스트하기 위해 유닛 테스트(Unit Test) 를 작성합니다.
이번 강의에서는 게시글(Post) 생성 페이지에 대한 GET 요청 테스트를 수행합니다.
테스트할 기능
- 게시글 작성 페이지에 GET 요청을 보낼 수 있는지 테스트
- 정상적인 HTTP 응답(200 OK)을 받는지 확인
- 올바른 템플릿이 사용되었는지 확인
2. 유닛 테스트의 필요성
- 직접 브라우저에서 테스트하는 것은 비효율적이며 시간이 많이 소요됨
- 유닛 테스트를 작성하면 자동화된 테스트 수행 가능
- 코드 변경 시 기존 기능이 정상적으로 동작하는지 확인 가능
- 예상치 못한 버그를 사전에 방지
3. 테스트 코드 작성하기
테스트 파일 생성
일반적으로 Django 프로젝트에서는 tests 디렉터리에 테스트 파일을 작성합니다.
뷰(View) 테스트를 위해 test_views.py 파일을 생성합니다.
???? django_instagram ┣ ???? posts ┃ ┣ ???? tests ┃ ┃ ┣ ???? test_views.py <-- 유닛 테스트 파일
테스트 코드 작성 (test_views.py)
from django.core.files.uploadedfile import SimpleUploadedFile # 테스트용 파일 업로드 클래스 from django.contrib.auth import get_user_model # 유저 모델을 가져오는 유틸리티 함수 from django.urls import reverse # URL 리버스 함수 from django.test import TestCase # Django의 테스트 케이스 클래스 class TestPosts(TestCase): """ 게시글(Post) 관련 기능에 대한 테스트 케이스. - 게시글 작성 페이지 접근 - 게시글 생성 - 로그인 여부에 따른 동작 테스트 """ def setUp(self): """ 테스트 실행 전 초기 설정. 테스트용 유저를 생성하여 로그인 및 인증 테스트에 사용합니다. """ User = get_user_model() # 현재 프로젝트의 User 모델 가져오기 self.user = User.objects.create_user( username='testuser', # 사용자 이름 email='testuser@example.com', # 이메일 주소 password='testpassword' # 비밀번호 ) def test_get_posts_page(self): """ 게시글 작성 페이지로 GET 요청을 테스트합니다. - 요청 성공 여부 확인 (HTTP 200) - 올바른 템플릿이 사용되었는지 확인 """ url = reverse('posts:post_create') # 게시글 작성 페이지의 URL 가져오기 response = self.client.get(url) # GET 요청 실행 # HTTP 상태 코드가 200인지 확인 self.assertEqual(response.status_code, 200) # 올바른 템플릿이 사용되었는지 확인 self.assertTemplateUsed(response, 'posts/post_create.html')
4. 테스트 실행하기
Django에서는 pytest를 사용하여 테스트를 실행할 수 있습니다.
pytest 실행 명령어
터미널에서 다음 명령어를 실행하여 테스트를 실행합니다.
pytest ./django_instagram/posts/tests/test_views.py
테스트 실행 과정
- pytest가 test_views.py 내부의 test_로 시작하는 함수를 실행
- setUp()에서 테스트용 사용자 생성
- test_get_posts_page()에서
- 게시글 작성 페이지 URL에 GET 요청
- HTTP 응답 코드가 200인지 확인
- 올바른 템플릿(post_create.html)이 사용되었는지 확인
- 모든 테스트를 통과하면 테스트 성공 메시지 출력
5. 테스트 과정에서 발생할 수 있는 에러와 해결 방법
에러 1: URL 네임스페이스 오류
오류 메시지
django.urls.exceptions.NoReverseMatch: 'posts:post_create' could not be found
- urls.py에서 'posts:post_create'가 올바르게 설정되었는지 확인
- app_name = 'posts'가 정의되어 있는지 확인
에러 2: 데이터베이스 마이그레이션 문제
오류 메시지
django.db.utils.OperationalError: no such table: posts_post
해결 방법
- 마이그레이션 파일 생성
python manage.py makemigrations
에러 3: 인증 문제
오류 메시지
AssertionError: 302 != 200
해결 방법
- 로그인되지 않은 사용자는 접근할 수 없도록 설정된 경우, self.client.login()을 사용하여 로그인 후 테스트 수행
self.client.login(username='testuser', password='testpassword')
6. 결론
- Django에서 유닛 테스트를 활용하면 API의 정상 동작을 자동으로 검증할 수 있음
- pytest를 사용하여 손쉽게 테스트 수행 가능
- 예상치 못한 오류를 방지하고, 유지보수를 쉽게 할 수 있음
11. 유닛 테스트 - 포스트 생성 API, POST 요청
1. 개요
Django Cookiecutter를 활용하여 Instagram 프로젝트를 개발하는 과정에서, 게시글(Post) 생성과 관련된 테스트를 작성하는 방법을 정리한다.
이 테스트에서는:
- 로그인된 사용자만 게시글을 생성할 수 있는지 검증
- 비로그인 사용자가 게시글 작성 시 적절한 리디렉션 처리 확인
- GET 요청으로 게시글 작성 페이지에 접근 가능 여부 확인
- POST 요청을 통한 게시글 생성 가능 여부 확인 을 목표로 한다.
2. 테스트 코드 설명
(1) 테스트 클래스 및 초기 설정
from django.core.files.uploadedfile import SimpleUploadedFile from django.contrib.auth import get_user_model from django.urls import reverse from django.test import TestCase class TestPosts(TestCase): """ 게시글(Post) 관련 기능 테스트 """ def setUp(self): """ 테스트 실행 전 초기 설정. 테스트용 유저 생성. """ User = get_user_model() self.user = User.objects.create_user( username='testuser', email='testuser@example.com', password='testpassword' )
설명:
- setUp() 함수에서 테스트 실행 전 필요한 데이터를 생성 및 초기화.
- get_user_model()을 사용하여 User 모델을 가져오고 테스트용 계정을 생성.
(2) 게시글 작성 페이지 접근 테스트 (GET 요청)
def test_get_posts_page(self): """ 게시글 작성 페이지 접근 테스트 (GET 요청) """ url = reverse('posts:post_create') response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'posts/post_create.html')
설명:
- reverse('posts:post_create')를 통해 URL을 가져오고 GET 요청 수행.
- HTTP 상태 코드가 200인지 확인.
- 해당 페이지가 posts/post_create.html 템플릿을 사용하는지 확인.
(3) 로그인 후 게시글 생성 테스트 (POST 요청)
def test_post_creating_posts(self): """ 로그인한 상태에서 게시글 작성 POST 요청을 테스트합니다. - 로그인 성공 여부 확인 - POST 요청 성공 여부 및 올바른 템플릿 사용 확인 """ # 테스트 유저로 로그인 login = self.client.login(username="testuser", password="testpassword") self.assertTrue(login) # 로그인 성공 여부 확인 url = reverse('posts:post_create') # 게시글 작성 URL 가져오기 # 테스트용 이미지 파일 생성 image = SimpleUploadedFile("test.jpg", b"whatevercontents") # 파일 이름 및 내용 # POST 요청 실행 response = self.client.post(url, { 'image': image, # 업로드 이미지 'caption': "test test" # 게시글 내용 }) # HTTP 상태 코드가 200인지 확인 self.assertEqual(response.status_code, 200) # 게시글 생성 후 올바른 템플릿이 사용되었는지 확인 self.assertTemplateUsed(response, "posts/base.html")
설명:
- self.client.login()을 통해 테스트 유저 로그인.
- SimpleUploadedFile을 이용해 임시 이미지 파일 생성 후 POST 요청 수행.
- HTTP 상태 코드 200 및 템플릿 확인.
(4) 비로그인 상태에서 게시글 생성 요청 테스트
def test_post_creat_not_login(self): """ 비로그인 상태에서 게시글 작성 시 리디렉션 테스트 """ url = reverse('posts:post_create') image = SimpleUploadedFile("test.jpg", b"whatevercontents", content_type="image/jpeg") response = self.client.post(url, { 'image': image, 'caption': "test test" }) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "users/main.html")
설명:
- 로그인하지 않은 상태에서 POST 요청 시 users/main.html로 이동하는지 확인.
3. 테스트 실행
테스트 실행은 pytest를 사용하여 수행할 수 있다.
pytest ./django_instagram/posts/tests/test_views.py
4. 주요 개념 정리
- setUp(): 테스트 시작 전 실행되는 메서드.
- reverse(): URL 패턴을 가져올 때 사용.
- SimpleUploadedFile(): 테스트용 가짜 파일 객체 생성.
- self.client.get(), self.client.post(): 클라이언트 요청 테스트.
- assertEqual(): 예상된 값과 실제 값 비교.
- assertTemplateUsed(): 특정 템플릿이 사용되었는지 확인.
5. 테스트 코드의 중요성
- 기능 구현 후 테스트 코드를 실행하면 버그를 사전에 방지할 수 있음.
- 반복적인 수동 테스트 없이, 자동화된 테스트로 검증 가능.
- 코드가 변경되었을 때, 기존 기능이 정상적으로 동작하는지 확인할 수 있음.
12. 포스트 등록 화면 - form
개요
이 문서는 Django를 사용하여 Instagram과 유사한 소셜 미디어 애플리케이션을 구축하는 방법을 설명합니다. 특히, Cookiecutter를 사용하여 프로젝트를 생성하고, Django Forms를 활용하여 사용자가 포스트를 생성할 수 있도록 구현하는 방법을 다룹니다.
1. Django Forms를 활용한 게시글 등록
Django Forms는 사용자 입력을 처리하는 강력한 기능을 제공합니다. 이를 활용하여 사용자가 게시글을 생성할 수 있도록 합니다.
1.1 CreatePostForm 정의
from django import forms from .models import Post, Comment class CreatePostForm(forms.ModelForm): class Meta: model = Post fields = ["caption", "image"] labels = { "caption": "내용", "image": "사진" } def clean_caption(self): caption = self.cleaned_data.get("caption") if len(caption) < 5: # 내용이 5자 이상이어야 한다는 유효성 검사 raise forms.ValidationError("내용은 최소 5자 이상이어야 합니다.") return caption def clean_image(self): image = self.cleaned_data.get("image") if image is None: raise forms.ValidationError("이미지를 업로드해야 합니다.") # 필수 입력 필드라면 추가 if not image.name.lower().endswith(('.png', '.jpg', '.jpeg')): raise forms.ValidationError("지원하는 이미지 형식은 PNG, JPG, JPEG입니다.") return image
1.2 post_create 뷰 구현
from django.shortcuts import render, get_object_or_404 from django_instagram.users.models import User as user_model from . import models from .forms import CreatePostForm def index(request): return render(request, 'posts/index.html') def post_create(request): if request.method == "GET": form = CreatePostForm() return render(request, 'posts/post_create.html', {'form': form}) elif request.method == "POST": if request.user.is_authenticated: user = get_object_or_404(user_model, pk=request.user.id) form = CreatePostForm(request.POST, request.FILES) if form.is_valid(): new_post = form.save(commit=False) new_post.author = user new_post.save() return render(request, 'posts/main.html') else: return render(request, 'posts/post_create.html', {'form': form}) else: return render(request, 'users/main.html', {'error_message': '권한오류: post 등록에 실패하였습니다.'})
2. Django Template을 활용한 포스트 생성 페이지
Django의 템플릿 시스템을 활용하여 사용자가 게시글을 등록할 수 있는 화면을 구성합니다.
2.1 post_create.html 템플릿
{% extends "posts/base.html" %} {% load static %} {% block title %} 포스트 생성 {% endblock title %} {% comment %} {{form.as_p}} {% endcomment %} {% block content %} <div class="flex justify-center items-center min-h-screen bg-gray-100"> <div class="w-3/4 aspect-[4/3] bg-white rounded-lg shadow-md p-8"> <h1 class="text-2xl font-bold text-gray-800 mb-6 text-center"> 게시글 등록 </h1> <form action="{% url 'posts:post_create' %}" method="post" enctype="multipart/form-data" class="space-y-6 w-4/6 mx-auto"> {% csrf_token %} <!--1.방법 {% for field in form %} {{field.label_tag}} {{field}} {{field.errors}} {% endfor %} <hr> --> <!-- 2.방법 {{form.as_p}} --> <!--3.방법--> <div> <label for="id_caption" class="block text-sm font-medium text-gray-700 mb-1"> 내용 </label> <textarea id="id_caption" name="caption" class="w-full border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500" rows="12" required ></textarea> {% if form.caption.errors %} <p class="text-sm text-red-500 mt-1"> {{ form.caption.errors.0 }} </p> {% endif %} </div> <div> <label for="id_image" class="block text-sm font-medium text-gray-700 mb-1"> 사진 </label> <input required id="id_image" type="file" name="image" class="w-full file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-indigo-600 file:text-white file:font-medium file:cursor-pointer file:hover:bg-indigo-500" /> {% if form.image.errors %} <p class="text-sm text-red-500 mt-1"> {{ form.image.errors.0 }} </p> {% endif %} </div> <button type="submit" class="w-full bg-indigo-600 text-white font-semibold rounded-lg py-3 text-center shadow-md hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > 등록하기 </button> </form> </div> </div> {% endblock content %}
3. 정리 및 요약
- Django Forms 활용: CreatePostForm을 정의하여 유효성 검사를 수행하고, 사용자 입력을 보다 안전하게 처리함.
- 게시글 생성 뷰: post_create 뷰를 작성하여 사용자 인증 여부를 확인하고, 게시글을 저장한 후 메인 페이지로 리디렉트함.
- 템플릿 활용: post_create.html에서 Django 템플릿 엔진을 활용하여 폼을 렌더링하고, Tailwind CSS를 적용하여 UI 개선.
- 유효성 검사 및 저장 패턴: form.save(commit=False) 패턴을 사용하여 저장을 지연하고, 사용자 정보를 추가한 후 저장하는 방식 활용.
이와 같은 방식으로 Django의 강력한 기능을 활용하여 Instagram과 같은 소셜 미디어 서비스를 구축할 수 있습니다.
13. 피드 페이지 데이터 분석 / DRF 사용 이유
Django로 만드는 Instagram 프로젝트 - 피드 페이지 데이터 분석 및 DRF 사용 이유
1. 개요
이번 강의에서는 Django를 활용하여 Instagram 프로젝트의 피드 페이지 데이터를 분석하고, Django REST Framework(DRF)를 사용하는 이유를 설명합니다.
2. 피드 페이지에서 필요한 데이터
피드 페이지에서 보여줘야 할 데이터는 다음과 같습니다:
- 유저 프로필 이미지
- 유저 이름
- 포스트 이미지
- 포스트 내용
- 댓글 목록
이 데이터들은 Django의 뷰 로직에서 추출되어 템플릿이나 API 응답으로 전달됩니다.
3. 필요한 데이터 모델
피드 페이지를 구성하기 위해 필요한 모델은 다음과 같습니다:
- User 모델: 유저 정보를 저장 (프로필 이미지 포함)
- Post 모델: 포스트 데이터를 저장 (포스트 이미지, 내용 등 포함)
- Comment 모델: 각 포스트에 달린 댓글 정보를 저장
4. 데이터 추출의 어려움
단순한 쿼리를 사용하여 데이터를 추출할 경우 다음과 같은 문제가 발생할 수 있습니다:
- 하나의 포스트에 여러 개의 댓글이 존재하는 경우, JOIN을 사용하면 포스트 데이터가 중복되어 나타날 수 있음.
- User, Post, Comment 테이블을 JOIN하면 쿼리 복잡도가 증가.
- 적절한 JSON 형태로 데이터를 변환하는 과정이 필요함.
5. Django REST Framework(DRF) 사용 이유
이러한 데이터 구조를 효과적으로 처리하기 위해 Django REST Framework를 사용합니다. DRF의 역할은 다음과 같습니다:
- API 호출 처리: 클라이언트가 GET 요청을 보내면, DB 데이터를 적절한 구조로 변환하여 응답.
- JSON 데이터 변환: API 응답이 JSON 형태로 제공되어 프론트엔드에서 쉽게 사용할 수 있음.
- 복잡한 데이터 구조 관리: Serializer를 활용하여 필요한 데이터 구조를 만들고, ViewSet을 이용해 데이터 접근을 간편화.
6. 결론
DRF를 사용하지 않으면, 원하는 JSON 데이터 구조를 만들기 위해 많은 개발 비용이 발생할 수 있습니다. Django REST Framework를 활용하면 필요한 데이터를 깔끔하게 정리하여 효율적으로 API를 구축할 수 있습니다.
14. 포스트 조회 for 피드 페이지
Django REST framework
https://www.django-rest-framework.org/api-guide/serializers/#serializers
django_instagram/users/admin.py
from allauth.account.decorators import secure_admin_login from django.conf import settings from django.contrib import admin from django.contrib.auth import admin as auth_admin from django.utils.translation import gettext_lazy as _ from .forms import UserAdminChangeForm from .forms import UserAdminCreationForm from .models import User if settings.DJANGO_ADMIN_FORCE_ALLAUTH: # Force the `admin` sign in process to go through the `django-allauth` workflow: # https://docs.allauth.org/en/latest/common/admin.html#admin admin.autodiscover() admin.site.login = secure_admin_login(admin.site.login) # type: ignore[method-assign] @admin.register(User) class UserAdmin(auth_admin.UserAdmin): form = UserAdminChangeForm add_form = UserAdminCreationForm fieldsets = ( (None, {"fields": ("username", "password")}), (_("Personal info"), {"fields": ("name", "email")}), (_("팔로워 && 팔로잉"), {"fields": ("followers", "following")}), ( _("Permissions"), { "fields": ( "is_active", "is_staff", "is_superuser", "groups", "user_permissions", ), }, ), (_("Important dates"), {"fields": ("last_login", "date_joined")}), ) list_display = ["username", "name", "is_superuser"] search_fields = ["name"]
1. admin.py에서 사용자 모델 확장
django_instagram/users/admin.py에서는 User 모델을 확장하여 Django 관리 패널에서 사용자가 쉽게 관리될 수 있도록 설정합니다.
주요 코드 설명
- secure_admin_login을 사용하여 Django 관리 패널 로그인 과정에서 django-allauth를 강제 적용.
- UserAdmin 클래스를 확장하여 사용자 정보를 관리.
- fieldsets를 통해 사용자 관리 화면에서 표시할 필드 그룹을 지정:
- username과 password
- 개인 정보 (이름, 이메일)
- 팔로워 && 팔로잉
- 권한 설정
- 중요한 날짜 (로그인, 가입일)
- list_display를 설정하여 관리자 화면에서 표시할 컬럼을 정의.
- search_fields를 추가하여 이름 기준 검색을 가능하게 함.
from django.shortcuts import render , get_object_or_404 from django_instagram.users.models import User as user_model # 사용자 모델 import from . import models from .forms import CreatePostForm from django.db.models import Q # Create your views here. def index(request): if request.method == 'GET': if request.user.is_authenticated: """ models.Post.objects.filter(...): 1.models.Post: Post는 게시글 모델로, 데이터베이스의 게시글 테이블과 매핑된 Django 모델입니다. 1).objects: 모델에 대해 데이터베이스 쿼리를 실행할 수 있는 관리 매니저입니다. 2).filter(): 특정 조건에 해당하는 레코드만 필터링하여 반환하는 ORM 메서드입니다. 2.Q 객체: **Q**는 Django의 ORM에서 OR 조건을 처리하거나 복잡한 조건을 생성할 때 사용하는 객체입니다. 3.결과: posts 변수에는 다음 두 조건을 만족하는 게시글들이 저장됩니다 1)팔로우 중인 사용자가 작성한 게시글 2)현재 사용자가 작성한 게시글 4.author__in :author__in은 변수로 저장되는 것이 아니라 author__in은 쿼리를 생성하기 위한 조건 설정에만 사용됩니다. 1)author 필드: Post 모델의 author 필드입니다. 이 필드는 ForeignKey로 연결되어 있으며, 게시글의 작성자를 나타냅니다 2)_in 룩업: Django의 쿼리 필터링 옵션 중 하나로, 특정 값들의 리스트, 쿼리셋, 또는 iterable 객체 안에 포함된 레코드를 조회합니다. 예) models.Post.objects.filter(author__in=[user1, user2]) 여기서 author가 user1 또는 user2인 게시글을 필터링합니다. """ # 사용자 정보 가져오기 user = get_object_or_404(user_model, pk=request.user.id) following=user.following.all() posts=models.Post.objects.filter( Q(author__in=following) | Q(author=user) ) return render(request, 'posts/main.html')
2. 피드 페이지에서 포스트 조회 (views.py)
django_instagram/posts/views.py에서는 사용자의 피드에서 보여줄 게시물을 조회하는 로직을 구현합니다.
주요 코드 설명
index 뷰 함수:
- GET 요청을 처리하며, 로그인된 사용자만 접근 가능.
- 현재 로그인된 사용자의 정보를 조회.
- 사용자가 팔로우하는 유저들의 게시물을 가져옴.
- 로그인한 사용자의 게시물도 포함하여 필터링.
- Q 객체를 사용하여 OR 조건을 적용 (팔로우한 유저 + 본인 게시물 필터링).
- 조회된 데이터를 posts/main.html 템플릿으로 렌더링.
Q 객체 활용:
- Q(author__in=following) | Q(author=user) 조건을 사용하여
- 사용자가 팔로우한 유저들의 게시물을 필터링.
- 본인이 작성한 게시물도 포함.
- Q(author__in=following) | Q(author=user) 조건을 사용하여
Q 객체 활용의 이점
- or 조건을 적용할 때 가독성이 좋고 복잡한 필터링 로직을 단순화할 수 있음.
- __in 룩업을 활용하여 다수의 유저를 대상으로 한 필터링이 가능함.
- ORM 문법을 활용하여 데이터베이스에 최적화된 SQL 쿼리를 자동 생성.
3. 강의 요약
주요 개념 정리
- Django ORM을 활용한 게시물 필터링 (Q 객체, filter, __in 등 활용).
- Django Admin 커스터마이징 (UserAdmin 확장, fieldsets, list_display, search_fields).
- Django Templates를 활용하여 데이터 렌더링 (posts/main.html).
15. 피드 데이터 출력 with Serializers, rest-framework
Django REST framework
https://www.django-rest-framework.org/api-guide/serializers/#serializers
https://www.django-rest-framework.org/#installation
1)설치 :
pip install djangorestframework pip install markdown # Markdown support for the browsable API. pip install django-filter # Filtering support
2)config/settings/base.py 파일의 THIRD_PARTY_APPS 에 추가 (INSTALLED_APPS )
THIRD_PARTY_APPS = [ ... "rest_framework", ] 쿠키커터가 아닌 일반 장고 프레임워크경우 INSTALLED_APPS = [ ... "rest_framework", ]
※ 2.Rest API 만들기 ※
1) 구조
posts/ ├── api/ # ✅ API 관련 코드 분리 │ ├── __init__.py │ ├── serializers.py # DRF Serializer │ ├── views.py # API View (APIView, ViewSet) │ ├── urls.py # API URL 라우팅 ├── migrations/ ├── templates/ # HTML 템플릿 (웹용) ├── __init__.py ├── admin.py ├── apps.py ├── models.py # Post 모델 ├── views.py # 일반 웹용 View (HTML 렌더링) ├── urls.py # 기존 posts URL 설정
2)django_instagram/posts/api/urls.py
from django.urls import path from .views import PostListView ,PostDetailView app_name = "posts_api" # 네임스페이스 충돌 방지 urlpatterns = [ path("posts/", PostListView.as_view(), name="posts-list"), # /api/posts/ path("posts/<int:pk>/", PostDetailView.as_view(), name="posts-detail"), # /api/posts/<id>/ ]
3) django_instagram/posts/api/views.py
from django.shortcuts import get_object_or_404 from django_instagram.users.models import User as user_model from rest_framework.response import Response from rest_framework import status, generics, permissions from django_instagram.posts.models import Post from .serializers import PostSerializer from django.db.models import Q class PostListView(generics.ListAPIView): serializer_class = PostSerializer permission_classes = [permissions.IsAuthenticated] # 로그인한 사용자만 접근 가능 def get_queryset(self): """현재 사용자의 게시글과 팔로잉한 사용자의 게시글을 가져옴""" user = get_object_or_404(user_model, pk=self.request.user.id) following = user.following.all() return Post.objects.filter(Q(author__in=following) | Q(author=user)) def list(self, request, *args, **kwargs): queryset = self.get_queryset() serializer = self.get_serializer(queryset, many=True, context={'request': request}) return Response( {"posts":serializer.data}, status=status.HTTP_200_OK) class PostDetailView(generics.RetrieveAPIView): pass
PostListView는 Django REST Framework(DRF)의 generics.ListAPIView를 기반으로 한 뷰입니다.
- PostListView는 generics.ListAPIView를 상속받아 리스트 조회 기능을 제공합니다.
- ListAPIView는 DRF의 제네릭 뷰(Generic Views) 중 하나로, GET 요청을 처리하는 데 최적화되어 있습니다.
- serializer_class = PostSerializer를 설정하여 Post 모델의 데이터를 JSON 형태로 변환합니다.
- permission_classes = [permissions.IsAuthenticated]를 통해 로그인한 사용자만 접근 가능하도록 설정했습니다.
- get_queryset() 메서드에서 로그인한 사용자의 게시물과 팔로우한 사용자의 게시물을 필터링하여 반환합니다.
- list() 메서드는 DRF의 Response 객체를 사용하여 JSON 응답을 반환합니다.
2) Django의 일반 함수형 뷰(FBV, Function-Based View)
#2) Django의 일반 함수형 뷰(FBV, Function-Based View) @api_view(['GET']) def posts_list_view(request): if request.method == 'GET': user = get_object_or_404(user_model, pk=request.user.id) following = user.following.all() # 게시글 필터링 __ 언더바 포함의미 caption__contains ==>캡션 포함이 되어 있는 것 followed_posts = models.Post.objects.filter(Q(author__in=following) | Q(author=user)).order_by('-created_at') # 시리얼라이저로 데이터 변환 serializer = PostSerializer(followed_posts, many=True, context={'request': request}) # 현재 로그인한 사용자 정보를 UserSerializer로 변환 login_user_serializer = UserSerializer(request.user, context={'request': request}) return JsonResponse({"posts": serializer.data,"loginUser": login_user_serializer.data}, status=200)
- @api_view(['GET']) 데코레이터를 사용했지만, 내부적으로는 Django의 일반 함수형 뷰(FBV, Function-Based View) 입니다.
- Response 대신 Django의 JsonResponse를 사용하여 JSON 데이터를 반환합니다.
- PostListView와 비교했을 때 DRF의 ListAPIView를 상속받지 않았으므로, DRF의 제네릭 뷰가 아닙니다.
비교: DRF 기반 vs. 일반 Django 뷰
4) config/settings/urls.py 에 등록
urlpatterns = [ ... # API 엔드포인트 path("api/", include(("django_instagram.posts.api.urls", "posts_api"), namespace="posts_api")), #path("api/", include(("django_instagram.users.urls", "users_api"), namespace="users_api")), ... ]
API
http://127.0.0.1:8000/api/posts/?format=api
JSON
http://127.0.0.1:8000/api/posts/?format=json
3.Serializer 구현
1. 프로젝트 개요
Django와 DRF(Django Rest Framework)를 활용하여 Instagram과 유사한 소셜 미디어 애플리케이션을 구축하는 프로젝트이다. 이 프로젝트는 Cookiecutter를 사용하여 기본적인 프로젝트 구조를 자동 생성한 후, 필요한 기능을 추가하는 방식으로 진행된다.
2. Serializer 구현
2.1 유저 모델 직렬화 (FeedAuthorSerializer)
- User 모델을 FeedAuthorSerializer로 직렬화하여 특정 필드(id, username, profile_photo)만 포함하도록 설정한다.
- 유저 정보를 활용하여 게시물 및 댓글 작성자를 직렬화할 때 사용된다.
class FeedAuthorSerializer(serializers.ModelSerializer): class Meta: model = user_model fields = ( "id", "username", "profile_photo", )
2.2 댓글 직렬화 (CommentSerializer)
- author 필드를 FeedAuthorSerializer를 사용하여 직렬화한다.
- contents 필드를 포함하여 댓글의 주요 내용을 반환한다.
class CommentSerializer(serializers.ModelSerializer): author = FeedAuthorSerializer() class Meta: model = models.Comment fields = ( 'id', 'contents', "author", )
2.3 게시글 직렬화 (PostSerializer)
- comment_post: 댓글 목록을 포함하도록 CommentSerializer를 사용하여 다중 직렬화(many=True) 적용.
- author: 게시글 작성자의 정보를 포함하도록 FeedAuthorSerializer 적용.
- csrf_token: 보안을 위해 CSRF 토큰을 반환하는 SerializerMethodField 사용.
class PostSerializer(serializers.ModelSerializer): comment_post = CommentSerializer(many=True) # related_name='comment_post' 필요 author = FeedAuthorSerializer() csrf_token = serializers.SerializerMethodField() class Meta: model = models.Post fields = ( "id", "image", "caption", "image_likes", "author", "comment_post", "csrf_token", ) def get_csrf_token(self, obj): request = self.context.get('request') return get_token(request) if request else None
3. 템플릿 (HTML)
게시글을 화면에 표시하는 Django 템플릿 코드이다.
- posts 목록을 순회하며 게시물 정보를 출력.
- 게시물 작성자의 ID, 닉네임, 프로필 사진, 게시물 ID, 이미지 및 설명을 출력.
- 해당 게시물의 댓글을 출력.
{% for post in posts %} <div class="text-left"> <p>{{ post.author.id }}</p> <p>{{ post.author.username }}</p> <p>{{ post.author.profile_photo }}</p> <p>{{ post.id }}</p> <p>{{ post.image }}</p> <p>{{ post.caption }}</p> <br/> <strong>댓글 목록</strong> {% for comment in post.comment_post %} <p>{{ comment.id }}</p> <p>{{ comment.contents }}</p> <p>{{ comment.author.id }}</p> {% endfor %} <hr/> </div> {% endfor %}
4. CSRF 토큰 관련 문제 해결 : rest api jwt 토큰 방식에서는 CSRF 토큰 피 에서는 필요 없으나 여기 html 폼 전송시 CSRF 필요
CSRF 토큰이 None이 반환되는 문제의 주요 원인 및 해결 방법:
- Serializer에서 context={'request': request}를 전달했는지 확인
serializer = PostSerializer(post, context={'request': request})
- Django CSRF 미들웨어 활성화 확인 (settings.py)
MIDDLEWARE = [ "django.middleware.csrf.CsrfViewMiddleware", # 기타 미들웨어... ]
- API 요청이 AJAX 또는 인증된 상태인지 확인
def get_csrf_token(self, obj): request = self.context.get('request') if request and hasattr(request, 'META'): return get_token(request) return None
- CSRF 토큰을 직접 생성하여 반환
from django.middleware.csrf import get_token from django.http import HttpRequest def get_csrf_token(self, obj): request = self.context.get('request') if request: return get_token(request) dummy_request = HttpRequest() return get_token(dummy_request)
전체 serializers.py 코드
from rest_framework import serializers from django_instagram.users.models import User as user_model from .. import models from django.middleware.csrf import get_token #유저 모델 class FeedAuthorSerializer(serializers.ModelSerializer): class Meta: model = user_model fields = ( "id", "username", "profile_photo", ) class CommentSerializer(serializers.ModelSerializer): # author의 username을 매핑하여 username 하나만 가져올경우 #username = serializers.CharField(source='author.username') #유저에 관련된 정보 가져오기 author = FeedAuthorSerializer() class Meta: model = models.Comment fields = ( 'id', 'contents', "author", #"username", ) class PostSerializer(serializers.ModelSerializer): #댓글 가져오기 comment_post = CommentSerializer(many=True) # related_name='comment_post' 필요 #유저정보 가져오기 author = FeedAuthorSerializer() #보안을 위해 csrf 토큰 가져옴 csrf_token =serializers.SerializerMethodField() class Meta: model = models.Post fields = ( "id", "image", "caption", "image_likes", "author", "comment_post", "csrf_token", ) def get_csrf_token(self, obj): # 요청 객체에서 CSRF 토큰 가져오기 request = self.context.get('request') return get_token(request) if request else None
DRF 기반 vs. 일반 Django 뷰 비교
3. 예제 코드 비교
(1) DRF 기반 API 예제
from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from .models import Item from .serializers import ItemSerializer class ItemListAPIView(APIView): def get(self, request): items = Item.objects.all() serializer = ItemSerializer(items, many=True) return Response(serializer.data) def post(self, request): serializer = ItemSerializer(data=request.data) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
(2) 일반 Django 뷰 API 예제
from django.http import JsonResponse import json from .models import Item def item_list(request): if request.method == "GET": items = list(Item.objects.values()) # QuerySet을 리스트로 변환 return JsonResponse(items, safe=False) elif request.method == "POST": data = json.loads(request.body) item = Item.objects.create(**data) return JsonResponse({"id": item.id, "name": item.name}, status=201)
4. 장단점 비교
✅ DRF 기반
✔️ 장점
- 강력한 Serializer 제공 (ModelSerializer 활용 가능)
- 인증, 권한 관리, 페이지네이션 기능 내장
- ViewSet과 Router를 사용하여 간결한 URL 관리 가능
- Browsable API 제공 → 테스트 및 디버깅이 편리함
❌ 단점
- DRF 패키지 설치 및 학습 필요
- 일반 Django 뷰보다 약간의 오버헤드 발생
- 간단한 API 구현에는 다소 복잡할 수 있음
✅ 일반 Django 뷰
✔️ 장점
- Django 기본 기능만 사용 → 추가 라이브러리 없이 가볍게 구현 가능
- 직접 JsonResponse를 다루므로 로직을 원하는 대로 구성 가능
- 간단한 API의 경우 빠르게 개발 가능
❌ 단점
- 직렬화(Serialization) 직접 구현 필요
- 권한/인증, 페이지네이션 기능 직접 추가 필요
- RESTful API 설계를 직접 관리해야 하므로 코드가 복잡해질 수 있음
5. 언제 어떤 방식을 선택해야 할까?
선택 기준추천 방식
대규모 API 개발, RESTful API 표준 준수 필요✅ DRF
CRUD 중심 API 개발✅ DRF (ModelSerializer 활용)
간단한 JSON 응답 API 필요✅ 일반 Django 뷰
외부 API 호출 및 데이터 반환만 수행✅ 일반 Django 뷰
인증, 권한, 페이지네이션 기능 필요✅ DRF
???? 결론
- DRF는 대규모 API 개발에 적합하며, RESTful 설계 및 다양한 기능을 제공하기 때문에 확장성이 뛰어납니다.
- 일반 Django 뷰는 단순한 JSON 응답이 필요한 경우, 빠르고 가볍게 API를 구현할 때 유리합니다.
즉, 규모와 복잡성을 고려하여 선택하는 것이 가장 좋습니다!
16. 피드 화면 실습 with Template, Serializers
1. 프로젝트 개요
cookiecutter를 이용하여 프로젝트 구조를 설정하였으며, Django의 기본적인 기능과 REST API를 활용하여 게시물 관리를 구현합니다.
2. 프로젝트 디렉토리 구조
Django-Instagram/ ├── django_instagram/ │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ ├── wsgi.py │ ├── asgi.py │ ├── manage.py │ ├── posts/ │ ├── api/ │ │ ├── __init__.py │ │ ├── urls.py │ │ ├── views.py │ │ ├── serializers.py │ ├── migrations/ │ ├── templates/ │ │ ├── posts/ │ │ │ ├── index.html │ ├── static/ │ │ ├── js/ │ │ │ ├── posts/ │ │ │ │ ├── loadMorePosts.js │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── models.py │ ├── serializers.py │ ├── tests.py │ ├── urls.py │ ├── views.py │ ├── users/ │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── models.py │ ├── serializers.py │ ├── tests.py │ ├── urls.py │ ├── views.py │ ├── db.sqlite3
3. URL 설정 및 뷰 구현
3.1 urls.py (posts 앱)
from django.urls import path from . import views app_name = "posts" urlpatterns = [ path('', views.index, name="index"), path('create/', views.post_create, name='post_create'), ]
- path('', views.index, name="index"): 루트 URL(posts/)에 접근하면 index 뷰를 실행합니다.
- path('create/', views.post_create, name='post_create'): 게시글 작성 페이지 URL입니다.
3.2 views.py (posts 앱)
from django.shortcuts import render, get_object_or_404, redirect from django_instagram.users.models import User as user_model from . import models from .forms import CreatePostForm from django.db.models import Q from django.http import JsonResponse, HttpResponse from django.urls import reverse def index(request): if request.method == 'GET': if request.user.is_authenticated: user = get_object_or_404(user_model, pk=request.user.id) return render(request, 'posts/index.html', {"user": user}) return redirect(reverse('users:main'))
- index 뷰는 로그인한 사용자의 정보를 가져와서 index.html에 전달합니다.
- 로그인이 되어 있지 않으면 users:main 페이지로 리디렉션합니다.
4. REST API 구현
4.1 urls.py (posts API) django_instagram/posts/api/urls.py
from django.urls import path from .views import PostListView, PostDetailView app_name = "posts_api" urlpatterns = [ path("posts/", PostListView.as_view(), name="posts-list"), path("posts/<int:pk>/", PostDetailView.as_view(), name="posts-detail"), ]
- PostListView: 전체 게시글 목록을 반환하는 API
- PostDetailView: 특정 게시글 정보를 반환하는 API
4.2 views.py (posts API) django_instagram/posts/api/views.py
from django.shortcuts import get_object_or_404 from django_instagram.users.models import User as user_model from rest_framework.response import Response from rest_framework import status, generics, permissions from django_instagram.posts.models import Post from .serializers import PostSerializer from django.db.models import Q class PostListView(generics.ListAPIView): serializer_class = PostSerializer permission_classes = [permissions.IsAuthenticated] def get_queryset(self): user = get_object_or_404(user_model, pk=self.request.user.id) following = user.following.all() return Post.objects.filter(Q(author__in=following) | Q(author=user)) def list(self, request, *args, **kwargs): queryset = self.get_queryset() serializer = self.get_serializer(queryset, many=True, context={'request': request}) return Response({"posts": serializer.data}, status=status.HTTP_200_OK) class PostDetailView(generics.RetrieveAPIView): pass
1. serializer_class = PostSerializer
- 이 뷰에서 사용할 직렬화(Serialization) 클래스를 PostSerializer로 설정합니다.
- PostSerializer는 Post 모델의 데이터를 JSON 형식으로 변환하는 역할을 합니다.
2. permission_classes = [permissions.IsAuthenticated]
- 이 API는 인증된 사용자만 접근할 수 있도록 설정되어 있습니다.
- IsAuthenticated 권한 클래스를 사용하여 로그인한 사용자만 요청할 수 있도록 제한합니다.
3. get_queryset 메서드
- 현재 요청을 보낸 사용자를 가져옵니다.
- user.following.all()을 호출하여 사용자가 팔로우한 사용자 목록을 가져옵니다.
- Post.objects.filter(Q(author__in=following) | Q(author=user))를 사용하여 본인이 작성한 게시물 + 팔로우한 사용자의 게시물을 가져옵니다.
4. list 메서드 (API 응답을 커스터마이징)
- get_queryset()을 호출하여 게시물 목록을 가져옵니다.
- self.get_serializer(queryset, many=True, context={'request': request})
- queryset 데이터를 PostSerializer를 이용하여 JSON 형식으로 변환합니다.
- context={'request': request}를 전달하여 직렬화 과정에서 추가적인 정보를 활용할 수 있도록 합니다.
- 변환된 데이터를 Response 객체로 감싸서 클라이언트에게 반환합니다.
- Response({"posts": serializer.data}, status=status.HTTP_200_OK):
- {"posts": [...]} 형태의 JSON 응답을 생성합니다.
- HTTP 응답 상태 코드를 200 OK로 설정합니다.
- Response({"posts": serializer.data}, status=status.HTTP_200_OK):
Django REST framework(DRF)에서 ListAPIView를 상속받으면 get() 메서드가 자동으로 list() 메서드를 호출하는 방식으로 동작합니다.
따라서, list 대신에 def get(self, request, *args, **kwargs): # list 대신 get 사용 가능
def list(self, request, *args, **kwargs):
위 코드에서 *args와 **kwargs는 파라미터를 동적으로 받을 수 있도록 해주는 Python의 문법입니다.
1. *args
- 개수에 제한이 없는 위치 인자(일반적인 값)를 받습니다.
- 튜플 형태로 전달됩니다.
- list(self, request, "example1", "example2")처럼 여러 개의 값을 넘기면 args에 ("example1", "example2")가 저장됩니다.
2. **kwargs
- 키워드 인자(딕셔너리 형태)를 받습니다.
- list(self, request, key1="value1", key2="value2")처럼 호출하면 kwargs에 {"key1": "value1", "key2": "value2"}가 저장됩니다.
- *args, **kwargs는 동적으로 인자를 받을 때 사용하며, 반드시 필요한 것은 아닙니다.
- list() 대신 get()을 오버라이드하여 같은 기능을 수행할 수도 있습니다.
- DRF에서는 기본적으로 *args, **kwargs를 포함하는 패턴을 사용하므로 유지하는 것이 일반적입니다.
5. 프론트엔드 템플릿
5.1 index.html
{% extends "posts/base.html" %} {% load static %} {% block title %} 포스트 생성 {% endblock title %} {% block staticBlock %} <script defer src="{% static 'js/posts/loadMorePosts.js' %}"></script> {% endblock staticBlock %} {% block content %} <div class="bg-gray-50 py-10"> <div class="mx-auto max-w-7xl px-6 lg:px-8"> <div class="mx-auto mt-10 grid max-w-2xl grid-cols-1 gap-x-8 gap-y-16 pt-10 sm:mt-16 sm:pt-16 lg:mx-0 lg:max-w-none lg:grid-cols-1" id="postList"> </div> </div> </div> {% endblock content %}
5.2 django_instagram/static/js/posts/loadMorePosts.js
let loading = false; // 로딩 중 여부 const container = document.querySelector('#postList'); // 게시글 컨테이너 const loadMorePosts = async () => { if (loading) return; loading = true; try { const response = await fetch(`/api/posts/?format=json`); const data = await response.json(); // 데이터 추가\ data.posts.forEach(post => { const postElement = ` <article class="w-3/4 border rounded-lg border-gray-300 mx-auto shadow-lg bg-white"> <!-- 상단 프로필 영역 --> <div class="w-full flex justify-between items-center border-b border-gray-200 p-4"> <!-- 왼쪽 콘텐츠 --> <div class="flex items-center space-x-3"> <span> ${post.author.profile_photo ? `<img src="${post.author.profile_photo}" class="w-12 h-12 rounded-full border border-gray-300">` : `<img src="/static/images/posts/no_avatar.png" class="w-12 h-12 rounded-full border border-gray-300">`} </span> <span class="font-semibold text-gray-800 text-lg" >${post.author.username}</span> </div> <!-- 오른쪽 콘텐츠 --> ${post.author.id ? ` <div class="flex items-center space-x-5"> <i class="fa fa-pencil fa-lg text-gray-500 cursor-pointer hover:text-gray-800" onclick="postUpdatePage('${post.id}', '${post.csrf_token}', this)" ></i> <i class="fa fa-trash-o fa-lg text-red-500 cursor-pointer hover:text-red-700" onclick="postDelete('${post.id}', '${post.csrf_token}', this)" ></i> </div> ` :'' } </div> <!-- 포스트 이미지 --> <div class="w-full"> <img class="w-full h-96 object-contain bg-gray-100" src="${post.image}" alt="Post image"> </div> <!-- 설명 및 좋아요 --> <div class="p-4"> <div class="flex items-center space-x-3" id="like-button-${post.id}" onclick="debouncedHandleLikeClick('${post.id}', '${post.csrf_token}', this)"> ${post.image_likes.includes(Number(userId)) ? `<i class="fa fa-heart fa-2x text-red-500 cursor-pointer hover:text-red-500"></i>` : `<i class="fa fa-heart-o fa-2x text-gray-500 cursor-pointer hover:text-red-500"></i>` } (<span id="like-count-${post.id}" class="mx-0 !mx-0">${post.image_likes.length}</span>) </div> <div class="mt-5"> <b class="text-gray-800">${post.author.username}</b> <span class="text-gray-600">${post.caption}</span> </div> </div> <!-- 댓글 영역 --> <div class="border-t border-gray-200 p-4 mt-5"> <h3 class="font-bold text-lg text-gray-800 mb-3">댓글</h3> ${post.comment_post.map(comment => ` <p id="comment-${comment.id}" class="text-sm text-gray-700 mb-3 border-b pb-2 flex justify-between"> <span> <span class="font-semibold text-gray-800 mr-2">${comment.author.username}</span> <span>${comment.contents}</span> </span> ${userId ==comment.author.id ? ` <span class="font-semibold text-gray-800 mr-3" onclick="commentDelete('${comment.id}', '${post.csrf_token}', this)" > <i class="fa fa-trash-o fa-lg text-red-500 cursor-pointer hover:text-red-700"></i> </span> ` : ''} </p> `).join('')} </div> <!-- 댓글 입력 폼 --> <div class="mt-5 p-4"> <form action="/posts/${post.id}/comment_create/" method="post" class="flex flex-col space-y-4"> <input type="hidden" name="csrfmiddlewaretoken" value="${post.csrf_token}"> <textarea name="contents" class="p-2 border rounded-lg w-full" placeholder="댓글을 입력하세요"></textarea> <button type="submit" class="px-4 py-2 bg-indigo-500 text-white font-semibold rounded-lg hover:bg-indigo-600"> 댓글 등록 </button> </form> </div> </article> `; container.insertAdjacentHTML('beforeend', postElement); }); } catch (error) { console.error("Error loading posts:", error); } finally { loading = false; } }; loadMorePosts();
6. 결론
이 프로젝트는 Django의 기본적인 기능과 Django REST framework를 활용하여 Instagram과 유사한 기능을 구현합니다.
- Django의 ListAPIView를 활용하여 RESTful API를 구축하고, ListAPIView, RetrieveAPIView 등의 클래스를 활용하여 데이터 제공 방식을 간결하게 구현했습니다.
- JavaScript를 이용하여 동적으로 데이터를 로드하고, 화면에 반영하는 방식으로 UX를 개선했습니다.
이후에는 댓글 기능 추가, 게시글 상세 보기 API 구현, 좋아요 기능 추가 개발 진행
17. 포스트 댓글 생성 - django form, api 개발
댓글 생성
1)django_instagram/posts/forms.py
from django import forms from .models import Post, Comment class CommentForm(forms.ModelForm): class Meta: model = Comment fields = ['contents'] labels = { "contents": "", } widgets = { "contents": forms.Textarea(attrs={ "placeholder": "댓글을 입력하세요...", "rows": 3, }), } def clean_contents(self): contents = self.cleaned_data.get("contents", "").strip() if not contents: # 빈 문자열 또는 공백만 입력한 경우 raise forms.ValidationError("댓글 내용을 입력해주세요.") if len(contents) < 2: # 최소 2자 이상 입력 raise forms.ValidationError("댓글은 최소 2자 이상 입력해야 합니다.") if len(contents) > 100: # 최대 100자 제한 raise forms.ValidationError("댓글은 100자 이하로 입력해야 합니다.") return contents
2)posts/urls.py
from django.urls import path from . import views app_name = "posts" urlpatterns = [ ~ # 댓글 생성 # /posts/1/comment_create path('<int:post_id>/comment_create/', views.comment_create, namespace='comment_create'), ]
3)views
from django.shortcuts import render , get_object_or_404 ,redirect from django_instagram.users.models import User as user_model # 사용자 모델 import from . import models from .forms import CreatePostForm, CommentForm from django.db.models import Q from .api import serializers from django.http import JsonResponse , HttpResponse from django.urls import reverse from django.views.decorators.http import require_POST, require_http_methods from django.contrib.auth.decorators import login_required from django_instagram.utils import form_errors_to_string # 공통 함수 가져오기 ~ # 댓글 생성 @require_POST def comment_create(request, post_id): # 인증 확인 먼저 수행 if not request.user.is_authenticated: return JsonResponse({"success": False, "message": "로그인이 필요합니다."}, status=401) # 게시글 존재 여부 확인 post = get_object_or_404(models.Post, pk=post_id) # 댓글 폼 처리 form = CommentForm(request.POST) if form.is_valid(): comment = form.save(commit=False) comment.author = request.user comment.post = post comment.save() return JsonResponse({"success": True, "comment": serializers.CommentSerializer(comment).data}) # 수정: 유효성 검사 실패 시 오류 메시지를 "error" 키에 담아 반환 # error_list = [] # for msgs in form.errors.values(): # 각 필드의 에러 메시지 리스트를 가져옴 # for msg in msgs: # 그 리스트 내부의 개별 메시지를 가져옴 # error_list.append(msg) #============한줄로 변경처리리==========> # error_messages = " ".join([msg for msgs in form.errors.values() for msg in msgs]) #print("dderror_messages", error_messages) # 유효성 검사 실패 시 오류 메시지 반환 return JsonResponse({ "success": False, "message": "댓글 등록 실패", "errors": form_errors_to_string(form) # 폼 오류 메시지 포함 }, status=400)
공통 처리 utils.py 생성 (django_instagram/utils.py
def form_errors_to_string(form): """ Django Form의 errors를 하나의 문자열로 변환하는 함수. :param form: Django Form 객체 :return: 문자열 형태의 오류 메시지 """ return ", ".join([msg for msgs in form.errors.values() for msg in msgs])
4)django_instagram/static/js/posts/loadMorePosts.js
//댓글 생성 async function commentCreate(postId, csrfToken, event) { event.preventDefault(); // 이벤트가 발생한 요소에서 가장 가까운 form 요소 찾기 const form = event.currentTarget.closest('form'); const contents = form.querySelector('textarea[name="contents"]').value.trim(); if (!contents) { alert("댓글을 입력하세요."); return; } try { const response = await fetch(`/posts/${postId}/comment_create/`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': csrfToken, }, body: new URLSearchParams({ contents }), }); const data = await response.json(); console.log("댓글 등록후 반환 데이터 :", data); if (data.success) { console.log("댓글 등록후 반환된 데이터 :", data); const articleElement = document.querySelector(`#article-${postId}`); const commentListElementContainer = articleElement.querySelector(`.comment-list`); commentListElementContainer.insertAdjacentHTML('afterbegin', commentListElement(data.comment, csrfToken, data.comment.author.username)); form.reset(); } if (data.errors) { alert(data.errors); } } catch (error) { console.error('댓글 등록 중 오류 발생:', error); alert('댓글 등록에 실패했습니다.'); } }
18. 포스트 댓글 삭제 - api 개발
✅ Django 템플릿에서 로그인된 사용자 정보는 view.py 컨텍스트에서 설정없이 사용 가능하다.
1. 로그인된 사용자 정보 출력
{% if user.is_authenticated %} <p>안녕하세요, {{ user.username }}님!</p> <p>이메일: {{ user.email }}</p> <a href="{% url 'logout' %}">로그아웃</a> {% else %} <p>로그인해주세요.</p> <a href="{% url 'login' %}">로그인</a> {% endif %}
user.is_authenticated를 사용하여 로그인 여부를 확인할 수 있어요.
2. 슈퍼유저(관리자) 여부 확인
{% if user.is_superuser %} <p>관리자 모드 활성화</p> {% endif %}
사용자가 속한 그룹을 조회할 수 있어요.
✅ Django에서 user 변수가 자동으로 전달되는 이유
Django의 RequestContext가 기본적으로 user 변수를 템플릿 컨텍스트에 포함하기 때문이에요.
즉, 템플릿에서 user 변수를 따로 전달하지 않아도 자동으로 사용 가능합니다.
하지만 만약 user 변수가 전달되지 않는다면, context_processors 설정을 확인해야 합니다.
???? context_processors.py 설정 확인
settings.py의 TEMPLATES 설정에서 django.contrib.auth.context_processors.auth가 활성화되어 있어야 합니다.
TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [BASE_DIR / 'templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', # ✅ 이 부분이 있어야 user 사용 가능 'django.messages.context_processors.messages', ], }, }, ]
✅ 정리
- Django 템플릿에서는 기본적으로 user 변수를 사용할 수 있음 (context_processors.auth 덕분).
- user.is_authenticated를 사용하여 로그인 여부를 확인 가능.
- user.is_superuser로 관리자인지 확인 가능.
- user.groups.all을 통해 유저의 그룹 정보 가져오기 가능.
1) 댓글 삭제 처리
그러나, 이 프로젝트에서 댓글 삭제는 목록에서 json 으로 데이터를 보내기 때문에 다음과 같이 로그인한 사용자정보를 내보냅니다.
1)django_instagram/posts/api/views.py
from django.shortcuts import get_object_or_404 from django_instagram.users.models import User as user_model from rest_framework.response import Response from rest_framework import status, generics, permissions from django_instagram.posts.models import Post from .serializers import PostSerializer , CommentFormSerializer from django.db.models import Q from django_instagram.posts.forms import CommentForm from django_instagram.posts import models from django.http import JsonResponse class PostListView(generics.ListAPIView): serializer_class = PostSerializer permission_classes = [permissions.IsAuthenticated] # 로그인한 사용자만 접근 가능 def get_queryset(self): """현재 사용자의 게시글과 팔로잉한 사용자의 게시글을 가져옴""" user = get_object_or_404(user_model, pk=self.request.user.id) following = user.following.all() return Post.objects.filter(Q(author__in=following) | Q(author=user)) def list(self, request, *args, **kwargs): queryset = self.get_queryset() loginUser = request.user # 현재 로그인한 사용자 객체 serializer = self.get_serializer(queryset, many=True, context={'request': request}) #ListAPIView는 DRF 기반이므로 Response를 사용 return Response({"posts": serializer.data, "loginUser": loginUser.username}, status=status.HTTP_200_OK) def posts_list_view(request): if request.method == 'GET': user = get_object_or_404(user_model, pk=request.user.id) following = user.following.all() loginUser = request.user # 현재 로그인한 사용자 객체 # 게시글 필터링 __ 언더바 포함의미 caption__contains ==>캡션 포함이 되어 있는 것 followed_posts = models.Post.objects.filter(Q(author__in=following) | Q(author=user)) # 시리얼라이저로 데이터 변환 serializer = PostSerializer(followed_posts, many=True, context={'request': request}) return JsonResponse({"posts": serializer.data,"loginUser": loginUser.username}, status=200)
http://127.0.0.1:8000/api/posts/ 에서 json 반환 처리 되는 데이터 형식을 보면 다음과 같습니다.
{ "posts": [ { "id": 6, "image": "http://127.0.0.1:8000/media/posts/cat-8141916_1280_W7gwdSC.jpg", "caption": "222222222222222222222", "image_likes": [], "author": { "id": 1, "username": "admin", "profile_photo": null }, "comment_post": [], "csrf_token": "CvTrywBmxLZXK3UkCB3EfTesEKoyFBaF5SdIGYmZbH07kz98YUTaACsP865CmyF8" }, { ~ ~ ], "loginUser": "admin" }
2)django_instagram/posts/url.py
from django.urls import path from . import views from django.urls import include app_name = "posts" urlpatterns = [ ~ #댓글 삭제 # /posts/1/comment_delete/ path('<int:comment_id>/comment_delete/', views.comment_delete, name='comment_delete'), ]
3)django_instagram/posts/views.py
from django.http import JsonResponse from django.shortcuts import get_object_or_404 from django.views.decorators.http import require_http_methods from django.contrib.auth.decorators import login_required from django_instagram.posts import models # models.Comment를 가져오기 위해 필요 @require_http_methods(["DELETE"]) @login_required def comment_delete(request, comment_id): comment = get_object_or_404(models.Comment, pk=comment_id) if comment.author == request.user: comment.delete() return JsonResponse({"success": True, "message": "댓글이 삭제되었습니다."}, status=200) return JsonResponse({"success": False, "message": "삭제 권한이 없습니다."}, status=403)
@require_http_methods(["DELETE"]) => DELETE 요청만 허용 (다른 요청은 405 응답)
@login_required => 로그인한 사용자만 접근 가능 (로그인 페이지로 리디렉트)
4)django_instagram/static/js/posts/loadMorePosts.js
~ //댓글 삭제 처리 async function commentDelete(commentId,csrfToken, target){ if(confirm("정말 삭제 하시겠습니까?")){ try{ const response = await fetch(`/posts/${commentId}/comment_delete/`,{ method:"DELETE", headers:{ 'X-CSRFToken':csrfToken } }); const data = await response.json(); if(!response.ok) throw new Error(data.error); if(data.success){ target.closest(`#comment-${commentId}`).remove(); // 댓글 부모 요소를 삭제 } }catch(error){ console.error("댓글 삭제 실패 ",error); } } } ~ //댓글 목록 템플릿 const commentListElement = (comment, csrf_token, loginUser) => { return `<p id="comment-${comment.id}" class="text-sm text-gray-700 mb-3 border-b pb-2 flex justify-between"> <span> <span class="font-semibold text-gray-800 mr-2">${comment.author.username}</span> <span class="break-all">${comment.contents} </span> </span> ${(loginUser===comment.author.username) ? ` <span class="font-semibold text-gray-800 mr-3" onclick="commentDelete('${comment.id}', '${csrf_token}', this)"> <i class="fa fa-trash-o fa-lg text-red-500 cursor-pointer hover:text-red-700"></i> </span> ` : ''} </p> `; }
2)게시글 삭제 처리
1)django_instagram/posts/url.py
from django.urls import path from . import views from django.urls import include app_name = "posts" urlpatterns = [ ~ # 게시글 삭제 : /posts/1/post_delete path('<int:post_id>/post_delete/', views.post_delete, name='post_delete'), ~ ]
2)django_instagram/posts/views.py
from django.http import JsonResponse from django.shortcuts import get_object_or_404 from django.views.decorators.http import require_http_methods from django.contrib.auth.decorators import login_required from django_instagram.posts import models # models.Comment를 가져오기 위해 필요 # 게시글 삭제 @require_http_methods(["DELETE"]) @login_required def post_delete(request, post_id): post=get_object_or_404(models.Post, pk=post_id ) if post.author == request.user: post.delete() return JsonResponse({"success": True, "message": "게시글 삭제되었습니다."}, status=200) return JsonResponse({"success": False, "message": "삭제 권한이 없습니다."}, status=403)
3)django_instagram/static/js/posts/loadMorePosts.js
//게시글 삭제 async function postDelete(postId,csrfToken, target) { if(confirm("정말 삭제 하시겠습니까?")){ try { const response = await fetch(`/posts/${postId}/post_delete/`, { method: "DELETE", headers: { "X-CSRFToken": csrfToken, }, }); const data = await response.json(); if (response.ok && data.success) { alert(data.message); // 삭제된 댓글 UI에서 제거 target.closest('article').remove(); // 댓글 부모 article 요소를 삭제 } else { alert(data.message || "댓글 삭제 중 오류가 발생했습니다."); } } catch (error) { console.error("댓글 삭제 중 오류:", error); } } } ~ //// 게시글 템플릿 const postsHtmlTemplate =(post,loginUser)=>{ ~ <!-- 오른쪽 콘텐츠 --> ${(post.author.username===loginUser) ? ` <div class="flex items-center space-x-5"> <i class="fa fa-pencil fa-lg text-gray-500 cursor-pointer hover:text-gray-800" onclick="postUpdatePage('${post.id}', '${post.csrf_token}', this)" ></i> <i class="fa fa-trash-o fa-lg text-red-500 cursor-pointer hover:text-red-700" onclick="postDelete('${post.id}', '${post.csrf_token}', this)" ></i> </div> ` :'' } </div> ~ }
19. 포스트 수정 - GET api 개발
디렉토리 구조
django_instagram/ ├── static/ │ └── js/ │ └── posts/ │ └── loadMorePosts.js ├── templates/ │ └── posts/ │ ├── urls.py │ ├── forms.py │ ├── views.py │ └── post_update.html
1. 수정 페이지로 이동 (JavaScript)
파일: django_instagram/static/js/posts/loadMorePosts.js
// 수정페이지 이동 function postUpdatePage(postId){ window.location.href = `/posts/${postId}/post_update/`; }
설명:
- postUpdatePage(postId) 함수는 특정 postId를 URL에 포함하여 수정 페이지로 이동합니다.
- window.location.href를 이용해 클라이언트 측에서 posts/{postId}/post_update/ 경로로 페이지 이동이 이루어집니다.
2. URL 패턴 설정 (Django URL 설정)
파일: django_instagram/templates/posts/urls.py
from django.urls import path from . import views app_name = "posts" urlpatterns = [ path('', views.index, name="index"), # 게시글 수정 path('<int:post_id>/post_update/', views.post_update, name='post_update'), ]
설명:
- <int:post_id>/post_update/ 경로를 views.post_update 뷰 함수와 연결합니다.
- URL 패턴에서 post_id를 정수 값으로 받아 해당 게시글의 수정 페이지를 제공하도록 설정합니다.
3. 수정 폼 정의 (Django Forms)
파일: django_instagram/templates/posts/forms.py
from django import forms from .models import Post class UpdatePostForm(forms.ModelForm): class Meta: model = Post fields = ["caption", "image"] labels = { "caption": "내용", "image": "사진" } def clean_caption(self): caption = self.cleaned_data.get("caption") if len(caption) < 5: raise forms.ValidationError("내용은 최소 5자 이상이어야 합니다.") return caption def clean_image(self): image = self.cleaned_data.get("image") if image and not image.name.endswith((".png", ".jpg", ".jpeg")): raise forms.ValidationError("지원하는 이미지 형식은 PNG, JPG, JPEG입니다.") return image
설명:
- UpdatePostForm은 caption(내용)과 image(사진)을 포함하는 게시글 수정 폼을 정의합니다.
- clean_caption() 및 clean_image()를 통해 유효성 검사를 수행합니다.
4. 수정 페이지 처리 (Django View)
파일: django_instagram/templates/posts/views.py
from django.shortcuts import render, get_object_or_404, redirect from django.contrib.auth.decorators import login_required from django.urls import reverse from . import models from .forms import UpdatePostForm @login_required def post_update(request, post_id): post = get_object_or_404(models.Post, pk=post_id) if post.author != request.user: return redirect(reverse('users:main')) if request.method == "GET": form = UpdatePostForm(instance=post) return render(request, 'posts/post_update.html', {'form': form, 'post': post})
설명:
- @login_required를 사용하여 로그인한 사용자만 수정 페이지에 접근할 수 있도록 합니다.
- get_object_or_404()를 사용해 post_id에 해당하는 게시글을 가져옵니다.
- 작성자가 현재 로그인한 사용자가 아니라면 users:main 페이지로 리디렉션합니다.
- GET 요청 시 해당 게시글 데이터를 가진 폼을 렌더링하여 사용자에게 수정할 수 있도록 합니다.
5. 수정 페이지 템플릿 (Django Template)
파일: django_instagram/templates/posts/post_update.html
{% extends "posts/base.html" %} {% load static %} {% block title %} 포스트 수정 {% endblock title %} {% block content %} <div class="flex justify-center items-center min-h-screen bg-gray-100"> <div class="w-3/4 aspect-[4/3] bg-white rounded-lg shadow-md p-8"> <h1 class="text-2xl font-bold text-gray-800 mb-6 text-center">게시글 수정</h1> <form action="{% url 'posts:post_update' post.id %}" method="post" enctype="multipart/form-data" class="space-y-6 w-4/6 mx-auto"> {% csrf_token %} {{ form.non_field_errors }} <div> <label for="id_caption" class="block text-sm font-medium text-gray-700 mb-1">내용</label> <textarea id="id_caption" name="caption" class="w-full border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500" rows="12" required>{{ form.caption.value }}</textarea> {% if form.caption.errors %} <p class="text-sm text-red-500 mt-1">{{ form.caption.errors.0 }}</p> {% endif %} </div> <div> <label for="id_image" class="block text-sm font-medium text-gray-700 mb-1">사진</label> {% if form.instance.image %} <div class="mb-4"> <p class="text-sm text-gray-600">현재 이미지:</p> <img src="{{ form.image.value.url }}" alt="Uploaded image preview" class="w-32 max-h-16 object-cover border border-gray-300 rounded-lg"/> </div> {% endif %} <input id="id_image" type="file" name="image" class="w-full file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-indigo-600 file:text-white file:font-medium file:cursor-pointer file:hover:bg-indigo-500"/> {% if form.image.errors %} <p class="text-sm text-red-500 mt-1">{{ form.image.errors.0 }}</p> {% endif %} </div> <button type="submit" class="w-full bg-indigo-600 text-white font-semibold rounded-lg py-3 text-center shadow-md hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">수정하기</button> </form> </div> </div> {% endblock content %}
설명:
- 수정할 내용을 입력할 수 있도록 폼을 구성합니다.
- 기존 이미지 미리보기를 제공하며, 새로운 이미지를 업로드할 수 있습니다.
- 제출 버튼을 클릭하면 폼이 제출되어 수정이 반영됩니다.
전체 흐름 정리
- JavaScript: postUpdatePage(postId) 함수가 window.location.href를 변경하여 수정 페이지로 이동합니다.
- URL 설정: <int:post_id>/post_update/ 패턴을 post_update 뷰와 연결합니다.
- 뷰 함수: post_update 함수에서 해당 게시글을 가져와 UpdatePostForm을 렌더링합니다.
- 템플릿: post_update.html에서 폼을 표시하여 사용자에게 게시글을 수정할 수 있도록 합니다.
20. 포스트 수정 - post api 개발
1. 게시글 수정 처리 과정
@login_required def post_update(request, post_id): post = get_object_or_404(models.Post, pk=post_id) if post.author != request.user: return redirect(reverse('users:main')) if request.method == "GET": form = UpdatePostForm(instance=post) return render(request, 'posts/post_update.html', {'form': form, 'post': post}) elif request.method == "POST": # 기존 객체를 폼에 채우고 수정 가능하도록 함 form = UpdatePostForm(request.POST, request.FILES, instance=post) if form.is_valid(): # 이미지 파일이 없는 경우 원래 이미지 유지 if not request.FILES.get('image'): form.instance.image = post.image # 변경 사항 저장 post = form.save(commit=False) post.author = request.user post.save() return redirect(reverse('posts:index')) else: # 유효성 검사 실패 시 오류 메시지 전달 return render(request, 'posts/post_update.html', {'form': form, 'post': post})
2. POST 방식으로 수정 처리하는 과정
- 사용자가 게시글 수정 폼에서 입력 후 "수정하기" 버튼을 클릭하면 POST 요청이 전송됨.
- UpdatePostForm(request.POST, request.FILES, instance=post)을 생성하여 기존 게시글 데이터를 새로운 입력값으로 갱신함.
- form.is_valid()를 통해 유효성 검사를 수행함.
- 유효한 경우:
- commit=False로 save()를 호출하여 객체를 생성하되 즉시 저장하지 않음.
- 기존 이미지가 유지되어야 하는 경우 form.instance.image = post.image를 설정하여 원본 이미지를 유지함.
- post.save()를 실행하여 최종적으로 데이터베이스에 저장한 후 redirect(reverse('posts:index'))로 이동함.
- 유효하지 않은 경우:
- return render(request, 'posts/post_update.html', {'form': form, 'post': post})를 실행하여 오류 메시지를 포함한 폼을 다시 렌더링함.
3. 오류 메시지 처리 과정
- form.is_valid()가 False일 경우, 입력값과 함께 오류 메시지를 포함한 폼을 다시 렌더링함.
- post_update.html에서:
- {{ form.non_field_errors }}를 이용해 폼 전체의 오류 메시지를 출력함.
- 개별 필드 오류 메시지는 {{ form.caption.errors }} 또는 {{ form.image.errors }}를 사용하여 표시함.
- 사용자가 올바르게 입력할 때까지 수정 폼을 유지하면서 오류 메시지를 보여줌.
21. 좋아요 기능 - 기능 설명(+ Ajax), js파일 연결
1. 목표
로그인한 사용자가 특정 게시물(Post)에 대해 좋아요(like)를 눌렀는지 여부를 확인하고, 좋아요 버튼을 눌렀을 때 디바운스(debounce) 처리하여 불필요한 요청을 줄이는 기능을 개발한다.
2. 백엔드 - Django API 개발
(1) 좋아요 여부 확인 기능 추가
기존 PostListView 및 posts_list_view API에 로그인한 사용자가 좋아요를 누른 게시물 목록(likedPosts)을 추가한다. 이를 통해 프론트엔드에서 좋아요 여부를 쉽게 판별할 수 있다.
posts/views.py
from django.shortcuts import get_object_or_404 from django_instagram.users.models import User as user_model from rest_framework.response import Response from rest_framework import status, generics, permissions from django_instagram.posts.models import Post from .serializers import PostSerializer , CommentFormSerializer ,UserSerializer from django.db.models import Q from django_instagram.posts.forms import CommentForm from django_instagram.posts import models from django.http import JsonResponse from rest_framework.decorators import api_view class PostListView(generics.ListAPIView): serializer_class = PostSerializer permission_classes = [permissions.IsAuthenticated] # 로그인한 사용자만 접근 가능 def get_queryset(self): """현재 사용자의 게시글과 팔로잉한 사용자의 게시글을 가져옴""" user = get_object_or_404(user_model, pk=self.request.user.id) following = user.following.all() return Post.objects.filter(Q(author__in=following) | Q(author=user)).order_by('-created_at') def list(self, request, *args, **kwargs): queryset = self.get_queryset() serializer = self.get_serializer(queryset, many=True, context={'request': request}) # 현재 로그인한 사용자 정보를 UserSerializer로 변환 login_user_serializer = UserSerializer(request.user, context={'request': request}) #ListAPIView는 DRF 기반이므로 Response를 사용 return Response({"posts": serializer.data, "loginUser": login_user_serializer.data}, status=status.HTTP_200_OK) @api_view(['GET']) def posts_list_view(request): if request.method == 'GET': user = get_object_or_404(user_model, pk=request.user.id) following = user.following.all() # 게시글 필터링 __ 언더바 포함의미 caption__contains ==>캡션 포함이 되어 있는 것 followed_posts = models.Post.objects.filter(Q(author__in=following) | Q(author=user)).order_by('-created_at') # 시리얼라이저로 데이터 변환 serializer = PostSerializer(followed_posts, many=True, context={'request': request}) # 현재 로그인한 사용자 정보를 UserSerializer로 변환 login_user_serializer = UserSerializer(request.user, context={'request': request}) return JsonResponse({"posts": serializer.data,"loginUser": login_user_serializer.data}, status=200)
(2) PostSerializer 수정
시리얼라이저에서 현재 로그인한 사용자가 좋아요를 눌렀는지 여부를 포함하도록 수정한다.
posts/api/serializers.py
PostSerializer 에서 image_likes 를 추가 및 UserSerializer 에 id 및 username 등을 추가
class PostSerializer(serializers.ModelSerializer): #댓글 가져오기 comment_post = CommentSerializer(many=True) # related_name='comment_post' 필요 #유저정보 가져오기 author = FeedAuthorSerializer() #보안을 위해 csrf 토큰 가져옴 csrf_token =serializers.SerializerMethodField() class Meta: model = models.Post fields = ( "id", "image", "caption", "image_likes", "author", "comment_post", "csrf_token", ) def get_csrf_token(self, obj): # 요청 객체에서 CSRF 토큰 가져오기 request = self.context.get('request') return get_token(request) if request else None class UserSerializer(serializers.ModelSerializer): class Meta: model = user_model fields = ( "id", "username", "email", "profile_photo", "bio", )
JSON 반환 처리 예
{ "posts": [{ "id": 8, "image": "http://localhost:8000/media/posts/waves-3473335_1280.jpg", "caption": "22222222222", "image_likes": [ 1 ], "author": { "id": 1, "username": "admin", "profile_photo": null }, "comment_post": [], "csrf_token": "1z0TzoqA0SI9Kwd5xjG3fNKkYJjk8sgL3vNZbFh78Nh7jUyLAcIz6eGEtyF7xhPX" }, { ~ } ], "loginUser": { "id": 1, "username": "admin", "email": "admin@gmail.com", "profile_photo": null, "bio": "" } }
3. 프론트엔드 - 좋아요 버튼 클릭 시 처리
(1) Debounce 함수 적용
연속된 클릭 이벤트를 방지하기 위해 디바운스 함수를 사용한다. 이 함수는 일정 시간 동안 추가 클릭이 발생하면 이전 요청을 취소하고 마지막 요청만 실행하도록 한다.
like.js
// Debounce 연속된 호출을 지연시키고, 마지막 호출만 실행되도록 합니다. function debounce(func, delay) { let timer; return function (...args) { clearTimeout(timer); // 이전 타이머를 지웁니다. timer = setTimeout(() => func.apply(this, args), delay); // 새로운 타이머를 설정합니다. }; }
(2) 좋아요 버튼 클릭 핸들러
버튼을 클릭하면 API 요청을 보내고, 응답을 받은 후 UI를 업데이트한다.
django_instagram/static/js/posts/loadMorePosts.js
//좋아요! 좋아요취소! async function handleLikeClick(postId,csrfToken, target){ console.log("handleLikeClick : ",postId,csrfToken, target); const iTag=target.querySelector("i"); const likeButton = target; // 버튼 자체 if (likeButton.disabled) { return; // 이미 처리 중이면 중복 요청 방지 } likeButton.disabled = true; // 버튼 비활성화 try { const response = await fetch(`/posts/${postId}/post_like/`, { method: "POST", headers: { "X-CSRFToken": csrfToken, }, }); const data = await response.json(); console.log("response : ", data); if (!response.ok || !data.success) { console.log(" 좋아요 API 호출 중 오류가 발생했습니다.: " , data.error); throw new Error(data.message || "좋아요 API 호출 중 오류가 발생했습니다."); } if(data.message==="like"){ //좋아요! iTag.classList.replace("fa-heart-o", "fa-heart"); iTag.classList.replace("text-gray-500", "text-red-500"); }else{ //좋아요 취소! iTag.classList.replace("fa-heart", "fa-heart-o"); iTag.classList.replace("text-red-500", "text-gray-500"); } target.querySelector("#like-count-"+postId).innerText=data.like_count; } catch (error) { console.error("Error liking post:", error); } finally { likeButton.disabled = false; // 버튼 활성화 } } // Debounced 함수 적용 (300ms 지연) const debouncedHandleLikeClick = debounce(handleLikeClick, 300);
각 게시글의 좋아요 버튼에 이벤트 리스너를 추가하여 디바운스된 handleLikeClick 함수를 호출한다.
//// 게시글 템플릿 const postsHtmlTemplate =(post,loginUser)=>{ return ` <article class="w-3/4 border rounded-lg border-gray-300 mx-auto shadow-lg bg-white" id="article-${post.id}"> ~~ <!-- 설명 및 좋아요 --> <div class="p-4"> <div class="flex items-center space-x-3" id="like-button-${post.id}" onclick="debouncedHandleLikeClick('${post.id}', '${post.csrf_token}', this)"> ${post.image_likes.includes(Number(loginUser.id)) ? `<i class="fa fa-heart fa-2x text-red-500 cursor-pointer hover:text-red-500"></i>` : `<i class="fa fa-heart-o fa-2x text-gray-500 cursor-pointer hover:text-red-500"></i>` } (<span id="like-count-${post.id}" class="mx-0 !mx-0">${post.image_likes.length}</span>) </div> ~ </div> </article> `; }
22. 좋아요 기능 - JS로 HTML 다루기
(1) Debounce 함수 적용
디바운스(debounce)란 사용자의 빠른 연속 클릭을 방지하는 기법으로, 특정 시간 동안 추가 클릭이 발생하면 이전 요청을 취소하고 마지막 요청만 실행하도록 한다.
이를 통해 불필요한 서버 요청을 줄이고 성능을 개선할 수 있다.
like.js
function debounce(func, delay) { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => func.apply(this, args), delay); }; }
이 함수는 다음과 같은 방식으로 작동한다:
- 사용자가 버튼을 클릭하면 debounce 함수가 실행된다.
- clearTimeout(timer)을 호출하여 이전에 설정된 타이머를 제거한다.
- 새로운 타이머를 설정하여 일정 시간이 지나면 (delay 이후) 실제 함수(func)가 실행된다.
- 이 과정에서 사용자가 일정 시간 내에 다시 클릭하면 이전 타이머가 제거되고 새로운 타이머가 설정되어 중복 호출을 방지한다.
(2) 좋아요 버튼 클릭 핸들러
버튼을 클릭하면 API 요청을 보내고, 응답을 받은 후 UI를 업데이트한다.
django_instagram/static/js/posts/loadMorePosts.js
async function handleLikeClick(postId, csrfToken, target) { const likeButton = target; if (likeButton.disabled) return; likeButton.disabled = true; try { const response = await fetch(`/api/posts/${postId}/like/`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, }); const data = await response.json(); const iTag = likeButton.querySelector("i"); if (data.liked) { iTag.classList.replace("fa-heart-o", "fa-heart"); iTag.classList.replace("text-gray-500", "text-red-500"); } else { iTag.classList.replace("fa-heart", "fa-heart-o"); iTag.classList.replace("text-red-500", "text-gray-500"); } likeButton.querySelector("#like-count-" + postId).innerText = data.like_count; } catch (error) { console.error("Error liking post:", error); } finally { likeButton.disabled = false; } } const debouncedHandleLikeClick = debounce(handleLikeClick, 300);
(3) HTML에서 버튼에 적용
각 게시글의 좋아요 버튼에 이벤트 리스너를 추가하여 디바운스된 handleLikeClick 함수를 호출한다.
<!-- 설명 및 좋아요 --> <div class="p-4"> <div class="flex items-center space-x-3" id="like-button-${post.id}" onclick="debouncedHandleLikeClick('${post.id}', '${post.csrf_token}', this)"> ${post.image_likes.includes(Number(loginUser.id)) ? `<i class="fa fa-heart fa-2x text-red-500 cursor-pointer hover:text-red-500"></i>` : `<i class="fa fa-heart-o fa-2x text-gray-500 cursor-pointer hover:text-red-500"></i>` } (<span id="like-count-${post.id}" class="mx-0 !mx-0">${post.image_likes.length}</span>) </div>
23. 좋아요 기능 - API
django_instagram/posts/models.py
#게시글 class Post(TimeStampedModel): author = models.ForeignKey( user_models.User, null=True, #여기서 null 은 데이터 베이스에 관련된 null 허용 여부 on_delete=models.CASCADE, related_name='post_author', verbose_name=_("작성자") ) image = models.ImageField(_("이미지"), upload_to="posts/", blank=False) caption = models.TextField(_("내용"), blank=False)#여기서 blank 유효성 검사를 위한 허용여부 image_likes = models.ManyToManyField( user_models.User, blank=True, related_name='post_image_likes', verbose_name=_("좋아요") ) """ 1)가독성 향상: 관리 화면(admin)에서 모델 객체가 기본적으로 Post object (1) 또는 Comment object (1)처럼 보일 수 있는데, __str__을 정의하면 더 읽기 쉬운 형태로 나타납니다. 2)디버깅 편의성 : 객체를 디버깅하거나 로깅할 때, 객체의 의미 있는 정보를 바로 확인할 수 있습니다. """ def __str__(self): return f"{self.author} : {self.caption}" class Meta: verbose_name = _("게시물") verbose_name_plural = _("게시물들") ordering = ['-created_at'] # 최신 게시물이 먼저 오도록 기본 정렬
django_instagram/posts/api/urls.py
from django.urls import path from .views import PostListView ,PostLikeView from . import views app_name = "posts_api" # 네임스페이스 충돌 방지 urlpatterns = [ # 게시글 목록 # get : http://localhost:8000/api/posts/ path("posts/", PostListView.as_view(), name="posts-list"), # /api/posts/ # get : http://localhost:8000/api/posts/list path("posts/list", views.posts_list_view , name="posts-list"), # /api/posts/list # 좋아요 추가 및 취소 # post : http://localhost:8000/api/1/post_like path('posts/<int:post_id>/post_like/', PostLikeView.as_view(), name='post_like'), ]
django_instagram/posts/api/views.py
from django.shortcuts import get_object_or_404 from django_instagram.users.models import User as user_model from rest_framework.response import Response from rest_framework import status, generics, permissions from django_instagram.posts.models import Post from .serializers import PostSerializer , CommentFormSerializer ,UserSerializer from django.db.models import Q from django_instagram.posts.forms import CommentForm from django_instagram.posts import models from django.http import JsonResponse from rest_framework.decorators import api_view from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_POST #1) Django REST Framework(DRF)의 generics.ListAPIView 는 class PostListView(generics.ListAPIView): serializer_class = PostSerializer permission_classes = [permissions.IsAuthenticated] # 로그인한 사용자만 접근 가능 def get_queryset(self): """현재 사용자의 게시글과 팔로잉한 사용자의 게시글을 가져옴""" user = get_object_or_404(user_model, pk=self.request.user.id) following = user.following.all() return Post.objects.filter(Q(author__in=following) | Q(author=user)).order_by('-created_at') def list(self, request, *args, **kwargs): queryset = self.get_queryset() serializer = self.get_serializer(queryset, many=True, context={'request': request}) # 현재 로그인한 사용자 정보를 UserSerializer로 변환 login_user_serializer = UserSerializer(request.user, context={'request': request}) #ListAPIView는 DRF 기반이므로 Response를 사용 return Response({"posts": serializer.data, "loginUser": login_user_serializer.data}, status=status.HTTP_200_OK) #2) Django의 일반 함수형 뷰(FBV, Function-Based View) @api_view(['GET']) def posts_list_view(request): if request.method == 'GET': user = get_object_or_404(user_model, pk=request.user.id) following = user.following.all() # 게시글 필터링 __ 언더바 포함의미 caption__contains ==>캡션 포함이 되어 있는 것 followed_posts = models.Post.objects.filter(Q(author__in=following) | Q(author=user)).order_by('-created_at') # 시리얼라이저로 데이터 변환 serializer = PostSerializer(followed_posts, many=True, context={'request': request}) # 현재 로그인한 사용자 정보를 UserSerializer로 변환 login_user_serializer = UserSerializer(request.user, context={'request': request}) return JsonResponse({"posts": serializer.data,"loginUser": login_user_serializer.data}, status=200) # # FBV : 일반 함수 장고 뷰방식 :좋아요 추가 및 취소 @require_POST @login_required def post_like(request, post_id): post = get_object_or_404(models.Post, pk=post_id) try: # ManyToManyField 에 한에서 기본적으로 중복 관계를 허용하지 않으므로, filter(pk=...)와 # .exists()만으로 충분히 유니크한 관계를 확인할 수 있습니다. if post.image_likes.filter(pk=request.user.pk).exists(): post.image_likes.remove(request.user) is_added = False else: post.image_likes.add(request.user) is_added = True return JsonResponse({"success": True,"message": "like" if is_added else "dislike", "like_count": post.image_likes.count(),}, status=200) except Exception as e: return JsonResponse({"success": False, "message": "오류가 발생했습니다.", "error": str(e)}, status=500) # DRF : 좋아요 추가 및 취소 class PostLikeView(generics.GenericAPIView): permission_classes = [permissions.IsAuthenticated] def post(self, request, post_id): """좋아요 추가 및 취소 기능""" post = get_object_or_404(models.Post, pk=post_id) user =request.user if post.image_likes.filter(pk=user.pk).exists(): post.image_likes.remove(user) is_added = False else: post.image_likes.add(user) is_added = True return Response({"success": True,"message": "like" if is_added else "dislike", "like_count": post.image_likes.count(),}, status=status.HTTP_200_OK)
24. 좋아요 기능 - ajax 호출, fetch, csrf
CSRF 토큰을 포함한 Serializer 으로 csrf 토큰을 같이 내보내기
목표: PostSerializer에서 csrf_token을 필드로 추가하여, Post와 함께 CSRF 토큰을 반환합니다.
- CSRF 토큰은 웹 애플리케이션에서 Cross-Site Request Forgery 공격을 방지하기 위해 사용되며, 클라이언트 측에서 보내는 요청이 유효한지 확인하는 데 필요합니다.
필요한 것:
- csrf_token을 가져오기 위해 Django의 django.middleware.csrf.get_token() 함수를 사용합니다.
- get_token(request)을 통해 현재 요청(request)의 CSRF 토큰을 가져옵니다.
1. PostSerializer 정의
from django.middleware.csrf import get_token class PostSerializer(serializers.ModelSerializer): comment_post = CommentSerializer(many=True) # 댓글 정보 author = FeedAuthorSerializer() # 게시물 작성자 정보 csrf_token = serializers.SerializerMethodField() # CSRF 토큰 class Meta: model = models.Post fields = ( "id", "image", "caption", "image_likes", "author", "comment_post", "csrf_token", # CSRF 토큰 추가 ) def get_csrf_token(self, obj): # 요청 객체에서 CSRF 토큰을 가져오기 request = self.context.get('request') # 요청 객체를 context에서 가져옴 return get_token(request) if request else None # 요청이 있을 경우 CSRF 토큰 반환
2. 설명
csrf_token = serializers.SerializerMethodField():
- 이 필드는 PostSerializer 내에 수동으로 계산된 값을 포함합니다.
- get_csrf_token() 메서드는 CSRF 토큰을 반환합니다. 이 메서드는 요청 객체(request)를 context에서 가져와서 CSRF 토큰을 추출합니다.
get_csrf_token(self, obj):
- self.context.get('request')는 현재 요청 객체를 가져옵니다.
- get_token(request)를 통해 요청에 포함된 CSRF 토큰을 반환합니다.
- 요청 객체가 없으면 None을 반환합니다.
3. 컨텍스트 설정
- PostSerializer를 사용하는 뷰나 뷰셋에서 context에 request 객체를 추가해야 CSRF 토큰을 제대로 가져올 수 있습니다.
뷰에서의 예시 (Context에 request 추가):
class PostViewSet(viewsets.ModelViewSet): queryset = models.Post.objects.all() serializer_class = PostSerializer def get_serializer_context(self): context = super().get_serializer_context() context['request'] = self.request # 요청 객체를 context에 추가 return context
- get_serializer_context() 메서드에서 request 객체를 context에 추가합니다. 이렇게 함으로써 PostSerializer 내에서 request 객체를 활용하여 CSRF 토큰을 가져올 수 있습니다.
요약:
- PostSerializer에서 CSRF 토큰을 포함하려면, SerializerMethodField를 사용하여 get_csrf_token() 메서드로 CSRF 토큰을 추출하고, 이를 context에서 요청 객체를 가져와 처리해야 합니다.
- 뷰에서 context['request'] = self.request를 통해 request 객체를 PostSerializer에 전달해야 합니다.
이 방식으로 PostSerializer에서 CSRF 토큰을 포함한 JSON 응답을 반환할 수 있습니다.
django_instagram/static/js/posts/loadMorePosts.js
//좋아요! 좋아요취소! async function handleLikeClick(postId,csrfToken, target){ console.log("handleLikeClick : ",postId,csrfToken, target); const iTag=target.querySelector("i"); const likeButton = target; // 버튼 자체 if (likeButton.disabled) { return; // 이미 처리 중이면 중복 요청 방지 } likeButton.disabled = true; // 버튼 비활성화 try { const response = await fetch(`/api/posts/${postId}/post_like/`, { method: "POST", headers: { "X-CSRFToken": csrfToken, }, }); const data = await response.json(); console.log("response : ", data); if (!response.ok || !data.success) { console.log(" 좋아요 API 호출 중 오류가 발생했습니다.: " , data.error); throw new Error(data.message || "좋아요 API 호출 중 오류가 발생했습니다."); } if(data.message==="like"){ //좋아요! iTag.classList.replace("fa-heart-o", "fa-heart"); iTag.classList.replace("text-gray-500", "text-red-500"); }else{ //좋아요 취소! iTag.classList.replace("fa-heart", "fa-heart-o"); iTag.classList.replace("text-red-500", "text-gray-500"); } target.querySelector("#like-count-"+postId).innerText=data.like_count; } catch (error) { console.error("Error liking post:", error); } finally { likeButton.disabled = false; // 버튼 활성화 } }
25. 검색 기능 - query string
django_instagram/posts/api/urls.py
from django.urls import path from .views import PostAllListView ,PostListView ,PostLikeView from . import views app_name = "posts_api" # 네임스페이스 충돌 방지 #1.현재 사용자의 게시글과 전체 최신 게시글 #2.현재 사용자의 게시글 팔로잉 게시글 urlpatterns = [ # 1.현재 사용자의 게시글과 전체 최신 게시글 # get : http://localhost:8000/api/posts/ path("all/posts/", PostAllListView.as_view(), name="api_posts"), # /api/posts/ # 2.현재 사용자의 게시글 팔로잉 게시글 # get : http://localhost:8000/api/posts/ path("posts/", PostListView.as_view(), name="api_posts"), # /api/posts/ # get : http://localhost:8000/api/posts/list path("posts/list", views.posts_list_view , name="api_posts_list"), # /api/posts/list # /api/posts/searchList path('posts/searchList/', views.posts_search_list, name='api_posts_searchList'), # 좋아요 추가 및 취소 # post : http://localhost:8000/api/1/post_like path('posts/<int:post_id>/post_like/', PostLikeView.as_view(), name='post_like'), ]
django_instagram/posts/api/views.py
from django.shortcuts import get_object_or_404 from django_instagram.users.models import User as user_model from rest_framework.response import Response from rest_framework import status, generics, permissions from django_instagram.posts.models import Post from .serializers import PostSerializer , CommentFormSerializer ,UserSerializer from django.db.models import Q, Count from django_instagram.posts.forms import CommentForm from django_instagram.posts import models from django.http import JsonResponse from rest_framework.decorators import api_view from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_POST from django.core.paginator import Paginator # 1.전체 게시글을 가져오되, class PostAllListView(generics.ListAPIView): serializer_class = PostSerializer permission_classes = [permissions.IsAuthenticated] def get_queryset(self): """현재 사용자의 게시글을 먼저 가져오고, 나머지 최신 게시글을 포함하여 정렬""" user = get_object_or_404(user_model, pk=self.request.user.id) search_keyword = self.request.GET.get('q', "") # 1️⃣ 현재 사용자의 게시글을 먼저 가져옴 user_posts = Post.objects.filter(author=user, caption__icontains=search_keyword) # 2️⃣ 현재 사용자의 게시글을 제외한 전체 최신 게시글을 가져옴 other_posts = Post.objects.filter(Q(caption__icontains=search_keyword)).exclude(author=user) # 3️⃣ 현재 사용자의 게시글을 먼저 배치하고, 최신순으로 정렬 #현재 사용자의 게시글을 우선적으로 정렬 #(user_posts | other_posts) #현재 사용자 글 제외한 글 queryset = ( other_posts).order_by( # 현재 사용자의 게시글을 먼저 정렬하고 최신순으로 정렬 '-author_id', # 현재 사용자의 글을 우선적으로 배치 (임시 정렬) '-created_at' # 최신 글 순서대로 정렬 ) return queryset def list(self, request, *args, **kwargs): queryset = self.get_queryset() # 4️⃣ 페이징 처리 page = self.request.GET.get('page', 1) page_size = self.request.GET.get('pageSize', 5) paginator = Paginator(queryset, page_size) page_obj = paginator.get_page(page) # 5️⃣ 직렬화 (JSON 변환) serializer = self.get_serializer(page_obj, many=True, context={'request': request}) login_user_serializer = UserSerializer(request.user, context={'request': request}) # 6️⃣ 응답 반환 return Response({ "posts": serializer.data, # 게시글 목록 "loginUser": login_user_serializer.data, # 현재 로그인한 사용자 정보 "has_next": page_obj.has_next(), # 다음 페이지 여부 "total_pages": paginator.num_pages # 총 페이지 수 }, status=status.HTTP_200_OK) #2.현재 사용자의 게시글 팔로잉 게시글 # http://localhost:8000/posts/ #1) 목록 : Django REST Framework(DRF)의 generics.ListAPIView 는class PostListView(generics.ListAPIView): class PostListView(generics.ListAPIView): serializer_class = PostSerializer permission_classes = [permissions.IsAuthenticated] def get_queryset(self): """현재 사용자의 게시글을 우선적으로 가져오고, 좋아요가 많고 최신 게시물을 포함하여 정렬""" user = get_object_or_404(user_model, pk=self.request.user.id) search_keyword = self.request.GET.get('q', "") following = user.following.all() # 현재 사용자의 게시글과 팔로잉 유저의 게시글을 DB에서 필터링 queryset = Post.objects.filter(Q(author=user) | Q(author__in=following),caption__icontains=search_keyword ).annotate(like_count=Count('image_likes')).order_by('-author', '-like_count', '-created_at') return queryset def list(self, request, *args, **kwargs): queryset = self.get_queryset() # ✅ 여기서 DB에 실제로 쿼리가 실행됨 (LIMIT 적용) page = self.request.GET.get('page', 1) page_size = self.request.GET.get('pageSize', 5) paginator = Paginator(queryset, page_size) page_obj = paginator.get_page(page) # ✅ 여기서 DB에서 데이터를 가져옴 # 시리얼라이저로 데이터 변환- ❌ get_serializer() 자체는 쿼리를 실행하지 않음 serializer = self.get_serializer(page_obj, many=True, context={'request': request}) #로그인한 유저 정보 가져오기 login_user_serializer = UserSerializer(request.user, context={'request': request}) return Response({ "posts": serializer.data, "loginUser": login_user_serializer.data, "has_next": page_obj.has_next(), "total_pages": paginator.num_pages }, status=status.HTTP_200_OK)
django_instagram/static/js/posts/loadMorePosts.js
<!-- Lodash를 먼저 로드 --> <script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
let page = 1; // 현재 페이지 let loading = false; // 로딩 중 여부 const container = document.querySelector('#postList'); // 게시글 컨테이너 let postUrl="/api/posts/"; const loadMorePosts = async () => { if (loading) return; loading = true; try { const q=document.querySelector('#q').value.trim(); // 검색어 가져오기 console.log("검색어 : ",q); const response = await fetch(`${postUrl}?format=json&page=${page}&q=${q}&pageSize=3`); const data = await response.json(); // 데이터 추가\ data.posts.forEach(post => { const postElement =postsHtmlTemplate(post, data.loginUser); container.insertAdjacentHTML('beforeend', postElement); }); if (!data.has_next) { window.removeEventListener('scroll', handleScroll); // 더 이상 로드하지 않음 } page += 1; // 다음 페이지 } catch (error) { console.error("Error loading posts:", error); } finally { loading = false; } };
//검색 처리 function searchOnEnter(event) { if (event.key === "Enter") { event.preventDefault(); // Enter 키 동작만 기본 동작을 막음 searchInstagram(); } } //버튼 클릭 검색 처리 function searchInstagram(){ document.querySelector("#postList").innerHTML = ""; page = 1; window.addEventListener('scroll', handleScroll); loadMorePosts(); } //로데시 const handleScroll = _.throttle(() => { const { scrollTop, scrollHeight, clientHeight } = document.documentElement; if (scrollTop + clientHeight >= scrollHeight - 10) { loadMorePosts(); } }, 200); // 200ms마다 실행 window.addEventListener('scroll', handleScroll); loadMorePosts(); // 처음 로드
각 부분의 역할
1️⃣ handleScroll 함수
- document.documentElement를 사용하여 현재 페이지의 스크롤 위치를 계산함.
- scrollTop: 현재 문서의 가장 위에서부터 스크롤된 거리.
- clientHeight: 현재 화면에 보이는 높이 (뷰포트 높이).
- scrollHeight: 전체 문서의 높이 (스크롤 가능한 전체 영역).
- scrollTop + clientHeight: 현재 화면의 맨 아래 위치를 의미.
- scrollHeight - 10: 거의 맨 아래에 도달했는지 확인하기 위해 여유 공간(10px) 설정.
- 즉, 사용자가 화면 아래에서 10px 이내에 도달하면 loadMorePosts() 호출하여 게시물 추가 로드.
2️⃣ window.addEventListener('scroll', handleScroll);
- 스크롤 이벤트 리스너를 추가하여 사용자가 스크롤할 때마다 handleScroll 함수 실행.
3️⃣ loadMorePosts(); (초기 실행)
- 처음 페이지가 로드될 때 최초 데이터 로드를 수행함.
작동 방식
- 사용자가 페이지에 접속하면 loadMorePosts();를 호출하여 초기 게시물 로드.
- 사용자가 아래로 스크롤하면 handleScroll 함수가 실행됨.
- 화면의 맨 아래에서 10px 이내에 도달하면 loadMorePosts()가 호출됨.
- 새로운 게시물이 추가 로드되고, 다시 같은 과정 반복됨.
. 검색 기능 구현 (searchOnEnter & searchInstagram)
searchOnEnter(event)
- 사용자가 Enter 키를 눌렀을 때 실행됨.
- event.preventDefault();를 사용하여 Enter 키 기본 동작(예: 폼 제출)을 막음.
- searchInstagram();을 호출하여 검색을 실행.
searchInstagram()
- #postList 요소의 innerHTML을 비워서 기존 검색 결과를 지움.
- page 변수를 1로 초기화하여 첫 페이지부터 다시 불러오도록 설정.
- window.addEventListener('scroll', handleScroll);를 통해 스크롤 이벤트 리스너를 추가.
- loadMorePosts();를 호출하여 첫 번째 페이지의 데이터를 로드.
2. 무한 스크롤 기능 (handleScroll)
handleScroll
- _.throttle()을 사용하여 200ms마다 실행되도록 제한.
- scrollTop + clientHeight >= scrollHeight - 10 조건을 체크하여 사용자가 거의 페이지 끝까지 스크롤했는지 확인.
- 조건이 충족되면 loadMorePosts();를 호출하여 추가 데이터를 로드.
3. 이벤트 리스너 및 첫 번째 데이터 로드
- window.addEventListener('scroll', handleScroll);을 통해 무한 스크롤을 활성화.
- loadMorePosts();를 호출하여 페이지 로드 시 첫 번째 데이터를 가져옴.
26. 상용으로 배포 - heroku, aws s3
댓글 ( 0)
댓글 남기기