ABOUT ME

Today
Yesterday
Total
  • [JavaScript] SpringBoot : 무한 스크롤(Intersection Observer API)
    카테고리 없음 2023. 7. 28. 22:24

    목적

    • 스크롤을 내리면 새로 게시물 리스트를 불러와 생성하는 기능 구현
    • 무한 스크롤 기능 구현

     

    보통 인스타그램이나 페이스북 같은 SNS 피드를 보면 화면을 아래로 계속 스크롤 해도 새로운 게시물이 무한하게 나오는 것을 볼 수 있다.

     

    이번 프로젝트에서도 이와 같은 기능을 구현하고자 했는데, 그 과정을 살펴보자.

     

    우선 크게 흐름을 설명하면 이렇다.

     

    1. 리스트 게시판에서 스크롤을 아래로 내려서 가장 하위에 위치한 요소가 감지됨.
    2. Ajax로 아래 이어 붙일 페이지에 대한 정보 서버에 요청
    3. 서버는 DB에 저장되어 있는 게시물을 가져와서 Ajax에 응답
    4. 응답 받은 데이터를 가지고 동적으로 요소 생성

     

    위 과정의 결과는 아래와 같다.

     

     

    결과 화면

     

    오른쪽에 스크롤 길이를 보면 내려갈 때 계속해서 아래쪽에 새 게시물이 생성되는 것을 볼 수 있다.

     

    [JavaScript]

    JS 전체 코드는 아래와 같다.

    $(function(){
        var list_ul = document.getElementById('gallery_list_ul');
        var page = 5;
        var start = 6;
        var end = 11;
        const observer = new IntersectionObserver(function(items){
            items.forEach(function(item){
                if(item.isIntersecting){
                    $.ajax({
                        url : "../gallery/moreList.do?start=" + start + "&end=" + end,
                        type : "get",
                        success : function(param){
                            updateMainList(param.list, param.imgList);
                            list_ul = document.getElementById('gallery_list_ul');
                            observer.observe(list_ul.lastElementChild);
                            start += page;
                            end += page;
                        }
                    });
                }
            });
    	});
        observer.observe(list_ul.lastElementChild);
    });
    
    function updateMainList(list, img){
        const list_ul = document.getElementById('gallery_list_ul');
    
        list.forEach(function(item, index){
            let newLi = document.createElement('li');
    
            let anchor = document.createElement('a');
            anchor.href = "../gallery/detail.do?g_num="+item.g_num;
            newLi.appendChild(anchor);
    
            let galleryItemWrap = document.createElement('div');
            galleryItemWrap.className = "gallery-item-wrap";
            anchor.appendChild(galleryItemWrap);
    
            let galleryItemImage = document.createElement('div');
            galleryItemImage.className = "gallery-item-image";
            galleryItemWrap.appendChild(galleryItemImage);
    
            let thumb = document.createElement('img');
            thumb.src = img[index];
            galleryItemImage.appendChild(thumb);
    
            let galleryItemText = document.createElement('div');
            galleryItemText.className = "gallery-item-text";
            galleryItemWrap.appendChild(galleryItemText);
    
            let date = document.createElement('p');
            date.className = 'gallery-item-text-date font-White';
            date.textContent = formatDate(item.g_date);
            galleryItemText.appendChild(date);
    
            const title = document.createElement('p');
            title.className = 'gallery-item-text-title font-White';
            title.textContent = item.g_title;
            galleryItemText.appendChild(title);
    
            const location = document.createElement('p');
            location.className = 'gallery-item-text-loc font-White';
            location.textContent = item.g_place;
            galleryItemText.appendChild(location);
    
            list_ul.appendChild(newLi);
        });
    }
    function formatDate(dateString) {
        const date = new Date(dateString);
        return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}`;
    }

    가장 먼저 선언해둔 변수를 보면 이렇다.

    var list_ul = document.getElementById('gallery_list_ul'); //li 담을 ul태그
    var page = 5; //한번에 불러올 게시물 개수
    var start = 6; // 시작번호
    var end = 11; //끝번호

    변수에 대한 설명은 주석과 같다.

     

    시작번호가 6번부터인 이유는 처음 게시물 리스트 표시가 될 때 기본적으로 5개를 가져오기 때문이다.

     

    다음은 지금 화면에 보이는 마지막 게시물을 감지하고, 감지됐을 때 Ajax를 실행하는 부분이다.

    const observer = new IntersectionObserver(function(items){
        items.forEach(function(item){
            if(item.isIntersecting){
                $.ajax({
                    url : "../gallery/moreList.do?start=" + start + "&end=" + end,
                    type : "get",
                    success : function(param){
                        updateMainList(param.list, param.imgList);
                        list_ul = document.getElementById('gallery_list_ul');
                        observer.observe(list_ul.lastElementChild);
                        start += page;
                        end += page;
                    }
                });
            }
        });
    });
    observer.observe(list_ul.lastElementChild);

    intersection observer api를 사용했다.

     

    관련 설명은 아래 링크를 참고하자.

     

    참고 : https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API

     

    Intersection Observer API - Web API | MDN

    Intersection Observer API는 타겟 요소와 상위 요소 또는 최상위 document 의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 방법입니다.

    developer.mozilla.org

    이용한 기능을 간단히 설명하면,

    const observer = new IntersectionObserver(function(items){});

    observer를 생성하고,

    observer.observe(list_ul.lastElementChild);

    해당 observer에게 감시할 대상을 지정해줬다.

     

    여기서는 <ul>태그의 가장 마지막 자식, 즉 가장 아래에 위치한 게시물을 감시하도록 했다.

     

    만약 화면에 일부라도 해당 요소가 보이면 IntersectionObserver 엔트리에 isIntersecting 프로퍼티가 true로 바뀐다. 

     

    이를 이용해서 마지막 요소가 화면에 감지되면 Ajax를 실행하도록 설정해둔 것이다.

     

    이정도만 사용해도 무한 스크롤 기능을 구현할 수 있다.

     

    $.ajax({
        url : "../gallery/moreList.do?start=" + start + "&end=" + end,
        type : "get",
        success : function(param){
            updateMainList(param.list, param.imgList);
            list_ul = document.getElementById('gallery_list_ul');
            observer.observe(list_ul.lastElementChild);
            start += page;
            end += page;
        }
    });

    Ajax 구문도 매우 간단하다.

     

    get방식으로 start와 end를 넘겨 컨트롤러에 요청을 보내도록 했다.

     

    서버에서는 게시물 리스트와 게시물 썸네일 이미지 리스트를 리턴해주고, 이를 updateMainList라는 함수의 인자로 넣어줬다.

     

    updateMainList 함수로 인해 <ul>태그의 요소가 바뀌었으므로, 새로 <ul>태그를 불러왔고 감시 대상도 다시 설정했다.

     

    updateMainList는 동적으로 해당 태그들을 생성하는 부분이니 코드만 적고 넘어가겠다.

    function updateMainList(list, img){
        const list_ul = document.getElementById('gallery_list_ul');
    
        list.forEach(function(item, index){
            let newLi = document.createElement('li');
    
            let anchor = document.createElement('a');
            anchor.href = "../gallery/detail.do?g_num="+item.g_num;
            newLi.appendChild(anchor);
    
            let galleryItemWrap = document.createElement('div');
            galleryItemWrap.className = "gallery-item-wrap";
            anchor.appendChild(galleryItemWrap);
    
            let galleryItemImage = document.createElement('div');
            galleryItemImage.className = "gallery-item-image";
            galleryItemWrap.appendChild(galleryItemImage);
    
            let thumb = document.createElement('img');
            thumb.src = img[index];
            galleryItemImage.appendChild(thumb);
    
            let galleryItemText = document.createElement('div');
            galleryItemText.className = "gallery-item-text";
            galleryItemWrap.appendChild(galleryItemText);
    
            let date = document.createElement('p');
            date.className = 'gallery-item-text-date font-White';
            date.textContent = formatDate(item.g_date);
            galleryItemText.appendChild(date);
    
            const title = document.createElement('p');
            title.className = 'gallery-item-text-title font-White';
            title.textContent = item.g_title;
            galleryItemText.appendChild(title);
    
            const location = document.createElement('p');
            location.className = 'gallery-item-text-loc font-White';
            location.textContent = item.g_place;
            galleryItemText.appendChild(location);
    
            list_ul.appendChild(newLi);
        });
    }
    function formatDate(dateString) {
    	const date = new Date(dateString);
    	return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}`;
    }

     

    [Java]

    이제 요청받은 서버에서 어떤 처리를 하는지 보도록 하자.

    @RequestMapping("/gallery/moreList.do")
    @ResponseBody
    public Map<String, Object> moreList(@RequestParam(value = "start") int start, @RequestParam(value = "end") int end, HttpSession session){
    
        MemberVO user = (MemberVO)session.getAttribute("user");
    
        Map<String, Object> mapAjax = new HashMap<String, Object>();
    
        List<GalleryVO> list = galleryService.getGalleryList(user.getMem_cookie(), start, end);
    
        List<String> imgList = new ArrayList<>();
    
        for(int i = 0; i < list.size(); i++) {
            int g_num = list.get(i).getG_num();
            GalleryImgVO galleryImgVo = galleryService.getThumbImg(g_num);
            String filename = galleryImgVo.getImg_filename();
            byte[] galImg = galleryImgVo.getImg_file();
    
    
            String ext = filename.substring(filename.lastIndexOf("."));
            if(ext.equalsIgnoreCase(".gif")) {
                ext = "image/gif";
            }else if(ext.equalsIgnoreCase(".png")) {
                ext = "image/png";
            }else {
                ext = "image/jpeg";
            }
    
            String galImg2Base64 = Base64.getEncoder().encodeToString(galImg);
            String fullBase64 = "data:" + ext + ";base64, " + galImg2Base64;
    
            imgList.add(i, fullBase64);
        }
    
        mapAjax.put("imgList", imgList);
        mapAjax.put("list", list);
    
        return mapAjax;
    }

    순서대로 보면 아래와 같다.

    MemberVO user = (MemberVO)session.getAttribute("user");
    
    Map<String, Object> mapAjax = new HashMap<String, Object>();
    
    List<GalleryVO> list = galleryService.getGalleryList(user.getMem_cookie(), start, end);
    
    List<String> imgList = new ArrayList<>();

    먼저 로그인한 user의 정보를 가져오고, Ajax 응답으로 보낼 Map객체를 생성했다.

     

    list에는 로그인한 회원의 쿠키번호와 가져올 시작 번호와 끝 번호를 인자로 넣어줬다.

     

    DB 설계상 게시물의 내용과 게시물 이미지를 다른 테이블에 두고 작업했기에 이미지를 담을 imgList를 하나 더 생성했다.

    for(int i = 0; i < list.size(); i++) {
        int g_num = list.get(i).getG_num();
        GalleryImgVO galleryImgVo = galleryService.getThumbImg(g_num);
        String filename = galleryImgVo.getImg_filename();
        byte[] galImg = galleryImgVo.getImg_file();
    
    
        String ext = filename.substring(filename.lastIndexOf("."));
        if(ext.equalsIgnoreCase(".gif")) {
            ext = "image/gif";
        }else if(ext.equalsIgnoreCase(".png")) {
            ext = "image/png";
        }else {
            ext = "image/jpeg";
        }
    
        String galImg2Base64 = Base64.getEncoder().encodeToString(galImg);
        String fullBase64 = "data:" + ext + ";base64, " + galImg2Base64;
    
        imgList.add(i, fullBase64);
    }

    이제 list의 길이만큼 반복문을 돌며 DB에서 썸네일 이미지를 하나씩 가져왔고, 해당 이미지를 Base64인코딩 형식으로 변환하는 과정을 거쳤다.

     

    Base64 인코딩 관련 내용은 이전 글에 설명한 적이 있는데 필요하면 참고하자(여기).

     

    마지막으로 이 데이터들을 생성한 Map객체에 담아주면 끝이다.

    mapAjax.put("imgList", imgList);
    mapAjax.put("list", list);
    
    return mapAjax;

     

    매퍼와 서비스는 흐름만 적고 넘어가도록 하겠다.

     

    [Mapper]

    @Mapper
    public interface GalleryMapper {
        @Select("SELECT g_num, g_title, g_place, g_cookie, g_date FROM gallery WHERE g_cookie = #{g_cookie} ORDER BY g_date DESC limit #{start},#{end}")
        public List<GalleryVO> getGalleryList(@Param(value = "g_cookie") String g_cookie, @Param(value = "start") int start, @Param(value = "end") int end);
        
        @Select("SELECT * FROM img_gallery where g_num = #{g_num} ORDER BY img_num asc limit 1")
        public GalleryImgVO getThumbImg(Integer g_num);
    }

    [Service]

    public interface GalleryService {
        public List<GalleryVO>getGalleryList(String g_cookie, int start, int end);
        public GalleryImgVO getThumbImg(Integer g_num);
    }

    [ServiceImpl]

    @Service
    @Transactional
    public class GalleryServiceImpl implements GalleryService{
        @Autowired
        private GalleryMapper galleryMapper;
    
        @Override
        public List<GalleryVO> getGalleryList(String g_cookie, int start, int end) {
            return galleryMapper.getGalleryList(g_cookie, start, end);
        }
        
        @Override
        public GalleryImgVO getThumbImg(Integer g_num) {
            return galleryMapper.getThumbImg(g_num);
        }
    }

    간단한 형태로 무한 스크롤 과정을 알아봤다.

     

    꼭 게시물 뿐만 아니라 특정 요소를 감지하여 감지된 순간의 이벤트를 처리할 수 있다는 점이 굉장이 매력적인데, 다른 기능으로도 활용가능 할 것 같아 기록을 남겨둔다.

Designed by Tistory.