본문 바로가기

Backend

페이지네이션이 항상 빠를까? 직접 실험해봤습니다

CS 말하기 학습 플랫폼 "CS 뽁뽁" 프로젝트를 개선하면서 겪은 경험을 공유합니다.

배경

 

CS 뽁뽁 서비스에는 말하기 퀴즈 목록 페이지가 있습니다.

사용자는 난이도와 카테고리를 선택해 퀴즈를 필터링할 수 있고, 이 페이지가 서비스 전체에서 가장 많은 요청이 발생하는 지점이었습니다.

퀴즈 목록 조회 시 말하기 퀴즈 테이블과 카테고리 테이블을 조인하여 데이터를 가져오는 구조로 구현했습니다.


1단계: 캐시 도입 (기존 방식)

처음에는 다음과 같은 구조였습니다.

사용자 필터 변경 → 클라이언트 → Next 서버 → API 서버 → DB → 필터링된 결과 반환

 

필터를 바꿀 때마다 DB까지 요청이 내려가는 구조였고, 팀원분이 이 부분에서 문제를 발견했습니다.

"사용자가 많아지면 이 구조가 그대로 버틸 수 있을까?"

 

그래서 Next.js 캐시를 도입했습니다.

 

개선된 구조:

최초 1회 → API 서버 → DB에서 전체 퀴즈 조회 → Next 서버에 캐시 저장 이후 요청 → 캐시에서 필터링 후 반환 (DB 접근 없음)
 
 

도입 전에 아래 세 가지를 먼저 검토했습니다.

캐시 크기 퀴즈 1000개, 개당 수백 바이트 → 수 MB 미만 → 문제없음
실시간성 필요 여부 퀴즈는 자주 변경되지 않음 → 캐시 적합
필터링 서버 부하 O(n), 1000개 수준 → Next 서버에서 충분히 처리 가능

 

당시에는 합리적인 판단이라고 생각되어 팀 내에서 반영하기로 결정했고, 실제로 응답 속도도 큰 무리없이 빠르게 조회가 되었습니다.


2단계: 페이지네이션 도입 (개선 방식)

하지만 실제 개발 과정에서 불편한 점이 생겼습니다.

퀴즈 데이터를 새로 추가하거나 수정할 때마다, 캐시에 저장된 이전 데이터가 그대로 반환되는 문제가 있었습니다. DB에는 분명 새 데이터가 들어갔는데 화면에는 반영이 안 되는 상황이 반복됐고, 매번 캐시를 수동으로 갱신하거나 서버를 재시작해야 했습니다.

퀴즈 데이터가 "자주 변경되지 않는다"는 전제로 캐시를 도입했지만, 개발 중에는 데이터를 자주 추가하고 확인해야 하다 보니 캐시 무효화 문제가 생각보다 번거롭게 느껴졌습니다.

 

또한 캐시 방식이 잘 동작하고 있었지만, 서비스를 개발하면서 한 가지 고민이 생겼습니다.

사용자는 한 번에 퀴즈 전체를 보지 않는다는 점이었습니다. 페이지를 열었을 때 모든 퀴즈데이터가 한번에 조회되는 것이 아닌, 난이도와 카테고리를 조합해서 원하는 퀴즈 몇 개만 찾아보는 게 일반적인 사용 패턴이었습니다.

그런데 그때마다 1000개를 통째로 불러오는 건 낭비 아닐까? 라는 생각이 들었고 이를 개선하기 위해 커서 기반 페이지네이션을 도입하여 개선을 진행했습니다.

  • main_quiz_id > :cursor 조건으로 20개씩만 조회
  • 사용자가 실제로 보는 데이터만 가져오므로 불필요한 DB 부하 제거
  • 무한스크롤과 결합해 UX도 자연스럽게 개선

커서 기반 페이지네이션이 오프셋 방식보다 나은 이유는 성능뿐만 아니라 데이터 일관성 때문이기도 했습니다.

오프셋 방식은 중간에 데이터가 추가/삭제되면 페이지가 밀리는 문제가 생기는데, 커서 방식은 WHERE main_quiz_id > {마지막_ID} 로 정확한 지점부터 읽기 때문에 이 문제가 발생하지 않는다는 점을 중점으로 도입하게 되었습니다.

 

페이지네이션을 도입한 이후 팀원분이 흥미로운 사실을 공유해주셨습니다.

SSR로 퀴즈 1000개를 한번에 반환했을 때 0.07s밖에 안 걸렸어요. 캐시를 쓰면 API 왕복 비용이 거의 없다 보니 오히려 더 빠르더라고요. 그래도 페이지네이션이 UX상 장점이 많아서 도입한 것도 좋은 선택이었다고 생각합니다.

 

여기서 자연스럽게 이런 의문이 생겼습니다.

 

"그럼 페이지네이션으로 바꾼 게 진짜 성능상 이득이 있는 건가? 수치로 확인해본 적이 없는데."

 

이 질문에서 출발해서 직접 EXPLAIN ANALYZE로 실험해봤습니다.


실험 환경

1. DB : PostgreSQL

2. 테이블

- tb_main_quiz (더미 데이터 각각 1000건, 1만건, 10만건으로 실험 진행)

- tb_quiz_category (8개의 카테고리로, 각 퀴즈 더미데이터가 균일하게 분포되도록 구성)

퀴즈 목록 조회 시 말하기 퀴즈 테이블과 카테고리 테이블을 조인하여 데이터를 가져옵니다.

말하기 퀴즈 ERD


시작하기 전에 : EXPLAIN ANALYZE 읽는 법

실험 결과를 보기 전에, 실행계획 해석법을 간단히 짚고 넘어가겠습니다.

처음에 저도 EXPLAIN ANALYZE를 통해 조회를 했지만 어떻게 해석하는건지 감이 잡히지 않아 AI와 열심히 학습했답니다ㅎㅎ

 

EXPLAIN ANALYZE를 활용하여 성능을 측정하려는 쿼리를 작성하면 다음과 같은 결과값을 얻을 수 있습니다.

기본적인 개념은 해당 글을 참고해서 학습했습니다.

 

간단한 키워드만 몇개 정리하여 현재 실행 계획에서 인덱스를 제대로 타고 있는지 확인하는 부분을 집중했습니다.

키워드 의미  
Seq Scan 테이블 전체를 처음부터 끝까지 읽음  
Index Scan 인덱스 타고 필요한 것만 읽음  
Rows Removed by Filter 읽고 버린 행 수 많을수록 비효율

어떻게 해석하는거지..?

 

일반적으로 안쪽(들여쓰기 깊은 곳)에서 바깥쪽으로, 즉 아래에서 위로 읽으며 분석합니다.

 

쿼리를 실행하면 옵티마이저는 아래 순서로 동작합니다.

 

1. tb_quiz_category 테이블 스캔 (build)

tb_quiz_category를 Seq Scan으로 전체 조회합니다. 총 8건밖에 없는 소규모 테이블이라 인덱스를 타는 것보다 풀스캔이 더 효율적입니다. 1페이지 전부 버퍼 캐시에서 읽어 디스크 I/O는 발생하지 않았습니다.

  • 소요 시간: 0.026 → 0.029 ms
  • 반환 행수: 8건
  • 버퍼: shared hit 1 page

2. Hash 빌드

Seq Scan으로 가져온 8건을 메모리에 해시 테이블로 올립니다. 메모리 사용량은 9kB에 불과하고, Batches = 1이므로 디스크로 스필(spill)되지 않고 전부 메모리 안에서 처리됐습니다. 해시 조인에서 가장 이상적인 상태입니다.

  • Buckets: 1024 / Batches: 1
  • Memory: 9 kB

3. tb_main_quiz 인덱스 스캔 (probe)

PK 인덱스를 사용해 main_quiz_id <= 1000 조건에 해당하는 행을 범위 스캔합니다. 읽어온 각 행의 quiz_category_id를 2에서 만든 해시 테이블에 조회해 매칭합니다. 22페이지 모두 버퍼 캐시 히트로 처리됐습니다.

  • 소요 시간: 0.138 → 0.696 ms
  • 반환 행수: 1,000건 (예측 1,039 → 통계 오차 작음)
  • 버퍼: shared hit 22 pages

4. Hash Join 종료

quiz_category_id 기준 조인을 완료하고 최종 1,000행을 반환합니다. 전체 23페이지 모두 버퍼 캐시에서 처리됐고, 실행 시간이 플래닝 시간보다 짧다는 점에서 쿼리 자체가 충분히 단순하다는 것을 알 수 있습니다.

  • 소요 시간: 0.331 → 1.325 ms
  • 전체 Execution Time: 1.634 ms
  • 버퍼: shared hit 23 pages

결론적으로 현재 tb_main_quiz는 PK 인덱스를 타고 조회되고 있으며, tb_quiz_category는 8건의 소규모 테이블이라 Seq Scan으로 처리됩니다. 두 테이블을 Hash Join으로 결합해 말하기 퀴즈 목록을 조회하는 데 총 1.634 ms가 소요되는 것을 확인할 수 있었습니다.


실험 1: 캐시 방식 vs 페이지네이션

캐시 방식의 경우, 결국 전체 데이터를 한번에 조회하여 가져오는 로직으로 구성되므로 다음과 같이 쿼리를 구성했습니다.

N은 각각 데이터가 1000건, 1만건, 10만건, 100만건인 경우를 비교하기 위해 where절로 조건을 추가했습니다.

Query

-- 캐시 방식 시뮬레이션 (전체 조회)
SELECT q.*, c.*
FROM tb_main_quiz q
JOIN tb_quiz_category c ON q.quiz_category_id = c.quiz_category_id

-- 페이지네이션
SELECT q.*, c.*
FROM tb_main_quiz q
JOIN tb_quiz_category c ON q.quiz_category_id = c.quiz_category_id
WHERE q.main_quiz_id > :cursor
ORDER BY q.main_quiz_id ASC
LIMIT 20;

 

결과

데이터 규모 전체 조회 (캐시 방식) 페이지네이션 차이
1,000건 1.712 ms 0.210 ms 8배
10,000건 8.603 ms 0.490 ms 17배
100,000건 49.243 ms 0.455 ms 108배
1,000,000건 250.205 ms 0.535 ms 467배

왜 이런 차이가 날까?

실행계획을 보면 명확합니다.

전체 조회 시, 실행 계획
페이징 처리로 조회 시, 실행 계획

전체 조회: Seq Scan on tb_main_quiz ← 100만건 전부 읽음

페이지네이션: Index Scan using PK ← 21개 읽고 멈춤

 

전체 조회는 데이터에 비례해서 선형으로 느려지고, 페이지네이션은 데이터가 늘어도 거의 일정합니다.

 

전체 조회: O(N) → 데이터 100배 = 응답시간 29배 증가
페이지네이션: O(log N) → 데이터 100배여도 응답시간 거의 동일

실험 2: 인덱스 추가 후 비교

단일 인덱스를 추가하고 다시 측정했습니다.

CREATE INDEX idx_main_quiz_category ON tb_main_quiz(quiz_category_id);
CREATE INDEX idx_quiz_category_name ON tb_quiz_category(name);

전체 조회 (캐시 방식)

데이터 규모 인덱스 없음 인덱스 있음 개선
1,000건 1.712 ms 0.918 ms 1.9배
10,000건 8.603 ms 7.437 ms 1.2배
100,000건 49.243 ms 38.431 ms 1.3배

 

전체 조회는 인덱스를 추가해도 별로 빨라지지 않습니다. 어차피 전체를 다 읽어야 하기 때문이죠.

최종 비교 

데이터 규모 전체 조회 페이지네이션 차이
1,000건 0.918 ms 0.242 ms 4배
10,000건 7.437 ms 0.316 ms 24배
100,000건 38.431 ms 0.318 ms 121배

 

결론적으로, 전체 조회 시 데이터가 증가할수록 조회 시간이 데이터 양에 비례해서 늘어나기 때문에, 페이지네이션을 적용해 일정량씩 나누어 조회하는 방식이 추후 데이터가 많아져도 일정한 응답 시간을 유지할 수 있다는 장점을 확인할 수 있었습니다.

실험 3: 필터 조건 추가 시 인덱스 효과

추가적으로 테스트를 한가지 더 진행해보았습니다.

실제 서비스에서는 카테고리와 난이도 필터가 붙습니다. 필터 조건이 추가 되었을 때, where절로 조건을 필요로 하는 경우 복합 인덱스를 생성하여 인덱스 효과가 있는지 분석해보고자 했습니다.


01 전체 조회 + 인덱스 최적화 분석

필터 있는 전체 조회에서 복합 인덱스 추가 전후를 비교했습니다.

1. 전체 조회 + 인덱스 적용하지 않음

전체 조회 + 인덱스 적용 x

 

인덱스가 적용되지 않은 경우, 100만건의 데이터를 loops를 3번 반복하여 돌면서 풀 스캔을 진행한 뒤, 330054건의 데이터를 버리는 것을 확인할 수 있습니다.

필요한 데이터만 골라 읽는 게 아니라 전체를 다 읽은 후 걸러내는 구조라 디스크 I/O가 8,531페이지나 발생하는 것을 확인할 수 있었습니다. 병렬 처리를 써도 읽어야 할 양 자체가 줄지는 않기 때문에 실행 시간은 86.071ms 가 나왔습니다.

 

2. 전체 조회 + 복합 인덱스 적용

이번에는 복합인덱스를 다음과 같이 적용하여 테스트를 진행했습니다.

-- 등치(=) 조건 먼저, 범위(>) 조건 마지막 순서가 핵심
CREATE INDEX idx_main_quiz_composite
ON tb_main_quiz(quiz_category_id, difficulty_level, main_quiz_id);

 

전체 조회 + 복합 인덱스 적용

 

복합 인덱스를 적용하자 옵티마이저가 Bitmap Index Scan 전략을 선택하여 동작했습니다. 세 조건을 인덱스 단계에서 한꺼번에 처리해 처음부터 1,251건만 추려내는 것을 확인할 수 있었습니다.

버리는 행이 0건이고 디스크 read도 9페이지로 줄었고, 실행 시간은 2.680ms 로 크게 줄어든 것을 확인했습니다.

 

상태 실행시간 스캔 방식
인덱스 없음 86.071 ms Seq Scan (330,054개 버림)
복합 인덱스 있음 2.680 ms Bitmap Index Scan

 

결론적으로, 인덱스를 적용하기 전/후의 차이는 약 32배 차이가 난다는 것을 확인할 수 있었습니다.

 


02 커서 기반 페이지네이션 + 인덱스 최적화 분석

이후에는, 페이지네이션 쿼리를 기반으로 인덱스 최적화가 되는지 확인해보고자 실험을 진행했습니다.

이 과정에서 여러가지 문제에 부딪히게 되었습니다.

-- 실제 커서 기반 쿼리
SELECT q.*, c.*
FROM tb_main_quiz q
JOIN tb_quiz_category c ON q.quiz_category_id = c.quiz_category_id
WHERE q.main_quiz_id > :cursor
  AND c.name = :category
  AND q.difficulty_level = :difficulty
ORDER BY q.main_quiz_id ASC
LIMIT 20;

 

 

1. 복합 인덱스 시도

난이도(difficulty_level)와 카테고리 조건을 함께 사용하는 쿼리에서 추가 최적화를 시도했습니다.

처음에는 (quiz_category_id, difficulty_level, main_quiz_id) 복합 인덱스를 걸었는데, 옵티마이저가 계속 PK를 선택하는 것을 확인할 수 있었습니다. 원인을 분석해보니 데이터 분포 때문이라는 결론이었습니다.

 
초반에 더미데이터를 삽입할 때 문제의 난이도와 카테고리를 모두 100만건을 기준으로 균등하게 분배되도록 조정했습니다.
 
sql
SELECT difficulty_level, COUNT(*) FROM tb_main_quiz GROUP BY difficulty_level;
-- 상: 333,384 / 중: 333,437 / 하: 333,342

이처럼 상/중/하가 33%씩 균등하게 분포되어 있어서 선택성이 너무 낮았다는 점이 문제가 되었습니다.

옵티마이저 입장에서는 복합 인덱스를 타도 전체의 33%를 읽어야 하니 PK로 순서대로 읽다가 LIMIT을 채우는 게 더 낫다고 판단하고 있었습니다.


2. 왜 인덱스가 안걸리지..?

그래서 '하' 데이터를 10,000건(전체의 1%)으로 줄이고 다시 실행계획을 뽑아보고자 했습니다.

 

데이터 크기 조정

 

하지만, 쿼리 구조를 수정하고 데이터 분포도 조정했는데도, 커서 값이 작을 때는 여전히 PK를 선택하는 경우가 있었습니다.

 

인덱스 x
복합 인덱스 적용 후

 

직접 커서 값을 바꿔가며 실험해보니 흥미로운 패턴이 나타났습니다.


커서 값 남은 범위 옵티마이저 판단
> 0 100만건 (전체) PK 선택
> 500000 50만건 PK 선택
> 800000 20만건 복합 인덱스 선택

커서 값이 작을수록 남은 범위가 넓어서 옵티마이저가 "PK로 순서대로 읽다가 20개 채우면 끝" 이라고 판단합니다. 반대로 커서 값이 클수록 남은 범위가 줄어들기 때문에 복합 인덱스로 필터링하는 게 더 유리하다고 판단하는 것이였습니다.

즉, 커서 기반 페이지네이션은 페이지가 뒤로 갈수록 복합 인덱스 효과가 커지는 구조로 동작하고 있었습니다. 앞 페이지에서 PK를 선택하는 것도 잘못된 게 아니라, 옵티마이저가 상황에 맞는 최적 경로를 선택한 결과라는 것을 알 수 있었습니다.

 

이 실험에서 배운 핵심은 인덱스는 걸었다고 무조건 타는 게 아니라는 점입니다.

데이터 분포, 쿼리 구조, 커서 값에 따라 옵티마이저가 매번 비용을 계산해서 최적 경로를 선택합니다.

특히 PostgreSQL은 MySQL과 달리 인덱스 힌트(USE INDEX, FORCE INDEX)를 지원하지 않기 때문에, 옵티마이저가 올바른 판단을 하도록 쿼리 구조와 인덱스 설계로 유도하는 것이 중요하다는 점을 알게 되었습니다.

 

  PK 인덱스 복합 인덱스
Rows Removed by Filter 200,163건 버림 0건
Execution Time 100.603 ms 0.338 ms

그래서, 페이지네이션으로 바꾼 건 잘한 걸까?

 

사실 이 질문에 대한 답을 내리기가 쉽지 않았습니다.

 

팀원분이 "SSR로 1,000개를 한번에 반환해도 0.07s밖에 안 걸렸다"고 하셨을 때, 솔직히 '그럼 굳이 바꿀 필요가 있었나?' 하는 생각이 들기도 했습니다. 실제로 캐시 방식도 당시 규모에서는 충분히 빠르게 동작하고 있었으니까요.

 

그런데 실험을 해보니, 데이터 규모를 늘려가며 직접 측정해보니 다음과 같은 결과가 나왔습니다.

1,000건 1.712 ms 0.210 ms 8배
10,000건 8.603 ms 0.490 ms 17배
100,000건 49.243 ms 0.455 ms 108배
1,000,000건 250.205 ms 0.535 ms 467배

 

지금은 1,000건이라 큰 차이가 체감되지 않지만, 데이터가 늘어날수록 전체 조회는 선형으로 느려지는 반면 페이지네이션은 데이터 양과 무관하게 응답 시간이 거의 일정하게 유지됐습니다. 이번 테스트를 통해 페이지네이션의 확장성 측면에서의 장점을 수치로 확인할 수 있었습니다.

 

다만, 퀴즈 데이터 특성상 데이터가 급격히 늘어날 가능성은 낮기 때문에, 현재 규모에서는 기존 방식도 충분히 유효했을 수 있습니다. 결국 이번 변경은 당장의 성능 개선보다는, 혹시 모를 확장 상황에 대비한 구조적 개선에 가깝다고 볼 수 있겠네요ㅎㅎ


성능 외에도 얻은 것들이 있다

페이지네이션으로 전환하면서 성능 이외에도 몇 가지가 함께 해결됐습니다.

캐시 방식에서는 데이터를 추가하거나 수정할 때마다 캐시를 수동으로 갱신하거나 서버를 재시작해야 하는 번거로움이 있었는데, 페이지네이션은 항상 DB에서 최신 데이터를 가져오기 때문에 이 문제가 자연스럽게 사라졌습니다.


돌아보며

페이지네이션 도입이 "정답이었다"고 자신 있게 말하기보다는, 지금 규모에서는 어느 쪽이든 크게 문제가 없었다고 볼 수 있겠네요.

다만, 확장성 측면에서 어떤 차이가 생기는지 직접 수치로 확인해볼 수 있었다는 점에서 의미가 있었습니다.
그리고 매번 조회 기능을 구현할 때에는 데이터를 한번에 불러오는 것보다는 페이징 처리를 통해 가져오는 것이 당연하다고만 생각해왔는데, 이러한 고정관념을 깰 수 있었던 경험이기도 했습니다.

무엇보다 이번 실험을 통해 "왜 이 방식을 선택했는가"에 대한 답을 감이 아닌 수치로 설명할 수 있게 됐다는 점이 가장 큰 수확이었습니다.