🔁 Django + Waypoints.js로 무한 스크롤 구현하기
Django에서 포스트 목록을 페이지네이션 하면서, 사용자가 스크롤을 내릴 때 자동으로 다음 페이지 데이터를 불러오는 "무한 스크롤" 기능을 구현하려면 Waypoints.js와 infinite-scroll 플러그인이 매우 유용합니다.
이 글에서는 Swiper.js로 이미지 슬라이더까지 결합하여, Django 기반의 소셜 피드 UI를 자연스럽고 효율적으로 구성하는 방법을 정리합니다.
1️⃣ Waypoints.js란?
- 📌 스크롤 위치를 감지하여 특정 이벤트를 실행할 수 있도록 도와주는 JS 라이브러리입니다.
- 📦 Infinite Scroll 플러그인을 함께 쓰면, 특정 요소가 뷰포트에 도달했을 때 자동으로 다음 페이지 요청 가능
- 📦 다운로드후 static 폴더에 waypoints 폴더 만든후 jquery.waypoints.min.js와 infinite.min.js 넣어주기
1️⃣ jQuery
- ✅ jQuery는 JavaScript를 더 쉽게 쓰도록 도와주는 라이브러리입니다.
- 🔧 HTML 요소 선택, 이벤트 처리, AJAX 통신 등을 간결하게 작성할 수 있어요.
- ⚠️ 최신 웹에서는 Vanilla JS나 React 등으로 대체되는 경우가 많지만, 여전히 레거시 코드에서 자주 사용됩니다
- ⚠️ 다운로드후 static 폴더에 js 폴더를 만든후
2️⃣ 전체 코드 흐름 설명
post/view.py
User = get_user_model()
class PostListView(ListView):
model = Post
queryset = Post.objects.all().select_related('user').prefetch_related('images')
template_name = 'post/list.html' # 전체 레이아웃용 템플릿
context_object_name = 'object_list'
paginate_by = 5
ordering = ('-created_at', )
def get(self, request, *args, **kwargs):
"""
- 일반 요청: 전체 페이지(list.html) 렌더링
- AJAX 요청: 내용 부분(post_list_content.html)만 렌더링
"""
self.object_list = self.get_queryset()
context = self.get_context_data()
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
# 부분 템플릿만 반환
html = render_to_string('post/post_list_content.html', context, request=request)
return HttpResponse(html)
return super().get(request, *args, **kwargs)
post/list.html
{# templates/post/list.html #}
{% extends 'base.html' %}
{% load static %}
{% block style %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper/swiper-bundle.min.css"/>
<style>
.post-image { aspect-ratio:1/1; object-fit:cover; }
</style>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-10 offset-1 col-lg-6 offset-lg-3 infinite-container">
{% include 'post/post_list_content.html' %}
</div>
</div>
{% endblock %}
{% block js %}
<script src="https://cdn.jsdelivr.net/npm/swiper/swiper-bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
let loading = false;
// 1) Swiper 초기화
function initSwipers() {
document.querySelectorAll('.swiper').forEach(el => {
new Swiper(el, {
direction: 'horizontal',
loop: false,
pagination: { el: el.querySelector('.swiper-pagination') },
});
});
}
initSwipers();
// 2) 다음 페이지 AJAX 로드 함수
async function loadNextPage() {
if (loading) return;
const link = document.querySelector('.infinite-more-link');
if (!link) return; // 다음 페이지 없으면 중단
loading = true;
try {
const res = await fetch(link.href, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const html = await res.text();
// 임시 컨테이너에 받기
const tmp = document.createElement('div');
tmp.innerHTML = html;
// 새 아이템 붙이기
tmp.querySelectorAll('.infinite-item').forEach(item =>
document.querySelector('.infinite-container').appendChild(item)
);
// 기존 링크·sentinel 제거
document.querySelector('.infinite-more-link').remove();
document.getElementById('scroll-sentinel').remove();
// 다음 링크·sentinel 다시 붙이기 (없으면 무한스크롤 종료)
const newLink = tmp.querySelector('.infinite-more-link');
if (newLink) {
document.querySelector('.infinite-container').appendChild(newLink);
const newSentinel = document.createElement('div');
newSentinel.id = 'scroll-sentinel';
document.querySelector('.infinite-container').appendChild(newSentinel);
observer.observe(newSentinel);
}
// Swiper 재초기화
initSwipers();
} catch (err) {
console.error('페이지 로드 실패:', err);
} finally {
loading = false;
}
}
// 3) IntersectionObserver 설정
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadNextPage();
}
});
}, {
root: null,
rootMargin: '0px 0px 200px 0px',
threshold: 0
});
// 초기 sentinel 관찰 시작
const sentinel = document.getElementById('scroll-sentinel');
if (sentinel) observer.observe(sentinel);
});
</script>
{% endblock %}
post_list_content.html
{# templates/post/post_list_content.html #}
{% load static %}
{% for post in object_list %}
<div class="border-bottom my-4 infinite-item">
<div class="mb-2">
<span class="p-2 border rounded-circle me-2">
<i class="fa-solid fa-user" style="width: 16px; padding-left: 1px;"></i>
</span>
{{ post.user.nickname }}
</div>
<div class="swiper" style="max-height: 500px;">
<div class="border-1 swiper-wrapper">
{% for post_image in post.images.all %}
<div class="swiper-slide">
<img class="img-fluid post-image" src="{{ post_image.image.url }}" alt="">
</div>
{% endfor %}
</div>
<div class="swiper-pagination"></div>
</div>
<div class="my-2">
{{ post.content|linebreaksbr }} {{ post.id }}
</div>
</div>
{% endfor %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}" class="infinite-more-link"></a>
<div id="scroll-sentinel" style="height:1px;"></div>
{% endif %}
✅ 기대 효과
- 포스트 리스트를 스크롤할 때마다 자연스럽게 다음 포스트 불러오기
- 포스트마다 Swiper로 이미지 슬라이드 제공
- UI/UX가 부드럽고, 사용자 체감 속도 향상
📎 참고
- Swiper.js: https://swiperjs.com/
- Waypoints: http://imakewebthings.com/waypoints/
- Infinite Scroll 플러그인: https://github.com/Infinite-AJAX-Scroll/waypoints-infinite
- jQuery: https://jquery.com/
이와 같이 Django + Waypoints + Swiper.js를 결합하면, 블로그나 인스타그램 피드처럼 자연스러운 무한 스크롤 + 이미지 갤러리 구현이 가능합니다! 💡
'Django' 카테고리의 다른 글
Chapter 9-5 Django 댓글 모델 만들기, 태그 모델 만들기 (0) | 2025.05.14 |
---|---|
Chapter 9-4 Django 포스트 생성, 수정 페이지 만들기 (0) | 2025.05.14 |
Chapter 9-2 Django Post 목록 페이지 만들기 (0) | 2025.05.14 |
Chapter 9-1 Django Post 기능 구현하기 (0) | 2025.05.14 |
🔍 Django에서 User.objects.model의 정체는? (0) | 2025.05.12 |