Database/MongoDB

[MongoDB] MongoDB 인덱스 2편 - 복합 인덱스의 원리와 효과적인 사용법

comgu 2025. 6. 1. 12:03
반응형

복합 인덱스(Compound Index)는 MongoDB에서 가장 강력하면서도 까다로운 인덱스 유형이다. 여러 필드를 조합하여 복잡한 쿼리의 성능을 극적으로 향상시킬 수 있지만, 잘못 설계하면 오히려 성능을 해칠 수 있다.



복합 인덱스의 내부 동작 원리

B-tree에서의 저장 구조

복합 인덱스 [("username", 1), ("age", -1), ("created_at", -1)]는 다음과 같이 정렬되어 저장된다:

alice, 30, 2024-01-15
alice, 25, 2024-01-14  
alice, 20, 2024-01-12
bob, 35, 2024-01-11
bob, 28, 2024-01-13
charlie, 40, 2024-01-16
charlie, 22, 2024-01-10

탐색 과정의 단계별 분석

쿼리 {"username": "bob", "age": 28}를 실행할 때:

1단계: 첫 번째 필드로 범위 좁히기

  • B-tree에서 username = "bob"인 구간을 빠르게 찾음
  • 전체 데이터에서 bob 관련 데이터만 추출

2단계: 두 번째 필드로 정확한 위치 찾기

  • username = "bob"인 영역 내에서 age = 28인 레코드 탐색
  • 이미 축소된 범위에서만 검색하므로 매우 효율적

 

인덱스 프리픽스 규칙의 의미

왜 [B]나 [B, C] 쿼리는 인덱스를 활용할 수 없을까?

핵심 이유: B-tree 탐색은 항상 첫 번째 필드부터 시작해야 한다.

# 복합 인덱스: [("category", 1), ("price", -1), ("created_at", -1)]

# ✅ 효율적인 쿼리들 (인덱스 활용 가능)
await collection.find({"category": "electronics"})
await collection.find({"category": "electronics", "price": {"$gte": 500}})
await collection.find({
    "category": "electronics", 
    "price": {"$gte": 500},
    "created_at": {"$gte": datetime(2024, 1, 14)}
})

# ⚠️ 부분적으로 효율적 (category만 인덱스 사용)
await collection.find({
    "category": "electronics",
    "created_at": {"$gte": datetime(2024, 1, 14)}
})

# ❌ 비효율적인 쿼리들 (Collection Scan 발생)
await collection.find({"price": {"$gte": 500}})
await collection.find({"created_at": {"$gte": datetime(2024, 1, 14)}})
await collection.find({
    "price": {"$gte": 500},
    "created_at": {"$gte": datetime(2024, 1, 14)}
})

성능 차이의 실제 측정

# 100만 개 문서가 있는 컬렉션에서

# 인덱스 활용 쿼리 - 0.001초
result1 = await collection.find({"category": "electronics"})

# 부분 인덱스 활용 - 0.005초  
result2 = await collection.find({
    "category": "electronics",
    "created_at": {"$gte": datetime(2024, 1, 12)}
})

# Collection Scan - 2.5초
result3 = await collection.find({"price": {"$gte": 500}})

 

복합 인덱스 설계 전략

1. ESR 규칙 (Equality, Sort, Range)

필드 순서를 정할 때 다음 우선순위를 따르는 것이 좋다:

  1. Equality (동등 조건): = 조건으로 검색하는 필드
  2. Sort (정렬): 정렬 기준이 되는 필드
  3. Range (범위 조건): $gte, $lt 등 범위 조건 필드
# 쿼리 패턴 분석
queries = [
    {"status": "published", "category": "tech", "views": {"$gte": 1000}},
    {"status": "published", "category": "python", "created_at": {"$gte": "2024-01-01"}},
    {"status": "draft", "category": "tutorial"}
]

# ESR 규칙에 따른 최적 인덱스
collection.create_index([
    ("status", 1),      # Equality - 항상 등등 조건
    ("category", 1),    # Equality - 항상 등등 조건  
    ("views", -1),      # Range - 범위 조건
    ("created_at", -1)  # Range - 범위 조건
])

2. 카디널리티 고려

카디널리티: 필드가 가질 수 있는 고유값의 개수

# 높은 카디널리티 → 낮은 카디널리티 순서로 배치
collection.create_index([
    ("user_id", 1),     # 카디널리티: 100만 (높음)
    ("status", 1),      # 카디널리티: 3 (낮음) 
    ("created_at", -1)
])

이유: 높은 카디널리티 필드를 앞에 배치하면 더 효과적으로 데이터 범위를 축소할 수 있다.

3. 쿼리 빈도 분석

# 80%의 쿼리가 이 패턴이라면
frequent_queries = [
    {"category": "tech", "status": "published"},
    {"category": "python", "status": "published"},
    {"category": "tutorial", "status": "draft"}
]

# category를 첫 번째 필드로 배치
collection.create_index([("category", 1), ("status", 1), ("created_at", -1)])

# 20%의 쿼리가 이 패턴이라면  
rare_queries = [
    {"status": "published", "views": {"$gte": 1000}},
    {"status": "draft", "author_id": "12345"}
]

# 별도 인덱스 생성 고려
collection.create_index([("status", 1), ("views", -1)])

 

복합 인덱스의 고급 활용

1. 정렬 최적화

# 정렬이 포함된 쿼리
result = await collection.find({
    "category": "technology"
}).sort([("created_at", -1)]).limit(10)

# 최적 인덱스: 필터 + 정렬 조합
collection.create_index([("category", 1), ("created_at", -1)])

효과: 정렬 작업이 메모리에서 수행되지 않고 인덱스 순서를 그대로 활용

2. 커버링 인덱스 (Covering Index)

# 자주 사용되는 쿼리
result = await collection.find(
    {"category": "tech", "status": "published"},
    {"title": 1, "created_at": 1, "_id": 0}
)

# 모든 필드를 포함하는 커버링 인덱스
collection.create_index([
    ("category", 1),
    ("status", 1), 
    ("title", 1),
    ("created_at", -1)
])

효과: 실제 문서를 읽지 않고 인덱스만으로 결과 반환 (성능 극대화)

3. 부분 인덱스와의 조합

# 활성 사용자의 최근 활동만 빠르게 검색
collection.create_index(
    [("user_id", 1), ("action_type", 1), ("timestamp", -1)],
    partialFilterExpression={
        "is_active": True,
        "timestamp": {"$gte": datetime(2024, 1, 1)}
    }
)

 

실행 계획으로 성능 검증

1. 기본 실행 계획 확인

explain = await collection.find({
    "category": "electronics", 
    "price": {"$gte": 500}
}).explain()

# 인덱스 사용 여부 확인
winning_plan = explain["queryPlanner"]["winningPlan"]
if winning_plan["stage"] == "IXSCAN":
    print(f"✅ 인덱스 사용: {winning_plan['indexName']}")
    print(f"검사한 키: {explain['executionStats']['totalKeysExamined']}")
    print(f"검사한 문서: {explain['executionStats']['totalDocsExamined']}")
else:
    print("❌ Collection Scan 발생")

2. 상세 성능 분석

# 실행 통계 상세 분석
stats = explain["executionStats"]

# 효율성 지표
key_to_doc_ratio = stats["totalKeysExamined"] / stats["totalDocsExamined"]
selectivity = stats["totalDocsReturned"] / stats["totalDocsExamined"]

print(f"키/문서 비율: {key_to_doc_ratio:.2f}")  # 1에 가까울수록 좋음
print(f"선택도: {selectivity:.2f}")              # 1에 가까울수록 좋음
print(f"실행 시간: {stats['executionTimeMillis']}ms")

 

복합 인덱스 설계 체크리스트

✅ 해야 할 것들

  1. 쿼리 패턴 분석: 실제 애플리케이션의 쿼리 로그 분석
  2. ESR 규칙 적용: Equality → Sort → Range 순서
  3. 카디널리티 고려: 높은 카디널리티 필드를 앞쪽에 배치
  4. 실행 계획 검증: explain()으로 성능 확인
  5. 정기적인 모니터링: 인덱스 사용률과 성능 추적

❌ 피해야 할 것들

  1. 과도한 복합 인덱스: 필요 이상으로 많은 필드 포함
  2. 중복 인덱스: 이미 존재하는 인덱스와 겹치는 조합
  3. 잘못된 필드 순서: ESR 규칙을 무시한 순서
  4. 미사용 인덱스: 실제로 사용되지 않는 인덱스 방치
  5. 성능 검증 없이 운영: explain() 없이 인덱스 배포

 

다음 편 예고

3편에서는 텍스트 인덱스의 심화 원리검색 성능 최적화 방법에 대해 알아본다. 특히 일반 인덱스 vs 텍스트 인덱스의 차이점과 복합 텍스트 인덱스의 효과적인 활용법을 다룬다.

반응형