기존 로직

	// 서비스
	@Transactional(readOnly = true)
    public List<PostDto> getPosts(String username, PostsConditionForm cond) {
        User userFind = userService.findUserByUsername(username);

        List<Post> posts = postRepository.searchPosts(cond, username);

        return posts.stream()
                .map(post -> PostDto.of(post, userFind))
                .toList();
    }
    
	//리포지토리
    public List<Post> searchPosts(PostsConditionForm form, String username) {

        QPost p = new QPost("p");
        QPostTag pt =  new QPostTag("pt");
        QTag t = new QTag("t");
        QSeries s = new QSeries("s");
        QUser u = new QUser("u");

        return queryFactory
                .select(p)
                .from(p)
                .innerJoin(p.user, u).fetchJoin()
                .leftJoin(p.postTags, pt).fetchJoin()
                .leftJoin(pt.tag, t).fetchJoin()
                .leftJoin(p.series, s).fetchJoin()
                .where(
                        searchCondition(form.getSearch(), p),
                        tagCondition(form.getTagName(), p, pt, t),
                        seriesCondition(form.getSeriesName(), p, s),
                        userCondition(username, u)
                )
                .orderBy(p.createDt.desc())
                .limit(5) // 추가됨
                .distinct()
                .fetch();
    }

기존 로직의 서비스 코드이다. searchPosts는 동적으로 쿼리스트링으로 들어온 조건을 반영하여 post(게시글) 엔티티를 뽑아온다.

동적 쿼리를 구현하기 위해 Querydsl을 활용하여 손쉽게 구현할 수 있었다. 하지만 문제가 하나 있었는데, Paging 처리가 되어 있지 않아 특정 사용자가 1000개의 게시글을 가지고 있을 경우, 이를 모두 가져오는 상황이 발생한다는 점이다.

블로그를 방문하는 사용자 입장에서 처음부터 1000개의 게시글 리스트를 한 번에 보는 경험은 비효율적이며, 현실적이지 않다. 따라서 페이징 처리는 반드시 필요한 요소라 할 수 있다.

이를 통해 초기 로드 시 적절한 개수(예: 5개)의 게시글만 가져오고, 이후 추가적인 데이터를 점진적으로 가져오는 방식으로 UX를 개선할 수 있다.

기존 로직은 querydsl에 위 코드에서 "추가됨" 주석부분에 해당하는 최대 5개만 가져오도록 해서 변경이 없도록 두었다.

Ajax를 이용한 비동기 요청 스크롤 이벤트 만들기

코드 테스트를 위해 단계마다 주석을 달아 console에서 관찰해보도록 하겠다.

스크롤 이벤트 바인딩

$(window).on('scroll', function () {
    console.log("Scroll event triggered");
    if (shouldLoadMore()) {
        console.log("Load more condition met. Current page: " + currentPage);
        isLoading = true;
        loadMorePosts(currentPage + 1);
    }
});

// 하단 도달 여부 확인 함수
function shouldLoadMore() {
    const nearBottom = $(window).scrollTop() + $(window).height() >= $(document).height() - 100;
    console.log("Checking if near bottom: " + nearBottom + ", isLoading: " + isLoading);
    return nearBottom && !isLoading;
}
  • shouldLoadMore(): 현재 스크롤 위치가 페이지 하단에 근접했는지 확인한다.
  • 중복 요청 방지: isLoading 플래그로 요청 중 상태를 관리한다.

하단에 도달했다면 loadMorePosts(currentPage + 1)로 하여금 새로운 데이터를 추가할 함수를 실행시킨다.

AJAX 요청을 통한 데이터 로드

function loadMorePosts(page) {
    console.log("Attempting to load posts for page: " + page);
    $('#loading').show();

    // URL에서 쿼리 스트링 파라미터 가져오기
    const params = new URLSearchParams(window.location.search);
    const tagName = params.get("tagName") || null;
    const seriesName = params.get("seriesName") || null;
    const search = params.get("search") || null;

    const queryParams = {
        page: page,
        size: pageSize,
        tagName: tagName,
        seriesName: seriesName,
        search: search
    };

    // AJAX 요청
    $.ajax({
        url: /*[[@{'/api/@' + ${postsViewDto.username} + '/posts'}]]*/, // 서버 API
        type: 'GET',
        data: queryParams,
        success: function (data) {
            console.log("AJAX success. Data received: ", data);

            // 데이터가 존재하는 경우 DOM에 추가
            if (Array.isArray(data) && data.length > 0) {
                console.log("Appending posts. Number of posts: " + data.length);
                appendPosts(data);
                currentPage = page;
            } else {
                console.log("No more posts available. Removing scroll event.");
                $(window).off('scroll'); // 더 이상 데이터가 없으면 스크롤 이벤트 해제
            }

            isLoading = false;
            $('#loading').hide();
        },
        error: function (error) {
            console.error("Error during AJAX request: ", error);
            isLoading = false;
            $('#loading').hide();
        }
    });
}

loadMorePosts(page)로 하여금 서버로 데이터를 요청한다. 현재URL의 쿼리 스트링을 포함하여 조건에 맞는 데이터를 페이징해서 가져오도록 서버에 요청을 보낸다. 데이터가 존재하는 경우 appendPosts()로 하여금 동적으로 받아온 데이터로 만든 subHtml을 현재 HTML에 추가한다.

function appendPosts(posts) {
    console.log("Appending posts to the DOM");
    posts.forEach(post => {
        console.log("Appending post: ", post);
        $('#post-list .row').append(`
            <a href="/@${post.username}/post/${post.postUrl}" class="card posts-style my-5 text-decoration-none">
                <img src="/image-print/${post.thumbnail || 'default/default_post.png'}" class="card-img-top thumbnail-posts-img" alt="Post Thumbnail" />
                <div class="card-body my-2 mx-2">
                    <h3 class="card-title">${post.title}</h3>
                    <p class="card-text">${post.postSummary}</p>
                </div>
                <div class="card-footer">
                    <small class="text-muted">${post.createByDt}</small>
                    <small class="text-muted"> . </small>
                    <small class="text-muted">${post.commentCount}개의 댓글</small>
                    <small class="text-muted"> . </small>
                    <small class="text-muted">♥${post.likeCount}</small>
                </div>
            </a>
        `);
    });
}

이제 서버에서 ajax 요청을 어떻게 받는지 알아보자.

스프링에서

기존, 처음 5개 게시글 카드를 가져오는 로직은 그대로 두고 새로운 컨트롤러로 하여금 구현하기로 했으므로 PostsApiController를 새로 만들어 이를 ajax 코드에서 타도록 만들었다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class PostsApiController {

    private final PostsService postsService;

    @GetMapping("/@{username}/posts")
    public ResponseEntity<List<PostDto>> posts(
                        @PathVariable("username") String usernamePost,
                        @RequestParam("tagName") String tagName,
                        @RequestParam("search") String search,
                        @RequestParam("seriesName") String seriesName,
                        Pageable pageable
    ) {
        PostsConditionForm form = new PostsConditionForm();
        form.setTagName(tagName);
        form.setSearch(search);
        form.setSeriesName(seriesName);

        List<PostDto> postsWithPageableDto = postsService.getPostsWithPageable(usernamePost, form, pageable);

        return ResponseEntity.ok(postsWithPageableDto);
    }
}

서비스 메서드는 통합해서 사용가능하겠지만 일단은 기존 로직을 변경하지 않도록 분리하여 새로 구현하였다. 새로운 getPosts로직은 Pageable를 추가로 파라미터로 받게 된다.

Pageable을 리포지토리 조회 로직이 추가로 전달하는 것 말고 서비스 메서드는 달라지는 것이 없다.

@Override
    public Page<Post> searchPostsWithPage(PostsConditionForm form, String username, Pageable pageable) {
        QPost p = new QPost("p");
        QPostTag pt = new QPostTag("pt");
        QTag t = new QTag("t");
        QSeries s = new QSeries("s");
        QUser u = new QUser("u");

        // 기본 쿼리
        JPQLQuery<Post> query = queryFactory
                .select(p)
                .from(p)
                .innerJoin(p.user, u).fetchJoin()
                .leftJoin(p.postTags, pt).fetchJoin()
                .leftJoin(pt.tag, t).fetchJoin()
                .leftJoin(p.series, s).fetchJoin()
                .where(
                        searchCondition(form.getSearch(), p),
                        tagCondition(form.getTagName(), p, pt, t),
                        seriesCondition(form.getSeriesName(), p, s),
                        userCondition(username, u)
                )
                .distinct();

        // 페이징 설정 (offset과 limit)
        long total = query.fetchCount(); // 전체 개수 조회
        List<Post> content = query
                .orderBy(p.createDt.desc()) // 정렬
                .offset(pageable.getOffset()) // 시작 인덱스
                .limit(pageable.getPageSize()) // 개수 제한
                .fetch();

        // Page 객체 반환
        return new PageImpl<>(content, pageable, total);
    }

처음 제시한 querydsl 조회로직과 다른 점은 offset과 limit에 넘겨받은 pageable를 활용하여 동적으로 페이징 설정이 가능해진 점이다.

완료

profile
자바집사의 거북이 수련법

0개의 댓글

관련 채용 정보

Powered by GraphCDN, the GraphQL CDN