Django를 활용한 이미지 공유 시스템 및 북마크릿 기능 구현

이번 가이드에서는 사용자가 외부 웹사이트의 이미지를 자신의 계정으로 북마크하고 공유할 수 있는 기능을 구현합니다. 이 과정에서 JavaScript 북마크릿(Bookmarklet) 작성 방법, Django에서의 AJAX 처리, 그리고 다대다(Many-to-Many) 관계를 활용한 '좋아요' 기능을 다룹니다.

1. 이미지 모델 및 다대다 관계 정의

먼저 공유된 이미지를 저장하기 위한 SharedImage 모델을 생성합니다. 이 모델은 이미지를 업로드한 사용자와 해당 이미지를 좋아하는 사용자들을 추적합니다.

from django.db import models
from django.conf import settings
from django.utils.text import slugify
from django.urls import reverse

class SharedImage(models.Model):
    author = models.ForeignKey(settings.AUTH_USER_MODEL, 
                               related_name='images_submitted', 
                               on_delete=models.CASCADE)
    label = models.CharField(max_length=250)
    slug = models.SlugField(max_length=250, blank=True)
    source_url = models.URLField()
    image_file = models.ImageField(upload_to='shared/%Y/%m/%d')
    info = models.TextField(blank=True)
    uploaded = models.DateField(auto_now_add=True, db_index=True)
    
    # 다대다 관계: 이미지를 좋아하는 사용자들
    liked_by = models.ManyToManyField(settings.AUTH_USER_MODEL, 
                                      related_name='images_favourited', 
                                      blank=True)

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.label)
        super().save(*args, **kwargs)

    def get_absolute_url(self):
        return reverse('images:detail', args=[self.id, self.slug])

    def __str__(self):
        return self.label

db_index=True를 사용하여 생성일자에 인덱스를 추가하면 쿼리 성능을 향상시킬 수 있습니다. 또한 save() 메서드를 오버라이드하여 제목을 기반으로 슬러그(slug)가 자동 생성되도록 구현했습니다.

2. 외부 이미지 저장을 위한 폼 구성

사용자가 이미지 URL을 제공하면 서버에서 해당 이미지를 직접 다운로드하여 저장해야 합니다. 이를 위해 ModelForm을 확장합니다.

from django import forms
from .models import SharedImage
from urllib import request
from django.core.files.base import ContentFile
from django.utils.text import slugify

class ImageSubmitForm(forms.ModelForm):
    class Meta:
        model = SharedImage
        fields = ('label', 'source_url', 'info')
        widgets = {
            'source_url': forms.HiddenInput,
        }

    def clean_source_url(self):
        url = self.cleaned_data['source_url']
        valid_formats = ['jpg', 'jpeg', 'png']
        extension = url.rsplit('.', 1)[1].lower()
        if extension not in valid_formats:
            raise forms.ValidationError('지원하지 않는 이미지 형식입니다.')
        return url

    def save(self, force_insert=False, force_update=False, commit=True):
        instance = super().save(commit=False)
        img_url = self.cleaned_data['source_url']
        name = f"{slugify(instance.label)}.{img_url.rsplit('.', 1)[1].lower()}"

        # URL에서 이미지 다운로드
        response = request.urlopen(img_url)
        instance.image_file.save(name, ContentFile(response.read()), save=False)
        
        if commit:
            instance.save()
        return instance

3. JavaScript 북마크릿 구현

북마크릿은 브라우저 북마크에 저장된 작은 JavaScript 도구입니다. 사용자가 다른 사이트에서 이 북마크를 클릭하면 현재 페이지의 이미지를 추출하여 우리 사이트로 전송합니다.

런처 스크립트 (launcher.js)

(function(){
    if (window.myAppBookmarklet !== undefined){
        myAppBookmarklet();
    }
    else {
        document.body.appendChild(document.createElement('script')).src='https://yourdomain.com/static/js/bookmarklet.js?r='+Math.floor(Math.random()*99999999999999999999);
    }
})();

메인 북마크릿 로직 (bookmarklet.js)

이 스크립트는 페이지 내 일정 크기 이상의 이미지를 찾아 사용자에게 선택창을 제공합니다.

function bookmarklet(msg) {
    // CSS 로드 및 HTML 구조 생성
    var css = jQuery('<link>');
    css.attr({
        rel: 'stylesheet',
        type: 'text/css',
        href: 'https://yourdomain.com/static/css/bookmarklet.css?r=' + Math.floor(Math.random()*99999999)
    });
    jQuery('head').append(css);

    box_html = '<div id="bookmarklet"><a href="#" id="close">×</a><h1>공유할 이미지를 선택하세요:</h1><div class="images"></div></div>';
    jQuery('body').append(box_html);

    // 이미지 필터링 및 노출
    jQuery.each(jQuery('img[src$="jpg"]'), function(index, image) {
        if (jQuery(image).width() >= 100 && jQuery(image).height() >= 100) {
            var img_url = jQuery(image).attr('src');
            jQuery('#bookmarklet .images').append('<a href="#"><img src="'+ img_url +'" /></a>');
        }
    });

    // 이미지 클릭 시 우리 사이트로 데이터 전송
    jQuery('#bookmarklet .images a').click(function(e){
        var selected_img = jQuery(this).children('img').attr('src');
        jQuery('#bookmarklet').hide();
        window.open('https://yourdomain.com/images/submit/?source_url='
                    + encodeURIComponent(selected_img)
                    + '&label='
                    + encodeURIComponent(jQuery('title').text()),
                    '_blank');
    });
};

4. AJAX를 활용한 '좋아요' 기능

페이지 새로고침 없이 '좋아요' 상태를 변경하기 위해 Django 뷰와 jQuery AJAX를 연동합니다.

비동기 전용 데코레이터 생성

from django.http import HttpResponseBadRequest

def ajax_only(f):
    def wrap(request, *args, **kwargs):
        if not request.is_ajax():
            return HttpResponseBadRequest()
        return f(request, *args, **kwargs)
    wrap.__doc__ = f.__doc__
    wrap.__name__ = f.__name__
    return wrap

좋아요 처리 뷰

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

@login_required
@ajax_only
@require_POST
def image_like_toggle(request):
    img_id = request.POST.get('id')
    mode = request.POST.get('action')
    if img_id and mode:
        try:
            target_img = SharedImage.objects.get(id=img_id)
            if mode == 'like':
                target_img.liked_by.add(request.user)
            else:
                target_img.liked_by.remove(request.user)
            return JsonResponse({'status': 'success'})
        except SharedImage.DoesNotExist:
            pass
    return JsonResponse({'status': 'error'})

프론트엔드 AJAX 요청

CSRF 토큰 보안을 유지하면서 jQuery로 POST 요청을 보냅니다.

$('a.like-btn').click(function(e) {
    e.preventDefault();
    $.post('{% url "images:like" %}',
        {
            id: $(this).data('id'),
            action: $(this).data('action')
        },
        function(data) {
            if (data['status'] === 'success') {
                var prev_action = $('a.like-btn').data('action');
                // 버튼 상태 업데이트
                $('a.like-btn').data('action', prev_action === 'like' ? 'unlike' : 'like');
                $('a.like-btn').text(prev_action === 'like' ? 'Unlike' : 'Like');
                
                // 숫자 업데이트
                var count = parseInt($('span.total-count').text());
                $('span.total-count').text(prev_action === 'like' ? count + 1 : count - 1);
            }
        }
    );
});

5. 무한 스크롤(Infinite Scroll) 구현

사용자가 페이지 하단에 도달할 때 자동으로 다음 이미지를 로드하는 기능을 추가합니다.

var page = 1;
var isLastPage = false;
var isLoading = false;

$(window).scroll(function() {
    var threshold = $(document).height() - $(window).height() - 250;
    if ($(window).scrollTop() > threshold && !isLastPage && !isLoading) {
        isLoading = true;
        page += 1;
        $.get('?page=' + page, function(data) {
            if (data === '') {
                isLastPage = true;
            } else {
                isLoading = false;
                $('#image-container').append(data);
            }
        });
    }
});

Django 뷰에서는 request.is_ajax()를 감지하여 전체 레이아웃이 포함된 페이지를 반환할지, 아니면 이미지 리스트 조각(HTML Fragment)만 반환할지 결정하여 효율적으로 처리합니다.

태그: Django JavaScript jQuery AJAX Bookmarklet

6월 24일 00:42에 게시됨