이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 인덱스는 왜 조회를 빠르게 만들 수 있는가?
  • 인덱스가 있어도 느린 쿼리는 왜 생기는가?
  • 백엔드 개발자는 어떤 API에서 인덱스를 먼저 의심해야 하는가?

개요

인덱스는 테이블의 일부 컬럼 값을 검색하기 좋은 형태로 따로 저장한 자료구조다. PostgreSQL 공식 문서는 인덱스를 데이터베이스 성능 향상을 위한 일반적인 방법으로 설명하고, MySQL 문서도 적절한 인덱스가 쿼리 성능에 매우 중요하다고 설명한다.

백엔드 개발자에게 인덱스는 단순 DB 튜닝 항목이 아니다. 목록 API, 상세 조회, 중복 검증, 로그인, 검색, 관리자 페이지, 배치 작업의 응답 시간과 DB 부하를 결정한다. 특히 Spring Data JPA나 ORM을 쓰면 코드에서는 findByEmail처럼 짧게 보이지만 DB 입장에서는 여전히 인덱스를 타거나 테이블을 훑는 SQL이다.

원리

인덱스가 없으면 DB는 조건에 맞는 행을 찾기 위해 테이블을 넓게 읽어야 한다. 데이터가 작으면 문제가 드러나지 않지만, 행 수가 늘면 읽어야 하는 페이지와 비교해야 하는 값이 증가한다.

SELECT id, email, name
FROM members
WHERE email = 'joseph@example.com';

members.email에 인덱스가 없다면 DB는 많은 행을 읽고 email 값을 비교해야 할 수 있다. 반대로 email 인덱스가 있으면 DB는 정렬된 자료구조에서 해당 값을 빠르게 찾고, 그 위치가 가리키는 테이블 행을 읽을 수 있다.

CREATE UNIQUE INDEX uq_members_email ON members (email);

이 인덱스는 조회 성능뿐 아니라 중복 이메일 저장을 막는 비즈니스 불변식도 보장한다. 백엔드 코드에서 existsByEmail을 먼저 호출하는 것만으로는 동시 가입 요청을 완전히 막을 수 없다. 두 요청이 동시에 존재 여부를 확인하고 둘 다 없다고 판단한 뒤 insert를 시도할 수 있기 때문이다. 최종 방어선은 DB의 Unique 제약 또는 Unique 인덱스여야 한다.

실행 계획에서는 단순히 index scan이라는 단어를 찾는 데서 멈추지 않는다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email, name
FROM members
WHERE email = 'joseph@example.com';

PostgreSQL에서는 실제 실행 시간, 예상 row와 실제 row 차이, buffer hit/read를 함께 본다. MySQL에서는 EXPLAINEXPLAIN ANALYZE 지원 범위를 버전별로 확인하고, rows 추정치와 filtered 비율을 본다. 공통 질문은 “DB가 몇 row를 찾기 위해 얼마나 읽었는가”다.

API에서 드러나는 위치

인덱스 문제는 보통 다음 API에서 먼저 보인다.

  • 로그인, 회원 조회처럼 특정 유니크 값으로 찾는 API
  • 게시글 목록, 주문 목록처럼 WHERE, ORDER BY, LIMIT이 함께 있는 API
  • 관리자 검색처럼 조건이 여러 개이고 데이터가 많은 API
  • 배치 작업처럼 많은 행을 반복 조회하거나 변경하는 작업
  • ORM 연관관계 탐색으로 같은 쿼리가 반복되는 API

예를 들어 주문 목록 API가 다음 SQL을 만든다고 하자.

SELECT id, member_id, status, created_at, total_price
FROM orders
WHERE member_id = 10
ORDER BY created_at DESC
LIMIT 20;

이 API에 필요한 인덱스는 단순히 member_id 인덱스 하나가 아닐 수 있다. 조건으로 회원을 좁히고, 그 회원의 주문을 최신순으로 이미 정렬된 상태에서 앞 20개만 가져오는 구조가 좋다.

CREATE INDEX idx_orders_member_created_id
ON orders (member_id, created_at DESC, id DESC);

이렇게 하면 DB가 회원별 주문을 최신순으로 찾기 쉬워진다. id DESC를 함께 둔 이유는 같은 created_at 값을 가진 행이 있을 때 정렬 순서를 안정적으로 만들기 위해서다.

이 인덱스가 정말 맞는지는 실행 계획과 API 요구로 확인한다. member_id 조건 없이 전체 최신 주문을 보는 관리자 API라면 같은 인덱스가 덜 유용할 수 있다. 반대로 회원별 주문 목록이 핵심 경로라면 ORDER BY created_at DESC, id DESC LIMIT 20까지 함께 지원하는 인덱스가 p95 지연을 크게 줄일 수 있다.

인덱스가 비용을 만드는 지점

인덱스는 조회를 빠르게 만들 수 있지만 무료는 아니다.

INSERT INTO orders (member_id, status, created_at, total_price)
VALUES (10, 'PAID', now(), 30000);

이 insert는 테이블에 행을 추가하는 것에서 끝나지 않는다. orders에 걸린 모든 관련 인덱스에도 새 값을 반영해야 한다. 인덱스가 많을수록 쓰기 비용과 저장 공간이 늘어난다.

UPDATE도 마찬가지다.

UPDATE orders
SET status = 'CANCELED'
WHERE id = 100;

만약 status가 여러 인덱스에 포함되어 있다면 상태 변경은 인덱스 갱신 비용을 함께 만든다. 그래서 조회가 느리다고 인덱스를 계속 추가하면, 어느 순간 쓰기 API와 배치 작업이 느려지고 디스크 사용량도 늘어난다.

실전 팁

  • 인덱스는 느린 쿼리와 자주 호출되는 쿼리부터 검토한다.
  • 단건 조회, 목록 조회, 조인, 중복 방지처럼 목적을 분명히 한 뒤 인덱스를 설계한다.
  • WHERE 조건만 보지 말고 ORDER BY, LIMIT, JOIN 조건을 함께 본다.
  • 개발 환경에서 빠르다는 이유만으로 운영 인덱스를 생략하지 않는다. 데이터가 작으면 테이블 스캔도 빠르다.
  • 인덱스를 추가하기 전 기존 인덱스와 중복되는지 확인한다.
  • 인덱스 추가 후에는 실행 계획과 실제 응답 시간을 확인한다.
  • 실행 계획에서는 index scan 여부뿐 아니라 읽은 row 수, sort 제거 여부, buffer/table 접근량을 본다.
  • 인덱스 추가 요청에는 SQL fingerprint, 호출량, p95/p99, 후보 인덱스, 쓰기 부하, DDL 위험을 함께 적는다.

주니어 팁

처음에는 “이 컬럼으로 자주 찾는가?”를 질문한다. 이메일, 주문 번호, 게시글 ID, 회원 ID처럼 자주 조건에 들어가는 컬럼은 인덱스 후보가 된다. 하지만 후보일 뿐 정답은 아니다. 실제 SQL을 보고 조건과 정렬이 어떻게 조합되는지 확인해야 한다.

JPA를 쓴다면 Repository 메서드 이름만 보지 말고 SQL 로그를 켠다.

spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.orm.jdbc.bind=trace

이 설정은 학습과 개발 환경에서 유용하다. 운영에서는 로그 양과 개인정보 노출을 주의해야 한다.

시니어 팁

시니어는 인덱스를 쿼리 하나의 속도만으로 판단하지 않는다. 쓰기 부하, 배포 방식, 기존 인덱스 중복, 데이터 증가율, 장애 시 영향까지 본다.

예를 들어 대형 테이블에 인덱스를 추가할 때는 다음을 확인한다.

  • DB가 온라인 인덱스 생성을 지원하는가?
  • 생성 중 쓰기와 읽기에 어떤 락이 걸리는가?
  • replica lag가 증가할 수 있는가?
  • 배포 시간대와 롤백 계획은 있는가?
  • 추가 후 기존 인덱스를 제거할 수 있는가?

인덱스는 코드 변경보다 운영 영향이 클 수 있다. 인프라/DBA에게는 “이 API가 느려요”보다 “이 SQL이 하루 평균 200만 번 호출되고, 현재 실행 계획은 full scan이며, 후보 인덱스는 (member_id, created_at desc, id desc)입니다”라고 말하는 편이 훨씬 생산적이다.

인덱스 추가 요청을 할 때는 다음 정보를 같이 가져간다.

대상 SQL fingerprint: orders by member_id latest
호출량: peak 350 rps, p99 1.8s
현재 계획: sequential scan + sort, rows estimate 180k
후보 인덱스: orders(member_id, created_at desc, id desc)
쓰기 영향: orders insert 약 900 rps, status update batch 존재
운영 질문: online 생성 가능 여부, replica lag 허용치, 배포 시간대

이렇게 말하면 인프라/DBA는 DDL 방식과 운영 위험을 판단할 수 있고, 백엔드 개발자는 기능 중요도와 호출 패턴을 설명할 수 있다.

위험 신호!

  • SELECT *와 넓은 목록 조회가 자주 호출된다.
  • WHERE DATE(created_at) = ?처럼 컬럼에 함수를 씌운다.
  • LIKE '%keyword%'를 일반 B-Tree 인덱스로 해결하려고 한다.
  • 같은 테이블에 비슷한 복합 인덱스가 계속 추가된다.
  • 운영에서 timeout이 나자마자 원인 분석 없이 인덱스를 추가한다.
  • 인덱스 추가 후 쓰기 API와 배치가 느려진다.
  • 실행 계획에 index scan이 보인다는 이유만으로 느린 쿼리 분석을 끝낸다.
  • 인덱스 추가가 replica lag나 배포 시간대에 미칠 영향을 DBA에게 설명하지 못한다.

확인 질문

  • 인덱스가 조회를 빠르게 만들 수 있는 근본 이유는 무엇인가?
    • 테이블 전체를 읽지 않고 정렬된 별도 자료구조에서 조건에 맞는 위치를 빠르게 찾을 수 있기 때문이다.
  • Unique 인덱스가 중복 검사 코드보다 강한 이유는 무엇인가?
    • 동시 요청이 애플리케이션 검사 사이를 통과해도 DB가 최종 insert/update 시점에서 중복을 막기 때문이다.
  • 인덱스가 많아질 때 생기는 대표 비용은 무엇인가?
    • 쓰기 시 인덱스 갱신 비용, 저장 공간 증가, 중복 인덱스 관리 비용, 배포 시 인덱스 생성 부하가 생긴다.
  • 실행 계획에서 index scan이 보인 뒤에도 추가로 봐야 할 것은 무엇인가?
    • 실제 읽은 row 수, 예상 row와 실제 row 차이, sort 발생 여부, buffer/table 접근량, API p95/p99 개선 여부다.

참고 문서