장고로 구현하는 사용자 활동 추적 시스템

사용자 팔로우 시스템 구축

이 장에서는 소셜 기능의 핵심인 팔로우 시스템을 구현한다. 사용자가 다른 사용자를 팔로우하고, 팔로우한 사용자의 활동을 확인할 수 있는 기능을 만들어 볼 것이다.

중간 테이블을 활용한 다대다 관계 구현

장고의 ManyToManyField는 기본적으로 중개 테이블을 자동으로 생성하지만, 관계에 추가 정보(생성 시간, 관계 유형 등)를 저장해야 하는 경우 직접 중간 모델을 정의해야 한다.

팔로우 관계는 사용자와 사용자 사이의 다대다 관계이므로, 다음과 같은 이유로 중간 모델을 사용한다:

  • 기본 User 모델을 직접 수정하지 않고 확장 가능
  • 팔로우가 발생한 시점을 기록할 수 있음

account 애플리케이션의 models.py에 Contact 클래스를 추가한다:

class Follow(models.Model):
    follower = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name='following_set',
        on_delete=models.CASCADE
    )
    following = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name='follower_set',
        on_delete=models.CASCADE
    )
    followed_at = models.DateTimeField(auto_now_add=True, db_index=True)

    class Meta:
        ordering = ('-followed_at',)
        unique_together = ('follower', 'following')

    def __str__(self):
        return f'{self.follower} → {self.following}'

이 모델의 핵심 필드는 다음과 같다:

  • follower: 팔로우를 요청한 사용자
  • following: 팔로우 대상 사용자
  • followed_at: 팔로우 발생 시각 (자동 기록)

이제 User 모델에 직접 ManyToManyField를 추가하기보다는, 동적 필드 추가를 통해 팔로우 관계를 설정한다:

from django.contrib.auth.models import User

User.add_to_class(
    'follows',
    models.ManyToManyField(
        'self',
        through=Follow,
        related_name='followers',
        symmetrical=False
    )
)

여기서 symmetrical=False는 핵심 설정이다. 다대다 필드가 자기 자신을 참조할 때, 장고는 기본적으로 대칭 관계로 처리한다. 즉, A가 B를 팔로우하면 자동으로 B도 A를 팔로우하지만, 실제 소셜 네트워크에서는 이러한 동작이 부적절하다.

중간 테이블을 사용하는 경우 add(), create(), remove() 같은 기본 매니저 메서드를 직접 사용할 수 없으므로, Follow 모델을 직접 조작해야 한다.

사용자 목록 및 상세 페이지 구현

views.py에 사용자 목록과 상세 정보를 반환하는 뷰를 추가한다:

from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required

@login_required
def user_list(request):
    users = User.objects.filter(is_active=True).select_related('profile')
    return render(request, 'account/user_list.html', {'users': users})

@login_required
def user_detail(request, username):
    user = get_object_or_404(User, username=username, is_active=True)
    return render(request, 'account/user_detail.html', {'user': user})

URL 패턴은 다음과 같이 설정한다:

path('users/', views.user_list, name='user_list'),
path('users/<str:username>/', views.user_detail, name='user_detail'),

built-in User 모델에 get_absolute_url 메서드를 동적으로 추가하려면 settings.py에 다음을 추가한다:

from django.urls import reverse_lazy

ABSOLUTE_URL_OVERRIDES = {
    'auth.user': lambda u: reverse_lazy('user_detail', args=[u.username])
}

AJAX 기반 팔로우 기능 구현

사용자가 팔로우/언팔로우时可以 AJAX 요청으로 처리한다:

from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required

@login_required
@require_POST
def follow_user(request):
    user_id = request.POST.get('user_id')
    action = request.POST.get('action')
    
    if not user_id or not action:
        return JsonResponse({'status': 'error'})
    
    try:
        target_user = User.objects.get(id=user_id)
        
        if action == 'follow':
            Follow.objects.get_or_create(
                follower=request.user,
                following=target_user
            )
        else:
            Follow.objects.filter(
                follower=request.user,
                following=target_user
            ).delete()
            
        return JsonResponse({'status': 'ok'})
    except User.DoesNotExist:
        return JsonResponse({'status': 'error'})

클라이언트 사이드 JavaScript:

$('.follow-btn').on('click', function(e) {
    e.preventDefault();
    const userId = $(this).data('user-id');
    const action = $(this).data('action');
    
    $.post('/account/users/follow/', {
        user_id: userId,
        action: action
    }, function(response) {
        if (response.status === 'ok') {
            const newAction = action === 'follow' ? 'unfollow' : 'follow';
            $('.follow-btn')
                .data('action', newAction)
                .text(newAction === 'follow' ? '팔로우' : '언팔로우');
        }
    });
});

액션 스트림 애플리케이션 구축

사용자 활동 기록을 저장하고 표시하는 액션 스트림을 구현한다. Facebook의 뉴스 피드와 유사한 기능이다.

액션 모델 정의

사용자의 다양한 활동을 기록하기 위한 모델을 만든다:

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

class Activity(models.Model):
    actor = models.ForeignKey(
        User,
        related_name='activities',
        on_delete=models.CASCADE
    )
    verb = models.CharField(max_length=200)
    target_content_type = models.ForeignKey(
        'contenttypes.ContentType',
        blank=True,
        null=True,
        on_delete=models.CASCADE
    )
    target_object_id = models.PositiveIntegerField(
        blank=True,
        null=True
    )
    target = GenericForeignKey(
        'target_content_type',
        'target_object_id'
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ('-created_at',)
        indexes = [
            models.Index(fields=['-created_at']),
        ]

이 모델은GenericForeignKey를 사용하여 어떤 모델과도 연결될 수 있다. 활동 주체(actor)와 활동 동사(verb), 그리고 선택적 대상(target)을 저장한다.

ContentTypes 프레임워크 활용

장고의 contenttypes 앱은 프로젝트의 모든 모델을 추적한다. ContentType 객체를 얻는 방법:

from django.contrib.contenttypes.models import ContentType

# 방법 1: 앱 레이블과 모델 이름으로 조회
image_type = ContentType.objects.get(app_label='images', model='image')

# 방법 2: 모델 클래스로 직접 조회
from images.models import Image
image_type = ContentType.objects.get_for_model(Image)

# 모델 클래스 확인
image_type.model_class()  # <class 'images.models.Image'>

액션 생성 유틸리티

액션 생성 로직을 캡슐화한 유틸리티 함수를 만든다:

from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
import datetime

def log_activity(user, verb, target=None):
    # 중복 방지: 60초 내 동일한 활동 검사
    time_threshold = timezone.now() - datetime.timedelta(seconds=60)
    existing = Activity.objects.filter(
        actor=user,
        verb=verb,
        created_at__gte=time_threshold
    )
    
    if target:
        target_type = ContentType.objects.get_for_model(target)
        existing = existing.filter(
            target_content_type=target_type,
            target_object_id=target.id
        )
    
    if not existing.exists():
        activity = Activity.objects.create(
            actor=user,
            verb=verb,
            target=target
        )
        return True
    return False

이 함수는 60초 내의 중복된 활동을 필터링하여 불필요한 중복 레코드 생성을 방지한다.

액션 스트림 조회 최적화

쿼리 성능을 최적화하기 위해 select_related와 prefetch_related를 활용한다:

from django.db.models import Prefetch

def get_activity_feed(user, following_list):
    activities = Activity.objects.exclude(actor=user)
    
    if following_list:
        activities = activities.filter(actor_id__in=following_list)
    
    # select_related: ForeignKey/OneToOne 관계 최적화
    # prefetch_related: ManyToMany/역방향 ForeignKey 최적화
    return activities.select_related(
        'actor',
        'actor__profile'
    ).prefetch_related(
        'target_content_type'
    )[:20]

액션 스트림 템플릿

활동 기록을 표시하는 템플릿:

{% load thumbnail %}
<div class="activity-feed">
    {% for activity in activities %}
    <div class="activity-item">
        <div class="activity-actor">
            {% with actor_profile=activity.actor.profile %}
                {% if actor_profile.photo %}
                    {% thumbnail actor_profile.photo "50x50" crop="center" as img %}
                        <img src="{{ img.url }}">
                    {% endthumbnail %}
                {% endif %}
            </div>
            <div class="activity-content">
                <p>
                    <strong>{{ activity.actor.get_full_name }}</strong>
                    {{ activity.verb }}
                    {% if activity.target %}
                        <a href="{{ activity.target.get_absolute_url }}">{{ activity.target }}</a>
                    {% endif %}
                </p>
                <span class="timestamp">{{ activity.created_at|timesince }} 전</span>
            </div>
        </div>
    {% endfor %}
</div>

시그널을 활용한 데이터 비정규화

데이터베이스 조회 성능을 높이기 위해 비정규화를 적용한다. 구체적인 예로 Image 모델에 좋아요 수 필드를 추가하고, 시그널을 통해 자동 동기화한다.

Image 모델에計数 필드 추가

class Image(models.Model):
    title = models.CharField(max_length=200)
    image = models.ImageField(upload_to='images/')
    user = models.ForeignKey(User, related_name='uploaded_images')
    users_like = models.ManyToManyField(User, related_name='liked_images')
    
    # 비정규화된 필드
    like_count = models.PositiveIntegerField(default=0, db_index=True)
    
    def __str__(self):
        return self.title

m2m_changed 시그널 처리

ManyToMany 관계가 변경될 때마다 like_count를 업데이트하는 시그널 핸들러:

# images/signals.py
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from .models import Image

@receiver(m2m_changed, sender=Image.users_like.through)
def update_like_count(sender, instance, **kwargs):
    """
    사용자가 이미지를 좋아요/좋아요 해제할 때마다
    like_count 필드를 자동으로 업데이트
    """
    instance.like_count = instance.users_like.count()
    instance.save(update_fields=['like_count'])

apps.py의 ready 메서드에서 시그널을 로드한다:

# images/apps.py
from django.apps import AppConfig

class ImagesConfig(AppConfig):
    name = 'images'
    
    def ready(self):
        import images.signals

이제 이미지 목록을 좋아요 수로 정렬할 때 복잡한.annotate() 쿼리 대신 간단하게 할 수 있다:

# 이전 방식
images = Image.objects.annotate(
    total_likes=Count('users_like')
).order_by('-total_likes')

# 비정규화 후
images = Image.objects.order_by('-like_count')

기존 데이터 마이그레이션

이미 존재하는 이미지들의 like_count를 초기화해야 한다:

for image in Image.objects.all():
    image.like_count = image.users_like.count()
    image.save(update_fields=['like_count'])

레디스 데이터베이스 통합

레디스는 인메모리 키-값 저장소로,高速 데이터 접근이 필요한场景에 적합하다.

레디스 서버 실행

# 레디스 서버 시작
redis-server

# 클라이언트 연결
redis-cli

기본 포트는 6379이다.

레디스 설치 및 Django 설정

pip install redis

settings.py에 연결 설정 추가:

REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 1

이미지 조회수 저장

레디스를 활용하여 이미지 조회수를 효율적으로 추적한다:

import redis
from django.conf import settings

redis_client = redis.Redis(
    host=settings.REDIS_HOST,
    port=settings.REDIS_PORT,
    db=settings.REDIS_DB
)

def image_detail(request, image_id):
    image = get_object_or_404(Image, id=image_id)
    
    # 조회수 증가 (atomic 연산)
    view_key = f'image:{image_id}:views'
    total_views = redis_client.incr(view_key)
    
    #_sorted set에 순위 정보 저장
    redis_client.zincrby('image_rankings', image_id, 1)
    
    return render(request, 'image_detail.html', {
        'image': image,
        'total_views': total_views
    })

레디스의 incr 명령은 원자적 연산이므로 동시 접근에서도 안전한 카운터 역할을 한다. zincrby는 정렬된 집합에서成员的 점수를 증가시킨다.

인기 이미지 순위 조회

def image_ranking(request):
    # 상위 10개 이미지 ID 조회 (내림차순)
    top_image_ids = redis_client.zrevrange('image_rankings', 0, 9)
    
    # 문자열을 정수로 변환
    image_ids = [int(img_id) for img_id in top_image_ids]
    
    # 장고 ORM으로 이미지 객체 조회
    ranked_images = Image.objects.filter(id__in=image_ids)
    
    # 순위 순서대로 정렬
    ranked_images = sorted(
        ranked_images,
        key=lambda x: image_ids.index(x.id)
    )
    
    return render(request, 'image_ranking.html', {
        'ranked_images': ranked_images
    })

레디스 활용 시나리오

  • 카운터: incr, incrby로 간단한计数器 구현
  • 캐싱: expire로 만료 시간 설정 가능
  • 실시간 분석:高速 I/O로 실시간 데이터 처리
  • 리더보드: sorted set으로 순위 시스템 구현
  • 세션 스토어: django-redis 등 라이브러리로 세션 백엔드 활용

레디스는 SQL 데이터베이스를 대체하는 것이 아니라, 특정 작업에 대한 성능 향상을 위한 보완제로 활용하는 것이最佳이다.

태그: Django python Redis user-activity social-network

6월 23일 17:15에 게시됨