인덱스가 쿼리 성능에 미치는 영향

인덱스가 쿼리 성능에 미치는 영향

인덱스를 만들었다고 해서 항상 쿼리가 빨라지는 건 아니에요. 언제 어떤 인덱스가 실제로 쓰이는지, 그리고 어떤 상황에서 성능이 크게 달라지는지를 알려면 실행 계획(execution plan) 을 봐야 해요. 이 페이지에서는 토큰 조회·범위·텍스트 인덱스가 각각 어떤 쿼리에서 쓰이고 DB 접근 횟수와 실행 시간을 어떻게 바꾸는지를 실행 계획 예시와 함께 풀어 드릴게요.

출처: Neo4j Cypher Manual — The impact of indexes on query performance

인덱스가 하는 일

검색 성능 인덱스는 노드 라벨/관계 타입과 프로퍼티 술어의 특정 조합을 해결해서 더 빠르고 효율적인 패턴 매칭을 가능하게 해 줘요. Cypher 플래너가 MATCH 절에서, 보통 쿼리 시작 부분에서 자동으로 이 인덱스를 사용해 패턴 매칭을 시작할 가장 적절한 지점을 찾아 스캔해요.

이 페이지의 예시는 뉴욕 센트럴 파크의 경로와 명소를 찾는 OpenStreetMap 데이터 모델을 기반으로 해요. 두 노드 라벨(OSMNode·PointOfInterest)과 하나의 관계 타입(ROUTE, 노드 사이 거리 미터 단위)이 있고, 그래프에는 총 69,165개의 노드(그중 PointOfInterest 라벨이 188개)와 152,077개의 ROUTE 관계가 들어 있어요.

토큰 조회 인덱스

노드 라벨용·관계 타입용 토큰 조회 인덱스 두 개는 Neo4j 데이터베이스를 만들 때 기본으로 생겨요. 이들은 데이터베이스의 모든 노드 라벨과 관계 타입의 복사본을 저장하고, 노드 라벨·관계 타입 술어만 해결해요.

type 프로퍼티가 baseballPointOfInterest 노드 수를 세는 아래 쿼리는 노드 라벨 조회 인덱스에 접근해요.

PROFILE
MATCH (n:PointOfInterest)
WHERE n.type = 'baseball'
RETURN count(n)

실행 계획에서 NodeByLabelScan 연산자는 노드 라벨 조회 인덱스에 접근해 PointOfInterest 라벨을 가진 188개 노드를 만들어 냈고, 쿼리는 565번의 DB 접근으로 8밀리초 남짓 만에 끝났어요.

토큰 조회 인덱스는 Cypher 쿼리와 다른 인덱스의 채우기 성능을 개선하기 때문에 아주 중요해요. 이 인덱스를 지우면 성능이 심각하게 떨어져요. 만약 노드 라벨 조회 인덱스가 없다면 NodeByLabelScanAllNodesScan으로 대체되어 결과를 내기 전에 데이터베이스의 69,165개 노드를 전부 읽어야 했을 거예요.

다만 토큰 조회 인덱스는 프로퍼티 관련 술어를 해결하지 못하므로, 크기가 작지 않은 데이터베이스를 다루는 애플리케이션에서는 보통 혼자 충분하지 않아요.

범위 인덱스

범위 인덱스는 대부분의 술어를 해결하고, 값의 범위로 데이터를 효율적으로 꺼내는 데 써요. 순서가 있고 비교 가능한 값을 가진 프로퍼티를 다룰 때 특히 유용하죠.

type 프로퍼티에 범위 인덱스를 만들고 같은 쿼리를 다시 돌려볼게요.

CREATE INDEX range_index_type FOR (n:PointOfInterest) ON (n.type)

인덱스를 만들 때 타입을 지정하지 않으면 Neo4j는 기본으로 범위 인덱스를 만들어요. 쿼리를 다시 실행한 실행 계획에서는 NodeByLabelScanNodeIndexSeek으로 바뀌었어요. 이 연산자는 typebaseball인 26개 PointOfInterest 노드만 만들고, DB 접근이 27번으로 줄었으며 실행 시간은 1밀리초 미만 — 인덱스가 없을 때보다 약 8배 빨라졌어요. 이게 바로 검색 성능 인덱스가 Cypher 쿼리 성능을 크게 개선할 수 있다는 핵심 포인트예요.

텍스트 인덱스

텍스트 인덱스는 STRING 프로퍼티로 필터링하는 쿼리에 써요. 같은 STRING 프로퍼티에 범위 인덱스와 텍스트 인덱스가 둘 다 있으면, 텍스트 인덱스는 CONTAINS·ENDS WITH 연산자로 필터링하는 쿼리에만 플래너가 사용해요. 그 외의 경우엔 범위 인덱스를 쓰죠.

같은 프로퍼티에 두 인덱스를 모두 만들어 볼게요.

CREATE TEXT INDEX text_index_name FOR (n:PointOfInterest) ON (n.name)
CREATE INDEX range_index_name FOR (n:PointOfInterest) ON (n.name)

이제 name'William'CONTAINS하는 모든 PointOfInterest 노드를 찾는 쿼리예요.

PROFILE
MATCH (n:PointOfInterest)
WHERE n.name CONTAINS 'William'
RETURN n.name AS name, n.type AS type

행 2개(William Shakespeare, William Tecumseh Sherman)가 돌아오고, 실행 계획은 텍스트 인덱스를 써서 결과를 효율적으로 찾아냈어요.

범위 인덱스는 STRING 값을 알파벳순으로 저장하기 때문에 정확 일치나 접두사 매칭에는 매우 효율적이지만, 접미사·CONTAINS 검색처럼 모든 관련 프로퍼티를 스캔해야 하는 경우에는 덜 효율적이에요. 텍스트 인덱스는 문자열을 알파벳순으로 저장하지 않고 접미사·CONTAINS 검색에 최적화돼 있죠. 만약 name 프로퍼티에 범위 인덱스가 없었다 해도 이 쿼리는 텍스트 인덱스를 활용할 수 있었어요.

텍스트 인덱스와 STRING 크기

인덱스되는 STRING 프로퍼티의 크기도 플래너의 인덱스 선택에 영향을 줘요. 범위 인덱스는 최대 키 크기가 약 8KB라서 8KB보다 큰 STRING 값은 인덱스할 수 없어요. 반면 텍스트 인덱스는 최대 키 크기가 약 32KB라서 그만큼 큰 문자열까지 인덱스할 수 있어요.

관계 프로퍼티 인덱스

인덱스는 관계의 프로퍼티에도 만들 수 있어요. 예를 들어 ROUTE 관계의 distance 프로퍼티에 범위 인덱스를 만들면, 거리 기준으로 관계를 탐색하는 쿼리에서 해당 인덱스가 쓰여 Sort 연산을 없애고 메모리 비용을 크게 줄일 수 있어요. 범위 인덱스가 distance를 미리 정렬해 두기 때문에 결과 정렬이 필요 없어지거든요.

여러 인덱스 함께 쓰기

인덱스는 주로 패턴의 시작점을 찾는 데 쓰여요. 쿼리에 MATCH 절이 하나면, 일반적으로 그 절의 술어에 가장 잘 맞는 인덱스 하나만 플래너가 선택해요. 하지만 쿼리에 MATCH 절이 두 개 이상이면 인덱스 여러 개를 함께 쓸 수 있어요.

예를 들어 PointOfInterest 노드의 lon(경도) 프로퍼티에 범위 인덱스를 만들고, William Shakespeare 동상 북쪽에 있는 PointOfInterest 노드를 찾는 쿼리는 두 인덱스를 함께 활용해요.

CREATE INDEX range_index_lon FOR (n:PointOfInterest) ON (n.lon)
PROFILE
MATCH (ws:PointOfInterest {name:'William Shakespeare'})
WITH ws
MATCH (poi:PointOfInterest)
WHERE poi.lon > ws.lon
RETURN poi.name AS name

과잉 인덱싱 피하기

인덱스가 많다고 무조건 좋은 건 아니에요. 인덱스는 데이터를 쓰는 작업에 오버헤드를 추가하고 저장 공간도 차지하니까요. 실제 쿼리 패턴과 데이터 규모를 기준으로, 성능 이점이 확실한 곳에만 인덱스를 만드는 게 좋아요. 어떤 인덱스를 언제 쓰고(안 쓰고) 할지에 대한 휴리스틱을 실행 계획과 함께 점검하는 습관이 중요해요.

더 알아보기