PRO DBDATA PRO DBDATA

MySQL 페이지 번호가 커질수록 느려지는 이유: OFFSET과 키셋 페이지네이션 비교

읽는 시간 약 26분
MySQL 페이지 번호가 커질수록 느려지는 이유

게시판이나 주문 내역처럼 목록을 페이지 단위로 보여주는 화면에서, 1페이지는 바로 열리는데 수만 번째 페이지로 갈수록 응답이 늦어지는 경우가 있습니다. 화면에 표시되는 건수는 똑같이 20건이라 인덱스 누락이나 서버 자원 부족을 먼저 의심하기 쉽습니다. 하지만 실행계획을 열어 보면 원인이 전혀 다른 곳에 있을 수 있습니다.

이 글에서는 MySQL 8.4.11 로컬 환경에 100만 건 테이블을 만들고 두 가지 조회 방식을 EXPLAIN ANALYZE로 비교했습니다. 하나는 LIMIT … OFFSET 방식이고, 다른 하나는 직전에 읽은 마지막 id를 조건으로 넘기는 키셋(keyset) 페이지네이션입니다. 실행 시간보다 각 실행 노드가 실제로 몇 행을 처리했는지를 기준으로 원인을 확인했습니다. 서비스 쿼리로 옮길 때 필요한 다음 페이지 판단, 이전 페이지 조회, 작성일 정렬용 커서 작성 방법도 함께 정리했습니다.

확인한 내용결과
OFFSET 900,000 조회PRIMARY 인덱스에서 900,020행을 읽고 20건 반환, 957ms
키셋(id > 900000) 조회PRIMARY 범위 스캔으로 20행을 읽고 20건 반환, 0.083ms
두 방식의 반환 결과id 900001~900020, 20건 완전히 동일
지연의 직접 원인인덱스 미사용이 아니라 건너뛸 행까지 모두 처리하는 구조

표의 시간 값은 각 쿼리를 한 번 실행한 캡처 기준이며 반복 측정 평균이 아닙니다. 그래서 몇 배 빨라졌다는 숫자보다 처리 행 수의 차이를 판단 근거로 사용했습니다.

테스트 환경과 100만 건 데이터 준비

항목내용
환경Windows 로컬 MySQL, MySQL Workbench
MySQL 버전8.4.11
스키마 / 테이블dba_pagination_lab / pagination_posts
데이터 건수1,000,000건 (id 1~1,000,000)
인덱스PRIMARY KEY (id)만 존재
InnoDB 버퍼 풀128MiB
격리 수준REPEATABLE-READ
조회 조건id, title, created_at 컬럼을 id 오름차순으로 20건
측정 도구EXPLAIN ANALYZE
MySQL 8.4.11, 버퍼 풀 128MiB, REPEATABLE-READ, 데이터 100만 건, id 범위 1~1000000, PRIMARY(id) 인덱스를 한 번에 조회한 결과
버전, 버퍼 풀, 격리 수준, 건수, id 범위, 인덱스 구성을 한 쿼리로 확인한 결과입니다.

인덱스는 기본키 하나뿐입니다. 이후 실행계획에 나오는 PRIMARY는 모두 이 클러스터형 인덱스를 의미합니다. InnoDB에서는 이 인덱스를 따라 읽는 것이 곧 테이블 행을 읽는 것입니다.

재현용 테이블과 데이터 생성 SQL

별도 테스트 스키마에서 한 번만 실행하는 준비 작업입니다. 같은 이름의 테이블이 이미 있다면 먼저 확인한 후 실행합니다.

CREATE DATABASE IF NOT EXISTS dba_pagination_lab;
USE dba_pagination_lab;

CREATE TABLE pagination_posts (
    id BIGINT UNSIGNED NOT NULL,
    title VARCHAR(100) NOT NULL,
    created_at DATETIME NOT NULL,
    payload VARCHAR(200) NOT NULL,
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO pagination_posts (id, title, created_at, payload)
WITH digits AS (
    SELECT 0 AS d UNION ALL SELECT 1 UNION ALL SELECT 2
    UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
    UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8
    UNION ALL SELECT 9
),
numbers AS (
    SELECT 1 + a.d + b.d * 10 + c.d * 100
             + d.d * 1000 + e.d * 10000 + f.d * 100000 AS n
    FROM digits a
    CROSS JOIN digits b
    CROSS JOIN digits c
    CROSS JOIN digits d
    CROSS JOIN digits e
    CROSS JOIN digits f
)
SELECT
    n,
    CONCAT('Post-', LPAD(n, 7, '0')),
    TIMESTAMPADD(SECOND, n, '2026-01-01 00:00:00'),
    REPEAT('x', 100)
FROM numbers
ORDER BY n;

COMMIT;
ANALYZE TABLE pagination_posts;

0~9 숫자 10개를 여섯 번 교차 조인하면 1부터 1,000,000까지의 번호가 만들어집니다. created_at은 행마다 1초씩 증가하므로 이 테스트 데이터에서는 작성일이 중복되지 않습니다. 이 특징은 뒤에서 작성일 정렬 커서를 다룰 때 다시 언급합니다. payload 컬럼은 행 크기를 실제 게시글 테이블과 비슷하게 만들기 위해 100자로 채웠습니다.

측정 기준

EXPLAIN ANALYZE는 쿼리를 실제로 실행한 후 노드별 시간과 행 수를 보여줍니다. 비교에는 각 실행계획 최상단 노드의 완료 시간을 사용했습니다. 이 값에는 계측 비용이 포함되며, 네트워크 전송과 화면 표시까지 포함하는 애플리케이션 응답 시간과는 다릅니다.

예열 여부와 실행 당시의 물리 읽기 횟수는 기록하지 않았습니다. 직접 재현할 때는 몇 가지 조건을 지키는 것이 좋습니다. 데이터 입력이 끝난 뒤에는 측정 중에 테이블을 변경하지 않습니다. 각 쿼리를 한 번 예열한 다음 같은 순서로 세 번 이상 실행하고 중앙값을 기록합니다.

OFFSET 900000은 id 900000이 아니라 앞의 90만 행입니다

페이지당 20건인 목록에서 페이지 번호와 OFFSET의 관계는 다음과 같습니다. 여기서 페이지는 웹 화면의 페이지 번호입니다. InnoDB가 데이터를 저장하는 16KB 단위의 물리 페이지와는 다른 개념입니다.

OFFSET = (페이지 번호 - 1) × 페이지 크기

1페이지:      (1 - 1) × 20 = 0
5,001페이지:  (5,001 - 1) × 20 = 100,000
45,001페이지: (45,001 - 1) × 20 = 900,000

LIMIT 20 OFFSET 900000은 id가 900000인 행을 찾아가라는 검색 조건이 아닙니다. 정렬된 결과의 앞에서부터 900,000행을 세어 버린 다음 20행을 돌려달라는 뜻입니다. 따라서 ORDER BY id에 맞는 기본키가 있어도 MySQL은 어디서부터 읽어야 하는지 알 수 없습니다. 결국 인덱스의 처음부터 읽기 시작합니다.

뒤쪽 페이지가 느려질 때 실행계획에서 확인할 질문은 세 가지로 나눌 수 있습니다.

확인할 질문실행계획에서 볼 부분
인덱스를 사용하고 있는가스캔 노드에 PRIMARY 등 인덱스 이름이 있는지
별도 정렬이 발생하는가Sort 노드가 있는지
건너뛸 행까지 처리하는가하위 스캔 노드의 actual rows가 OFFSET + LIMIT에 가까운지

이번 테스트에서는 앞의 두 질문에는 문제가 없었습니다. 원인은 세 번째 질문에서 드러났습니다.

EXPLAIN ANALYZE로 본 OFFSET 크기별 처리 행 수

OFFSET 0: 인덱스에서 20행만 읽고 끝납니다

EXPLAIN ANALYZE
SELECT id, title, created_at
FROM pagination_posts
ORDER BY id
LIMIT 20 OFFSET 0;
-> Limit: 20 row(s)  (cost=0.094 rows=20) (actual time=0.217..0.227 rows=20 loops=1)
    -> Index scan on pagination_posts using PRIMARY  (cost=0.094 rows=20) (actual time=0.211..0.219 rows=20 loops=1)
OFFSET 0 조회의 EXPLAIN ANALYZE 결과. PRIMARY 인덱스 스캔에서 20행을 읽고 Limit 노드가 20건을 반환했으며 최상단 완료 시간은 0.227ms
첫 페이지는 PRIMARY 인덱스를 처음부터 20행만 읽고 바로 종료했습니다.

Sort 노드 없이 기본키 순서대로 읽었습니다. 하위 Index scan의 실제 행 수와 최종 반환 행 수가 모두 20입니다. 읽은 만큼 그대로 돌려준 가장 효율적인 형태입니다.

OFFSET 100,000: 반환은 20건, 처리는 100,020행입니다

EXPLAIN ANALYZE
SELECT id, title, created_at
FROM pagination_posts
ORDER BY id
LIMIT 20 OFFSET 100000;
-> Limit/Offset: 20/100000 row(s)  (cost=1363 rows=20) (actual time=52.8..52.8 rows=20 loops=1)
    -> Index scan on pagination_posts using PRIMARY  (cost=1363 rows=100020) (actual time=1.48..49.7 rows=100020 loops=1)
OFFSET 100000 조회의 EXPLAIN ANALYZE 결과. 반환 건수는 20건이지만 PRIMARY 인덱스 스캔의 실제 행 수가 100020으로 증가했고 최상단 완료 시간은 52.8ms
최상단 노드 이름이 Limit/Offset으로 바뀌고, 하위 스캔이 100,020행을 처리했습니다.

화면에 보여줄 20행을 얻기 위해 버릴 100,000행까지 인덱스에서 읽어 상위 노드로 올렸습니다. 실행계획의 행 수는 정확히 OFFSET + LIMIT입니다.

OFFSET 900,000: 900,020행을 읽은 뒤 20건만 남깁니다

EXPLAIN ANALYZE
SELECT id, title, created_at
FROM pagination_posts
ORDER BY id
LIMIT 20 OFFSET 900000;
-> Limit/Offset: 20/900000 row(s)  (cost=86011 rows=20) (actual time=957..957 rows=20 loops=1)
    -> Index scan on pagination_posts using PRIMARY  (cost=86011 rows=900020) (actual time=9.72..868 rows=900020 loops=1)
OFFSET 900000 조회의 EXPLAIN ANALYZE 결과. 20건을 반환하기 위해 PRIMARY 인덱스에서 900020행을 읽었고 최상단 완료 시간은 957ms
인덱스를 사용하고 정렬도 없지만, 20건을 위해 900,020행을 처리했습니다.

이 계획에도 Sort 노드는 없습니다. 인덱스가 정렬 순서를 해결해 주더라도 앞쪽 행을 읽고 버리는 작업은 그대로 남습니다. 인덱스 이름만 보고 문제가 없다고 판단하면 놓치게 되는 부분입니다.

실행계획 숫자를 읽을 때 구분할 점

actual time의 두 숫자는 해당 노드가 첫 행을 반환한 시점과 마지막 행을 반환한 시점입니다. 단위는 밀리초입니다. 이번 캡처는 loops가 모두 1이므로 값을 반복 횟수로 나누어 해석할 필요가 없습니다.

OFFSET 900,000의 최상단 노드가 957..957로 표시된 것은 첫 결과 행조차 앞의 90만 행을 버린 다음에야 나온다는 뜻입니다. 이후 20행 처리 시간이 0이라는 의미가 아니라 표시 정밀도에서 같은 값으로 보이는 것입니다.

상위 노드의 시간에는 하위 노드의 시간이 포함됩니다. 따라서 957ms와 868ms를 더하지 않습니다. 두 값의 차이인 약 89ms는 Limit/Offset 노드가 하위에서 올라온 행을 받아 세고 버리는 과정과 계측 비용이 더해진 부분으로 볼 수 있습니다.

actual rows는 실행 노드가 상위로 넘긴 행 수입니다. 디스크에서 읽은 횟수나 InnoDB 페이지 접근 횟수와는 다른 값입니다.

처리 행이 9배 늘 때 시간은 약 18배 늘었습니다

캡처 값을 행당 시간으로 나누어 보면 흥미로운 차이가 보입니다.

조회처리 행 수최상단 완료 시간행당 시간(계산값)
OFFSET 100,000100,02052.8ms약 0.53µs
OFFSET 900,000900,020957ms약 1.06µs

처리 행은 약 9배인데 시간은 약 18배였습니다. 테이블 뒤쪽까지 읽는 동안 행당 비용이 올라간 것입니다. 이 테이블은 행마다 100자 payload를 가지고 있어 전체 데이터가 128MiB 버퍼 풀을 넘었을 가능성이 있습니다. 그렇다면 스캔 후반에 디스크 읽기가 섞였을 수 있습니다. 다만 이번 테스트에서는 물리 읽기를 측정하지 않았으므로 원인을 확정할 수는 없습니다.

직접 확인하려면 테이블 크기와 버퍼 풀 읽기 카운터를 쿼리 실행 전후로 비교합니다.

-- 테이블 데이터 크기 (ANALYZE TABLE 이후 값이 갱신됩니다)
SELECT table_name,
       ROUND(data_length / 1024 / 1024, 1) AS data_mb
FROM information_schema.TABLES
WHERE table_schema = 'dba_pagination_lab'
  AND table_name = 'pagination_posts';

-- OFFSET 쿼리 실행 직전과 직후에 각각 조회해 차이를 비교합니다
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';

Innodb_buffer_pool_read_requests는 버퍼 풀에 대한 논리 읽기 요청 수입니다. Innodb_buffer_pool_reads는 버퍼 풀에 없어서 디스크에서 직접 읽어야 했던 횟수입니다. 이번 OFFSET 쿼리처럼 인덱스를 순서대로 길게 읽으면 InnoDB가 선행 읽기(read-ahead)로 페이지를 미리 가져오는데, 이 페이지는 Innodb_buffer_pool_read_ahead에 따로 집계됩니다. 따라서 실행 전후로 reads와 read_ahead를 함께 비교해야 디스크 읽기가 지연에 섞였는지 판단할 수 있습니다. 모두 전역 카운터이므로 다른 세션이 없는 테스트 환경에서 비교해야 의미가 있습니다.

어느 쪽으로 확인되든 근본 원인은 같습니다. 메모리에서 읽든 디스크에서 읽든, 90만 행을 처리하는 구조 자체를 바꾸지 않으면 비용은 OFFSET에 비례해 계속 늘어납니다.

마지막 id를 조건으로 넘긴 키셋 쿼리

키셋 방식은 몇 행을 건너뛸지 대신 어디서부터 읽을지를 조건으로 전달합니다. 이전 페이지의 마지막 id가 900000이었다면 다음 페이지는 다음과 같이 조회합니다.

EXPLAIN ANALYZE
SELECT id, title, created_at
FROM pagination_posts
WHERE id > 900000
ORDER BY id
LIMIT 20;
-> Limit: 20 row(s)  (cost=42130 rows=20) (actual time=0.0753..0.083 rows=20 loops=1)
    -> Filter: (pagination_posts.id > 900000)  (cost=42130 rows=210240) (actual time=0.0738..0.0796 rows=20 loops=1)
        -> Index range scan on pagination_posts using PRIMARY over (900000 < id)  (cost=42130 rows=210240) (actual time=0.0717..0.0757 rows=20 loops=1)
id > 900000 조건을 사용한 키셋 조회의 EXPLAIN ANALYZE 결과. PRIMARY 인덱스 범위 스캔으로 20행만 읽었고 최상단 완료 시간은 0.083ms
스캔 방식이 Index scan에서 Index range scan으로 바뀌고, 실제 행 수가 20으로 줄었습니다.

실행계획은 세 단계로 읽을 수 있습니다. 가장 아래 Index range scan은 B-tree에서 900000 다음 위치를 바로 찾아가 읽기 시작합니다. 중간의 Filter 노드는 범위 조건을 한 번 더 확인하는 단계로, 20행에 대해서만 수행되므로 비용은 무시할 수준입니다. 최상단 Limit 노드는 20행이 채워지는 순간 스캔을 멈춥니다.

범위 스캔 노드의 예상 행 수는 210,240으로 표시됩니다. 실제로 id가 900000보다 큰 행은 100,000건이고, 실제로 읽은 행은 20건입니다. 예상치는 범위 전체에 대한 옵티마이저 추정입니다. 실제 행 수가 작은 이유는 LIMIT 때문에 실행이 일찍 끝났기 때문입니다. 이 경우 접근 경로가 PRIMARY 범위 스캔 하나뿐이어서 추정치가 실제 범위의 두 배쯤 되어도 계획 선택에는 영향이 없었습니다. 예상과 실제가 다르다는 이유만으로 통계가 잘못됐다고 판단하지 않는 것이 좋습니다.

네 가지 조회 결과 비교

조회 방식스캔 방식하위 스캔 실제 행 수최상단 완료 시간반환 행 수
OFFSET 0Index scan200.227ms20
OFFSET 100,000Index scan100,02052.8ms20
OFFSET 900,000Index scan900,020957ms20
키셋 id > 900000Index range scan200.083ms20

키셋 조회는 데이터의 90% 지점에서 시작하면서도 첫 페이지와 같은 20행만 처리했습니다. 처리량이 페이지 위치와 무관하게 일정하다는 점이 이 방식의 핵심입니다.

키셋 조회(0.083ms)가 첫 페이지(0.227ms)보다 빠르게 나온 것은 방식의 우열로 보지 않습니다. 둘 다 20행을 읽었고, 1ms 미만의 실행 시간은 캐시 상태와 실행 순서, 계측 오차에 쉽게 흔들립니다.

두 방식이 같은 20건을 반환하는지 검증

쿼리를 바꿀 때는 빨라졌는지와 함께 같은 행을 반환하는지도 확인해야 합니다. 두 결과의 건수, 첫 id, 마지막 id, 전체 id 목록을 한 화면에서 비교했습니다.

WITH offset_page AS (
    SELECT id, title, created_at
    FROM pagination_posts
    ORDER BY id
    LIMIT 20 OFFSET 900000
),
keyset_page AS (
    SELECT id, title, created_at
    FROM pagination_posts
    WHERE id > 900000
    ORDER BY id
    LIMIT 20
)
SELECT
    'OFFSET' AS method,
    COUNT(*) AS returned_rows,
    MIN(id) AS first_id,
    MAX(id) AS last_id,
    GROUP_CONCAT(id ORDER BY id SEPARATOR ',') AS id_list
FROM offset_page

UNION ALL

SELECT
    'KEYSET',
    COUNT(*),
    MIN(id),
    MAX(id),
    GROUP_CONCAT(id ORDER BY id SEPARATOR ',')
FROM keyset_page;
OFFSET 방식과 KEYSET 방식이 모두 20건, 첫 id 900001, 마지막 id 900020, 동일한 id 목록을 반환한 검증 결과
두 방식 모두 id 900001부터 900020까지 같은 20건을 반환했습니다.

두 방식 모두 20건을 반환했고 첫 id 900001, 마지막 id 900020, 전체 id 목록까지 일치했습니다. id가 1부터 빠짐없이 이어지고 테스트 중 데이터 변경이 없었기 때문에 OFFSET 900000과 id > 900000이 같은 위치를 가리킨 것입니다. 이 검증 쿼리는 집계와 문자열 연결이 포함되어 있으므로 성능 비교에는 사용하지 않습니다.

서비스 쿼리로 옮길 때 필요한 키셋 패턴

테스트 쿼리는 마지막 id를 숫자로 직접 적었지만 실제 서비스에서는 애플리케이션이 커서 값을 바인딩해 전달합니다. 아래 패턴은 이번 테스트에서 시간을 측정한 쿼리가 아닙니다. 적용 전에 각 환경에서 EXPLAIN ANALYZE로 범위 스캔이 나오는지 확인해야 합니다.

다음 페이지 여부는 21건을 조회해 판단합니다

-- ? : 현재 페이지 마지막 행의 id
SELECT id, title, created_at
FROM pagination_posts
WHERE id > ?
ORDER BY id
LIMIT 21;

21건이 돌아오면 앞의 20건만 화면에 표시하고 다음 페이지 버튼을 활성화합니다. 다음 요청의 커서는 20번째 행의 id입니다. 다음 페이지가 있는지 알기 위해 COUNT(*)로 전체 건수를 세면 그 쿼리가 다시 인덱스 전체를 읽게 되므로, 키셋으로 얻은 이점이 줄어듭니다.

이전 페이지는 역방향으로 읽고 다시 정렬합니다

-- ? : 현재 페이지 첫 행의 id
SELECT id, title, created_at
FROM (
    SELECT id, title, created_at
    FROM pagination_posts
    WHERE id < ?
    ORDER BY id DESC
    LIMIT 20
) AS prev_page
ORDER BY id;

안쪽 쿼리는 현재 첫 행 바로 앞에서부터 기본키를 거꾸로 20행 읽습니다. 바깥쪽 ORDER BY는 화면 표시 순서인 오름차순으로 되돌리는 역할이며, 대상이 20행뿐이라 정렬 비용은 작습니다. 애플리케이션에서 배열을 뒤집는 방식으로 처리해도 됩니다.

작성일 정렬은 작성일과 id를 함께 커서로 사용합니다

이번 테스트는 중복이 없는 id로 정렬했기 때문에 id 하나로 경계를 정할 수 있었습니다. 작성일 순 목록은 같은 시각에 작성된 행이 여러 개일 수 있습니다. created_at만 커서로 쓰면 경계에 걸린 행이 누락되거나 중복됩니다. 정렬을 created_at, id로 확정하고 커서에도 두 값을 함께 담아야 합니다.

-- 정렬 순서와 같은 복합 인덱스 (운영 테이블은 DDL 영향 검토 후 적용)
ALTER TABLE pagination_posts ADD INDEX idx_created_id (created_at, id);

-- ? : 현재 페이지 마지막 행의 created_at, created_at, id
SELECT id, title, created_at
FROM pagination_posts
WHERE created_at > ?
   OR (created_at = ? AND id > ?)
ORDER BY created_at, id
LIMIT 20;

실행계획에서 idx_created_id를 사용한 범위 스캔이 나오는지 확인합니다. 환경에 따라 OR 조건 때문에 범위 스캔이 선택되지 않으면 created_at >= ? AND (created_at > ? OR id > ?) 형태로 시작 경계를 먼저 고정하는 방법을 비교해 볼 수 있습니다. 앞서 설명했듯 이 테스트 데이터는 작성일이 모두 다르므로, 동점 처리를 검증하려면 일부 행의 created_at을 같은 값으로 바꾼 뒤 확인해야 합니다.

검색 조건이 붙으면 다시 측정합니다

상태나 카테고리 같은 필터가 추가되면 키셋 방식도 조건에 맞는 20건을 찾기 위해 더 많은 후보 행을 읽을 수 있습니다. 필터 컬럼을 앞에 두고 정렬 컬럼을 뒤에 둔 복합 인덱스가 필요한지 판단하려면, 필터를 적용한 쿼리로 actual rows를 다시 확인해야 합니다.

키셋 방식으로 바꾸기 전에 확인할 화면 요구사항

특정 페이지 번호로 바로 이동해야 하는가

키셋 방식은 직전 페이지의 경계 값을 알고 있을 때만 쓸 수 있습니다. 다음 페이지, 더 보기, 무한 스크롤처럼 순서대로 이동하는 화면에 잘 맞습니다. 반대로 45,001페이지로 바로 이동하는 기능이 필요하다면 그 위치의 커서를 얻을 별도 방법이 있어야 합니다.

페이지 번호에 20을 곱해 id를 계산하는 방식은 사용할 수 없습니다. 삭제로 id 사이가 비거나 검색 조건이 붙으면 행의 순번과 id가 어긋납니다. 커서는 항상 이전 응답의 실제 행에서 가져와야 합니다. 커서를 찾기 위해 큰 OFFSET을 매번 실행한다면 비용이 다른 쿼리로 옮겨갈 뿐입니다. 이런 화면은 이동 가능한 페이지 범위를 제한하거나 검색 조건으로 대상을 좁히는 방법을 함께 검토합니다.

여러 페이지를 같은 시점의 데이터로 보여줘야 하는가

키셋 방식은 앞쪽 행이 삭제될 때 OFFSET 위치가 밀려 행이 건너뛰어지는 문제를 줄여 줍니다. 그러나 요청 사이에 데이터나 정렬 키가 바뀌면 다음 페이지의 구성도 달라집니다. 격리 수준이 REPEATABLE-READ라도 각 HTTP 요청은 별도 트랜잭션이므로 여러 페이지가 하나의 스냅샷을 공유하지 않습니다. 페이지 간 일관성이 꼭 필요하다면 페이지네이션 방식과 별도로 설계해야 합니다.

버퍼 풀을 늘리기 전에 처리 행 수부터 확인합니다

메모리를 늘리면 같은 90만 행을 더 빨리 읽을 수는 있습니다. 하지만 90만 행을 읽어야 하는 구조는 그대로입니다. 먼저 실행계획의 actual rows로 처리량을 줄일 수 있는지 확인하고, 그 다음에 남는 지연을 자원 지표와 함께 분석하는 순서가 비용 대비 효과가 큽니다.

화면 요구사항판단 기준
앞쪽 몇 페이지만 주로 보는 목록OFFSET으로도 충분할 수 있으며 실제 지연을 보고 판단
깊은 구간까지 순서대로 넘기는 목록정렬 기준에 맞춘 키셋 방식 검토
임의 페이지 번호로 바로 이동OFFSET 비용과 커서 확보 방식, 이동 범위 제한을 함께 비교
정렬·검색 조건이 다양함조건별 인덱스와 실행계획의 actual rows 확인
여러 페이지를 같은 시점으로 표시페이지네이션과 별도로 일관성 요구사항 설계

이번 테스트의 OFFSET 900,000 조회는 PRIMARY 인덱스를 정상적으로 사용했고 정렬 단계도 없었습니다. 그런데도 957ms가 걸린 이유는 반환하지 않을 90만 행까지 처리했기 때문입니다. 페이지 번호가 커질수록 느려지는 목록을 진단할 때는 인덱스 이름보다 하위 스캔 노드의 actual rows를 먼저 보고, 그 값이 OFFSET에 비례해 늘어난다면 화면의 이동 방식에 맞춰 키셋 전환을 검토하는 것이 출발점입니다.

PRODB
MySQL·Aurora 운영 DBA. 운영에서 겪은 문제를 MySQL 8.4에서 직접 재현해 기록합니다.

함께 보면 좋은 글

댓글 0

첫 댓글을 남겨보세요.

광고 차단 알림

광고 클릭 제한을 초과하여 광고가 차단되었습니다.

단시간에 반복적인 광고 클릭은 시스템에 의해 감지되며, IP가 수집되어 사이트 관리자가 확인 가능합니다.