Backend DEv

[Kotlin] no-offset 방식을 통한 댓글 무한스크롤 구현

  • -
728x90

프로젝트에서 페이징 기능을 구현하기 위해 다양한 고민을 하고 있었다. Post(게시글)의 경우 페이지네이션 형태로 구현하여 진행하기로 하였으나, 내가 맡은 댓글 파트의 특성상 페이지네이션 형태로 구현할 경우 모바일에서 유저들이 페이지를 직접 클릭해서 넘기게 되는데, 이런 방식은 좋은 UX가 아니라고 생각한다. 실제로 많은 소셜 네트워크 서비스(인스타, 페이스북, 에타)들도 무한 스크롤 방식을 사용하고 있어서 많은 고민 끝에 댓글에서는 무한 스크롤을 구현해보기로 했다.

 

다른 분이 진행한 post api에서도 Querydsl을 사용하였고, 나 역시 no Offset  기능을 적용하기 위해 Querydsl을 사용하였다. 스프링 데이터 JPA의 Page 기능을 통해 구현할 수도 있지만, no offset을 적용하지 않을 경우 데이터 값이 늘어나면 전체 데이터를 조회하는 과정에서 성능 저하가 늘어나기 때문에 no Offset 사용을 결심했다.

 

no Offset을 offset과 비교하여 정리해보면 아래와 같은 이점을 가진다고 볼 수 있다.

 

성능 향상: Offset 방식에서는 특정 페이지로 이동할 때마다 처음부터 데이터를 세어야 하므로 데이터베이스에서 많은 작업이 필요하다.. 이에 비해 no-offset 방식에서는 이전 페이지의 마지막 항목을 기준으로 다음 페이지를 가져오기 때문에 성능이 향상된다.

 

일관성 있는 성능: Offset 방식에서는 페이지가 커질수록 성능이 떨어질 수 있다. 특히 데이터베이스의 특정 위치에서 가져오는 것이 어려운 경우 더 극단적으로 나타날 수 있다. 이에 비해 No-offset 방식은 페이지 크기에 상관없이 일관된 성능을 제공한다.

 

예측 가능한 쿼리 성능: Offset 방식에서는 특정 페이지로 이동할 때마다 쿼리가 동적으로 생성되며, 이에 따라 데이터베이스에서 성능을 최적화하기 어려울 수 있다. No-offset 방식에서는 이전 페이지의 마지막 항목을 기준으로 쿼리를 생성하므로 성능 최적화가 더 용이하다.

 

데이터베이스 인덱스 활용: No-offset 방식은 데이터베이스 인덱스를 효과적으로 활용할 수 있습니다. 이전 페이지의 마지막 항목의 값으로 인덱스를 활용하여 더 빠르게 데이터를 검색할 수 있다.

Controller단

    // TODO: 댓글 목록 조회 리스트 api
    @GetMapping
    fun getAllComment(@RequestParam lastCommentId: Long, pageable: Pageable): ResponseEntity<Slice<Comment>> {
        val comments = commentService.getAllComment(lastCommentId, pageable)
        return ResponseEntity.ok().body(comments)
    }

no offset을 위해 필요한 마지막으로 조회한 lastCommentId, Pageable 객체가 필요하다. 또, Slice 형태로 응답을 반환하기 위해 ResponseEntity<Slice<Comment>>와 같이 사용하였다. comment 엔티티의 모든 정보를 반환해야 해서 추가적인 DTO를 생성하지 않았다.

Repository단

    package com.kotlin.study.dongambackend.domain.comment.repository

    import com.kotlin.study.dongambackend.domain.comment.entity.Comment
    import com.kotlin.study.dongambackend.domain.comment.entity.QComment
    import com.kotlin.study.dongambackend.domain.post.entity.Post
    import com.querydsl.core.types.dsl.BooleanExpression
    import com.querydsl.jpa.impl.JPAQueryFactory
    import org.springframework.data.domain.*
    import org.springframework.stereotype.Repository

    @Repository
    class CommentQueryDslRepository(val queryDslFactory: JPAQueryFactory) {
        val qComment = QComment.comment;

        fun searchCommentsBySlice(lastCommentId: Long, pageable: Pageable): Slice<Comment> {
            val results: List<Comment> = queryDslFactory.selectFrom(qComment)
                .where(
                    qComment.isDeleted.eq(false),
                    // no-offset
                    qComment.id.gt(lastCommentId)
                )
                .orderBy(qComment.id.asc())
                .limit((pageable.pageSize + 1).toLong())
                .fetch() as List<Comment>

            return checkLastPage(pageable, results)
        }


        // 무한 스크롤
        private fun checkLastPage(pageable: Pageable, results: List<Comment>): Slice<Comment> {
            val hasNext = results.size > pageable.pageSize
            val mutableResults = if (hasNext) results.subList(0, pageable.pageSize) else results.toMutableList()

            return SliceImpl(mutableResults, pageable, hasNext)
        }


    }

Querydsl을 통해 no offset을 적용하여 무한 스크롤을 구현한 쿼리문이다. where절을 통해 삭제되지 않은 댓글만 가져오도록 하였다.

  • 주어진 lastCommentId를 기준으로 해당 ID보다 큰 댓글들을 가져오도록 설정해서 전체 row 검색 시 나타나는 성능 저하를 방지하였다. 이때 이미 로드된 페이지의 마지막 댓글 ID와 그 이후의 댓글을 가져오기 위해 gt(특정 값보다 큰 값을 비교하는 연산자) 를 사용하였다. (이 부분을 다른걸로 적었다가 해맸다.)
  • 코틀린이기 때문에 lastCommentId에는 null 값이 들어올 수 없다. 초기 lastCommentId값으로는 0이 들어온다.
  • limit는 프론트가 요청한 페이지 크기보다 1 크게 조회하여 다음 페이지의 존재 여부를 판단한다.
  • checkLastPage 함수는 페이지에 따라 결과를 Slice로 반환한다.

Service 단

    @Transactional(readOnly = true)
    fun getAllComment(commentId: Long, pageable: Pageable): Slice<Comment> {
        return commentQueryDslRepository.searchCommentsBySlice(commentId, pageable)
    }

이 부분은 추후 데이터 유효성을 검증하는 부분이 추가적으로 필요할 것 같다. 현재는 위 respository의 쿼리문을 통해 검색된 데이터들을 가져오고 return 하도록 구현되어 있다.

호출 결과

JSON을 더 자세히 보면 아래와 같다.

{
    "content": [
        {
            "createdAt": "2023-09-30T00:00:00",
            "updatedAt": "2023-08-15T00:00:00",
            "id": 1,
            "userId": 73,
            "postId": 78,
            "content": "🐵 🙈 🙉 🙊",
            "isDeleted": false
        },
        {
            "createdAt": "2023-01-21T00:00:00",
            "updatedAt": "2023-04-30T00:00:00",
            "id": 3,
            "userId": 65,
            "postId": 67,
            "content": "̡͓̞ͅI̗̘̦͝n͇͇͙v̮̫ok̲̫̙͈i̖͙̭̹̠̞n̡̻̮̣̺g̲͈͙̭͙̬͎ ̰t͔̦h̞̲e̢̤ ͍̬̲͖f̴̘͕̣è͖ẹ̥̩l͖͔͚i͓͚̦͠n͖͍̗͓̳̮g͍ ̨o͚̪͡f̘̣̬ ̖̘͖̟͙̮c҉͔̫͖͓͇͖ͅh̵̤̣͚͔á̗̼͕ͅo̼̣̥s̱͈̺̖̦̻͢.̛̖̞̠̫̰",
            "isDeleted": false
        },
        {
            "createdAt": "2023-08-24T00:00:00",
            "updatedAt": "2023-05-28T00:00:00",
            "id": 4,
            "userId": 43,
            "postId": 74,
            "content": ",./;'[]\\-=",
            "isDeleted": false
        },
        {
            "createdAt": "2023-02-22T00:00:00",
            "updatedAt": "2023-10-26T00:00:00",
            "id": 5,
            "userId": 2,
            "postId": 28,
            "content": "田中さんにあげて下さい",
            "isDeleted": false
        },
        {
            "createdAt": "2023-01-02T00:00:00",
            "updatedAt": "2023-05-18T00:00:00",
            "id": 6,
            "userId": 32,
            "postId": 70,
            "content": "__ロ(,_,*)",
            "isDeleted": false
        },
        {
            "createdAt": "2023-05-11T00:00:00",
            "updatedAt": "2023-11-06T00:00:00",
            "id": 10,
            "userId": 84,
            "postId": 52,
            "content": "₀₁₂",
            "isDeleted": false
        },
        {
            "createdAt": "2022-12-06T00:00:00",
            "updatedAt": "2023-11-01T00:00:00",
            "id": 11,
            "userId": 66,
            "postId": 75,
            "content": "⁰⁴⁵₀₁₂",
            "isDeleted": false
        },
        {
            "createdAt": "2023-02-24T00:00:00",
            "updatedAt": "2023-08-20T00:00:00",
            "id": 15,
            "userId": 65,
            "postId": 11,
            "content": "1/0",
            "isDeleted": false
        },
        {
            "createdAt": "2023-06-13T00:00:00",
            "updatedAt": "2023-11-18T00:00:00",
            "id": 16,
            "userId": 91,
            "postId": 77,
            "content": "-1",
            "isDeleted": false
        },
        {
            "createdAt": "2023-05-18T00:00:00",
            "updatedAt": "2023-02-18T00:00:00",
            "id": 17,
            "userId": 41,
            "postId": 9,
            "content": ",./;'[]\\-=",
            "isDeleted": false
        },
        {
            "createdAt": "2023-11-17T00:00:00",
            "updatedAt": "2023-06-26T00:00:00",
            "id": 18,
            "userId": 98,
            "postId": 33,
            "content": "-1/2",
            "isDeleted": false
        },
        {
            "createdAt": "2022-12-05T00:00:00",
            "updatedAt": "2023-03-17T00:00:00",
            "id": 19,
            "userId": 20,
            "postId": 44,
            "content": "✋🏿 💪🏿 👐🏿 🙌🏿 👏🏿 🙏🏿",
            "isDeleted": false
        },
        {
            "createdAt": "2023-04-30T00:00:00",
            "updatedAt": "2023-03-04T00:00:00",
            "id": 21,
            "userId": 79,
            "postId": 30,
            "content": "\"",
            "isDeleted": false
        },
        {
            "createdAt": "2023-05-12T00:00:00",
            "updatedAt": "2023-10-20T00:00:00",
            "id": 22,
            "userId": 99,
            "postId": 90,
            "content": "⁦test⁧",
            "isDeleted": false
        },
        {
            "createdAt": "2023-06-14T00:00:00",
            "updatedAt": "2023-09-25T00:00:00",
            "id": 23,
            "userId": 15,
            "postId": 97,
            "content": "␡",
            "isDeleted": false
        },
        {
            "createdAt": "2023-03-02T00:00:00",
            "updatedAt": "2023-10-04T00:00:00",
            "id": 24,
            "userId": 84,
            "postId": 69,
            "content": "Z̮̞̠͙͔ͅḀ̗̞͈̻̗Ḷ͙͎̯̹̞͓G̻O̭̗̮",
            "isDeleted": false
        },
        {
            "createdAt": "2023-03-18T00:00:00",
            "updatedAt": "2023-02-10T00:00:00",
            "id": 26,
            "userId": 61,
            "postId": 90,
            "content": "1'; DROP TABLE users--",
            "isDeleted": false
        },
        {
            "createdAt": "2023-10-06T00:00:00",
            "updatedAt": "2023-08-07T00:00:00",
            "id": 27,
            "userId": 28,
            "postId": 20,
            "content": "() { _; } >_[$($())] { touch /tmp/blns.shellshock2.fail; }",
            "isDeleted": false
        },
        {
            "createdAt": "2023-04-11T00:00:00",
            "updatedAt": "2023-02-02T00:00:00",
            "id": 29,
            "userId": 75,
            "postId": 68,
            "content": "₀₁₂",
            "isDeleted": false
        },
        {
            "createdAt": "2023-09-20T00:00:00",
            "updatedAt": "2023-09-02T00:00:00",
            "id": 30,
            "userId": 28,
            "postId": 62,
            "content": "👾 🙇 💁 🙅 🙆 🙋 🙎 🙍 ",
            "isDeleted": false
        }
    ],
    "pageable": {
        "sort": {
            "empty": true,
            "unsorted": true,
            "sorted": false
        },
        "offset": 0,
        "pageNumber": 0,
        "pageSize": 20,
        "paged": true,
        "unpaged": false
    },
    "first": true,
    "last": false,
    "size": 20,
    "number": 0,
    "sort": {
        "empty": true,
        "unsorted": true,
        "sorted": false
    },
    "numberOfElements": 20,
    "empty": false
}

Slice를 사용해서 구현했기 때문에 first, last와 같은 정보를 통해 프론트가 해당 JSON을 구분하는 데에 도움이 될 것이다. 

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.