조회 방식에 관한 생각
관계형 데이터베이스에 데이터를 저장하고, 이를 "조회" 해야 하는 상황은 대부분의 백엔드 서비스에서 필연적으로 발생한다. 데이터의 양이 많지 않다면 문제는 단순하다. SELECT * FROM table 과 같은 쿼리로 데이터를 한 번에 조회하더라도 성능이나 응답 시간에 큰 영향을 주지 않는다. 데이터가 조금 더 늘어나면 보통 페이지네이션(pagination) 을 도입한다. 데이터를 일정 단위로 나누어 가져오는 방식은 초기 단계의 서비스에서는 충분히 합리적인 선택이다. 하지만 서비스가 성장하면서 데이터의 규모가 수만, 수십만, 수백만 건으로 증가하면 기존의 방식은 점점 한계를 드러낸다. 조회 속도는 느려지고, 불필요한 리소스 소모가 발생하며, 결과적으로 사용자 경험에도 영향을 미치게 된다. 이러한 문제는 단순히 느리다는 차원이 아니라, 현재 서비스의 특성과 맞지 않는 조회 방식을 사용하고 있기 때문에 발생한다. 중요한 점은 여기서 OFFSET 기반 페이징이 "나쁜 방식"이라서 문제가 되는 것이 아니라, 데이터 규모와 사용 패턴이 달라졌음에도 동일한 설계를 유지하고 있다는 점이다. 이 포스트에서는 대용량 데이터를 조회하는 상황에서 기존 조회 방식이 왜 한계에 부딪히는지 살펴보고, 그 대안으로 등장한 커서 기반 페이징 을 알아본다. 그리고 이번에 만들 토이프로젝트에 적용시킬 예정이라 정리하면서 하려고한다.
기본 조회 방식 (select * from table)
SELECT * FROM users;
나는 보통 테스트나 디버깅 용도로 사용을 한다. 현재 테이블에 어떤 컬럼들이 존재하는지, 그리고 어떤 형태의 데이터가 저장되어 있는지를 빠르게 확인하기 편하기 때문이다. 하지만 이 방식을 그대로 서비스 로직이나 운영 환경에서 사용하게 되면 문제가 된다.
id 컬럼부터 password 와 같은 민감한 정보까지 엔티티의 모든 컬럼이 함께 조회되기 때문이다. 설령 조회되는 데이터의 개수가 적다고 하더라도, 엔티티 전체를 그대로 추출하는 행위는 보안 측면과 설계 관점 모두에서 매우 위험한 접근이라고 볼 수 있다.
페이지네이션 조회 방식
SELECT *
FROM post
ORDER BY id DESC
LIMIT 20 OFFSET 40;
페이지네이션은 대량의 데이터를 여러 페이지로 분할하여 조회하는 기법이다. 예를 들어 페이지 크기가 20이라면, 1페이지에서는 처음 20개의 데이터를 조회하고, 2페이지에서는 앞의 20개를 건너뛴 뒤 21번째 데이터부터 다음 20개를 조회하는 방식이다.
페이지 계산 공식
offset = (page - 1) * size;
지금까지 페이지네이션이 어떻게 동작하는지 간단히 살펴보았다. 이제 이 방식이 가지는 문제점을 생각해볼 차례다. 오프셋기반 페이지네이션에서 만약 오프셋값이 100,000이라면, 데이터베이스는 앞의 10만 행을 먼저 읽은 뒤 그 결과를 모두 버리고 나서야 실제로 필요한 데이터를 반환한다. 이 과정은 인덱스가 존재하더라도 크게 달라지지 않는다. 인덱스는 정렬과 탐색을 빠르게 도와줄 뿐 오프셋으로 인해 발생하는 "읽고 버리는 작업" 자체를 없애주지는 못한다. 결과적으로 OFFSET 값이 커질수록 스캔해야 할 데이터의 양도 함께 증가하고, 조회 성능은 점점 저하된다.
또 다른 문제점
1페이지 → 2페이지 → 데이터 삭제 → 3페이지
- 중복 조회
- 누락 발생
커서 기반 조회
앞서 살펴본 문제들의 공통점은 하나다. OFFSET 기반 페이지네이션은 데이터의 위치를 기준으로 조회한다는 점이다. 하지만 실제 서비스 환경에서 데이터는 끊임없이 추가되고 삭제되며, 이로 인해 "몇 번째 데이터"라는 기준은 쉽게 무너진다. 커서 기반 조회는 이러한 문제를 해결하기 위해 데이터의 위치가 아닌, 정렬 기준이 되는 값 자체를 커서(cursor)로 사용한다. 예를 들어 게시글을 id DESC 기준으로 조회한다고 가정해보자.
SELECT *
FROM post
ORDER BY id DESC
LIMIT 20;
첫 요청에서는 가장 최신 게시글 20개를 조회한다. 그리고 응답 결과의 마지막 id 값을 다음 요청의 커서로 사용한다.
SELECT *
FROM post
WHERE id < :cursor
ORDER BY id DESC
LIMIT 20;
이 방식에서는 데이터베이스가 더 이상 앞의 데이터를 읽고 버릴 필요가 없다. 인덱스를 기준으로 커서 값 이후의 데이터만 바로 탐색하기 때문에, 조회 성능은 데이터의 전체 크기와 무관하게 일정하게 유지된다. 또한 중간에 데이터가 추가되거나 삭제되더라도, "마지막으로 본 데이터 이후"라는 기준은 변하지 않기 때문에 중복 조회나 누락 문제도 발생하지 않는다.
- OFFSET 사용하지 않음
- 읽고 버리는 행 없음
- 데이터 변경에 강함
- 무한 스크롤 / 피드 형태의 서비스에 적합
참고
Cursor based Pagination(커서 기반 페이지네이션)이란? - Querydsl로 무한스크롤 구현하기
Cursor based Pagination, 커서 기반 페이징, 무한스크롤
velog.io
