인덱스를 만들었는데 쿼리가 느린 이유

2026. 10. 12. 13:00·IT
728x90
반응형

데이터베이스에 인덱스를 만들어 뒀는데 쿼리가 여전히 느리다면, 인덱스가 있는 것과 인덱스를 타는 것이 다르기 때문이다. 이 글은 인덱스가 무시되는 흔한 패턴을 실제 쿼리와 함께 정리한다. PostgreSQL과 MySQL 기준으로 쓰지만 원리는 대부분의 관계형 데이터베이스에 공통이다.

먼저 실행 계획을 보는 법

추측하지 말고 확인해야 한다. 모든 판단은 실행 계획에서 시작한다.

-- PostgreSQL
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 42;

-- MySQL
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;

여기서 볼 것은 딱 하나다. Seq Scan(PostgreSQL)이나 type: ALL(MySQL)이 나오는가. 나오면 테이블을 처음부터 끝까지 읽고 있다는 뜻이다. Index Scan이나 Index Only Scan이 나와야 인덱스를 탄 것이다.

ANALYZE를 붙이면 예상이 아니라 실제 실행 결과가 나온다. 예상 행 수와 실제 행 수가 크게 다르면 통계가 낡은 것이니 ANALYZE 테이블명을 돌려야 한다.

컬럼을 가공하면 인덱스를 못 쓴다

가장 흔한 실수다. 인덱스는 컬럼의 원래 값으로 정렬돼 있다. 컬럼에 함수를 씌우면 그 값이 인덱스에 없으므로 전체를 훑을 수밖에 없다.

-- 인덱스를 못 탄다
WHERE DATE(created_at) = '2026-09-11'
WHERE UPPER(email) = 'A@B.COM'
WHERE user_id + 0 = 42
WHERE SUBSTRING(phone, 1, 3) = '010'

-- 인덱스를 탄다
WHERE created_at >= '2026-09-11' AND created_at < '2026-09-12'
WHERE email = 'a@b.com'
WHERE user_id = 42
WHERE phone LIKE '010%'

날짜 조건이 특히 자주 걸린다. DATE(created_at) 대신 범위 조건으로 바꾸면 된다. 시작 시각 이상, 다음 날 미만으로 쓰는 것이 안전하다. BETWEEN으로 '2026-09-11' AND '2026-09-11 23:59:59'를 쓰면 밀리초 단위 데이터를 놓칠 수 있다.

꼭 가공된 값으로 조회해야 한다면 함수 기반 인덱스를 만든다.

CREATE INDEX idx_orders_created_date ON orders ((DATE(created_at)));
CREATE INDEX idx_users_email_lower ON users (LOWER(email));

타입이 다르면 조용히 변환된다

컬럼과 비교 값의 타입이 다르면 데이터베이스가 암묵적으로 변환한다. 그 과정에서 컬럼 쪽이 변환되면 인덱스를 못 쓴다.

-- user_id 가 VARCHAR 인데 숫자로 비교
WHERE user_id = 42        -- 컬럼이 숫자로 변환 → 인덱스 무시

-- 올바른 비교
WHERE user_id = '42'

이 문제는 실행 계획을 보기 전까지 드러나지 않는다. 쿼리는 정상 동작하고 결과도 맞기 때문이다. 데이터가 적을 때는 빠르기까지 하다. 테이블이 커진 뒤에 갑자기 느려진다.

ORM을 쓰면 파라미터 타입이 코드에서 결정되므로 더 조심해야 한다. 스키마의 컬럼 타입과 코드의 변수 타입을 맞춰 두는 것이 근본 해결이다.

복합 인덱스는 왼쪽부터 써야 한다

(a, b, c) 순서로 만든 복합 인덱스는 전화번호부와 같다. 성으로 먼저 정렬하고, 같은 성 안에서 이름으로 정렬한다. 그래서 성을 모르면 이름만으로는 찾을 수 없다.

CREATE INDEX idx ON orders (user_id, status, created_at);

WHERE user_id = 42                                   -- 사용 (a)
WHERE user_id = 42 AND status = 'paid'               -- 사용 (a, b)
WHERE user_id = 42 AND status = 'paid'
  AND created_at > '2026-01-01'                      -- 전부 사용
WHERE status = 'paid'                                -- 못 씀 (b 만)
WHERE status = 'paid' AND created_at > '2026-01-01'  -- 못 씀

한 가지 예외가 있다. 중간 컬럼에 범위 조건(>, <, BETWEEN, LIKE 'x%')이 오면 그 뒤 컬럼은 인덱스로 못 좁힌다. 그래서 등호 조건 컬럼을 앞에, 범위 조건 컬럼을 뒤에 두는 것이 복합 인덱스 설계의 기본이다.

선택도가 낮으면 옵티마이저가 무시한다

인덱스를 탈 수 있는데도 안 타는 경우가 있다. 옵티마이저가 전체를 읽는 편이 빠르다고 판단한 것이다.

예를 들어 status 컬럼에 값이 두 종류뿐이고, 전체의 70%가 'active'라면, WHERE status = 'active'는 인덱스를 타봐야 손해다. 인덱스에서 행 위치를 찾고 다시 테이블을 읽는 왕복 비용이 전체를 순차적으로 읽는 것보다 비싸기 때문이다.

이건 옵티마이저가 옳다. 억지로 인덱스를 강제하면 더 느려진다. 선택도가 낮은 컬럼에는 단독 인덱스를 만들지 않는 것이 맞다. 대신 선택도 높은 컬럼과 묶어 복합 인덱스로 만든다.

다만 통계가 낡으면 판단이 틀린다. 데이터 분포가 크게 바뀌었는데 ANALYZE를 안 돌렸다면, 옵티마이저는 옛날 분포를 보고 결정한다. 대량 적재 후에는 통계 갱신이 필요하다.

인덱스를 많이 만들면 쓰기가 느려진다

마지막으로 균형 이야기다. 인덱스는 공짜가 아니다.

행을 하나 넣을 때마다 모든 인덱스를 갱신해야 한다. 인덱스가 열 개면 쓰기 한 번에 열한 번의 갱신이 일어난다. 읽기를 빠르게 하려고 만든 인덱스가 쓰기를 느리게 만든다. 디스크도 그만큼 더 쓴다.

그래서 나는 안 쓰는 인덱스를 주기적으로 찾아 지우는 것을 권한다. PostgreSQL이라면 이렇게 확인한다.

SELECT relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY relname;

idx_scan이 0이면 한 번도 안 쓰인 인덱스다. 통계가 초기화된 뒤 충분한 기간이 지났다면 지워도 된다. 다만 유니크 제약을 위한 인덱스는 조회에 안 쓰여도 필요하니 확인하고 지워야 한다.

인덱스 설계의 목표는 개수가 아니라 쿼리 패턴과의 일치다. 실제로 돌아가는 느린 쿼리 목록을 뽑아서, 그 쿼리들이 공통으로 쓰는 조건에 맞춰 만드는 것이 순서다. 테이블을 보고 "이 컬럼도 조회할 것 같으니 인덱스를 만들자"는 방식은 대개 쓰이지 않는 인덱스를 남긴다.

참고 자료

  • PostgreSQL: Using EXPLAIN — 실행 계획을 읽는 법과 스캔 종류
  • PostgreSQL: Indexes — 인덱스 종류, 복합 인덱스, 함수 기반 인덱스
  • MySQL: Optimizing Queries with EXPLAIN — MySQL 실행 계획 해석
728x90
반응형

'IT' 카테고리의 다른 글

캐시 전략, 무효화와 스탬피드 대응  (0) 2026.10.14
N+1 쿼리 문제, 발견하고 고치는 법  (0) 2026.10.13
AI 코딩 에이전트 시대의 코드 리뷰  (0) 2026.10.11
Git 브랜치 전략, rebase와 merge 중 무엇을 쓸까  (0) 2026.10.10
npm 공급망 공격 방어, 락파일부터 쿨다운까지  (0) 2026.10.09
'IT' 카테고리의 다른 글
  • 캐시 전략, 무효화와 스탬피드 대응
  • N+1 쿼리 문제, 발견하고 고치는 법
  • AI 코딩 에이전트 시대의 코드 리뷰
  • Git 브랜치 전략, rebase와 merge 중 무엇을 쓸까
밍글링글링
밍글링글링
mingling - 밍글링, 밍글밍글링. 코드와 어우러지다. IT/ 프로그래밍/소스
    반응형
    250x250
  • 밍글링글링
    mingling
    밍글링글링
  • 전체
    오늘
    어제
    • 밍글링글링 (451) N
      • Flutter (2)
      • 일상생활 (8)
        • 리뷰 (1)
        • 생활정보 (4)
        • 맛집 (0)
        • 여행 (0)
        • 모든정보 (3)
      • JAVA (126)
        • 개념 (6)
        • 예제 (115)
        • Exception (2)
      • C (1)
        • C (1)
        • C++ (0)
        • C# (0)
      • JS (29)
        • JavaScript (18)
        • JQuery (5)
        • AJax (0)
        • NODE.JS (6)
        • Angular.JS 2.0 (0)
      • WEB (87)
        • HTML (6)
        • CSS (61)
        • JSP (20)
        • JSTL (0)
      • FrameWork (8)
        • Spring (8)
        • BootStrap (0)
        • MyBATIS (0)
        • JUnit (0)
      • 외부 라이브러리 (5)
      • 공유 소스 관리 (5)
        • Git (5)
        • SVN (0)
      • 빅데이터 프로그래밍 (37)
        • Python (37)
        • R Programming (0)
      • DB (7)
        • ORACLE (0)
        • MySql (6)
      • Development Tools (7)
        • StarUML (0)
        • eXERD (0)
        • Eclipse (4)
      • SKILL (6)
        • Migration (0)
        • Security (6)
      • MicroSoft (0)
        • Excel (0)
        • Word (0)
      • Android (0)
      • Server (21)
        • Ubuntu (5)
        • Linux (15)
      • IOS (0)
      • XML (0)
      • 미디어 (0)
      • 공지사항 (3)
      • NETWORK (1)
      • 게임 (4)
        • 피파 (1)
        • 리니지M (0)
        • 배틀그라운드 (1)
        • 듀랑고 (2)
      • 세상 이슈 (6)
      • 일렉트론 (0)
      • 대회 소식 (4)
      • 업무 (2)
      • Express, Vue (6)
      • docker (11)
      • svelte (3)
      • 블록체인 (1)
      • IT (54) N
      • Rust (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 미디어로그
    • 위치로그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    servlet class
    css perspective
    gitlab 설치
    Extension Bisect
    svelte
    AI 반도체
    docker
    nginx ssl 설정
    mysql db
    리눅스 설치
    ssl 인증서 발급
    vscode
    자바 객체 지향
    클론코딩
    css transition
    vue cli
    프런트엔드
    vue 설치
    jsp include
    자바 배열
    nginx
    자바 클래스
    css float
    자바 for문
    에디터 팁
    spring java
    자바 생성자
    제미나이
    nginx ssl 적용
    ubuntu
    AI 코딩 에이전트
    rust linux
    React Compiler
    자바 exception
    Rust lang
    lang rust
    브라우저 자동화
    SSL 인증서
    러스트
    오류
    css tb
    jsp parameter
    API 설계
    Node
    VS Code 팁
    티스토리 자동화
    java casting
    css table
    proxy pass
    Java Array
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
밍글링글링
인덱스를 만들었는데 쿼리가 느린 이유
상단으로

티스토리툴바