노트

B-tree와 B+tree

B-tree

CS#db#algorithm · 연결된 개념 7개

쉽게 말하면

B-tree는 두꺼운 사전의 구간 표시 같아요. 첫 장에서 큰 구간을, 다음 장에서 더 좁은 구간을 보고 몇 번만 펼치면 원하는 단어가 있는 쪽에 닿죠. 그래서 디스크를 조금만 읽고도 값을 찾아요.

비유의 한계 사전과 달리 B-tree는 값이 들어오고 빠질 때마다 모양을 고쳐요. 노드가 넘치면 쪼개서 균형을 맞추고, DB가 쓰는 B+tree는 잎끼리 이어져 있어 범위를 옆으로 훑어요.

B-tree는 한 노드가 키 여러 개와 자식 여러 개를 가지는 균형 잡힌 다진 탐색 트리(balanced multi-way search tree)다. 어떤 키를 찾든 루트에서 잎까지의 깊이가 같다. 이진 탐색 트리처럼 정렬 규칙으로 가지를 고르지만(이진 탐색 트리), 노드 하나에 키를 많이 담아 트리가 아주 낮다. 1970년대에 바이어(Bayer)와 맥크레이트(McCreight)가 만들었고, "B"가 무엇의 약자인지는 저자들이 밝힌 적이 없다(balanced, Bayer, Boeing 등 여러 설이 있다).

왜 디스크에 맞나

DB는 데이터를 페이지 단위(PostgreSQL은 8KB)로 읽는다. 디스크에서는 몇 바이트를 읽든 페이지 하나를 읽는 비용이 비슷하므로, 노드 하나 = 페이지 하나로 두고 그 안에 키를 꽉 채운다.

  • 노드 하나가 자식을 수백 개 가리키면, 높이 3~4만으로 수억 개의 키를 다룬다
  • 찾기는 높이만큼의 페이지 읽기로 끝난다. 위쪽 몇 단은 자주 읽혀 거의 늘 메모리에 있다
  • 탐색·삽입·삭제 모두 O(log n)(빅오 표기법). 노드가 넘치면 나누고(split) 부모로 키를 올려 균형을 유지한다

B+tree: DB가 실제로 쓰는 모양

DB 인덱스의 B-tree는 대개 B+tree 형태다.

내부 노드: [ 30 | 60 ]          ← 길 안내용 키만
          /     |     \
잎:  [10 20] ⇄ [30 45] ⇄ [60 75]   ← 모든 키 + 행 위치, 형제끼리 연결
  • 키와 행 위치(PostgreSQL에서는 힙의 튜플 위치)는 잎에만 있고, 내부 노드는 범위를 안내하는 키만 가진다. 그래서 내부 노드에 키가 더 많이 들어가 트리가 더 낮다. PostgreSQL 문서에 따르면 B-tree 인덱스 페이지의 99% 이상이 잎이다
  • 잎끼리 양방향 연결 리스트로 이어져 있어, 범위의 시작점 하나만 트리로 찾고 나머지는 옆으로 훑는다

잘하는 것과 못하는 것

  • 잘함: 같음(=), 범위(<·BETWEEN), 정렬(ORDER BY를 정렬 없이 순서대로 읽기), 앞이 고정된 패턴(LIKE 'abc%'. PostgreSQL에서는 C 로캘이 아니면 text_pattern_ops 같은 별도 연산자 클래스가 필요하다)
  • 못함: LIKE '%abc'처럼 앞이 열린 패턴, 전문 검색, 배열·JSON 포함 관계. PostgreSQL에서는 이런 건 GIN·GiST 같은 다른 인덱스가 맡는다(역색인과 검색 엔진)
  • 비용: 쓰기마다 트리를 고쳐야 하고 페이지 분할이 일어난다. 무작위 키(UUIDv4 등)는 연달아 만든 값도 인덱스의 아무 위치에나 들어가서, 여기저기 페이지가 쪼개지고 쓰기와 캐시 효율이 떨어진다. 시간순으로 커지는 UUIDv7이 나온 이유다

잎만 읽고 끝내기: 인덱스 온리 스캔

잎에는 키 값이 그대로 들어 있다. 그래서 쿼리가 고르는 컬럼이 모두 인덱스에 있으면 힙에 가지 않고 인덱스만 읽어 답할 수 있다. PostgreSQL은 이것을 인덱스 온리 스캔(Index-Only Scan)이라 부르고, 조건을 둘로 정리한다.

  • 인덱스가 원래 값을 저장하거나 되살릴 수 있어야 한다. B-tree는 늘 된다. GIN은 항목마다 값의 일부만 가져서 안 된다
  • 쿼리가 인덱스에 저장된 컬럼만 참조해야 한다. (x, y) 인덱스로 SELECT x, y FROM tab WHERE x = 'key'는 되지만, 인덱스에 없는 z까지 고르면 안 된다

인덱스 항목에는 그 행이 지금 트랜잭션에 보이는지(MVCC 가시성) 정보가 없다. 그래서 인덱스 온리 스캔은 힙 페이지마다 "모든 행이 모두에게 보인다"를 표시한 가시성 맵(Visibility Map)을 먼저 본다. 표시된 페이지의 행은 힙을 읽지 않고 돌려주고, 표시되지 않은 페이지는 결국 힙을 읽는다. 이 표시는 VACUUM이 켜고, 페이지의 행이 바뀌면 꺼진다. 그래서 자주 바뀌는 테이블일수록 이득이 줄어든다 → PostgreSQL 내부 동작(MVCC·VACUUM·WAL·플래너)

INCLUDE 컬럼: 커버링 인덱스

검색 조건에는 쓰지 않지만 자주 함께 읽는 컬럼을 잎에 얹어 두면, 인덱스 온리 스캔으로 끝나는 쿼리가 늘어난다. 이런 인덱스를 커버링 인덱스(Covering Index)라고 한다.

CREATE INDEX tab_x_y ON tab (x) INCLUDE (y);
-- SELECT y FROM tab WHERE x = 'key'; 를 힙 없이 처리할 수 있다
  • INCLUDE 컬럼은 잎 항목에만 저장되고, 길을 안내하는 위쪽 노드에는 들어가지 않는다. 검색이나 탐색 방향을 정하는 데는 쓰이지 않는다
  • UNIQUE 인덱스에 INCLUDE를 붙이면 유일성은 키 컬럼(x)에만 적용된다
  • 테이블 데이터를 한 벌 더 저장하는 셈이라 인덱스가 커지고 검색이 느려질 수 있다. INCLUDE가 있는 B-tree는 중복 제거(deduplication)도 쓰지 못한다. 넓은 컬럼은 조심하고, 테이블이 천천히 바뀌어 힙을 읽을 일이 드물 때 붙일 만하다
  • PostgreSQL에서는 B-tree, GiST, SP-GiST가 INCLUDE를 지원한다(18 기준)

해시 테이블(해시 테이블)은 같음 비교만이라면 더 빠를 수 있지만 순서가 없어서 범위·정렬을 못 한다. B-tree가 관계형 DB의 기본 인덱스인 이유가 이 "정렬된 채로 낮은 높이"다. 인덱스를 거는 판단은 DB 인덱스와 트레이드오프, PostgreSQL에서 인덱스가 실제로 어떻게 쓰이는지는 PostgreSQL 내부 동작(MVCC·VACUUM·WAL·플래너)를 본다.

책: 8장. 데이터베이스: 인덱스와 트랜잭션

출처: PostgreSQL 18 — B-Tree Indexes: Implementation · PostgreSQL 18 — Index-Only Scans and Covering Indexes · PostgreSQL 18 — CREATE INDEX: INCLUDE · PostgreSQL 18 — Updating the Visibility Map · PostgreSQL 18 — Index Types: B-Tree · RFC 9562 — UUID: Sorting · Wikipedia — B-tree · Wikipedia — B+ tree

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • 그래프

    정점(vertex, 노드)과 정점들을 잇는 간선(edge)으로 이루어진 자료구조. 트리도 그래프의 한 종류다. SNS 친구 관계, 지도와 경로, 웹 페이지 링크, 추천 시스템, 패키지 의존성처럼 "무엇과 무엇이 연결돼 있다"는 모든 것을 표현한다. 이 지식 맵도 노트를 정점, 링크를 간선으로 한 그래프다.

  • 이진 힙

    부모가 항상 자식보다 크거나(최대 힙, Max Heap) 작은(최소 힙, Min Heap) 완전 이진 트리(Complete Binary Tree). 형제끼리의 순서는 정하지 않는다. 가장 크거나 작은 값이 항상 루트에 있어 꺼내기 쉽다.

  • 낙관적 잠금과 비관적 잠금

    낙관적 잠금(Optimistic Locking)은 충돌이 드물다고 보고 읽을 때 잠그지 않는 동시성 제어 방식이다. 쓸 때 읽어 둔 버전(또는 타임스탬프)과 지금 값을 비교해, 그사이 다른 쪽이 바꿨으면 쓰기를 실패시킨다. 두 사용자가 같은 행을 읽고 차례로 저장해 먼저 저장한 변경이 사라지는 갱신 손실(lost update)을 막는다.

  • 데이터 무결성

    데이터 무결성(data integrity)은 저장된 데이터가 정확하고 일관되며 믿을 수 있는 상태로 유지되는 것이다. 재고가 100개로 보이는데 실제로 50개라면 무결성이 깨진 것이다. 관계형 DB는 이를 제약 조건(constraint)으로 강제한다.

  • 트리 순회

    트리의 모든 노드를 한 번씩 방문하는 방법. 같은 층을 먼저 훑는 너비 우선(Breadth-First)과, 한 가지를 끝까지 내려가는 깊이 우선(Depth-First)이 있고, 깊이 우선은 노드를 언제 방문하느냐에 따라 셋으로 나뉜다.

보기 옵션