본문 바로가기
카테고리 없음

B-tree만 쓰다 만난 GIN·BRIN의 세계

by 테크구루스 2026. 7. 8.

B-tree만 쓰다 만난 GIN·BRIN의 세계를, 인덱스 타입별 강점과 선택 기준으로 정리한 글입니다.

 

B-tree만 쓰다 만난 GIN·BRIN의 세계
B-tree만 쓰다 만난 GIN·BRIN의 세계

B-tree가 잘하는 것과 못하는 것

데이터베이스를 오래 다뤄 온 분이라도, 인덱스라는 단어를 들으면 자동으로 B-tree를 떠올리는 경우가 많습니다. 저 역시 10년 넘게 PostgreSQL과 MySQL을 만지면서 CREATE INDEX 한 줄이면 세상 모든 성능 문제가 풀리는 줄 알았던 시절이 있었죠. B-tree가 워낙 범용적이라 그렇습니다. 정렬된 트리 구조 덕분에 동등 비교(WHERE id = 100), 범위 검색(BETWEEN, >, <), 그리고 ORDER BY 정렬까지 한 인덱스로 깔끔하게 처리해 줍니다. 대부분의 OLTP 워크로드에서 B-tree만으로 90%는 커버가 됩니다.

문제는 나머지 10%였습니다. B-tree는 값을 '통째로' 비교하는 구조라, 값 안쪽을 들여다봐야 하는 질의에는 손을 못 씁니다. 제가 실제로 벽에 부딪혔던 대표적인 경우를 꼽아 보면 이렇습니다.

  • 전문 검색: WHERE body LIKE '%장애%'처럼 문자열 중간을 찾는 질의. 앞부분이 고정된 '장애%'는 B-tree가 쓰이지만, 앞에 %가 붙는 순간 인덱스는 무용지물이 되죠.
  • JSONB·배열 포함 검색: WHERE tags @> '["urgent"]' 같은 포함 연산자. B-tree는 JSONB 문서 하나를 하나의 값으로만 보기 때문에 내부 키를 색인하지 못합니다.
  • 공간 데이터: 좌표 범위가 겹치는지 묻는 && 연산. 2차원 이상의 겹침은 1차원 정렬 트리로 표현이 안 됩니다.
  • 초거대 append 전용 테이블: 로그나 시계열처럼 계속 쌓이기만 하는 테이블. 여기서는 B-tree가 동작은 하지만 인덱스 크기가 감당이 안 됩니다.

여기에 한 가지 덧붙이자면, B-tree의 약점은 '값을 통째로 본다'는 특성에서 파생됩니다. 예컨대 한 행의 배열 컬럼에 태그가 다섯 개 들어 있으면, 사람은 그걸 다섯 개의 검색 대상으로 보지만 B-tree는 배열 하나를 단일 값으로만 취급합니다. 그래서 "이 태그를 포함하는 행"이라는 질의에 인덱스를 태우지 못하고 결국 Seq Scan으로 떨어지죠. 전문 검색이나 JSONB도 원리는 똑같습니다. '내부를 쪼개서 색인해야 하는 데이터'에는 B-tree의 정렬 트리 모델 자체가 맞지 않는 것입니다.

특히 마지막 항목은 뒤에서 따로 풀어놓을 만큼 저를 오래 괴롭혔습니다. 아래 지도는 인덱스 타입을 질의 성격에 따라 어디에 배치해야 하는지 제가 팀 위키에 그려 붙였던 그림입니다.

 

타입별 강점

PostgreSQL이 좋은 이유는 B-tree 말고도 여러 인덱스 타입을 기본 제공한다는 점입니다. 각각은 특정 질의 형태에 최적화돼 있어서, 상황에 맞게 고르면 성능이 몇 배가 아니라 몇 십 배씩 벌어지곤 하죠. 제가 실무에서 쓰면서 정리한 용도는 다음과 같습니다.

  • Hash — 오직 동등 비교(=)만을 위한 타입입니다. 범위나 정렬은 못 하지만, 순수 등가 조회에서는 B-tree보다 살짝 가볍습니다. 세션 토큰처럼 긴 문자열을 =로만 찾는 컬럼에 가끔 씁니다. 다만 B-tree가 워낙 잘해서 실전 채택률은 낮은 편이죠.
  • GiST — 공간 데이터와 범위 겹침의 왕입니다. PostGIS의 좌표 검색, tsrange 예약 시간대가 겹치는지 확인하는 && 배제 제약 등에 씁니다. 회의실 예약 서비스에서 시간대 중복을 막을 때 EXCLUDE USING gist를 걸어 두면 애플리케이션 코드 없이 DB가 충돌을 막아 주죠.
  • GIN — 전문 검색, JSONB, 배열의 포함 검색을 담당합니다. to_tsvector 기반 문서 검색, jsonb @> ..., array @> ...가 전부 GIN 몫입니다. 하나의 행에 여러 토큰이 들어 있는 '다중 값' 컬럼을 색인할 때 압도적입니다.
  • BRIN — Block Range Index. 정렬돼 쌓이는 초대형 테이블용입니다. 개별 행이 아니라 블록 묶음의 최소·최대값만 요약해 저장하기 때문에, 인덱스가 놀랄 만큼 작습니다.

아래 표는 위 네 타입을 한눈에 비교하려고 만든 것입니다.

 

데이터 타입별 인덱스
데이터 타입별 인덱스

 

제가 GIN의 위력을 처음 체감한 건 사내 검색 기능을 만들 때였습니다. 문서 30만 건을 LIKE '%키워드%'로 훑던 질의가 평균 2.3초씩 걸리던 걸, to_tsvector 컬럼에 GIN 인덱스를 얹자 20ms대로 떨어졌습니다. 100배 넘게 빨라진 거죠. 대신 GIN은 쓰기 비용이 큽니다. 하나의 행을 넣을 때 토큰 수만큼 인덱스 항목이 갱신되기 때문에, 삽입이 잦은 테이블에서는 fastupdate 옵션과 gin_pending_list_limit를 함께 조율해야 부하가 잡힙니다. 강점과 비용이 늘 짝으로 온다는 점을 잊으면 안 되겠죠.

핵심은 "이 컬럼을 어떤 연산자로 조회하는가"입니다. =·범위·정렬이면 B-tree, 포함(@>)·전문 검색이면 GIN, 공간·겹침이면 GiST, 그리고 물리적으로 정렬된 거대 테이블이면 BRIN. 이 한 줄만 몸에 배어도 인덱스 설계의 절반은 끝난 셈이죠.

시계열에 BRIN을 써 본 이야기

2024년 초, 제가 맡았던 IoT 센서 수집 파이프라인 이야기입니다. 디바이스들이 초당 수천 건씩 측정값을 밀어 넣는 구조였는데, measurements 테이블이 약 18억 행, 물리 크기로 약 240GB까지 불어나 있었습니다. 조회는 대부분 WHERE recorded_at BETWEEN ... AND ... 형태의 시간 범위였고, 저는 아무 의심 없이 recorded_at에 B-tree 인덱스를 걸어 두고 있었죠.

문제는 인덱스 크기였습니다. 이 B-tree 하나가 약 9.7GB를 차지했고, 매일 파티션을 붙일 때마다 인덱스 유지 비용과 autovacuum 부담이 눈에 띄게 커졌습니다. 야간 배치가 도는 시간에 인덱스 재구성이 겹치면 수집 지연이 밀리기까지 했습니다. 그때 동료가 "이거 append-only에 시간순으로 쌓이잖아, BRIN 안 봤어?"라고 던진 한마디가 시작이었습니다.

BRIN이 통했던 이유는 데이터의 물리적 순서였습니다. recorded_at은 삽입 순서와 거의 일치했기 때문에, 각 블록 묶음의 최소·최대 시각만 요약해도 범위 질의에서 관련 없는 블록을 통째로 건너뛸 수 있었습니다. 바꾼 명령은 사실상 이 정도였습니다.

-- 블록 요약 인덱스로 교체 (128개 블록 단위 요약)
CREATE INDEX idx_meas_brin ON measurements
  USING brin (recorded_at) WITH (pages_per_range = 128);

결과는 인상적이었습니다. 9.7GB짜리 B-tree가 BRIN에서는 약 40MB로, 240분의 1 수준까지 줄었습니다. 인덱스 빌드 시간도 수십 분에서 1분 남짓으로 짧아졌고, 무엇보다 autovacuum이 인덱스를 훑는 부담이 사라지면서 야간 수집 지연이 없어졌습니다.

물론 공짜는 아니었습니다. 트레이드오프는 솔직히 적어 둬야 공정하겠죠.

  • 좁은 범위 조회(예: 특정 1분 구간)에서는 BRIN이 블록 묶음을 넉넉히 훑기 때문에, 핀포인트 조회는 B-tree가 여전히 더 빨랐습니다.
  • 데이터의 물리 순서가 컬럼 값과 어긋나면(중간 UPDATE가 잦거나 순서가 뒤섞이면) BRIN의 블록 요약이 넓어져 효율이 급락합니다. 저희는 append-only라 다행이었죠.
  • 새로 들어온 블록은 brin_summarize_new_values()로 요약을 갱신해 줘야 최신 구간까지 잘 걸립니다. 저희는 이 호출을 수집 배치 끝단에 넣어 자동화했죠.

한 가지 더 배운 건 pages_per_range 값의 감각입니다. 이 값이 작으면 요약이 촘촘해져 조회는 정확해지지만 인덱스가 커지고, 크면 반대가 되죠. 저는 128로 시작해 실제 조회 패턴을 EXPLAIN ANALYZE로 며칠 관찰한 뒤 64로 낮춰 조회 효율과 크기의 균형점을 잡았습니다. 결국 인덱스 튜닝은 이론값이 아니라 우리 데이터의 실제 분포와 질의를 보고 맞춰 가는 작업이라는 걸 다시 확인했습니다.

지금이라면 시계열·로그 테이블은 처음부터 시간 컬럼에 BRIN을 깔고, 정말 좁은 핀포인트 조회가 잦은 컬럼에만 B-tree를 추가하는 식으로 설계하겠습니다. 아래 비교 그림이 그때의 크기 차이를 한눈에 보여 줍니다.

 

btree vs brin 비교
btree vs brin 비교

인덱스는 하나의 만능 도구가 아니라, 질의 형태에 맞춰 고르는 연장통이라는 걸 그해에 제대로 배웠습니다. B-tree만 알던 저에게 GIN과 BRIN은 정말 새로운 세계였죠.

 

2026.07.05 - [분류 전체보기] - 대용량 업로드로 서버 메모리가 터진 뒤 바꾼 방식