왜 목록 조회가 먼저 느려지는가

업무 시스템에서 데이터베이스 성능 문제가 가장 자주 드러나는 화면은 상세 화면보다 목록 화면입니다. 고객 목록, 주문 목록, 문의 내역, 재고 이동 내역처럼 조건 검색과 정렬, 페이지네이션이 같이 들어가는 화면은 데이터가 적을 때는 빠르지만 몇십만 건을 넘기면 갑자기 응답 시간이 길어집니다. 이때 단순히 CPU나 메모리를 늘리기 전에 먼저 확인해야 할 것은 쿼리가 어떤 인덱스를 타고 있는지, 그리고 WHERE 조건과 ORDER BY 조건이 같은 방향으로 설계되어 있는지입니다.

복합 인덱스는 여러 컬럼을 하나의 정렬된 구조로 묶어 둔 것입니다. 중요한 점은 컬럼을 많이 넣는다고 자동으로 빨라지는 것이 아니라는 점입니다. 인덱스의 앞쪽 컬럼부터 조건이 맞아야 하고, 범위 조건이 나온 뒤에는 뒤쪽 컬럼을 탐색 조건으로 충분히 활용하지 못할 수 있습니다. 따라서 실무에서는 화면의 검색 패턴을 기준으로 자주 쓰는 조건, 선택도가 높은 조건, 정렬 조건을 함께 놓고 설계해야 합니다.

예제 테이블과 느린 쿼리

아래는 주문 목록 화면에서 흔히 볼 수 있는 구조입니다. 관리자는 상태, 기간, 고객 ID로 검색하고 최신 주문순으로 목록을 봅니다. 데이터가 적을 때는 문제가 없지만 주문이 누적되면 filesort와 임시 테이블이 나타나거나, 너무 많은 행을 읽은 뒤 버리는 문제가 생깁니다.

CREATE TABLE orders (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  customer_id BIGINT NOT NULL,
  status VARCHAR(20) NOT NULL,
  ordered_at DATETIME NOT NULL,
  total_amount DECIMAL(12,2) NOT NULL,
  deleted_at DATETIME NULL
);

SELECT id, customer_id, status, ordered_at, total_amount
FROM orders
WHERE deleted_at IS NULL
  AND status = 'PAID'
  AND ordered_at >= '2026-06-01 00:00:00'
  AND ordered_at < '2026-07-01 00:00:00'
ORDER BY ordered_at DESC, id DESC
LIMIT 50;

이 쿼리를 개선하기 위해 status, ordered_at, id를 각각 단일 인덱스로 만드는 경우가 많습니다. 하지만 단일 인덱스 여러 개는 항상 좋은 해결책이 아닙니다. MySQL과 MariaDB는 상황에 따라 index merge를 사용할 수 있지만, 목록 조회처럼 정렬과 LIMIT가 중요한 쿼리에서는 원하는 순서대로 이미 정렬된 인덱스를 끝까지 활용하는 쪽이 더 안정적입니다.

복합 인덱스의 컬럼 순서 정하기

첫 번째 기준은 등호 조건입니다. status = 'PAID'처럼 특정 값으로 고정되는 조건은 앞쪽에 오기 좋습니다. 두 번째 기준은 범위 조건입니다. ordered_at처럼 기간 검색에 쓰이는 컬럼은 등호 조건 뒤에 놓는 것이 일반적입니다. 세 번째 기준은 정렬 조건입니다. ORDER BY ordered_at DESC, id DESC처럼 목록의 정렬 기준이 명확하다면 인덱스에도 같은 순서를 반영해야 불필요한 정렬을 줄일 수 있습니다.

CREATE INDEX idx_orders_status_ordered_id
ON orders (status, ordered_at DESC, id DESC);

이 인덱스는 status가 고정된 상태에서 ordered_at 범위를 빠르게 찾고, 같은 인덱스 순서로 최신 주문을 읽을 수 있게 해 줍니다. deleted_at IS NULL 조건이 항상 들어간다면 deleted_at을 앞에 넣고 싶은 유혹이 생기지만, 대부분의 행이 삭제되지 않은 상태라면 deleted_at의 선택도는 낮습니다. 이런 경우 deleted_at을 인덱스 앞쪽에 두면 실제 필터링 효율은 낮고 인덱스 크기만 커질 수 있습니다. 반대로 삭제 데이터가 많고 모든 조회가 미삭제 데이터만 대상으로 한다면 별도 검토가 필요합니다.

EXPLAIN으로 확인할 항목

인덱스를 만든 뒤에는 느낌이 아니라 실행 계획으로 확인해야 합니다. EXPLAIN에서 key가 의도한 인덱스를 가리키는지, rows 추정치가 과도하게 크지 않은지, Extra에 Using filesort가 계속 남는지 확인합니다. Using where 자체는 나쁜 신호가 아닙니다. 인덱스로 후보를 줄인 뒤 일부 조건을 추가로 거르는 것은 자연스러운 일입니다. 문제는 너무 많은 행을 읽고 정렬한 뒤 LIMIT 50만 반환하는 구조입니다.

EXPLAIN
SELECT id, customer_id, status, ordered_at, total_amount
FROM orders
WHERE status = 'PAID'
  AND ordered_at >= '2026-06-01 00:00:00'
  AND ordered_at < '2026-07-01 00:00:00'
ORDER BY ordered_at DESC, id DESC
LIMIT 50;

운영 환경에서는 EXPLAIN ANALYZE를 사용할 수 있다면 실제 실행 시간과 실제 읽은 행 수까지 확인하는 것이 좋습니다. 단, 실서비스의 무거운 쿼리에 바로 적용하면 부하를 줄 수 있으므로 복제 서버, 스테이징, 또는 제한된 조건에서 먼저 점검하는 편이 안전합니다.

페이지네이션까지 같이 바꾸기

인덱스를 잘 설계해도 OFFSET이 커지면 느려질 수 있습니다. LIMIT 50 OFFSET 50000은 앞의 5만 건을 읽고 버려야 하기 때문입니다. 관리자 화면에서 깊은 페이지 이동이 반드시 필요하지 않다면 커서 기반 페이지네이션을 적용하는 것이 좋습니다. 최신순 목록에서는 마지막으로 본 ordered_at과 id를 다음 요청의 기준값으로 넘기면 됩니다.

SELECT id, customer_id, status, ordered_at, total_amount
FROM orders
WHERE status = 'PAID'
  AND (
    ordered_at < '2026-06-07 13:10:00'
    OR (ordered_at = '2026-06-07 13:10:00' AND id < 984221)
  )
ORDER BY ordered_at DESC, id DESC
LIMIT 50;

이 방식은 사용자가 다음 페이지를 요청할 때 이전 페이지의 마지막 행보다 더 과거의 데이터만 찾습니다. 인덱스 순서와 잘 맞으면 깊은 페이지에서도 읽어야 할 행 수가 크게 늘지 않습니다. 단, 같은 시간에 생성된 데이터가 있을 수 있으므로 ordered_at 하나만 커서로 쓰지 말고 id 같은 고유하고 단조로운 보조 기준을 함께 사용하는 것이 안전합니다.

운영 적용 전 체크리스트

  • 실제 화면에서 가장 자주 쓰는 WHERE 조건과 ORDER BY 조합을 먼저 수집합니다.
  • 등호 조건, 범위 조건, 정렬 조건 순서로 복합 인덱스 후보를 설계합니다.
  • 단일 인덱스를 여러 개 추가하기 전에 하나의 복합 인덱스로 정렬까지 해결할 수 있는지 확인합니다.
  • EXPLAIN 또는 EXPLAIN ANALYZE로 key, rows, filesort 여부를 확인합니다.
  • OFFSET이 큰 목록은 커서 기반 페이지네이션으로 바꾸는 방안을 검토합니다.
  • 인덱스 추가 후 쓰기 성능과 디스크 사용량이 늘어나는지도 함께 관찰합니다.

복합 인덱스 튜닝의 핵심은 모든 쿼리에 통하는 만능 인덱스를 찾는 것이 아니라, 실제 업무 화면의 조회 패턴과 데이터 분포에 맞는 좁고 분명한 인덱스를 만드는 것입니다. 목록 조회가 느려졌다면 서버 증설보다 먼저 실행 계획, 정렬 방식, 페이지네이션 방식을 함께 확인하는 것이 문제를 가장 빠르게 줄이는 출발점입니다.