어떻게 하면 화면에 나타내는 데이터를 효율적으로 나타낼 수 있을까?

SeokHwan An·2025년 2월 4일
0

dayone

목록 보기
1/3

DayOne 서비스를 구축하는 과정에서 BookLog(책에 대한 자신의 생각을 남긴 글) 데이터를 클라이언트(프론트)에게 제공하는 것에 대해서 프론트 분과 이야기를 나누었습니다. 기존의 legacy 코드에서는 해당 데이터들을 모두 제공하고 클라이언트(프론트) 측에서 해당 데이터를 모두 나타내는 방향으로 구현되어 있었습니다.

전체 데이터를 반환하는 것은 적은 데이터 량에서는 큰 문제가 없겠지만 데이터가 늘어나는 상황에서는 조회성능에 문제가 발생할 것이라고 생각했습니다.
아직 배포가 되지 않았지만 MySQL에서 실제 데이터 10,000 개가 있을 때 성능을 살펴보았습니다.(배포 후에는 실 환경에서 테스트를 해보고자 합니다.)

순수하게 db에서 데이터를 조회하는데 걸리는 시간이 1,106ms이고 사용자까지 전달되는 네트워크 통신까지 고려하며 1.5초가 넘는 시간이 발생할 수 있다고 판단했습니다.

그래서 전체 데이터를 한번에 제공하는 대신에 일정한 개수의 데이터를 제공하는 페이지네이션을 적용해 예상되는 조회 성능 문제를 예방하고자 했습니다.

페이지 네이션(Pagination)이란?

페이지 네이션(Pagination)은 조회 데이터를 불러올 때 데이터를 부분적으로 불러오는 기법입니다.페이지 네이션의 방식에는 Off-set 방식과 Cursor 방식이 있었고 이 두 방식 중 어떤 방식을 DayOne 서비스에 적용할지 고민했고 결정에 앞서서 이 둘을 비교했습니다.

OffSet Pagination

OffSet 페이지네이션 방식은 offset과 limit을 활용해 데이터를 페이징하는 방식입니다. offset은 가져올 데이터의 시작위치를 의미하며 limit은 보여줄 데이터의 량을 의미합니다. OffSet 페이징 방식은 많은 게시판에서 자주 활용되며 구글에서도 활용하는 방식입니다.

OffSet 페이지네이션의 SQL의 형태는 다음과 같습니다. (정렬은 있을 수도 있고 없을 수도 있습니다.)

SELECT 조회할 정보
FROM 테이블
ORDER BY 정렬 컬럼
LIMIT 10 OFFSET 10;

해당 쿼리는 offset이 10이고 10개의 데이터를 조회하는 쿼리입니다. 해당 쿼리가 실행되는 방식에 대해서 살펴보겠습니다.

응답에 필요한 데이터를 조회하기 앞서 offset이 10인 것은 만족하기위해서 테이블 내 데이터를 순차적으로 불러온 후에 필요한 데이터를 반환하는 방식입니다. 해당 방식의 문제로는 offset의 값이 커질 수록 그만큼 데이터를 탐색하는 시간이 늘어나 성능저하가 발생할 수 있습니다.

데이터를 늘려가면서 확인해보겠습니다.

  • 데이터 개수가 10,000개일 때 OffSet이 9950이고 Limit이 10개인 경우 → 55ms

  • 데이터 개수가 1,000,000개일 때 OffSet이 999,950이고 Limit이 10개인 경우 → 305ms

  • 데이터 개수가 20,000,000개일 때 OffSet이 19,999,950 Limit이 10개인 경우 → 4s

데이터가 많아지고 예전 데이터를 조회할 때 성능이 기하 급수적으로 낮아지는 것을 볼 수 있습니다.

추가적으로 OffSet 페이지네이션의 경우 최신 순 데이터를 불러오는 과정에서는 중복된 데이터 조회가 발생할 수 있다는 특징이 있습니다. 간단하게 예시를 살펴보겠습니다.

  1. 사용자1이 페이지1에 속한 데이터를 조회한다.
  2. 사용자2가 새로운 데이터를 추가한다.
  3. 사용자1이 페이지2에 속한 데이터를 조회한다.
  4. 사용자1은 페이지1에서 조회했던 데이터를 페이지2에서도 조회합니다. (이름이 최장민 이라는 데이터가 중복되어서 조회 됩니다.)

해당 원인은 OffSet 방식은 특정 컬럼값 이후로 데이터를 조회하는 것이 아닌 앞선 데이터의 개수를 기반으로 데이터를 조회하다보니 발생할 수 있는 해프닝입니다.

Cursor Pagination

Cursor 페이지 네이션은 non-off set 페이지네이션이라고 불리며 Cursor와 Limit 정보를 바탕으로 데이터를 조회하는 방식입니다. Cursor는 불러올 데이터의 포인터이며 Limit은 OffSet 페이지네이션과 마찬가지로 보여줄 데이터의 개수를 의미합니다.

Cursor Pagination의 SQL의 형태는 다음과 같습니다.

SELECT 조회할 정보
FROM 테이블
WHERE Cursor 컬럼 > 특정한 포인트 정보
LIMIT 10

해당 쿼리는 point 정보를 기반으로 10개의 데이터를 불러오는 쿼리입니다. 해당 쿼리가 어떻게 동작하는지 알아보겠습니다.

다음과 같이 유저 id가 3보다 큰 유저정보 10개를 불러오는 SQL 요청이 발생하면 유저 id가 3인 컬럼을 찾을 후 응답 데이터를 순차 탐색해 반환해줍니다. WHERE절을 통한 데이터 필터 방식이기에 인덱스를 활용하면 조회 성능을 높일 수 있습니다. (인덱스를 컬럼을 설정하면 정렬이 되기 때문에 해당 조건을 빠르게 탐색할 수 있습니다.)

  • 데이터 개수가 10,000개일 때 Cursor가 9950이고 Limit이 10개인 경우 → 59ms

  • 데이터 개수가 1,000,000개일 때 Cursor가 999,950이고 Limit이 10개인 경우 → 70ms

  • 데이터 개수가 20,000,000개일 때 Cursor가 19,999,950이고 Limit이 10개인 경우 → 81ms

데이터가 늘어남에도 조회 성능차이가 크게 나지 않는다는 것을 볼 수 있습니다.

그래서 어떤 방식을 사용할 것인가요?

지금까지 Cursor 방식과 Offset 방식을 비교하며 데이터를 조회하는 방법을 살펴보았습니다. 데이터의 양이 많지 않고 데이터가 자주 생성되지 않는 경우라면 두 방식 중 어느 것을 선택해도 큰 문제가 발생하지 않을 것입니다. 현재 DayOne 서비스는 기수제로 운영되고 있고 많은 회원이 있는 상황은 아니며, 1인당 매주 최대 4개의 BookLog를 작성하도록 되어 있습니다. 이를 바탕으로 예측해보면, 최대 데이터는 주당 약 100개의 BookLog에 불과합니다. 그렇기에 데이터가 자주 생성되지 않아 Offset 방식의 문제인 중복된 데이터 조회 문제가 자주 발생하지 않을 것입니다.

그럼에도 불구하고 이번에는 Cursor 방식을 선택했는데, 그 이유는 “사용자 편의성”에 중점을 두었기 때문입니다. DayOne 서비스는 모바일 친화적인 UI를 제공하고 있으며, 사용자는 인스타그램이나 유튜브 쇼츠처럼 빠르게 목록을 탐색하는 방식에 익숙한 상태입니다. 이러한 사용 패턴을 고려했을 때, 무한 스크롤 방식이 적합하다고 판단했습니다.

반면, Offset 방식을 사용할 경우 데이터가 증가함에 따라 페이지 번호가 늘어나게 됩니다. 이는 모바일 화면에서 적절히 표시하기 어려울 뿐 아니라, 사용자가 번호를 클릭해 탐색해야 하는 불편함을 초래할 수 있습니다. 이러한 이유로 저는 Cursor 방식을 채택하여 사용자 경험을 제공하고자 했습니다.

Cursor Pagination 적용하기

JPA에서 페이지네이션을 구현하는 것은 크게 어렵지 않았습니다. JpaRepository는 PagingAndSortingRepository를 상속받고 있기에 Pagable 객체를 인자로 받아 손쉽게 페이지네이션을 적용할 수 있습니다. Pagable은 JPA에서 페이징 처리를 쉽게 하기 위해 제공하는 인터페이스 입니다. Pagable에는 ‘페이지 번호’와 ‘페이지 크기’ 요소가 있는데 Cursor 페이지 네이션의 경우 페이지 번호가 중요하지 않습니다.

DayOne에서는 bookLog를 최신 순으로 제공하는 방식을 정책으로 결정하여 Cursor에 이용할 정보를 id로 설정했습니다. Cursor 페이지 네이션을 구현하기 위해서는 다음에 조회할 데이터 위치에 대한 정보를 클라언트 측으로 부터 받아야 합니다. 그렇기에 백엔드 쪽에서도 다음에 조회할 데이터의 존재 유무와 위치 정보를 응답으로 제공해주어야 하고 다음과 같은 응답 형색을 구성했습니다.

{
    "next" : false, // 뒤에 데이터가 존재하면 true 그렇지 않으면 false
    "book_logs" : [ {
      "id" : 1,
      "passage" : "의미있는 구절",
      "comment" : "내가 느낀 감정",
      "like_count" : 1,
      "book_title" : "책 제목",
      "created_at" : "2025-02-04T11:41:48.605719"
    } ],
    "next_cursor" : -1 // 뒤에 데이터가 존재하면 다음 조회 id 제공 없으면 -1
}

Repository

Cursor 페이지네이션을 적용하기 위해 두가지 시나리오를 떠올렸습니다.

  1. BookLog를 처음으로 조회하는 경우
  2. 특정 BookLog 뒤에 이어진 데이터를 조회하는 경우

1번과 같이 처음 데이터를 조회하는 경우에는 Cursor에 정보가 필요 없고 가장 최신 데이터부터 반환하는 방식을 적용했고 2번과 같이 특정 데이터 이후에 정보를 조회하기 위해서는 Cursor 정보인 id를 기준으로 뒤의 데이터를 불러오는 방식을 선택하여 다음과 같이 구현했습니다.

각 메소드들은 생성일자를 기준으로 역정렬(최신순)을 한 뒤 pageable을 통해 필요한 데이터 개수만큼 조회하는 방식입니다. 이때 반환되는 데이터 타입을 List를 하지 않고 Slice 타입을 이용했습니다. Slice 타입은 JPA에서 제공하는 페이지 처리 결과 타입 중 하나로 다음 페이지가 존재하는지 여부를 쉽게 파악할 수 있는 특징이 있습니다. (Page 객체를 이용하는 방법도 있겠지만 전체 데이터 개수, 전제 페이지 수가 필요한 것은 아니기에 Slice를 이용했습니다.)

Service

Service에서는 cursor 정보를 인자로 받아 초기 BookLog 요청인지 아닌지 파악하고 알맞은 응답을 제공해줍니다.

앞선 처음에 실험했던 동일한 데이터에 대해서 페이지네이션 쿼리를 실행했을 때 57ms가 나오는 것을 볼 수 있습니다.

정리

페이지네이션을 구현하는 방식은 어렵지 않았습니다. 하지만 이번 경험을 통해 전체 데이터를 어떻게 효율적으로 제공할 수 있을까에 대해서 고민하면서 페이지네이션을 접했고 이를 구현한 두 가지 방식에 대해 학습할 수 있습니다.

페이징처리 방식은 서비스가 발전하면서 변화될 수도 있겠지만 지금처럼 왜 적용하려고 하는지 잘 생각해보면서 적용하는 것이 중요할 것 같습니다. (단순히 좋아보인다고 해서 적용하는 것은 지양하고자 합니다.)

참고자료

0개의 댓글

관련 채용 정보