database index가 쿼리를 빠르게 만드는 이유를 설명해 주세요
- 면접 출제
- ★
- 예상 시간
- 7분
Index는 책의 색인처럼 원하는 데이터를 빠르게 찾기 위한 자료구조입니다. index가 없으면 조건에 맞는 row를 찾기 위해 table 전체를 읽어야 합니다.
대부분의 RDBMS index는 B+Tree 구조로 되어 있어서 어떤 값을 찾아도 탐색 깊이가 O(log n)으로 일정합니다. 트리가 항상 균형을 유지하기 때문입니다. 또한 실제 데이터는 leaf node에만 저장되고 leaf node끼리 연결 리스트로 이어져 있어 range query나 ORDER BY에도 효율적입니다.
하지만 index는 공짜가 아닙니다. insert, update, delete가 일어날 때 index도 함께 갱신해야 하고, 별도 저장 공간도 필요합니다. 그래서 자주 조회하고 선택도가 높은 column에 신중하게 걸어야 합니다.
index의 본질은 정렬을 미리 해 두는 것입니다. 정렬되어 있으면 이진 탐색처럼 절반씩 좁혀 갈 수 있는데, table 자체를 특정 column 순서로 유지하면 다른 column으로 찾을 때는 소용이 없습니다. 그래서 table은 그대로 두고, "정렬된 값과 그 row의 위치"만 담은 별도 구조를 column마다 만들 수 있게 한 것이 index입니다. 책 본문은 페이지 순서대로 두고 색인만 가나다순으로 만드는 것과 같습니다.
그 정렬 구조로 하필 B+Tree를 쓰는 이유는 데이터가 디스크에 있기 때문입니다. 디스크는 바이트 단위가 아니라 page(수 KB) 단위로 읽으므로, 한 번 읽을 때 최대한 많은 분기 정보를 얻는 구조가 유리합니다. 노드 하나를 disk page 하나에 맞추고 자식을 수백 개씩 두면, 트리 높이가 3-4 수준으로 낮아져 디스크 읽기 몇 번으로 수억 row에서 목표를 찾습니다.
꼬리질문
조금 더 깊게 물어본다면
답변 뒤에 이어질 수 있는 질문들을 하나씩 열어볼 수 있어요.
index 내부는 어떤 자료구조인가요?
대부분의 RDBMS는 B+Tree를 사용합니다. B+Tree는 트리가 항상 균형을 유지해 탐색이 O(log n)이고, leaf node들이 연결 리스트로 이어져 있어 범위 검색(BETWEEN, >, <)도 효율적입니다. 값을 찾은 뒤 leaf node를 따라 읽으면 되기 때문입니다.
점선 박스는 PostgreSQL 기준입니다. PostgreSQL은 row를 index와 별도로 삽입 순서대로 저장합니다. 그래서 name 알파벳 순(Ahn, Cho, Lee, Park)과 id 순서(3, 7, 1, 5)가 일치하지 않습니다.
"왜 hash table이 아닌가"를 생각해 보면 B+Tree 선택의 이유가 분명해집니다. hash index는 등호 비교(=)는 O(1)로 더 빠르지만, hash 함수가 값의 순서를 파괴하므로 범위 검색(>, BETWEEN), 정렬(ORDER BY), 접두사 검색(LIKE 'kim%')에 전혀 쓸 수 없습니다. 실제 쿼리의 상당수가 순서에 의존하기 때문에, 순서를 보존하는 B+Tree가 범용 기본값이 된 것입니다.
높이가 실제로 얼마나 낮은지는 계산으로 확인할 수 있습니다. 노드 하나가 disk page 하나(MySQL InnoDB 기준 16KB)에 대응하고 자식 포인터를 수백 개 담을 수 있으므로, 높이 3이면 수백의 3제곱, 즉 수천만-수억 row를 커버합니다. row가 10배로 늘어도 높이는 거의 그대로라, "데이터가 커져도 조회 속도가 유지된다"는 index의 약속이 이 구조에서 나옵니다.
B-Tree와 B+Tree의 차이는?
앞의 B+Tree와 달리 내부 노드에도 →가 있고, leaf 노드 사이에 연결 리스트가 없습니다.
B-Tree는 내부 노드에도 실제 데이터를 저장합니다. B+Tree는 내부 노드에 key만 두고 실제 데이터는 leaf 노드에만 저장하며, leaf 노드끼리 연결 리스트로 이어져 있습니다.
이 차이로 B+Tree가 DB index에 더 유리합니다. 내부 노드에 key만 있으니 같은 공간에 더 많은 key를 넣을 수 있어 트리가 더 넓고 낮아지고, leaf 연결 리스트 덕분에 range query를 leaf를 순서대로 읽기만 하면 처리할 수 있습니다.
두 장점이 어떻게 생기는지 이어 보면, 내부 노드의 역할은 "어느 자식으로 갈지" 안내뿐입니다. 여기에 데이터까지 두면(B-Tree) 그만큼 key가 덜 들어가 분기 수가 줄고 트리가 높아집니다. 트리 높이는 곧 디스크 읽기 횟수이므로, 내부 노드를 key 전용으로 비워 분기를 늘리는 것(B+Tree)이 디스크 기반 저장소에서는 직접적인 성능 이득입니다.
range query의 차이도 구체적입니다. B-Tree에서 범위를 훑으려면 트리를 오르내리며 다음 값을 찾아야 하지만, B+Tree는 범위의 시작점만 트리로 찾고 그다음부터는 leaf의 연결 리스트를 따라 순서대로 읽으면 끝입니다. WHERE id BETWEEN 100 AND 200 같은 쿼리가 "탐색 한 번 + 순차 읽기"로 처리되는 것이고, 이 패턴은 디스크의 순차 읽기 강점과도 맞아떨어집니다.
leaf node는 왜 연결 리스트로 이어져 있나요?
정렬 순서대로 여러 값을 읽을 때 트리를 다시 타지 않기 위해서입니다. 연결 리스트가 없다면 "다음 값"을 찾을 때마다 부모로 올라갔다가 다른 서브트리로 내려오는 순회가 필요합니다. leaf끼리 이어져 있으면 범위의 시작점만 트리로 찾고, 그다음부터는 옆 leaf로 이동하며 읽으면 됩니다.
BETWEEN, ORDER BY, LIKE 'kim%'처럼 정렬 순서대로 연속된 값을 읽는 쿼리가 모두 이 구조의 수혜자입니다.
연결 리스트가 없을 때의 비용을 따져 보면 이유가 분명해집니다. 트리에서 어떤 key의 바로 다음 key를 찾으려면 부모 방향으로 올라가 경로를 되짚어야 하는데, DB에서는 노드 하나가 disk page 하나이므로 오르내리는 매 단계가 page 읽기입니다. 범위에 걸린 값이 1만 개라면 값 하나마다 트리 순회가 반복되는 셈입니다. leaf에 다음 leaf의 위치를 적어 두면 같은 작업이 "이웃 page 따라 읽기"가 되어, 1만 개를 읽어도 leaf page 수십 장을 차례로 읽는 것으로 끝납니다.
이 설계는 B+Tree라서 가능합니다. B-Tree는 데이터가 내부 노드에도 흩어져 있어 leaf만 이어 봐야 정렬된 전체를 얻을 수 없지만, B+Tree는 모든 데이터가 leaf 층에 모여 있으므로 leaf 리스트를 따라가는 것만으로 전체가 정렬 순서가 됩니다. MySQL InnoDB는 leaf를 양방향 연결 리스트로 이어 ORDER BY ... DESC 같은 역방향 읽기도 같은 방식으로 처리합니다.
B-Tree index가 full scan보다 빠른 이유는?
full scan은 row 수에 비례해서 O(n)이지만, B-Tree는 트리 높이만큼만 내려가면 되므로 O(log n)입니다. 100만 row 기준으로 B-Tree 높이는 20 안팎이라 탐색 횟수 차이가 큽니다.
Big-O 비교보다 디스크 I/O 횟수로 보면 차이가 더 실감납니다. full scan은 table의 모든 disk page를 읽어야 하므로, row 100만 개가 page 수만 개에 담겨 있다면 수만 번의 page 읽기입니다. B+Tree 탐색은 root에서 leaf까지 높이만큼, 즉 page 서너 개 읽기로 끝납니다. 게다가 root와 상위 노드는 거의 모든 쿼리가 거치므로 메모리 캐시(buffer pool)에 이미 올라와 있어, 실제 디스크 읽기는 더 적습니다.
단서도 있습니다. 이 비교는 소수의 row를 찾을 때 이야기이고, 조건에 걸리는 row가 table의 큰 비율이면 관계가 뒤집힙니다. index로 수십만 row를 하나씩 찾아가는 random 접근보다 table을 처음부터 순차로 읽는 full scan이 빠를 수 있어, optimizer는 예상 row 수를 보고 두 방식 중 싼 쪽을 고릅니다. "full scan = 나쁨"이 아니라 "찾는 양에 따라 다름"이 정확한 이해입니다.
index의 정렬은 언제 일어나나요? 조회할 때마다 정렬하나요?
아닙니다. 정렬은 쓰기 시점에 일어나고, 조회는 이미 정렬된 구조를 타기만 합니다. INSERT가 들어오면 B+Tree를 타고 내려가 그 값이 있어야 할 자리를 찾아 끼워 넣습니다. 전체를 다시 정렬하는 것이 아니라 새 값 하나를 제자리에 꽂는 O(log n) 작업입니다.
그래서 index는 만들어진 순간부터 항상 정렬된 상태로 유지되고, 조회 시점에 정렬 비용은 없습니다.
조회할 때마다 정렬한다면 index는 성립할 수 없습니다. 정렬은 O(n log n)이라 full scan의 O(n)보다도 느리므로, 그 방식이면 index를 만들 이유 자체가 사라집니다. index의 전제는 정렬 비용을 조회 시점에 내지 않는 것입니다.
그 비용은 쓰기가 대신 냅니다. INSERT 한 번마다 "제자리 찾기 + 끼워 넣기"가 붙고, index가 5개면 5번 붙습니다. "index는 쓰기 성능을 깎는다"는 트레이드오프의 정체가 바로 이 비용으로, 매 쓰기마다 O(log n)씩 분할 납부하고 조회 때 회수하는 구조입니다.
책장에 비유하면 index는 새 책을 살 때마다 가나다순 제자리에 꽂아 두는 방식이고, full scan은 산 순서대로 쌓아 두고 찾을 때마다 전부 뒤지는 방식입니다. "찾을 때마다 책장 전체를 재배열한 뒤 찾기"에 해당하는 세 번째 방식은 어디에도 없습니다. 두 방식 중 무엇이 나은지는 꽂는 횟수(쓰기)와 찾는 횟수(읽기)의 비율이 정합니다.
index가 B+Tree라면 table 자체는 어떤 구조인가요?
DBMS마다 다릅니다. PostgreSQL은 table을 정렬 없는 heap으로 저장하고, B+Tree는 index에만 씁니다. row는 삽입되는 대로 빈 page에 쌓이고, index의 leaf가 heap 안의 row 위치를 가리킵니다.
MySQL InnoDB는 table 자체가 PRIMARY KEY 기준의 B+Tree입니다. leaf node에 row 전체가 들어 있어, PK index가 곧 table입니다. 이것이 clustered index 구조입니다.
full scan의 실체도 이 구조가 정합니다. PostgreSQL의 full scan은 heap page를 처음부터 끝까지 읽는 것이고, InnoDB의 full scan은 clustered B+Tree의 leaf 체인을 처음부터 끝까지 따라 읽는 것입니다. 어느 쪽이든 "트리 탐색 없이 전부 순차로 읽기"라는 점은 같습니다.
구조 차이는 조회 경로 차이로 이어집니다. PostgreSQL은 어떤 index로 찾든 "index → heap"의 간접 참조 한 번으로 경로가 균일합니다. InnoDB는 PK 조회라면 트리 하나로 row까지 도달해 가장 빠르지만, secondary index 조회는 PK 값을 얻어 clustered tree를 다시 타는 이중 조회가 됩니다. InnoDB에서 PK 설계(짧게, 단조 증가)가 유독 강조되는 이유가 table 자체가 PK 순서로 저장되기 때문이고, PostgreSQL에서 모든 index가 대등한 이유가 table이 어떤 index와도 무관한 heap이기 때문입니다.
composite index는 순서가 중요한가요?
중요합니다. (a, b) index는 보통 a를 기준으로 먼저 정렬되고 그 안에서 b가 정렬되는 식으로 이해할 수 있습니다. 그래서 query 조건이 index의 앞쪽 column을 어떻게 사용하는지가 중요합니다.
이걸 leftmost prefix 관점으로 설명하는 경우가 많습니다.
전화번호부 비유가 정확합니다. (성, 이름) 순으로 정렬된 전화번호부에서 "김"씨는 몰려 있으니 바로 찾고, "김"씨 중의 "민수"도 그 안에서 정렬되어 있으니 찾을 수 있습니다. 하지만 성을 모른 채 이름이 "민수"인 사람을 찾으려면 결국 전체를 훑어야 합니다. (a, b) index에서 WHERE a = ?와 WHERE a = ? AND b = ?는 index를 타지만 WHERE b = ?만으로는 못 타는 이유가 이것입니다.
범위 조건이 끼면 한 가지 규칙이 더 생깁니다. WHERE a > 10 AND b = 5처럼 앞 column에 범위 조건을 쓰면, a가 10보다 큰 구간 안에서 b는 더 이상 정렬 상태가 아니므로 b 조건은 index 탐색에 쓰이지 못하고 걸러내기로만 쓰입니다. 그래서 composite index를 설계할 때는 등호로 쓰는 column을 앞에, 범위로 쓰는 column을 뒤에 두는 것이 일반 원칙입니다.
clustered vs non-clustered index 차이는?
clustered index는 데이터가 index 순서대로 물리적으로 저장됩니다. MySQL InnoDB에서 PRIMARY KEY가 이에 해당합니다. leaf node에 실제 row 데이터가 들어 있어서 index를 타면 바로 row를 얻습니다.
non-clustered index는 데이터와 index가 별도로 존재합니다. PostgreSQL 기본 구조(heap table)가 이에 해당합니다. leaf node에는 heap 내 위치 정보가 저장되고, 거기서 실제 row를 한 번 더 읽어야 합니다.
InnoDB의 secondary index(PRIMARY KEY가 아닌 일반 index)는 leaf에 primary key 값을 저장합니다. 그래서 secondary index로 조회하면 "secondary index → PK → clustered index" 순으로 두 번 탐색이 일어납니다. 이를 이중 조회(double lookup)라고 합니다.
clustered 구조에서는 PRIMARY KEY 선택이 저장 배치를 결정하므로 성능 문제가 됩니다. auto increment PK는 새 row가 항상 마지막 page에 순서대로 붙어 삽입이 순차적입니다. 반면 무작위 UUID를 PK로 쓰면 삽입 위치가 트리 전체에 흩어져, 이미 찬 page 중간에 끼워 넣느라 page를 반으로 쪼개는 작업(page split)이 반복되고 캐시 효율도 나빠집니다. InnoDB에서 "PK는 짧고 단조 증가하게"라는 권장이 여기서 나옵니다.
이중 조회를 없애는 기법이 covering index입니다. 쿼리가 필요로 하는 column이 전부 index 안에 있으면, table까지 가지 않고 index만 읽고 끝낼 수 있습니다. SELECT user_id FROM orders WHERE status = ?에 (status, user_id) index가 있다면 leaf에 답이 이미 있는 식입니다. 자주 쓰는 쿼리의 SELECT 목록에 맞춰 index에 column을 추가하는 것은 이 효과를 노린 설계입니다.
선택도(cardinality)는 무엇인가요?
선택도는 조건이 얼마나 많은 row를 걸러내는지를 뜻합니다. 값이 다양해서 소수 row만 선택되는 column은 index 효과가 좋을 가능성이 큽니다.
반대로 성별처럼 값 종류가 적고 많은 row가 같은 값을 가지면 index 효과가 제한적일 수 있습니다.
DB가 선택도를 아는 방법은 통계입니다. optimizer는 쿼리를 실행하기 전에 "이 조건에 몇 row가 걸릴까"를 추정해야 index 사용 여부를 정할 수 있는데, 이를 위해 각 column의 고유값 수와 값 분포(histogram)를 주기적으로 수집해 둡니다. PostgreSQL의 ANALYZE, MySQL의 통계 갱신이 이 작업입니다.
이 구조의 함정은 통계가 낡을 수 있다는 것입니다. 대량 적재나 삭제 직후에는 통계가 실제 분포와 어긋나, optimizer가 "소수만 걸릴 것"이라 잘못 추정하고 index를 탔다가 실제로는 수십만 row를 random 접근하는 나쁜 실행 계획이 나올 수 있습니다. "어제까지 빠르던 쿼리가 갑자기 느려졌다"의 단골 원인 중 하나가 통계 불일치이고, 이때 통계 재수집이 처방이 됩니다.
선택도가 낮은 컬럼에 인덱스를 걸면 왜 오히려 느려질 수 있나요?
인덱스로 row를 찾은 뒤 실제 row를 가져올 때 random I/O가 발생하기 때문입니다. 결과 row가 많을수록 random I/O 비용이 full scan의 sequential I/O보다 커집니다.
성별(M/F) 컬럼에 인덱스를 걸고 WHERE gender = 'M'을 조회하면, 인덱스가 전체의 절반에 해당하는 row를 가리킵니다. 그 row들은 디스크에 흩어져 있어 하나씩 random access로 읽어야 합니다.
반면 full scan은 디스크를 처음부터 순차적으로 읽는 sequential I/O이고, 이는 random I/O보다 훨씬 빠릅니다.
optimizer가 이를 판단해 인덱스를 무시하고 full scan을 선택하기도 합니다.
index를 많이 만들면 왜 안 좋나요?
index마다 별도 저장 공간이 필요하고, row가 변경될 때 index도 갱신해야 합니다. 쓰기 작업이 느려질 수 있고, optimizer가 고려해야 할 선택지도 늘어납니다.
그래서 조회 패턴을 보고 필요한 index를 설계해야 합니다.
비용을 구체화하면, index 5개가 걸린 table에 INSERT 하나가 들어오면 table 쓰기 1번에 B+Tree 갱신 5번이 따라붙습니다. 쓰기 한 번이 여러 배의 물리적 쓰기로 불어나는 이 현상을 쓰기 증폭(write amplification)이라 부릅니다. UPDATE도 바뀐 column을 포함한 index마다 "옛 위치에서 삭제 + 새 위치에 삽입"이 일어납니다.
그래서 index 설계는 추가만이 아니라 정리도 포함합니다. 대부분의 DB는 index별 사용 통계를 제공하므로(PostgreSQL의 pg_stat_user_indexes 등), 오랫동안 조회에 쓰이지 않으면서 쓰기 비용만 만드는 index를 찾아 제거할 수 있습니다. 겹치는 index도 정리 대상입니다. (a) index는 (a, b) index가 있으면 leftmost prefix로 대체되는 경우가 많아 중복일 수 있습니다.
index가 있는데도 query가 느릴 수 있나요?
그럴 수 있습니다. index를 타도 결과 row가 너무 많거나, random access 비용이 크거나, 잘못된 조건 때문에 index를 활용하지 못할 수 있습니다.
실제로는 execution plan을 보고 database가 어떤 방식으로 query를 실행하는지 확인해야 합니다.
"index가 있는데 안 탄다"의 단골 원인들은 패턴이 정해져 있습니다. column을 함수로 감싸면(WHERE UPPER(email) = ?) index는 원본 값 기준으로 정렬되어 있으므로 못 씁니다. 앞에 와일드카드가 붙은 LIKE '%kim'은 정렬의 시작점을 정할 수 없어 못 씁니다. 문자열 column을 숫자와 비교하면(WHERE phone = 01012345678) 암시적 형변환이 column 쪽에 걸려 함수로 감싼 것과 같아집니다. 셋 다 "index는 저장된 값 그대로의 정렬"이라는 성질에서 나오는 제약입니다.
진단 도구는 실행 계획입니다. EXPLAIN(PostgreSQL은 EXPLAIN ANALYZE)으로 실제 어떤 index를 탔는지, 예상 row 수와 실제 row 수가 얼마나 어긋나는지 확인할 수 있습니다. "느린 쿼리 → 추측으로 index 추가"가 아니라 "실행 계획 확인 → 원인에 맞는 수정"이 올바른 순서입니다.
인덱스와 쓰기 성능의 관계는?
INSERT, UPDATE, DELETE가 발생하면 해당 table의 index도 함께 갱신해야 합니다. index가 많을수록 쓰기 한 번에 업데이트해야 할 B+Tree 구조가 늘어납니다.
특히 쓰기가 빈번한 column에 index를 많이 걸면 write 처리량이 낮아질 수 있습니다. 읽기와 쓰기 비율을 고려해 index를 설계해야 합니다.
B+Tree 갱신이 특히 비싸지는 순간이 page split입니다. 삽입할 leaf page가 이미 가득 차 있으면 page를 둘로 쪼개고 부모에 새 분기를 추가해야 하는데, 부모도 가득 차 있으면 분열이 위로 전파됩니다. 값이 무작위 순서로 들어오는 index일수록 삽입 위치가 흩어져 split이 자주 일어나고, 단조 증가하는 값(auto increment, timestamp)은 항상 끝에 붙어 split이 훨씬 적습니다.
이 비용 구조를 활용하는 실무 패턴이 대량 적재 시 index 전략입니다. 수백만 row를 넣어야 할 때 index가 걸린 채로 넣으면 row마다 트리 갱신이 반복되지만, index를 제거하고 데이터를 다 넣은 뒤 index를 다시 만들면 정렬된 데이터로 트리를 한 번에 쌓아 올릴 수 있어 훨씬 빠릅니다. 마이그레이션이나 초기 적재에서 흔히 쓰는 방법입니다.
index 갱신이 필요한 이유는 트리 구조 때문인가요?
아닙니다. index가 데이터의 정렬된 복사본이기 때문입니다. 원본 table에 row가 추가됐는데 복사본에 반영하지 않으면, index로 조회할 때 그 row는 존재하지 않는 셈이 됩니다. 구조가 B+Tree든 hash든 쓰기마다 갱신 자체는 피할 수 없습니다.
트리라는 사실이 정하는 것은 갱신의 여부가 아니라 비용입니다. B+Tree라서 제자리 찾기가 O(log n)이 되고, page가 가득 찼을 때 split이 발생합니다.
여기서 자연스러운 다음 의문은 "InnoDB는 table 자체가 B+Tree인데, 그럼 INSERT마다 트리를 고치느라 느리지 않나"입니다. 이론상 맞지만 실제로는 빠르게 동작하도록 설계되어 있습니다. auto increment PK라면 새 row는 항상 가장 오른쪽 leaf 끝에 붙으므로 제자리를 찾는 탐색이 사실상 필요 없고, 그 끝 page와 상위 노드들은 계속 쓰이는 곳이라 메모리(buffer pool)에 이미 올라와 있습니다. 트리에 넣는다고 해도 실질적으로는 append입니다.
디스크 쓰기도 트리 조작과 분리되어 있습니다. 커밋 시점에 즉시 디스크에 쓰는 것은 변경 내역을 기록하는 redo log(순차 쓰기)뿐이고, B+Tree page 자체는 메모리에서 고쳐 뒀다가 나중에 모아서 내려씁니다. secondary index도 change buffer라는 장치가 변경 내용만 적어 뒀다가 나중에 병합합니다. 그래서 INSERT 하나는 보통 1ms 미만이고, 느려지는 것은 무작위 UUID PK로 삽입 위치가 흩어져 page split과 random I/O가 반복되거나 secondary index가 많아 갱신이 여러 배로 불어나는 경우입니다.
부가 설명
SELECT * FROM users WHERE email = 'a@example.com';email에 index가 없다면 database는 많은 row를 확인해야 할 수 있습니다. email index가 있으면 해당 값을 빠르게 찾아 row에 접근할 수 있습니다.
CREATE INDEX users_email_idx ON users (email);다만 모든 column에 index를 만들면 쓰기 성능과 저장 공간에서 손해를 볼 수 있습니다.
index가 항상 사용되는 것도 아닙니다. 조건이 너무 넓어서 table 대부분을 읽어야 하거나, 함수로 column을 감싸 index를 타기 어렵게 만들거나, 통계 정보상 sequential scan이 더 낫다고 판단되면 index를 쓰지 않을 수 있습니다.
Index는 읽기를 빠르게 하지만 쓰기와 저장 공간 비용을 늘리는 trade-off가 있습니다.
한 줄 정리
index는 table scan 범위를 줄여 읽기를 빠르게 만들지만, 저장 공간과 쓰기 갱신 비용을 함께 만드는 자료구조입니다.