앱이나 웹 서비스를 만들면서 무한 스크롤이나 페이지네이션을 구현해봤다면, 한 번쯤 데이터베이스 쿼리 속도 때문에 머리를 싸맨 경험이 있을 겁니다. 저도 Supabase로 처음 프로젝트를 시작했을 때 초반엔 괜찮았는데, 데이터가 쌓이고 페이지를 넘길수록 로딩 시간이 눈에 띄게 길어지더라고요. 2019년 Shopify가 겪었던 문제도 바로 이거였습니다. 흔히 쓰는 LIMIT-OFFSET 방식이 특정 상황에서는 성능을 400배나 떨어뜨릴 수 있다는 사실, 알고 계셨나요? 이번 글에서는 왜 이런 문제가 발생하는지, 그리고 실무에서 어떻게 해결할 수 있는지 제 경험과 함께 풀어보겠습니다.

페이지 뒤로 갈수록 느려지는 이유: Offset의 함정
LIMIT-OFFSET은 데이터베이스 페이징에서 가장 기본적으로 배우는 방식입니다. 예를 들어 20개씩 보여주는 상품 목록에서 2페이지를 조회한다면 SELECT * FROM products LIMIT 20 OFFSET 20 이런 식으로 쿼리를 작성하죠. 여기서 OFFSET이란 건너뛸 행의 개수를 의미합니다. 쉽게 말해 처음 20개는 무시하고 그다음 20개만 가져오겠다는 뜻입니다.
문제는 페이지 번호가 커질수록 발생합니다. 50,000페이지를 조회한다면 OFFSET 1000000이 되는데, 데이터베이스는 실제로 100만 개 행을 전부 읽은 뒤 그중 99만 9,980개를 버리고 겨우 20개만 반환합니다. Shopify가 테스트했을 때 작은 OFFSET에서는 밀리초 단위로 빠르게 응답하던 쿼리가, OFFSET이 커질수록 수 초까지 느려졌고, 100만 건 기준에서는 아예 타임아웃이 발생했다고 합니다(출처: Shopify Engineering Blog).
저도 Lovable이랑 처음 Supabase 연동해서 앱 만들 때 이 부분을 간과했습니다. 초반 테스트에서는 데이터가 몇백 개 수준이라 문제없었는데, 나중에 페이지를 막 넘기면서 테스트해보니까 확실히 체감되더라고요. 특히 Supabase 무료 플랜은 리소스가 제한적이라 더 민감하게 느껴졌습니다. 제가 만든 프로젝트는 아직 사용자가 많지 않아서 큰 문제는 안 됐지만, 실제 서비스라면 치명적일 수 있습니다.
더 심각한 건 Shopify처럼 여러 스토어가 같은 데이터베이스 인스턴스를 공유하는 구조에서는 한 스토어의 무거운 쿼리가 다른 스토어에까지 영향을 준다는 점입니다. 인기 있는 쇼핑몰이 대량의 상품 목록을 조회할 때마다 같은 서버를 쓰는 작은 스토어까지 느려지는 거죠. 실제로 소규모 스토어의 첫 페이지 조회조차 실패하는 경우도 있었다고 합니다.
Cursor 기반 페이징으로 400배 빠르게 만들기
Shopify가 찾은 해결책은 Cursor 기반 페이징입니다. Cursor란 특정 행의 식별자(보통 ID)를 기준으로 그다음 데이터를 가져오는 방식을 말합니다. 예를 들어 20개를 조회한 뒤 마지막 항목의 ID가 1000이라면, 다음 페이지에서는 SELECT * FROM products WHERE id > 1000 LIMIT 20처럼 쿼리를 작성하는 겁니다.
이 방식의 핵심 장점은 데이터베이스가 건너뛸 행을 일일이 읽지 않아도 된다는 점입니다. 인덱스가 걸린 ID 컬럼을 기준으로 바로 해당 위치를 찾아가니까요. Shopify 테스트 결과 OFFSET 방식이 2초 걸리던 쿼리가 Cursor 방식으로는 0.107밀리초, 즉 400배 가까이 빨라졌습니다. 페이지가 뒤로 갈수록 격차는 더 벌어집니다.
Cursor 기반 페이징을 적용할 때 주의할 점을 정리하면 다음과 같습니다:
- ID 컬럼에 인덱스가 반드시 있어야 합니다. 인덱스가 없으면 WHERE 조건이 의미가 없어집니다
- 정렬 기준이 되는 컬럼이 중복되지 않고 순서가 보장되어야 합니다. 보통 자동 증가하는 ID나 생성 시간을 사용합니다
- 마지막 조회 결과에서 21개를 가져와 20개만 표시하고, 21번째의 ID를 다음 페이지 Cursor로 사용하는 식으로 구현합니다
저는 지금 Cursor로 작업하는 대규모 프로젝트에서 MCP 기능으로 Supabase를 연결해서 쓰고 있는데, 확실히 데이터가 몇만 건 쌓여도 응답 속도가 일정하게 유지됩니다. 제 경험상 이 방식이 실무에서 훨씬 안정적입니다.
다만 한 가지 제약은 있습니다. 임의의 페이지 번호로 바로 이동하는 건 어렵습니다. OFFSET처럼 "50,000페이지로 가기" 같은 건 불가능하고, 앞뒤로 순차적으로 넘기는 방식만 가능하죠. 하지만 실제 서비스에서 사용자가 임의의 페이지 번호를 직접 클릭하는 경우가 얼마나 될까요? 대부분 무한 스크롤이나 다음/이전 버튼으로 탐색하니까 큰 문제는 아닙니다. 정 필요하면 초반 몇 페이지까지는 OFFSET을 쓰고, 그 이후부터 Cursor로 전환하는 하이브리드 방식도 고려할 수 있습니다.
백엔드 설계에서 놓치기 쉬운 현실적인 고민
일반적으로 스타트업이나 개인 개발자들은 처음엔 데이터가 적어서 이런 문제를 체감하기 어렵습니다. 저도 그랬고요. 그런데 서비스가 조금씩 성장하면서 사용자가 늘고 데이터가 쌓이면, 갑자기 느려진 쿼리 때문에 사용자 경험이 망가지는 순간이 옵니다. 그때 가서 코드 전체를 뜯어고치는 것보다, 처음부터 확장 가능한 구조로 설계하는 게 훨씬 현명하다는 걸 배웠습니다.
Supabase를 쓰면서 하나 아쉬운 점은 무료 플랜에서 일정 기간 활동이 없으면 프로젝트가 자동으로 멈춘다는 겁니다. 저도 바쁜 일로 며칠 건드리지 않았다가 갑자기 데이터베이스가 멈춰 있는 걸 발견하고 당황한 적이 있습니다. 다행히 90일 안에 재활성화하면 데이터는 살릴 수 있지만, 메일 알림을 놓치면 어느 순간 닫혀 있을 수 있어서 신경 써야 합니다. 그래도 무료로 쓸 수 있다는 장점이 크기 때문에, 연습용이나 부업 프로젝트로는 여전히 유용합니다.
지금 AWS가 클라우드 데이터베이스 시장에서 압도적인 점유율을 차지하고 있지만, Supabase나 다른 신생 플랫폼들도 점점 성능과 기능을 개선하고 있습니다. 제가 만약 스타트업을 창업한다면 당연히 안정성이 검증된 솔루션을 선택하겠지만, 스타트업에 다니는 개발자로서 사이드 프로젝트를 할 때는 Supabase 같은 플랫폼을 충분히 활용할 것 같습니다(출처: Stack Overflow Developer Survey 2024). 실무에서 바로 쓸 만한 기술을 익히는 동시에 비용 부담 없이 실험해볼 수 있으니까요.
페이징 최적화는 단순히 쿼리 하나 바꾸는 문제가 아니라, 서비스 전체의 확장성과 사용자 경험에 직결되는 문제입니다. 저는 이번에 Shopify 사례를 보면서, 기본기를 제대로 다지는 게 얼마나 중요한지 다시 한번 느꼈습니다. 여러분도 지금 LIMIT-OFFSET으로 페이징을 구현하고 있다면, 한 번쯤 Cursor 방식으로 전환을 고려해보시길 권합니다. 특히 데이터가 많거나 성능이 중요한 서비스라면 말이죠.