[Django] N:M (2)

송수빈·2026년 6월 1일

SSAFY

목록 보기
17/18

Django Many to Many Relationships (M:N)

좋아요(M:N Article-User), 팔로우(M:N User-User), Fixtures 활용, N+1 문제 해결까지 한 번에 정리


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'
    )
  • Article(M) ↔ User(N) 다대다 관계
  • 게시글에 좋아요가 없을 수도, 여러 개 존재할 수도 있음
  • 유저도 좋아요를 하나도 안 누를 수도, 여러 게시글에 누를 수도 있음

역참조 충돌 문제와 해결

충돌 원인

  • ForeignKey(작성자)와 ManyToManyField(좋아요) 모두 User ↔ Article 관계
  • 역참조 기본값 article_set이 중복 → Django가 어떤 관계인지 구분 불가

해결 전략

관계 종류성격역참조 이름변경 여부
ForeignKey (작성자)소유 관계user.article_set기본값 유지
ManyToManyField (좋아요)행위 관계user.like_articlesrelated_name 명시

핵심적인 '소유' 관계 기본값 유지, 부가적인 '행위' 관계에 구체적 이름 부여

표현의미관계
article.user게시글 작성자N:1 정참조
user.article_set유저가 작성한 게시글N:1 역참조
article.like_users게시글에 좋아요한 유저M:N 정참조
user.like_articles유저가 좋아요한 게시글M:N 역참조

URL 설정

# articles/urls.py
app_name = 'articles'
urlpatterns = [
    ...,
    path('<int:article_pk>/likes/', views.likes, name='likes'),
]
  • 어떤 게시글인지 특정하기 위해 article_pk를 variable routing으로 전달

View 함수

# 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 %} 으로 현재 유저의 좋아요 여부 판단
  • 상태에 따라 버튼 텍스트 동적 변경

2. 팔로우 기능 구현

프로필 페이지 구현

  • 팔로우 기능 구현 전 각 유저의 개인 페이지(프로필) 필요
  • 프로필에 표시할 정보
    • 해당 유저가 작성한 게시글 목록
    • 해당 유저가 작성한 댓글 목록
    • 해당 유저가 좋아요한 게시글 목록

URL 설정

# accounts/urls.py
app_name = 'accounts'
urlpatterns = [
    ...,
    path('profile/<username>/', views.profile, name='profile'),
]
  • 사용자 username을 variable routing으로 전달
  • 로그인한 유저뿐 아니라 다른 사람의 프로필도 조회 가능해야 하므로 @login_required 미적용

View 함수

# 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 금지)
  • URL로 전달된 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 %}

index 페이지에 프로필 링크 추가

<!-- 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')
  • User(M) ↔ User(N) 자기 자신과의 다대다 관계
  • 'self' 사용 가능 (자기 자신 참조)
  • symmetrical=False 필수 → 팔로우는 단방향 관계 (A→B라도 B→A 자동 성립 안 됨)
필드/속성의미
followings내가 팔로우하는 사람들 (정참조)
followers나를 팔로우하는 사람들 (역참조, related_name)

Migration 후 중개 테이블 생성 확인 필요


URL 및 View 구현

URL

# accounts/urls.py
urlpatterns = [
    ...,
    path('<int:user_pk>/follow/', views.follow, name='follow'),
]

View 함수

# 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 필터로 팔로잉·팔로워 수 출력

3. Fixtures

개념 및 목적

  • Django 개발 시 데이터베이스 초기화 및 공유를 위해 사용하는 파일 형식 (주로 JSON)
목적설명
초기 데이터 세팅서비스 시작 시 필요한 기본 데이터 (권한 그룹, 카테고리 등) 미리 세팅
테스트 샘플 준비항상 동일하고 예측 가능한 데이터 환경 구축
협업 환경 통일팀원 간 동일한 초기 데이터 공유 및 적용

dumpdata — DB → 파일

# 기본 문법
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 NJSON 들여쓰기 N칸
--format yaml 등출력 형식 지정 (기본값: JSON)

전체 추출 시 파일 용량이 커질 수 있으므로, 변경이 잦은 경우 앱/모델 단위로 관리 권장


loaddata — 파일 → DB

# 기본 문법
python manage.py loaddata 파일경로

# 순서를 지켜 개별 실행
python manage.py loaddata users.json
python manage.py loaddata articles.json
python manage.py loaddata comments.json
  • Fixtures 파일 기본 경로: app_name/fixtures/
  • Django가 설치된 모든 앱의 fixtures/ 폴더에서 자동 탐색

loaddata 실행 순서가 중요한 이유

comment → article의 FK + user의 FK 필요
article → user의 FK 필요
user    → 의존성 없음
  • 올바른 순서: user → article → comment
  • 한 번에 실행 시 Django가 자동으로 우선순위를 판단하므로 순서 무관
  • 별도로 실행할 경우 반드시 순서 준수 필요

loaddata 주의사항

항목내용
마이그레이션loaddata 전 해당 모델 migration 완료 필수
PK 중복동일 PK 데이터 존재 시 에러 발생 → 기존 데이터 삭제 또는 새 Fixture 사용
인코딩 에러OS 환경 간 인코딩 불일치 시 UnicodeDecodeError 발생 가능

인코딩 에러 해결법

# dumpdata 시 UTF-8 명시
python -Xutf8 manage.py dumpdata [생략]
  • 이미 추출된 파일이 있다면 에디터(VSCode 등)에서 인코딩을 UTF-8로 변경 후 재저장

4. 쿼리 최적화 (Improve Query)

N+1 Problem이란?

  • 데이터 1건을 조회하는 쿼리 1개 + 연관 데이터 N건을 각각 조회하는 쿼리 N개 = 총 N+1번 쿼리 발생
  • 게시글 10개 → 각 게시글 작성자/댓글 조회 쿼리 10개 추가 = 11번 쿼리
  • 사용자가 많아질수록 서버 부하 급증

annotate — 계산 필드 추가 (GROUP BY)

  • 쿼리셋의 각 객체에 집계 계산 필드를 추가하여 반환
  • Count, Sum, Avg, Max, Min 등 집계 함수와 함께 사용
  • SQL의 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 로 자동 생성

  • ForeignKey 또는 OneToOne 관계에서 사용
  • 내부적으로 SQL 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된 객체 접근 시 추가 쿼리 불필요

  • ManyToManyField 또는 역참조(M:1) 관계에서 사용
  • SQL JOIN 대신 Python에서 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')를 함께 적용
  • 댓글과 댓글 작성자 정보를 한 번에 JOIN하여 가져온 뒤, 게시글과 함께 prefetch

세 가지 최적화 메서드 비교

메서드적용 관계동작 방식쿼리 수
annotate집계 계산 필드 추가SQL GROUP BY1
select_relatedForeignKey, OneToOneSQL INNER JOIN1
prefetch_relatedM:N, 역참조Python JOIN (별도 쿼리)2

5. .exists() 메서드

QuerySet.exists()  # → True / False 반환
  • QuerySet에 결과가 하나라도 있으면 True, 없으면 False
  • 전체 QuerySet을 평가(로딩)하지 않고 존재 여부만 DB에서 확인 → 대량 데이터에서 효율적

좋아요에 적용

# 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() 패턴 대비 불필요한 전체 데이터 로딩을 방지

profile
🌱 🐜

0개의 댓글