좋아요(M:N Article-User), 팔로우(M:N User-User), Fixtures 활용, N+1 문제 해결까지 한 번에 정리
# articles/models.py
class Article(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE
)
like_users = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name='like_articles'
)
충돌 원인
ForeignKey(작성자)와 ManyToManyField(좋아요) 모두 User ↔ Article 관계article_set이 중복 → Django가 어떤 관계인지 구분 불가해결 전략
| 관계 종류 | 성격 | 역참조 이름 | 변경 여부 |
|---|---|---|---|
ForeignKey (작성자) | 소유 관계 | user.article_set | 기본값 유지 |
ManyToManyField (좋아요) | 행위 관계 | user.like_articles | related_name 명시 |
핵심적인 '소유' 관계 기본값 유지, 부가적인 '행위' 관계에 구체적 이름 부여
| 표현 | 의미 | 관계 |
|---|---|---|
article.user | 게시글 작성자 | N:1 정참조 |
user.article_set | 유저가 작성한 게시글 | N:1 역참조 |
article.like_users | 게시글에 좋아요한 유저 | M:N 정참조 |
user.like_articles | 유저가 좋아요한 게시글 | M:N 역참조 |
# articles/urls.py
app_name = 'articles'
urlpatterns = [
...,
path('<int:article_pk>/likes/', views.likes, name='likes'),
]
article_pk를 variable routing으로 전달# articles/views.py
@login_required
def likes(request, article_pk):
article = Article.objects.get(pk=article_pk)
if article.like_users.filter(pk=request.user.pk).exists():
article.like_users.remove(request.user) # 이미 좋아요 → 취소
else:
article.like_users.add(request.user) # 아직 좋아요 안 함 → 추가
return redirect('articles:index')
.exists() 사용으로 전체 QuerySet 평가 없이 존재 여부만 확인 → 성능 향상request.user in article.like_users.all() 대비 효율적<!-- articles/index.html -->
{% for article in articles %}
...
<form action="{% url 'articles:likes' article.pk %}" method="POST">
{% csrf_token %}
{% if request.user in article.like_users.all %}
<input type="submit" value="좋아요 취소">
{% else %}
<input type="submit" value="좋아요">
{% endif %}
</form>
<hr>
{% endfor %}
{% if request.user in article.like_users.all %} 으로 현재 유저의 좋아요 여부 판단# accounts/urls.py
app_name = 'accounts'
urlpatterns = [
...,
path('profile/<username>/', views.profile, name='profile'),
]
username을 variable routing으로 전달@login_required 미적용# accounts/views.py
from django.contrib.auth import get_user_model
def profile(request, username):
User = get_user_model()
person = User.objects.get(username=username)
context = {
'person': person,
}
return render(request, 'accounts/profile.html', context)
get_user_model() 사용 필수 (User 모델 직접 import 금지)username으로 유저 조회 후 템플릿에 전달<!-- accounts/profile.html -->
<h1>{{ person.username }}님의 프로필</h1>
<hr>
<h2>{{ person.username }}가 작성한 게시글</h2>
{% for article in person.article_set.all %}
<div>{{ article.title }}</div>
{% endfor %}
<hr>
<h2>{{ person.username }}가 작성한 댓글</h2>
{% for comment in person.comment_set.all %}
<div>{{ comment.content }}</div>
{% endfor %}
<hr>
<h2>{{ person.username }}가 좋아요한 게시글</h2>
{% for article in person.like_articles.all %}
<div>{{ article.title }}</div>
{% endfor %}
<!-- articles/index.html -->
<!-- 내 프로필 링크 -->
<a href="{% url 'accounts:profile' user.username %}">내 프로필</a>
<!-- 게시글 작성자 → 해당 유저 프로필 링크 -->
{% for article in articles %}
<p>작성자 : <a href="{% url 'accounts:profile' article.user.username %}">
{{ article.user }}</a></p>
{% endfor %}
# accounts/models.py
class User(AbstractUser):
followings = models.ManyToManyField('self', symmetrical=False, related_name='followers')
'self' 사용 가능 (자기 자신 참조)symmetrical=False 필수 → 팔로우는 단방향 관계 (A→B라도 B→A 자동 성립 안 됨)| 필드/속성 | 의미 |
|---|---|
followings | 내가 팔로우하는 사람들 (정참조) |
followers | 나를 팔로우하는 사람들 (역참조, related_name) |
Migration 후 중개 테이블 생성 확인 필요
# accounts/urls.py
urlpatterns = [
...,
path('<int:user_pk>/follow/', views.follow, name='follow'),
]
# accounts/views.py
@login_required
def follow(request, user_pk):
User = get_user_model()
person = User.objects.get(pk=user_pk)
if person != request.user: # 자기 자신은 팔로우 불가
if person.followers.filter(pk=request.user.pk).exists():
person.followers.remove(request.user) # 이미 팔로우 → 언팔로우
else:
person.followers.add(request.user) # 미팔로우 → 팔로우
return redirect('accounts:profile', person.username)
person != request.user 조건으로 자기 자신 팔로우 방지.exists() 사용으로 쿼리 효율화<!-- accounts/profile.html -->
<h1>{{ person.username }}님의 프로필</h1>
<div>
팔로잉 : {{ person.followings.all|length }} / 팔로워 : {{ person.followers.all|length }}
</div>
{% if request.user != person %}
<form action="{% url 'accounts:follow' person.pk %}" method="POST">
{% csrf_token %}
{% if request.user in person.followers.all %}
<input type="submit" value="Unfollow">
{% else %}
<input type="submit" value="Follow">
{% endif %}
</form>
{% endif %}
{% if request.user != person %} — 자기 자신 프로필에선 팔로우 버튼 숨김|length 필터로 팔로잉·팔로워 수 출력| 목적 | 설명 |
|---|---|
| 초기 데이터 세팅 | 서비스 시작 시 필요한 기본 데이터 (권한 그룹, 카테고리 등) 미리 세팅 |
| 테스트 샘플 준비 | 항상 동일하고 예측 가능한 데이터 환경 구축 |
| 협업 환경 통일 | 팀원 간 동일한 초기 데이터 공유 및 적용 |
# 기본 문법
python manage.py dumpdata [앱이름.모델이름] [옵션] > 파일명.json
# 예시: Article 모델만 추출, 들여쓰기 4칸
python manage.py dumpdata --indent 4 articles.article > articles.json
# 여러 모델을 파일 1개로 추출
python manage.py dumpdata --indent 4 articles.article articles.comment accounts.user > data.json
# 전체 모델 추출
python manage.py dumpdata --indent 4 > data.json
| 옵션 | 동작 |
|---|---|
앱이름 만 지정 | 해당 앱 전체 모델 추출 |
앱이름.모델이름 지정 | 특정 모델만 추출 |
| 지정 없음 | 프로젝트 전체 추출 |
--indent N | JSON 들여쓰기 N칸 |
--format yaml 등 | 출력 형식 지정 (기본값: JSON) |
전체 추출 시 파일 용량이 커질 수 있으므로, 변경이 잦은 경우 앱/모델 단위로 관리 권장
# 기본 문법
python manage.py loaddata 파일경로
# 순서를 지켜 개별 실행
python manage.py loaddata users.json
python manage.py loaddata articles.json
python manage.py loaddata comments.json
app_name/fixtures/fixtures/ 폴더에서 자동 탐색comment → article의 FK + user의 FK 필요
article → user의 FK 필요
user → 의존성 없음
user → article → comment| 항목 | 내용 |
|---|---|
| 마이그레이션 | loaddata 전 해당 모델 migration 완료 필수 |
| PK 중복 | 동일 PK 데이터 존재 시 에러 발생 → 기존 데이터 삭제 또는 새 Fixture 사용 |
| 인코딩 에러 | OS 환경 간 인코딩 불일치 시 UnicodeDecodeError 발생 가능 |
# dumpdata 시 UTF-8 명시
python -Xutf8 manage.py dumpdata [생략]
Count, Sum, Avg, Max, Min 등 집계 함수와 함께 사용GROUP BY 활용적용 전 (N+1)
<!-- 게시글마다 댓글 개수를 DB에 별도 요청 -->
<p>댓글 개수: {{ article.comment_set.count }}</p>
<!-- 결과: 11 queries (게시글 1 + 댓글 수 조회 10) -->
적용 후
# articles/views.py
articles = Article.objects.annotate(comment_count=Count('comment')).order_by('-pk')
<p>댓글 개수: {{ article.comment_count }}</p>
<!-- 결과: 1 query -->
comment_count 라는 새 필드를 각 게시글 객체에 추가하여 반환article.comment_count 로 접근 가능comment__count 로 자동 생성INNER JOIN 수행 → 단일 쿼리로 관련 객체까지 한 번에 조회적용 전 (N+1)
{% for article in articles %}
<h3>작성자 : {{ article.user.username }}</h3>
{% endfor %}
<!-- 결과: 11 queries including 10 similar and 8 duplicates -->
적용 후
# articles/views.py
def index(request):
articles = Article.objects.select_related('user').order_by('-pk')
...
결과: 11 queries → 1 query
book.publisher.name 처럼 JOIN된 객체 접근 시 추가 쿼리 불필요적용 전 (N+1)
{% for article in articles %}
{% for comment in article.comment_set.all %}
<p>{{ comment.content }}</p>
{% endfor %}
{% endfor %}
<!-- 결과: 11 queries including 10 similar -->
적용 후
# articles/views.py
def index(request):
articles = Article.objects.prefetch_related('comment_set').order_by('-pk')
...
결과: 11 queries → 2 queries
(게시글 1 + 댓글 전체 1 = 2)
문제 상황
{% for article in articles %}
{% for comment in article.comment_set.all %}
<p>{{ comment.user.username }} : {{ comment.content }}</p>
{% endfor %}
{% endfor %}
<!-- 게시글 → 댓글 목록 → 댓글 작성자 3단계 중첩 조회 -->
<!-- 결과: 111 queries including 110 similar and 100 duplicates -->
prefetch_related만 적용
articles = Article.objects.prefetch_related('comment_set').order_by('-pk')
# 결과: 102 queries (댓글 작성자 쿼리는 여전히 중복 발생)
select_related + prefetch_related 병행 적용
from django.db.models import Prefetch
def index(request):
articles = Article.objects.prefetch_related(
Prefetch('comment_set', queryset=Comment.objects.select_related('user'))
).order_by('-pk')
context = {'articles': articles}
return render(request, 'articles/index.html', context)
# 결과: 102 queries → 2 queries
Prefetch() 객체로 댓글 조회 시 select_related('user')를 함께 적용| 메서드 | 적용 관계 | 동작 방식 | 쿼리 수 |
|---|---|---|---|
annotate | 집계 계산 필드 추가 | SQL GROUP BY | 1 |
select_related | ForeignKey, OneToOne | SQL INNER JOIN | 1 |
prefetch_related | M:N, 역참조 | Python JOIN (별도 쿼리) | 2 |
.exists() 메서드QuerySet.exists() # → True / False 반환
True, 없으면 False좋아요에 적용
# Before
if request.user in article.like_users.all():
# After
if article.like_users.filter(pk=request.user.pk).exists():
팔로우에 적용
# Before
if request.user in person.followers.all():
# After
if person.followers.filter(pk=request.user.pk).exists():
.filter().exists()패턴은in ... .all()패턴 대비 불필요한 전체 데이터 로딩을 방지