본문 바로가기
프로젝트 관련

Springboot With React: 커서 기반 페이징 기법을 통한 댓글 무한 스크롤 구현

by khds 2023. 10. 19.

 

들어가기

 

현재 진행 중인 프로젝트에서는 하나의 게시글에 있는 댓글을 조회할 때, 프론트 단에서 백엔드 단으로 요청을 할 때, 모든 댓글을 한꺼번에 가져오고 있다. 

여기서 문제가 하나 있다면, 매우 많은 댓글이 넘어오게 된다면 DB 내에 매우 많은 '랜덤 액세스'가 일어나게 되어 성능적으로 좋지가 않다. 

그렇기에 많이 페이징 방식을 사용해서 일부의 데이터를 조금씩 가져와야 한다. 페이징 방식하면 떠오르는 것은 페이지 번호가 있고 원하는 번호를 누르면 해당 번호의 페이지로 넘어가는 방식과 무한 스크롤 방식이 있다. 

진행 중인 프로젝트는 모바일 화면에 맞춰서 개발을 하고 있기에 모바일 UI 에 더 익숙한 무한 스크롤 방식으로 댓글 페이징을 진행하고자 한다. 

프로젝트에서는 Springboot, Mybatis, mysql, react 등을 사용하였다.

 

 

Springboot with Mybatis: 댓글 페이징 구현

1. 커서 기반 페이징 기법이란?

 

본격적으로 코드를 작성하기에 앞서페이징 기법에 대해 간단하게 설명하고 넘어가고자 한다.

페이징 기법은 전체 데이터 중 페이지 별로 나눈 일부만을 전달하는 방법으로

1. 오프셋 기반 페이징 기법

2. 커서 기반 페이징 기법

이렇게 두가지가 있다. 

 

오프셋 기반 방식은 'offset'와 'limit'를 가지고 sql 쿼리를 날릴 때 어디부터 어디까지의 데이터를 가져오도록 하는 방식이다. 아래의 예시 코드를 보면 이해가 빠를 것이다.

SELECT *
FROM article WHERE member_id = 10L
ORDER BY id desc
limit 10 offset 100

 

 

위 코드를 보면 offset이 100, limit가 10으로 나와있는데 id 값에 오름차순으로 정열한 후 100번째 데이터부터 10개의 데이터를 가져온다는 것이다. 프론트로부터 특정 페이지를 조회하고자 요청을 해왔다 해보자. 그 요청한 페이지 수에 페이지 당 사이즈(=limit)를 곱하고 -1을 해주면 오프셋(=offset) 값이 나온다.(0~9까지의 정보가 0페이지가 아닌 1페이지기 때문에 -1을 해준다) 이렇게 구한 값들을 위의 쿼리에 넣어주면 원하는 페이지의 정보를 얻을 수 있다.

 

커서 기반 방식은 offset 은 쓰지 않고 limit와 where절 만을 사용해서 데이터를 가져오는 것이다. 아래의 코드를 한번 봐보자.

SELECT *
FROM article WHERE member_id = 10L AND id < #{cursorId}
ORDER BY id desc limit 10

 

 

cursorId는 프론트에서 받은 페이지 내에서 마지막 글의 번호를 전달하는데, 이것이 커서번호(cursorId)가 된다. 이러한 커서번호는 '키'로서 어디서부터 페이징을 시작할지를 알려주는 중요한 정보가 된다.

쿼리를 보면 그 번호보다 낮은 번호를 10개 가져오는 것으로 페이징 처리가 이루어진다. 이때, 페이지 정보만이 아니라 해당 커서 번호도 넘겨줘서 다음에 프론트 단에서 또 요청을 할때 갱신된 커서 번호를 보내게 된다. offset에 해당하는 '100' 이 들어간다면 위에서 작성한 오프셋 기반 방식과 똑같은 결과가 나온다. 

 

댓글 무한스크롤은 페이지의 번호보다는 마지막 댓글의 id를 커서 id로 지정하고 넘겨주는 방식이 더 간편하기에 커서 기반 방식을 선택하게 되었다.

 

 

2. Mybatis Mapper  XML (DB 접근 Repository) 작성

 

프로젝트에서는 게시글 페이지를 처음 조회할 때 첫번 째 댓글리스트들은 한번에 같이 가져오고 있다. 즉, 댓글 조회 API가 따로 있는 것이 아니라, 게시글 내용을 조회할 때, 댓글도 같이 조회되는 것이다. 그러니 cursorId를 가지고 다음페이지를 요청할 시 동작하는 API를 새로 구현할 필요가 있다.

 

우선 페이지 당 최대 댓글 수(=size)는 10개로 고정시켰다. 11번째부터는 다음페이지 조회시 얻을 수 있는 댓글이다.

이제 커서 기반 페이징 기법을 사용하여 repository 메서드 및 쿼리를 구현해보겠다. where과 orderBy, limit 만을 사용하는 것이기에 구현하는데에는 큰 어려움은 없었다. 하지만 신경써야할 부분이 한가지 있었다. 다음 페이지의 시작을 알리는 커서 id를  댓글의 Primary Key인 'id'로 지정을 하였는데 만약 페이지가 첫 페이지라면 어떻게 할 것인가? 

사실 위에서 언급했듯이 게시글 조회 API 실행 시, 댓글 첫 페이지도 같이 조회하도록 하여서 문제는 없다. 하지만 repository 쿼리만을 봤을 때는 첫 페이지를 조회할 수 있는 가능성도 존재한다. 이런 부분은 확실히 조정을 해주고 싶기에 해결하고 넘어가기로 하였다.

 

커서 기반 페이징 기법은 키 방식으로 어디서 부터 진행할지 정해주는 키가 필요하다. 하지만 첫 페이지를 조회시에는 키를 서버로부터 받지 못한 상태이므로 이를 어떻게 처리할지를 고려해야 한다. 생각해보면 이는 단순하게 해결할 수 있었다. 키가 없다는 것은 첫 페이지라는 것이고, 가장 처음부분을 가져가는 것이므로 where 절을 없애면 된다.

이는 mybatis 내 동적쿼리를 통해 if 문으로cursorId가 null 이 아닐 경우에만 where 절에 키 값을 활용한 동작을 넣어 주었다. cursorId가 null 경우는 페이지가 없는 것으로 간주한다.

 

완성된 쿼리는 아래와 같다.

<select id="findAllByArticleIdOnPage" resultType="CommentResponse">
    SELECT c.id, c.content, c.member_id, m.nick_name, m.image_url, c.create_date, count(l.id)
    FROM comment c LEFT JOIN comment_like as l ON (c.id = l.comment_id)
    LEFT JOIN member m ON (c.member_id = m.id)
    WHERE c.article_id = #{articleId}
    <if test="cursorId !=null">
      AND c.id &lt; #{cursorId}  <!-- &lt;는 부등호 '<'를 의미-->
    </if>
    GROUP BY c.id ORDER BY c.id desc LIMIT 11
</select>

 

 

보는바와 같이 <if>를 통해 cursorId가 null이 아닐 경우에만 cursorId 보다 작은 id를 조회한다.

여기서 의아한 점이 한가지 있을 것이다. 위에서는 분명 댓글 수를 10개로 고정시켰는데 어째서 LIMIT는 11인 것일까?

11로 설정한 이유는 다음페이지의 유무를 확인하기 위해서이다.

만약 댓글 수가 11보다 작으면 다음페이지가 없는 것이고, 11이면 다음페이지가 있는 것이다. 그 이유는 댓글 수는 10개만을 넘겨주므로 11번째 댓글이 있다는 것은 10개의 댓글을 넘겨주고도 1개의 댓글이 더 있다는 것이다. 그렇기에 다음페이지가 있다는 것을 알려주는 셈이다. 

다음페이지의 유무를 확인하는 로직은 아래의 Service 메서드 내 구현하였다.

 

 

아래는 위 xml 파일 내 쿼리를 사용하는 Repository 메서드이다.

@Mapper
public interface FindCommentRepository {
    List<CommentResponse> findAllByArticleIdOnPage(Long articleId, Long cursorId);
}

 

 

2. Service 메서드  작성


Service 메서드를 구현할 때,  고려할 사항은 다음페이지의 유무를 전달해주는 것이다. 
이는 위에서 언급했다시피 repository 메서드로부터 받은 댓글리스트의 사이즈가 11 보다 적으면 다음페이지가 없고, 11이면 다음페이지가 있는 것으로 하였다. 즉, 11이면 10개하고도 1개의 댓글이 더 있는 것이니 다음페이지가 있음을 알 수 있다.

Controller로 전달하는 dto 내에 hasNextPage 란 변수를 true/false 로 구분하여 false 일 경우 프론트 단에서 다음페이지가 없음을 알고 API 요청을 하지 않도록 하는 것이다. 



댓글리스트의 사이즈가 11이면 다음페이지가 있다는 것이므로 hasNextPage를 true로 하고 다음 cursorId는 repository로 부터 받은 comments의 10번째 comment(다음 페이지보다 한칸 더 앞선 댓글)의 id로 전달하도록 하였다. 이후 Api가 다시 호출되면 이 id가 새로운 cursorId가 될 것이다. 


아래는 구현한 service 코드이다.

@Transactional(readOnly = true)
public CommentOnPageResponse findComments(Long articleId, Long cursorId) {
    List<CommentResponse> comments = findCommentRepository.findAllByArticleIdOnPage(articleId,
        cursorId);
    if (comments.size() < 11) {
        return new CommentOnPageResponse(comments);
    }
    return new CommentOnPageResponse(comments, comments.get(9).getId());
}

 

 

FindCommentRepository를 의존성 주입하고, 구현한 findAllByArticleIdOnPage() 메서드를 호출하여 댓글리스트를 받았다. 필요한 정보는 커서 Id, 다음페이지 유무도 있기에 추가적인 가공 작업을 거쳐 Controller로 넘겨주도록 하였다.

CommentOnPageResponse 는 아래의 변수들로 구성되어 있다.

private final List<CommentResponse> comments;
private final Long cursorId;
private final boolean hasNextPage;

 

 

댓글리스트의 사이즈가 11보다 작으면 다음페이지가 없는 것이므로 아래의 메서드가 실행되도록 하였다. 다음페이지가 없으니 hasNextPage 값은 false이고, 커서 Id는 의미없는 값(= -1)을 넣었다.

public CommentOnPageResponse(List<CommentResponse> comments) {
    this.comments = comments;
    this.cursorId = -1L;
    hasNextPage = false;
}

 

 

댓글리스트의 사이즈가 11이라면 다음페이지가 있는 것이므로 아래의 메서드가 실행되도록 하였다. 위의 Service 메서드에서 커서 Id를 넣을 때, 10번째 댓글의 Id로 넣었다. 댓글은 11번째 댓글을 제외한 10개의 댓글을 보내도록 하였다.

public CommentOnPageResponse(List<CommentResponse> comments, Long cursorId) {
    this.comments = comments.subList(0, 10);
    this.cursorId = cursorId;
    hasNextPage = true;
}

 

 

3. Controller 메서드  작성

 

controller를 구현할 때 고려한 사항은 커서 Id가 0보다 작은 값이 넣어질 경우이다. 

이는 명백히 잘못된 값으로 Service를 거치지 않고 바로 빈 객체를 반환하도록 하였다.



아래는 구현한 코드이다.

@GetMapping("/comments/read/{articleId}/{cursorId}")
public ResponseEntity<CommentOnPageResponse> findCommentsOnPage(
    @PathVariable("articleId") Long articleId, @PathVariable("cursorId") Long cursorId) {
    if (cursorId < 0) {
        return ResponseEntity.ok().body(new CommentOnPageResponse());
    }
    CommentOnPageResponse response = findCommentService.findComments(articleId, cursorId);
    return ResponseEntity.ok().body(response);
}

 

 

React를 사용한 댓글 무한스크롤 구현

1. react-intersection-observer 를 통한 구현

 

구현하기 전에 생각해둔 것은 스크롤 바가 댓글리스트 블록의 가장 아래에 닿으면 다음 페이지를 호출하는 방식이다. 스크롤 이벤트를 따로 빼두면 구현하는데 크게 어려움이 없을 것이라고 생각하였고 이 방식대로 진행하였다.

하지만 많은 블로그들에서 언급하길, 스크롤 이벤트를 지속적으로 실행하는 것은 비용 낭비라는 것이다. 이보다 더 좋은 방식은 가장 아래에 특정 블록을 놓고 그 블록이 화면에 보일 시에 다음 페이지를 호출하는 방식이다.

결국 필자는 위와 같은 방식으로 진행을 하였다.

IntersectionObserver 라는 객체가 기본적으로 존재하고 이를 통해 특정 블록이 보일 시 이벤트를 발생시킬 수 있다고 한다. 특정 블록은 useRef() 를 통해 따로 빼두어서 블록이 보일 시 이벤트가 발생하도록 할 수 있었다.

거기다가 React에서는 이를 편리하게 사용할 수 있도록 라이브러리인 ‘react-intersection-observer’ 가 따로 존재한다.

 

이 글에서는 react-intersection-observer를 통해 댓글 무한스크롤을 구현할 것이다.

 



Spring 부분을 미리 구현해 놓았기에 이에 맞춰 필요한 값들을 세팅해야 한다. 댓글 한페이지당 10개의 댓글이 들어갈 것이다. 우선 게시글을 첫 조회시 같이 오는 댓글들을 통해서 설정을 해야한다.

 

아래는 구현된 부분 중 댓글에 관련된 부분만 뽑아낸 코드이다. (게시글 최초 접근 시 실행하는 것으로 위에서 구현한 Api를 실행하는 것이 아니다.)

useEffect(() => {
  findWritingApi(articleId, mapType, props.history).then(
    (articlePromise) => {
      setComments(articlePromise.comments);
      if(articlePromise.comments.length >= 10){
        setCommentCursorId(articlePromise.comments[9].id);
        setHasNextPage(true);
      });
  }, [articleId]);

 

 

첫번째로 커서 Id이다. 이는 다음페이지의 첫 댓글의 Id 바로 앞의 값이 될 것이기 때문에 댓글리스트를 가져왔을 때 가장 마지막 댓글의 id로 세팅을 하였다. 

두번째로 다음페이지의 유무인데 이는 댓글이 10개가 되면 다음페이지로 넘길 수 있도록 하였다. 

위 두가지는 댓글이 10개 이상일 때만 사용돼도록 하였다. 

댓글이 9개 이하면 다음페이지가 존재하지 않기 때문이다. 

 

아래는 API를 실행하는 메서드이다.

const findWritingApi = (articleId, mapType, history) => {
const accessToken = sessionStorage.getItem(ACCESS_TOKEN);
const config = {
    headers: {
      "Authorization": "Bearer " + accessToken
    }
};
 
    return axios.get(BACKEND_ADDRESS + "/articles/" + mapType + "/details/" + articleId, config)
      .then(response => response.data)
        .catch(error => {
          if (error.response.status === 400 || error.response.status === 404) {
            alert(error.response.data.errorMessage);
            history.push("/");
            return Promise.reject();
          } else alert("게시 글 가져오기에 실패했음...");
        });
};

 

 

스크롤을 내려서 특정 블록에 닿을 때 이벤트가 발생하는 것은 다음페이지가 있는지 없는지(true인지 false인지)에 따라 구분하여 실행하도록 하였다. true, false 값은 최초접근 시에는 위에서 언급한데로, 댓글리스트 사이즈를 통해 구할 수 있고, 다음 댓글리스트를 조회할 때부터는 Api를 요청했을 때 오는  dto에 포함되어 있기에 바로 적용할 수 있었다.
블록이 보일 시에 이벤트를 발생시키니, 블록을 아예 볼 수 없도록 하면 이벤트를 호출하는 if문을 실행하지 않을 수 있을 것이다. 그래서 삼항 연산자를 통해 다음페이지가 존재하지 않으면(false일 시) 아예 블록이 보이지 않도록 하였다.

 

아래는 무한스크롤을 일으킬 블록을 구현한 것이다. 블록은 이미지로 하였다. 

{hasNextPage ? 
<div ref={ref} style={{textAlign: "center"}}>
<img src="/loading.png" alt="my image" style={{width: "60px", height: "60px"}}/>
</div> : ""}

 

 

아래는 적용된 블록이다.

 

 


이렇게 게시글 첫 조회시 댓글 관련한 값들을 설정하였다. 이제는 가장 아래의 특정 블록이 보일 때, 다음페이지를 요청하는 기능을 구현해야 한다.

 

우선  react 라이브러리인 ‘react-intersection-observer’ 를 설치해야 한다. 

npm install react-intersection-observer --save

 

 

userIdView()를 통해 ref와 inView를 갖는데 ref는 이벤트를 발생시킬 ref, inView는 ref에 해당하는 블록이 화면에 보이면 true로, 화면에서 사라지면 false 값이 된다. 단순히 true, false 값에 따라서 다음페이지를 호출할지 말지를 정할 수가 있는 것이다. ref는 위에서 삼항연산자로 표현된 블록에 지정을 하였다.

 

아래는 구현된 부분이다.

import { useInView } from "react-intersection-observer"

const [commentCursorId, setCommentCursorId] = useState(-1); // 다음 커서 id
const [hasNextPage, setHasNextPage] = useState(false); // 다음 페이지 유무
const [ref, inView] = useInView() // react-intersection-observer 라이브러리

const updateComments = () => {
  //위에서 구현한 Springboot 댓글 다음 페이지 호출 Api
  findCommentApi(articleId, commentCursorId).then(
    (commentPromise) => {
      setComments([...comments.concat(commentPromise.comments)]);
      setHasNextPage(commentPromise.hasNextPage);
      setCommentCursorId(commentPromise.cursorId);
    })
};

//지정한 블록이 보일 때마다 updateComments() 실행
useEffect(() => {
  if(inView && hasNextPage) updateComments()
},[inView]);

...

<div ref={ref} style={{textAlign: "center"}}>
<img src="/loading.png" alt="my image" style={{width: "60px", height: "60px"}}/>
</div> : ""}

 

 

findCommentAPi()는 위에서 구현한 댓글 다음페이지 호출 Api를 실행하는 메서드며 아래와 같다.

const findCommentApi = (articleId, commentCursorId) => {
    return axios.get(BACKEND_ADDRESS + "/comments/read/" + articleId + "/" + commentCursorId)
      .then(response => response.data)
        .catch(error => {
          if (error.response.status === 500) {
            alert("알 수 없는 오류가 발생하였습니다. 관리자에게 문의해 주십시오.")
            return Promise.reject();
          } 
        });
};

 

 

 

 

참고

https://www.npmjs.com/package/react-intersection-observer

 

react-intersection-observer

Monitor if a component is inside the viewport, using IntersectionObserver API. Latest version: 9.5.2, last published: 4 months ago. Start using react-intersection-observer in your project by running `npm i react-intersection-observer`. There are 932 other

www.npmjs.com

 

https://velog.io/@ahn-sujin/ReactIntersection-Observer-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EB%AC%B4%ED%95%9C%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

[React]Intersection Observer 사용하여 무한스크롤 구현하기

무한 스크롤...? 어렵지 않아요😅

velog.io

 

https://velog.io/@jsi06138/React-Intersection-Observer-API%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

[React] Intersection Observer API를 이용한 무한 스크롤 구현하기

이 글은 리액트에서 어떻게 Intersection Oberver API를 이용해서 무한 스크롤을 구현하는지에 대해 설명하고있습니다. Intersection Observer API에 대한 자세한 내용은 Intersection Oberver API - WEB API | M

velog.io

 

https://yunae.tistory.com/entry/React-react-Intersection-Observer-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%B4-%EB%AC%B4%ED%95%9C%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

[React] react-Intersection-Observer 라이브러리를 이용해 무한스크롤 구현하기

InterSerction Observer? => 브라우저 Viewport와 설정한 요소의 교차점을 관찰하여 요소가 viewport에 포함되는지 구별하는 기능 제공 페이지 스크롤 시 이미지를 Lazy-Loading 할 때 무한 스크롤을 통해 새로

yunae.tistory.com