Backend

페이징 성능 개선하기 (w. 커서 페이징)

mopil 2023. 12. 29. 01:27
반응형

일반적으로 많은 양의 데이터를 조회할 때 (목록조회) 이를 한 번에 다 가져오지 않고 페이지로 쪼개서 가져온다.

 

이를 페이징(페이지네이션)이라고 하며 일반적으로 

 

이런 형태로 보인다.

 

# 일반 페이징 구현

일반적인 페이징은 SQL의 LIMIT, OFFSET 구문으로 구현된다. (MySQL 기준이긴 하지만, 다른 디비도 비슷하다)

 

select *
from board
limit 10 offset 10;

 

LIMIT은 한 번에 가져올 양, OFFSET은 page * size로 동적으로 계산된다.

 

예를 들어, 3번째 페이지의 내용물을 가져오고 싶으면 limit 10 offset 3 * 10 이 된다.

 

추가적으로 프론트에게 몇 개의 총 데이터 수와, 페이지가 존재하는지 알려주기 위해서 전체 데이터 count() 쿼리도 포함된다.

 

위 그림을 예시로, 총 5개의 페이지가 존재함을 미리 알고 있다. 이는 전체 데이터 count() 쿼리를 통해 전체 존재하는 데이터 수를 구하고, 이를 limit 수 (보통 size 수)로 나눈 값을 의미한다.

 

 

# 일반 페이징의 단점

일반 페이징은 데이터 수가 많아질수록 성능이 저하된다.

(여기서 데이터 수는 테이블의 전체 데이터 수를 의미한다.)

 

이는 전체 페이지 수를 계산할 때, 전체 데이터 count() 로직이 시간이 많이 소요되기 때문이다.

(full-scan을 하면서 몇 개의 데이터가 존재하는지 카운트 하기 때문)

 

그리고 SQL의 오프셋의 크기가 커질수록 데이터베이스는 첫 번째 오프셋부터 순차적으로 가져와서 나머지는 버린 후 레코드들을 리턴하기 때문이다.

 

select *
from board
limit 10 offset 100000;

 

100,000 ~ 100,010 번째 데이터를 가져오기 위해서 디비는 0 ~ 100,000까지 데이터를 임시테이블로 가져오고 버린다.

 

따라서 페이징 성능을 향상하기 위해선, 저 두 부분 (오프셋 로직과 전체 카운트)을 개선할 필요가 있음을 알 수 있다.

 

 

# 커서 페이징

이러한 일반 페이징의 단점을 해결할 수 있는 방법이 커서 페이징 방법이다.

 

커서 페이징은 노 오프셋 페이징이라고도 불리는데, 말 그대로 성능 저하가 발생하는 오프셋 부분을 제거하는 것이다.

 

커서 페이징의 기본 아이디어는 id(커서)를 기준으로 다음번 데이터를 조회하는 것이다.

 

select *
from board
where borad_id > 123
limit 10

 

여기서 id(123)가 커서를 의미한다.

 

커서는 보통 PK로 설정된다. 

 

그 이유는,

  • 한 레코드를 식별할 수 있는 값임
  • PK는 생성과 동시에 인덱스가 설정되어 있음 (즉, 인덱스로 인한 정렬이 이미 되어있어서 빠름)

 

하지만 다음과 같이 일반 칼럼을 적용할 수도 있다.

 

select *
from board
where created_at > '2023-11-02 11:22:33'
limit 10

 

이때 주의해야 할 점은, where 조건에 들어가는 커서가 되는 칼럼은 인덱스가 설정되어 있어야 한다.

 

이 부분만 잘 챙긴다면, 커서는 어떤 칼럼으로도 가능하다.

 

 

우리는 여기서 커서 페이징이 빠른 이유가 인덱스를 기반으로 레코드를 찾기 때문임을 알 수 있다. 

 

 

 

 

첫 번째 페이지를 조회하고 싶다면 cursor_id를 0으로 주거나, 어플리케이션 레벨에서 별도의 분기로직을 작성하면 된다.

 

select *
from board
where borad_id > 0
order by borad_id
limit 10

 

 

커서 페이징 방법을 사용하면 오프셋 구문을 사용하지 않기 때문에, 오프셋에 의한 성능저하로부터 자유롭다.

(덤으로 오프셋에 의한 임시 테이블 생성도 없어져서 디비 부하도 줄어든다.)

 

이러한 커서 페이징 방식은 실시간 조회 성능이 중요한 무한 스크롤 방식의 구현 방법으로도 널리 사용된다.

(ex. 인스타그램 피드)

 

마지막으로 성능저하를 야기했던, 전체 페이지 수 count()도 제거하여 커서 페이징에서는 사용하지 않는다.

 

 

 

# 커서 페이징 단점?

일반 페이징의 성능 저하가 발생하는 부분을 모두 제거했으니, 보편적인 경우 커서 페이징의 성능이 압도적으로 우월하다.

 

하지만 은 탄환은 없듯이 단점도 존재한다.

 

전체 페이지 수, 총 데이터 수 등 정보를 얻을 수 없다

일반 페이징에서 얻을 수 있는 정보들을 얻을 수 없다.

 

이는 성능 저하가 발생하는 count() 쿼리 부분을 제거했기 때문에 당연히 발생하는 trade-off다.

 

즉, 사용자는 임의의 페이지(ex. 100페이지 중 34번 페이지)를 접근하는 것이 불가능하다.

 

 

구현이 복잡하다

커서가 되는 칼럼(PK)에 대한 정렬 로직이 필요하다.

 

정렬이 PK 오름차순만 있으면 간단한데, 내림차순만 있거나 다른 칼럼 정렬이 들어가면 구현이 좀 복잡해진다.

 

 

 

 

# 다른 방법으로 개선해 보기

커서 페이징 방법을 사용하지 않고 일반 페이징 성능을 향상 시키는 시도를 해볼 순 있다.

 

커버링 인덱스 사용

일반 페이징 where 조건에 들어가는 칼럼들을 모두 인덱스로 만들면 오프셋을 조회하는 부분에서 성능 향상을 가져올 수 있다.

 

count() 쿼리를 비동기로 수행

결국 두 번의 쿼리를 수행하여 어플리케이션 레벨에서 요청한 페이지 정보와, 몇 개의 페이지가 존재하는지를 리턴해야 할 때 몇 개의 페이지가 존재하는지 부분 (count 쿼리 부분)을 비동기로 수행하는 것이다.

 

조회 로직이 복잡하면, 페이지 조회 + 카운트 쿼리를 동시에 수행함으로써 성능 향상을 가져갈 수 있을 것이다.

 

쿼리 튜닝

본문 예시에서는 조회 쿼리 로직이 간단하지만, 보통 목록조회는 여럿 테이블을 조인하고 데이터를 가공하는 등 복잡한 로직을 거치는 경우가 많다.

 

필요 없는 조인은 제거하고, 인덱스를 추가하거나, 필요한 정보를 모아놓은 별도의 임시테이블을 생성하는 것도 방법이 될 수 있다.

 

(물론 이미 대용량 데이터가 있다는 것은 디비 단위에서 뭔가를 하는 게 굉장히 부담스러운 작업이긴 할 것이다..)

 

 

파티셔닝

결국 큰 모수가 문제이므로, 여러 테이블로 파티셔닝 하는 것도 방법이다.

 

가령, 게시물의 경우 생성일자별로, 연도별 혹은 월별로 파티셔닝을 설계할 수도 있을 것이다.

 

 

 

 

# 결론

커서 페이징이 성능적으로 우수한 것은 인덱스를 활용하기 때문임을 알 수 있다.

 

하지만 일반 페이징의 정보(전체 페이지 수 등)를 알 수 없게되는 trade-off가 발생한다.

 

따라서 다음과 같은 상황에 적절히 페이징 구현 방법을 적용하면 좋을 것 같다.

 

데이터 수가 많지 않음, 전체 페이지 정보가 중요함 -> 일반 페이징

데이터 수가 많음, 전체 페이지 정보가 중요하지 않음 -> 커서 페이징

 

 

보통 데이터 수가 많으면 데이터베이스 레벨에서 개선을 하는 것 보단, 어플리케이션 레벨에서 해결하는게 부담이 적으므로, 커서페이징 도입을 적극적으로 고려해보자.

 

반응형