데이터베이스 인덱스 이해하기: 작동 원리와 성능 저하 시점

발행: (2025년 12월 13일 오전 06:05 GMT+9)
10 min read
원문: Dev.to

Source: Dev.to

악몽: 전체 테이블 스캔

도서관에 100,000권의 책이 있고 특정 제목을 찾는다고 상상해 보세요. 책들이 방 한가운데 무작위로 쌓여 있다면, 올바른 책을 찾을 때까지 모든 책을 하나씩 들어봐야 합니다.

데이터베이스 용어로 이것은 전체 테이블 스캔 입니다.

테이블에 100만 행이 있고 검색하려는 컬럼에 인덱스가 없으면, 데이터베이스 엔진은 디스크에서 모든 행을 읽어야 합니다. 이는 O(N) 복잡도로, 느리고 I/O 비용이 많이 들며 확장성이 없습니다.

“사전” 비유

인덱스를 설명할 때 가장 흔히 쓰는 비유는 책 뒤의 목차이지만, 사전이 더 적절합니다.

사전에서는 단어가 알파벳 순으로 정렬되어 있습니다. 정렬돼 있기 때문에 “Node”라는 단어를 찾기 위해 첫 페이지부터 시작하지 않습니다. 중간 페이지로 뛰어가 “N”이 현재 페이지 뒤에 있음을 확인하고 탐색 범위를 좁혀갑니다.

인덱스가 하는 일: 실제 데이터 위치를 가리키는 별도의 정렬된 구조를 만든다.

내부 구조: B‑트리

주니어에서 시니어로 성장하려면 B‑Tree(균형 트리)라는 이름을 알아야 합니다. 대부분의 최신 데이터베이스(PostgreSQL, MySQL, SQL Server)는 기본 인덱싱에 B‑Tree를 사용합니다.

B‑Tree는 단순 정렬 리스트의 쓰기 속도 문제를 해결하기 위해 데이터를 노드 계층 구조로 조직합니다.

  • 루트(Root): 진입점.
  • 내부 노드(Internal Nodes): 표지판 역할을 함(“A‑M은 왼쪽, N‑Z는 오른쪽”).
  • 리프 노드(Leaf Nodes): 디스크에 있는 실제 행을 가리키는 포인터를 포함.

트리가 균형 잡혀 있기 때문에 최상위에서 어느 데이터까지의 거리는 항상 대략 동일합니다. B‑Tree 탐색은 O(log N) 입니다. 100만 행이라면 약 20번의 “점프”만 하면 됩니다.

개념적 탐색 예시

email = 'chris@example.com'인 사용자를 대규모 users 테이블에서 찾고, email 컬럼에 인덱스가 있다고 가정합니다.

백만 행을 모두 읽는 대신, 데이터베이스는 논리적으로 몇 단계만 수행합니다:

  1. 루트 노드에서 시작 (점프 1): 루트는 각 서브‑브랜치의 최솟값·최댓값 같은 경계를 보유합니다. chris@example.com이 중간 내부 노드 값 사이에 있음을 판단합니다.

  2. 내부 노드로 이동 (점프 2): 데이터베이스는 중간 노드를 로드하고, 예를 들어 다음과 같이 표시될 수 있습니다:

    * Emails  O:   Go Right

    cF보다 앞에 있으므로 데이터베이스는 왼쪽 리프 노드 포인터를 선택합니다.

  3. 리프 노드로 이동 (점프 3): 리프 노드는 이메일과 기본키 ID(포인터)를 포함한 조밀하고 정렬된 리스트입니다. 데이터베이스는 이 작은 리스트에서 이진 탐색을 수행합니다:

    [
      { "email": "adam@example.com",  "userId": 101 },
      { "email": "ben@example.com",   "userId": 450 },
      { "email": "chris@example.com", "userId": 221 }, // 찾음!
      { "email": "diana@example.com", "userId": 890 }
    ]
  4. 데이터 가져오기: userId: 221을 사용해 메인 테이블에서 전체 사용자 레코드를 조회합니다.

탐색은 네 번의 디스크 작업(log N) 으로 끝나며, 백만 번이 아니라는 점이 B‑Tree 인덱스의 힘입니다.

클러스터드 vs. 논클러스터드 인덱스

클러스터드 인덱스 (물리적 순서)

사전 자체와 같습니다. 데이터가 인덱스 순서대로 물리적으로 디스크에 저장됩니다—대부분 기본 키가 됩니다.

규칙: 테이블당 하나만 클러스터드 인덱스를 가질 수 있습니다(같은 데이터를 두 가지 다른 물리적 순서로 정렬할 수 없기 때문).

논클러스터드 인덱스 (“전화번호부”)

실제 테이블과 별개의 구조입니다. 인덱싱된 컬럼의 복사본과 행이 실제로 위치한 곳을 가리키는 “포인터”(GPS 좌표와 같은)를 포함합니다.

규칙: 논클러스터드 인덱스는 여러 개 만들 수 있지만, 각각이 데이터베이스에 “무게”를 추가합니다.

비용: 인덱스가 성능을 저하시키는 경우

인덱스가 마법이라면 왜 모든 컬럼에 인덱스를 만들지 않을까요? 인덱스마다 트레이드‑오프가 존재합니다: 읽기는 빨라지지만 쓰기는 느려지고, 저장 비용도 발생합니다.

쓰기 페널티

인덱싱된 컬럼을 수정할 때마다 데이터베이스는 B‑Tree를 업데이트해야 합니다:

  • INSERT: 새 값을 추가하고 필요 시 트리를 재균형.
  • UPDATE: 기존 엔트리를 삭제하고 새 값을 삽입.
  • DELETE: 엔트리를 찾아 삭제.

테이블에 인덱스가 다섯 개라면, 하나의 INSERT가 메인 레코드 쓰기에 추가로 다섯 번의 인덱스 쓰기 작업을 트리거합니다. 로그나 텔레메트리처럼 쓰기가 많은 테이블에서는 인덱스가 과도하면 성능이 크게 떨어집니다.

저장소 및 메모리 오버헤드

인덱스는 별도의 데이터 구조로 디스크 공간 메모리를 차지합니다. 데이터베이스는 자주 접근되는 인덱스 노드를 RAM에 유지하려고 합니다. 인덱스가 사용 가능한 메모리보다 크면 시스템은 인덱스 노드를 디스크에서 읽어야 하며, 이는 속도 이점을 상쇄하고 I/O 병목을 일으킵니다. 큰 인덱스는 실제 데이터 캐시용 메모리도 감소시킵니다.

고급 개념: 정밀한 인덱싱

복합 인덱스(순서가 중요)

복합 인덱스는 여러 컬럼을 특정 순서로 결합합니다. 복잡한 WHEREORDER BY 절을 지원하는 데 필수적입니다.

예시 쿼리

SELECT * FROM orders
WHERE customer_id = 123 AND order_date > '2023-01-01'
ORDER BY order_date DESC;

위 패턴에 맞는 복합 인덱스를 생성합니다: (customer_id, order_date).

순서가 중요한 이유

  • 첫 번째 컬럼(customer_id)이 데이터 슬라이스를 좁혀줍니다.
  • 두 번째 컬럼(order_date)이 추가 필터와 ORDER BY를 별도 정렬 없이 만족시킵니다.

순서를 (order_date, customer_id)로 바꾸면, customer_id만으로 필터링하는 쿼리에서는 인덱스가 무용지물이 됩니다. 규칙: 가장 선택도가 높은 컬럼을 먼저 둡니다.

카디널리티 함정

카디널리티는 컬럼의 고유값 개수가 전체 행 수에 비해 어느 정도인지를 나타냅니다.

  • 높은 카디널리티: email이나 SSN처럼 값이 거의 모두 고유한 컬럼. 인덱스 효율이 매우 높습니다.
  • 낮은 카디널리티: 예를 들어… (내용이 잘렸음)
Back to Blog

관련 글

더 보기 »