05. 인덱스

디스크 읽기 방식

  • 랜덤 I/O(Random I/O)
    • 데이터를 비연속적인(물리적으로 떨어진) 위치에서 임의로 읽거나 쓰는 작업이다.
    • 디스크 헤드가 여러 위치로 이동해야 하므로, 작업당 seek time(탐색 시간)·latency가 크게 증가한다.
    • 인덱스 기반 탐색, WHERE 조건 조회, 특정 레코드 단건 접근, 임의 위치 갱신·삭제 등에서 주로 발생한다.
    • HDD에서는 랜덤 I/O가 매우 느리며, SSD는 그 차이가 적지만 throughput(처리량)은 여전히 낮은 편이다.
      • ex) 인덱스 레인지 스캔, 키 값 하나씩 랜덤 조회 등.
  • 순차 I/O(Sequential I/O)
    • 물리적으로 연속된(붙은) 위치에서 데이터를 순서대로 읽거나 쓴다.
    • 디스크 헤드를 거의 이동시키지 않고, 한 번 seek한 뒤 연달아 데이터를 읽거나 쓸 수 있다.
    • 전체 테이블 스캔, 대량 정렬·그룹화, 연속 블록 덤프 등에서 발생.
    • 대용량 데이터 일괄 처리에 유리하며, HDD·SSD 모두에서 랜덤 I/O보다 월등히 빠르다.
    • ex) 풀 테이블 스캔(모든 로우 직접 읽기), 파일 연속 저장 등.
  • 성능 개선 관점
    • DB 쿼리 성능을 높이려면 랜덤 I/O를 최소화하고, 순차 I/O가 많이 발생하도록 데이터 구조·쿼리를 최적화하는 것이 바람직하다.
    • 이는 꼭 필요한 데이터만 읽고 쓰도록 설계하거나, 액세스 패턴을 연속적으로 유지하는 방식으로 이뤄질 수 있다.

인덱스란?

  • 모든 데이터를 뒤져서 원하는 결과를 가져오는 데에 시간이 걸리기 때문에 컬럼과 레코드가 저장된 위치를 key-value 관리
    • DBMS의 인덱스도 컬럼의 값을 주어진 순서로 미리 정렬해서 가지고 있다.
    • SortedList: DBMS의 인덱스와 동일한 자료 구조로 정렬된 상태 유지
      • 저장하는 과정은 복잡하고 느리지만 원하는 값을 빠르게 찾을 수 있다.
    • ArrayList: 데이터 파일과 동일한 자료 구조 사용로 저장된 순서 그대로 유지
  • 인덱스는 데이터 저장 방식에 따라 분류할 수 있으며 대표적으로 B-Tree 인덱스, Hash 인덱스가 있다
  • 데이터 중복 여부에 따라 unique index, non-unique index로 구분할 수 있다

MongoDB 인덱스의 개요

  • 클러스터링 인덱스
    • 클러스터링 인덱스는 기본 키 값을 기준으로 데이터가 물리적으로 정렬 저장되는 인덱스 방식이다.
    • MongoDB 인덱스 구조와 차이점
      • MongoDB는 모든 인덱스를 B-Tree 기반으로 구현하며, 인덱스는 키와 도큐먼트 위치(논리적 주소)를 별도로 저장한다.
      • 도큐먼트 저장 위치와 인덱스는 분리되어 있다.
        • 도큐먼트가 물리적으로 정렬되는 구조가 아니고, 인덱스가 클러스터된 데이터를 포함하지 않는다.
      • 따라서 기본 키(_id) 인덱스도 단순 유니크 인덱스일 뿐, 클러스터링과 같은 기능은 하지 않는다.
    • 지원하지 않는 이유
      • MongoDB는 도큐먼트 지향 DB로, 각 도큐먼트는 BSON 형태로 저장되고, 크기와 구조가 가변적이어서 물리적 정렬 저장이 어렵다.
      • 도큐먼트 크기 변동과 메모리 조각화 현상 때문에 클러스터링 인덱스처럼 물리적 위치 재정렬을 실시간으로 유지하는 게 비효율적이다.
      • 대신 인덱스는 빠른 탐색을 위한 별도의 구조로 유지하고, 도큐먼트는 자유롭게 분산·저장한다.
  • 인덱스 내부(WiredTiger 스토리지 엔진 기준)
    • B+Tree 기반: WiredTiger 인덱스는 B+Tree 자료구조를 사용
      • 이는 계층적 구조(루트 노드, 내부 노드, 리프 노드)로 구성된다. B+Tree는 빠른 탐색, 삽입, 삭제를 지원한다.
    • 레코드 ID: 인덱스 키 엔트리는 각 도큐먼트에 고유 식별자인 Record-Id를 할당한다.
      • Record-Id는 64비트 정수로 컬렉션 단위로 별도 자동 증가 값을 사용하며, 도큐먼트 위치와 논리적으로 연결된다.
    • MVCC 지원: WiredTiger는 다중 버전 동시성 제어(MVCC)를 지원
      • 인덱스는 여러 버전을 관리하며, 읽기 작업은 특정 시점의 일관된 버전을 참조하고, 쓰기 작업은 새 버전을 생성한다.
    • 저널링 및 체크포인트: 인덱스 구조 변경(노드 분할, 병합 등)은 원자적으로 이루어져야 하며, 저널 로그를 통해 내구성이 보장된다.
      • 변경된 인덱스는 메모리에서 빠르게 처리 후 체크포인트 시 디스크에 반영되어 랜덤 I/O를 순차 I/O로 변환해 성능을 향상시킨다.
    • 압축: 인덱스는 기본적으로 프리픽스 압축(prefix compression) 지원
      • 인덱스 키들의 공통 접두사 부분을 압축해 저장 공간을 절감한다.
  • 로컬 인덱스
    • 샤딩 클러스터 환경에서 각 샤드가 자신이 저장하고 있는 도큐먼트에 대해서만 관리하는 인덱스를 의미한다.
    • 특징
      • 각 샤드는 자신의 데이터에 대해 별도로 인덱스를 생성·관리한다.
      • 인덱스가 저장하고 있는 도큐먼트들은 해당 샤드 내에 물리적으로 존재하는 데이터에 한정된다.
      • 애플리케이션 레벨에서 전역 유니크성을 보장해야 한다.
        • 샤딩 환경에서 유니크 인덱스나 프라이머리 키 인덱스가 있다면 반드시 샤드 키를 포함하는 등의 방법 필요
    • 의미
      • 샤딩된 컬렉션은 데이터가 여러 샤드에 분산 저장되므로, 인덱스 역시 각 샤드별로 로컬하게 존재하고 관리된다.
      • 샤드간의 인덱스 통합 구조가 아니라 각자 독립적인 인덱스를 갖는다.
    • 제한 사항
      • 전역 유니크 인덱스는 기본적으로 지원하지 않으며, 이를 위해 샤드 키와 연계하거나 외부에서 중복 체크를 해야 한다.
      • 인덱스가 많아질수록 밸런싱 속도와 시스템 부하에 영향을 줄 수 있다
  • 인덱스 키 엔트리 자료구조
    • 키 값(Key): 인덱스를 생성할 때 지정한 필드의 값이 인덱스 키로 사용되며 BSON 타입으로 복합 인덱스인 경우 여러 필드가 조합되어 하나의 키를 형성
    • Record-Id (도큐먼트 위치 정보): 각 인덱스 키와 연결된 도큐먼트의 저장 위치를 의미한다.
      • MongoDB 내부적으로는 Record-Id로 관리되며, 도큐먼트의 논리적 위치 혹은 물리적 위치를 가리킨다.
    • 노드 종류
      • 브랜치 노드: 하위 노드들을 가리키는 인덱스 엔트리들을 포함한다.
      • 리프 노드: 실제 키와 Record-Id 쌍으로 구성되어, 도큐먼트 바로 접근이 가능하다.
    • 정렬: 키 값들은 B-Tree 특성상 정렬되어 저장되어 있어 빠른 탐색과 범위 쿼리가 가능하다.
    • 멀티 키 인덱스: 배열 필드를 인덱스할 때 하나의 도큐먼트에 여러 키 엔트리가 생성될 수 있다.
    • 동작 및 특징: 인덱스는 빠른 검색을 위해 키 값에 따라 정렬과 트리 탐색 구조를 유지한다.
      • Record-Id를 통해 인덱스에서 도큐먼트로 빠르게 접근 가능하며, 추가/삭제 시 B-Tree 노드가 분할/병합된다.
      • BSON 기반의 가변 길이 키 값을 처리하기 때문에 인덱스 키 엔트리 크기가 달라질 수 있다.

B-Tree 인덱스

  • 구조 및 특성
    • 인덱싱 알고리즘 중 가장 일반적이고 오래된, 범용적인 목적을 만족시키는 알고리즘
    • B-Tree에서 B는 Binary가 아닌 Balanced를 의미
    • 인덱스 리프 노드의 각 키 값은 테이블의 데이터 레코드를 찾기 위한 물리적 주소값을 가지고 있다
      • 인덱스는 기본적으로 모두 정렬돼 있지만, 데이터 레코드는 기본적으로 정렬되지 않고 insert 순서대로 저장
    • 레코드 주소는 물리적인 위치일수도 있고, 논리적인 위치일수도 있음
      • 물리적인 위치: 디스크 블록 번호와 오프셋
      • 논리적인 위치: 프라이머리 키 값
  • B-Tree 인덱스 키 추가 및 삭제
    • 인덱스 키 추가
      • 리프 노드에 인덱스 값을 추가하고 하위에 데이터 레코드가 저장된 위치 저장
      • 노드가 꽉차게 되면 분할을 진행하는 데, 상위 연관 노드까지 변경해야하는 경우가 있다
      • 일반적으로 인덱스가 한나도 없다면 작업 비용이 1이고, 인덱스의 개수만큼 1.5 배 씩 증가
        • ex) 인덱스 3개: 1.5*3 + 1 = 5.5
    • 인덱스 키 삭제
      • 삭제에 경우 해당 키값을 찾아 삭제 마크만 하면 작업 완료 (그대로 방치되거나 재활용할 수 있다)
    • 인덱스 키 변경
      • 키 값을 삭제한 후 다시 새로운 키 값을 추가하는 형태로 처리
    • 인덱스 키 검색
      • 루트노드부터 시작하여 브랜치 노드를 거쳐 최종 리프 노드까지 비교하는 과정을 통해 이동 - Tree traversal
  • B-Tree 인덱스 사용에 영향을 미치는 요소
    • 인덱스 키 값의 사이즈
      • mongo에서 디스크에 데이터를 저장하는 가장 기본적인 단위를 페이지 또는 블록이라고 한다
      • WiredTiger 스토리지 엔진에서 페이지 크기를 16KB로 사용하고 인덱스의 키값이 16B 라고 하고 자식 노드가 가질 수 있는 복합적인 정보를 대략 12B 라고 하면
        • 16 * 1024 / (16+12) = 585개 저장 가능하다
        • 만약 500개의 도큐먼트를 읽어야 한다면 한번의 Disk I/O로 가능하지만 1000개라면 두번의 Disk I/O가 필요
    • B-Tree 의 깊이(Depth)
      • 하나의 인덱스 페이지에 585개를 저장할 수 있고, B-Tree의 깊이가 4개라면 최대 1천억개 정도의 키를 담을 수 있다.
      • 깊이가 깊어져서 디스크 읽기가 더 많이 필요해진다.
    • 선택도 (기수성)
      • 선택도(Selectivity) 또는 기수성(Cardinality)은 거의 같은 의미로 사용하며 인덱스 키 값중에 유니크한 값의 개수 의미
      • 인덱스는 선택도가 높아야 좋고 그만큼 빠른 검색으로 연결
    • 읽어야 하는 레코드의 건수
      • 인덱스를 통해 컬렉션의 도큐먼트를 읽는 것은 인덱스를 거치지 않고 컬렉션의 도큐먼트를 읽는 것보다 상당히 고비용 작업이다.
      • 하지만 읽어들인 건수가 많다면 full scan이 더 효율적일 수 있다. 이런 작업은 옵티마이저가 알아서 처리하겠지만 기본적으로 알고 있자
  • B-Tree 인덱스를 통한 데이터 읽기
    • 인덱스 레인지 스캔
      • 인덱스 접근 방식 중 가장 대표적인 방식으로 나머지 방식보다 빠르다
      • 인덱스 레인지 스캔은 검색해야 할 인덱스의 범위가 결정된 경우에 사용하며 시작 위치를 찾기 위해 수직탐색(루트노드부터 비교를 시작) 진행
        • 시작 지점을 찾게 되면 수평탐색(시작지점부터 끝지점까지 순차적으로 탐색) 진행
    • 인덱스 프리픽스 스캔 (LIKE)
      • 문자열 필드를 대상으로 일부만 일치하는 패턴을 검색하고자 할 때 사용하는 방법, 정규 표현식을 이용하여 문자열 검색 수행 가능
      • 3가지 조건
        • 반드시 문자열의 처음부터 일치하도록 ^로 시작해야 한다
        • 검색 문자열이 시작표시 이외의 정규 표현식을 포함하지 않아야 한다
        • 문자열 마지막 표현하는 $ 는 없어야 한다
    • 커버링 인덱스
      • 필요한 모든 필드가 인덱스 내부에 포함되어 있어 데이터 문서를 직접 읽지 않고도 쿼리를 만족시키는 인덱스를 의미
      • 커버링 인덱스의 작동 원리
        • 일반적인 인덱스 쿼리는 두 단계를 거칩니다. (인덱스 검색 후 문서 조회)
        • 커버링 인덱스는 쿼리가 필요로 하는 모든 필드(쿼리 조건, 프로젝션)가 이미 인덱스에 저장되어 있어 인덱스 검색만 진행
      • 커버링 인덱스의 조건
        • 쿼리 조건에 사용되는 모든 필드가 인덱스의 일부여야 한다
        • 반환(프로젝션)될 모든 필드가 인덱스의 일부여야 한다
      • 주의할 점
      • _id 필드: MongoDB의 모든 인덱스는 기본적으로 _id 필드를 포함합니다. 따라서 쿼리 결과에 _id 필드가 포함되더라도 커버링 인덱스로 작동
      • 인덱스 크기: 인덱스에 너무 많은 필드를 추가하면 인덱스의 크기가 커져 메모리 사용량이 늘어나고 쓰기 성능이 저하될 수 있다
        • 커버링 인덱스는 필요한 필드만 포함하도록 신중하게 설계 필요
    • 인덱스 인터렉션
      • 하나의 쿼리를 만족시키기 위해 여러 개의 개별 인덱스를 동시에 사용하는 최적화 기법
      • AND 조건을 포함하는 쿼리를 처리할 때 가장 효율적인 하나의 인덱스를 선택하여 사용.
        • 그러나 때로는 여러 인덱스를 결합하여 더 적은 수의 문서를 스캔하는 것이 더 효율적일 수 있다
      • 작동 방식
        • MongoDB 쿼리 옵티마이저는 AND 조건을 포함하는 쿼리를 분석하여 각 조건에 대해 가장 적합한 인덱스를 찾습니다.
        • 각 인덱스에서 조건에 맞는 문서의 _id를 찾아 목록을 만든 후 목록들을 교집합(intersection) 연산으로 결합하여 최종 결과셋 생성
        • 최종 _id 목록을 사용해 실제 데이터를 가져옵니다.
      • 인덱스 인터섹션과 복합 인덱스
        • 인덱스 인터섹션은 복합 인덱스(Compound Index)의 대안으로 사용될 수 있지만, 항상 더 좋은 것은 아니다
    • 인덱스 풀 스캔
      • 쿼리 조건에 맞는 문서를 찾기 위해 전체 인덱스를 처음부터 끝까지 모두 읽는 작업
      • 인덱스 풀 스캔의 발생 원인 및 특징
        • 인덱스 풀 스캔은 다음과 같은 상황에서 주로 발생
        • 정렬(sort)만 있는 쿼리: 쿼리 조건은 없지만, sort()만 사용해 인덱스를 이용해 정렬된 결과를 얻으려 할 때 발생
          • MongoDB는 정렬 순서를 유지하기 위해 인덱스의 모든 항목을 순차적으로 스캔
        • 비교(range) 쿼리: “$gt”, “$lt” 같은 비교 연산자가 인덱스의 첫 번째 필드에 적용될 때 발생 가능
        • 카디널리티(Cardinality)가 낮은 필드: 특정 필드의 값이 몇 가지로만 한정되어 있을 때 해당 필드로 인덱스를 만들어도 _id를 제외한 모든 문서를 스캔해야 할 만큼 결과가 많아지면 인덱스 풀 스캔이 발생할 수 있다
      • 인덱스 스캔과 풀 스캔의 차이
        • 인덱스 스캔(Index Scan): 쿼리 조건에 맞는 인덱스의 일부만 효율적으로 탐색하는 작업으로 이는 일반적으로 쿼리의 성능을 크게 향상
        • 인덱스 풀 스캔(Index Full Scan): 쿼리 조건에 맞는 문서를 찾기 위해 전체 인덱스를 스캔하는 작업
          • 디스크에서 문서를 직접 스캔하는 것보다 빠를 수는 있지만, 인덱스 스캔에 비해 비효율적
  • 컴파운드 인덱스
    • 여러 필드를 결합하여 생성하는 단일 인덱스로 이는 여러 필드를 조건으로 하는 쿼리의 성능을 향상시키기 위해 사용
    • 컴파운드 인덱스의 작동 원리
      • 컴파운드 인덱스는 정의된 필드의 순서에 따라 데이터를 정렬
        • MongoDB는 인덱스의 첫 번째 필드부터 순차적으로 데이터를 정렬하고, 첫 번째 필드의 값이 동일할 경우 두 번째 필드로 정렬하는 방식
    • 컴파운드 인덱스의 장점
      • 다중 조건 쿼리 최적화: AND 연산자를 사용하는 쿼리에서 매우 효율적
      • 정렬(Sort) 최적화: 쿼리 조건뿐만 아니라 sort() 연산도 최적화 가능
      • 다중 쿼리 지원: 컴파운드 인덱스는 age 필드만 사용하는 쿼리(db.users.find({ “age”: 25 }))에도 활용가능
        • 인덱스의 가장 왼쪽 필드부터 시작하는 쿼리는 모두 이 인덱스를 사용할 수 있습니다.
    • 주의할 점
      • 필드 순서: 인덱스에 포함되는 필드의 순서는 매우 중요합니다. 쿼리에서 가장 자주 사용되는 필드를 인덱스의 가장 앞쪽에 배치하자
      • 카디널리티(Cardinality): 카디널리티가 높은 필드(고유한 값이 많은 필드)를 인덱스의 앞쪽에 두는 것이 쿼리 성능에 유리하다
      • 크기: 너무 많은 필드를 컴파운드 인덱스에 포함하면 인덱스 자체의 크기가 커져 메모리 사용량이 늘어나고 쓰기 성능이 저하된다

해시 인덱스

  • 해시 인덱스의 구조 및 특성
    • 쿼리의 검색 성능을 향상시키기 위한 인덱스의 목적보다 해시 샤딩을 구현하기 위해 꼭 필요한 인덱스
    • 검색하고자 하는 값이 주어지면 tree traversal 이 아닌 hash function 을 통해 해당 데이터의 레코드를 가져올 수 있다
    • 해시 함수의 결과값의 범위가 크면 그만큼 버켓이 많이 필요하여 공간의 낭비가 커진다.
  • 해시 인덱스의 가용성 및 효율성
    • 빠른 속도 장점이 있지만 해시 함수의 결과값으로 접근해야 하고, 정렬이 보장되지 않는다는 단점이 있다
    • 단일 키 값을 검색하는 기능(일치/불일치) 외에는 B-Tree 인덱스의 범용성을 따라가지 못한다
  • MongoDB 해시 인덱스의 구조 및 특성

  • MongoDB 해시 인덱스의 제한사항

멀티 키 인덱스

  • 멀티 키 인덱스의 주의 사항
  • 멀티 키 인덱스의 성능
  • 멀티 키 인덱스의 제한 사항

인덱스 속성

  • 프라이머리 키와 세컨드리 인덱스
    • 모든 컬렉션이 반드시 프라이머리 키를 갖으며 MongoDB의 프라이머리 키 필드는 사용자가 필드의 이름을 결정할 수 없다
      • 사용자가 필드의 이름을 결정할 수 없고 _id라는 이름의 도큐먼트에 저장해야 한다
    • 그 외에 인덱스는 세컨드리 인덱스라 한다.
    • 컬렉션이 샤딩 된 경우에는 각 샤드간 프라이머리 키 값의 중복 체크를 응용 프로그램에서 처리해야 한다
    • 서브 도큐먼트를 인덱스 키로 사용하는 경우 서브 도큐먼트의 필드 개수와 필드 이름, 값까지 같아야 같은 값으로 판단
      • 필드 개수나 순서와 관계없이 검색하고자 한다면 각 팔디를 조건에 명시하면 된다. 하지만 이렇게 할 경우 인덱스 조회를 못함
      • COLLSCAN 발생 ``` $ db.users.insert({“_id”: {“birth_date”: “1980-01-01”, “name”: “matt”}}) $ db.users.insert({“_id”: {“name”: “matt”, “birth_date”: “1980-01-01”}})

      $ db.users.find({“_id.birth_date”: “1980-01-01”, “_id.name”: “matt”}) ```

  • 유니크 인덱스
    $ db.unique.createIndex({"age": 1}, {unique: true})
    
    • 유니크 인덱스라고 해서 NULL 값이 제한되는 것은 아니며 프라이머리 키는 기본적으로 유니크 속성 부여
      • 하지만 NULL 값이 2개 이상일 수 없다.
    • MongoDB에는 유니크 인덱스는 도큐먼트 간의 중복된 값을 체크하지만 도큐먼트 내에서 중복된 값을 체크하지 않는다.
      • 서브 도큐먼트를 배열로 갖으면, 해당 필드를 유니크 인덱스가 있지만, 같은 번호를 저장해도 에러가 발생하지 않는다.
        $ db.users.createIndex({"contacts.no": 1}, {unique: true})
        $ db.users.insert({ name: "matt", contacts: [
        {type:"office", no: {"010-0000-0000"}, {type:"mobile", no: {"010-0000-0000"}}]});
        
    • 샤딩을 적용하지 않으면 유니크 인덱스를 생성할 수 있지만 샤딩에 경우 샤드키를 선행 필들르 가지는 인덱스에만 유니크 인덱스를 생성할 수 있다
  • Partial 인덱스
    • Partial Index(부분 인덱스)는 컬렉션 내 도큐먼트 중에서 특정 조건에 부합하는 도큐먼트에 대해서만 인덱스를 생성
    • 주요 특징
      • 조건부 인덱스 생성: 인덱싱할 때 partialFilterExpression 옵션에 정의한 조건을 만족하는 도큐먼트만 인덱싱된다.
        $ db.collection.createIndex({score:1}, {partialFilterExpression:{score:{$gte:100}}})
        // score가 100 이상인 도큐먼트만 인덱스 엔트리 생성.
        
      • 스토리지 절약 및 성능 최적화: 전체 데이터가 아닌 일부 조건을 만족하는 데이터에만 인덱싱되므로 인덱스 크기가 대폭 줄고, 인덱스 관리(생성/갱신) 비용이 감소한다.
      • 복합 조건 가능: 필드 존재 여부($exists), 비교 연산($gt, $gte, $lt, $lte 등), 타입($type), 그리고 상위에 $and 연산자를 활용한 조건식도 지정할 수 있다.
    • 제약점
      • 샤드 키/Primary Key에 사용 불가: partial 인덱스는 샤드 키 인덱스나 _id(Primary Key) 인덱스는 지원하지 않는다.
      • 커버링 인덱스 제한: 쿼리가 partial 조건을 벗어나면 인덱스를 사용할 수 없고, 인덱스가 해당 쿼리를 커버하지 못함.
  • Sparse 인덱스
    $ db.sparse.createIndex( {birth_date: 1}, {sparse: true} )
    
    • sparse index(희소 인덱스)는 인덱싱할 필드가 존재하는 도큐먼트에만 인덱스 엔트리를 생성하는 인덱스 옵션이다.
    • 핵심 특징
      • 필드 존재 조건: 인덱스 대상 필드가 컬렉션의 도큐먼트에 존재하면 인덱스 엔트리를 만들며, 해당 필드가 없는 도큐먼트는 인덱스에 포함되지 않는다.
      • NULL 값 처리 및 효율: 해당 값이 null인 경우나 필드가 아예 없는 경우 인덱싱하지 않아 저장 공간과 인덱싱 비용이 줄어든다.
        • 희소 인덱스는 유니크 인덱스와 조합할 경우, 필드가 없는 도큐먼트끼리의 중복을 허용한다.
      • 적용 시기: 컬렉션 내에 인덱싱 필드가 없는 도큐먼트가 상당수 있을 때, 쓸모 없는 인덱스 엔트리 생성을 방지해 효율적
    • 제약 및 주의점
      • 희소 인덱스가 걸린 필드는 쿼리 인덱스 최적화에는 좋지만, 컬렉션 전체에서 값을 반드시 커버하지 못한다.
        • 일부 쿼리 결과에 빠진 도큐먼트가 있을 수 있다.
      • 커버링 인덱스 성능 최적화가 제한될 수 있으며, 쿼리 힌트 사용 시 결과 불완전 가능성도 있다
  • TTL(Time-To-Live) 인덱스
    db.logs.createIndex({ "createdAt": 1 }, { expireAfterSeconds: 3600 })
    // createdAt 기준 1시간(3600초) 후 도큐먼트 자동 삭제
    
    • TTL 인덱스는 컬렉션 내의 도큐먼트가 특정 시간이 지나면 자동으로 삭제되도록 하는 특수 인덱스 기능이다.
    • 핵심 특징
      • 자동 데이터 만료 및 삭제: Date 타입 필드(예: createdAt, expires 등)를 기준으로 정해진 시간이 지나면, 도큐먼트가 백그라운드에서 자동 삭제
      • 단일 필드만 적용: TTL 인덱스는 반드시 전용 날짜(Date) 필드에만 적용되며, 복합 인덱스에서는 지원하지 않는다.
      • 옵션 사용법: 인덱스 생성시 expireAfterSeconds 옵션을 명시해, 데이트 필드 기준 n초 후 도큐먼트를 삭제한다.
      • 백그라운드 동작: TTL 인덱스는 실시간이 아닌, 내부 스케줄러가 약 60초마다(최대 1분 지연) 만료 데이터를 삭제한다.
    • 활용 사례
      • 로그, 세션, 임시 인증 정보, 캐시 등 일정 시간 뒤 삭제가 필요한 데이터 관리에 많이 사용된다.
    • 주의사항
      • 타이머 기반이므로 Date 필드 값이 변경되면 TTL 타이머도 갱신된다. 대량 삭제 시 일시적으로 성능 영향을 받을 수 있다
  • 외래키
    • 외래키에 대한 제약 기능을 지원하지 않는다.
    • 컬렉션이 샤딩을 가정하고 구현했기 때문에 외래 키에 대한 제약을 구현한다 하더라도 성능을 보장할 수 없기 때문
    • 외래 키에 대한 일관된 처리가 꼭 필요하다면 응용 프로그램에서 직접 구현하는 것 외에는 방법이 없다

잠금과 트랜잭션

잠금

  • 동시 처리 중에 발생할 수 있는 쓰레드간의 충돌 문제를 막기 위해 잠금 사용
    • Intention Lock, Multiple granularity locking(다중 레벨의 잠금) 활용
  • MongoDB 엔진의 잠금
    • 글로벌 잠금
      • 유일하게 명시적으로 사용할 수 있는 잠금은 글로벌 잠금으로 현재 3.4 버전의 다른 모든 잠금은 전부 묵시적으로만 사용
        • 쿼리나 데이터 변경 명령이 실행되면 묵시적으로 MongoDB 서버 인스턴스에 단 하나만 있는 잠금, 이를 인스턴스 잠금이라 한다
          $ db.fsyncLock({fsync: 1, lock: true})
          $ db.fsyncUnlock() // 잠금 해제
          $ db.currentOp() // 글로벌 잠금 상태를 알 수 있다
          
      • fsync 옵션을 1로 설정하면 디스크에 기록되지 못한 데이터를 모두 디스크로 플러시(기록)한다
      • lock 옵션을 false로 하면 잠금을 걸지 않은 체 디스크로 플러시 한다
      • fsyncLock 은 내부적으로 쓰기 잠금이 아닌 읽기 잠금에 해당
        • 다른 컨넥션의 데이터 읽기를 막지 않으며 모든 커넥션의 데이터 저장이나 변경을 실행할 수 없다.
        • 데이터 변경 명령이 블로킹되므로 다른 읽기 쿼리도 실행하지 못한다.
    • 오브젝트 잠금
      • 2.6 버전 이하 버전의 MMAPv1 스토리지 엔진에서는 DB 수준의 잠금 사용
      • 3.0 버전 이후 MMAPv1 스토리지 엔진은 컬렉션 수준의 잠금으로 조금 더 최적화됐다.
        • 컬렉션 단위의 자금이 도입되면서 계층형 오브젝트에 대한 동시성 처리 보장을 위한 다중 레벨 잠금 방식 도입
        • S/X Lock, IS(Intent Shared) Lock, IX(Intent Exclusive) Lock 제공
      • Intent Shared Lock
        $ SELECT ... LOCK IN SHARE MODE // 테이블에는 IS-Lock이 걸리고, 실제 행(Row)은 S-Lock
        
        • 트랜잭션이 레코드에 공유 락(Shared Lock, S-Lock)을 걸 의도가 있음을 테이블 레벨에서 미리 표시하는 락
        • 여러 트랜잭션이 동시에 IS-Lock을 획득할 수 있다.
      • Intent Exclusive Lock
        SELECT ... FOR UPDATE, INSERT, UPDATE, DELETE
        
        • 트랜잭션이 레코드에 베타 락(Exclusive Lock, X-Lock)을 걸 의도가 있음을 테이블 레벨에서 미리 표시하는 락
        • 여러 트랜잭션이 동시에 IX-Lock을 획득할 수 있다.
      • 왜 의도 락이 필요한가?
        • 테이블 레벨과 행(Row) 레벨의 락이 혼재하는 환경에서, 양쪽의 락 상태를 효과적 관리
        • 충돌 시 빠르게 판단하기 위해 사용된다.
        • 한 트랜잭션이 행에 X-Lock을 걸고 있을 때, 다른 트랜잭션이 테이블 전체에 대한 X-Lock(스키마 변경 등)을 시도하면
          • IX-Lock 덕분에 바로 충돌을 감지하며, 일관성과 무결성을 유지할 수 있다
  • WiredTiger 스토리지 엔진의 잠금
    • MongoDB의 IS/IX Lock 작동 방식
      • MongoDB는 글로벌, 데이터베이스, 컬렉션, 문서 등 여러 계층에서 락을 관리
        • 실제 컬렉션/도큐먼트 접근시 하위 트랜잭션의 락 목적을 상위 계층에 알리기 위해 IS/IX 락 사용
        • 컬렉션에서 문서 단위로 읽기(Shared)를 할 경우 데이터베이스와 글로벌 수준에서는 IS 락을 획득
      • 반대로, 컬렉션 또는 문서 단위로 쓰기(Exclusive)를 수행할 때 데이터베이스/글로벌 단계에서는 IX 락 획득
      • IS/IX 락은 상호 호환성(동시처리 허용)이 높다.
        • 여러 트랜잭션이 동시에 동일 데이터베이스/컬렉션에 대해 읽기 또는 쓰기 작업을 의도하는 것을 막지 않는다.
    • 활용 예시 및 자동화
      • 컬렉션 인덱스 생성 작업: 해당 컬렉션을 포함하는 데이터베이스와 글로벌에 대해 IX 락을 선점하고, 인덱스 생성 작업을 처리
      • 도큐먼트 삽입, 수정, 삭제 등: 컬렉션, 데이터베이스, 글로벌에 대해 IX 락을 먼저 획득
        • 문서에 대한 실제 X 락을 건다. 이 방식은 대규모 동시 write 작업시 경합 문제를 최소화
      • 조회 명령은 IS 락을 적용하지만 WiredTiger 스토리지 엔진의 MVCC 구조 덕분에 도큐먼트엔 실제 락을 거의 걸지 않는다.
      • 락은 사용자가 직접 명령을 통해 제어하지 않고, MongoDB 내부 엔진(WiredTiger 등)이 자동 관리
    • 운영 시 유의점
      • IS/IX 락은 테이블, 컬렉션, 데이터베이스 등 상위 레벨에 미리 락 목적을 알려 충돌·병목을 예방
      • 스키마 변경, 대량 인덱스 작업 등은 락 경합(Contestion) 문제 가능
      • 쿼리 효율화, 적절한 인덱스 설계, 데이터 구조 최적화 등을 통해 자주 발생하는 경합을 줄이는 것이 중요하다
  • 잠금 Yield
    $ db.runCommand({ getParameter: '*' })
    $ db.runCommand({ setParameter:1, "internalQueryExecYieldIterations": 256 })
    // 인덱스를 갖지 않는 컬렉션 전수 검색 시 내부적으로 기본값 128개 도큐먼트를 읽을 때 yield 발생
    $ db.runCommand({ setParameter:1, "internalQueryExecYieldPeriodMS": 20 })
    // 20ms 이상 수행되면 yield 발생 default 10ms
    
    • 쿼리를 처리하기 위해 한번 잠금을 획득하면 쿼리의 처리가 완료될떄까지 획득할 때 잠금을 다시 해제하지 않는다.
    • 쿼리를 실행하는 도중에 잠깐 쉬었다가 쿼리의 실행을 재개하는 것을 Yield(양보)라고 한다
    • MongoDB Yield 동작 원리
      • MongoDB는 긴 쿼리 또는 대량 데이터 처리 중 일정 횟수(document 개수) 또는 시간이 경과
        • 커넥션이 가진 모든 락을 해제, CPU 자원을 놓고, OS의 스케줄러가 다시 재개할 때 락을 재획득하여 이어 처리하는 방식
    • yield를 통해 다른 커넥션에 락 양보 및 자원 선순환 처리가 가능하며, 동시성 효율이 극대화된다.
    • 주요 용도 및 한계
      • yield는 장기 실행 쿼리(인덱스 생성·삭제, 대량 데이터 처리 등)에서 시스템 전체 성능과 병목 현상을 최소화 전략
      • 대량 작업 도중 데이터가 변경될 수 있으므로, 데이터 일관성이 반드시 유지되어야 하는 트랜잭션에는 주의가 필요
      • 사용자는 직접 yield를 제어하지 못하고, 내부 엔진(WiredTiger 등)이 자동으로 관리
  • 잠금 진단
    • db.currentOp()
      • 현재 실행중인 명령들의 목록을 조회
      • 각 프로세스틔 목록은 클라이언트 정보
      • 쿼리의 내용과 더불어 잠금의 내용 확인
    • r: DB 레벨의 Shared Lock (2.6) / Intention Shared Lock (3.0 이상)
    • w: DB 레벨의 Exclusive Lock (2.6) / Intention Exclusive Lock (3.0 이상)
    • R: 글로벌 Shared Lock (2.6) / Shared Lock (3.0 이상)
    • W: 글로벌 Exclusive Lock (2.6) / Exclusive Lock (3.0 이상)

트랜잭션

  • WiredTiger 스토리지 엔진에서 트랜잭션과 관련된 설명이 추가되었다.
    • ACID 속성에 대한 특성
      • 최고 레벨의 격리 수준은 Snapshot
      • 트랜잭션의 커밋과 체크포인트 2가지 형태로 영속성 보장
      • 커밋되지 않은 변경 데이터는 공유 캐시 크기보다 작아야 한다
    • Read Unccommited, Read Commited, Snapshot(Repeatable Read) 수준의 격리 수준 제공
  • 쓰기 충돌
    • 하나의 데이터를 동시에 변경하려고 하는 상황에서는 쓰기 충돌 발생하며 변경하고자 하는 도큐먼트의 락이 있으면 업데이트 실행 취소
    • WriteConflict Exception 발생 -> 내부적으로 해당 예외에 경우 재시도 처리를 하며 어플리케이션 단에서는 모를 수 있다
  • 단일 도큐먼트 트랜잭션
    • 처음부터 단일 도큐먼트의 트랜잭션만 지원하며 이는 단일 도큐먼트 변경의 원자단위 처리가 보장되는 것
      • collection 에 insert를 하면, collection -> index 0 ~ N -> OpLog 순으로 insert 진행
    • 샤딩을 고려하여 단일 샤드만 처리 가능
  • 문장의 트랜잭션 처리
    $ db.users.insert( {_id: 1, name: "matt"}, {_id: 2, name: "jonathan"})
    $ db.users.update( {"name: "matt"}, {$set: {score:90}}, {multi: true})
    BEGIN
      db.users.insert( {_id: 1, name: "matt"} )
    COMMIT
    BEGIN
      db.users.insert( {_id: 2, name: "jonathan"} )
    COMMIT
    
    • 여러 도큐먼트가 저장되는 배치 insert, 한번에 여러 도큐먼트를 변경하는 업데이트 문장에서 트랜잭션 처리
      • Mongo에서는 작은 트랜잭션으로 쪼개져서 처리
    • 처리도중 예외가 발생해도 rollback 되지 않고 이전 처리 결과는 남게 된다

격리 수준

  • 격리 수준에 최하위 수준인 Read Uncommited는 커밋되지 않는 데이터를 다른 세션에서 읽을 수 있기 때문에 서비스 환경에서 거의 사용되지 않는다’
  • 최고 수준인 Serializable은 동시성 제어를 위해 락을 많이 사용하기 때문에 성능 저하가 심해 서비스 환경에서 거의 사용되지 않는다
  • MMAPv1 스토리지 엔진은 Shared View, Private View 기능을 활용하기 때문에 Read Committed 격리 수준을 제공
  • WiredTiger 스토리지 엔진은 다중 버전 동시성 제어(MVCC)를 활용하여 Read Committed 격리 수준 이상을 제공하여 READ COMMITED 나 SERIALIZABLE 격리 수준 사용

  • Read Commited
    • 격리 수준은 반드시 하나의 트랜잭션 범위 내에서만 작동하는 기준으로 하나의 컨넥션에서 여러 트랜잭션이 실행될 땐 격리 수준이 적용되지 않는다
  • SNAPSHOT (Repeatable Read)
    • 하나의 트랜잭션 내에서 하나의 쿼리는 항상 같은 결과 반환하며 새로운 트랜잭션에서는 다른 결과 반환할 수 있다
  • MongoDB 서버의 격리 수준
    • Mongo에서는 단일 다큐먼트 단위의 트랜잭션만 지원하며 트랜잭션을 제어하는 기능도 없다
    • Mongo에서는 트랜잭션이 자동으로 제어되며 도큐먼트 단위로 자동 커밋이 되기 때문에 장시간 실행되는 트랜잭션이 존재할 수 없다
    • snapshot 유지 조건: 쿼리가 지정된 건수의 도큐먼트를 읽은 경우, 쿼리가 지정된 시간 동안 수행된 경우
  • MongoDB 서버의 격리 수준과 정렬
    • Repeatable read 격리 수준에서 정렬을 사용하면 snapshot이 깨지고 쿼리 결과가 달라질 수 있다
    • 정렬에 경우 mongo서버가 컬렉션의 데이터를 모두 가져와 메모리에 적재하고 정렬을 실행하여 보여주기 떄문이다

Read & Write Concern과 Read Preference

  • Write Concern
    • 트랜잭션 시작과 종료를 명시적으로 실행할 방법이 없기 때문에 데이터 변경 요청에 응답이 반환되는 시점에 커밋으로 간주된다.
      • 사용자가 요청한 변경 사항이 어떤 상태까지 완료되면 응답을 내려보낼 지 신중하게 판단해야 한다.
    • 이를 결정하는 옵션을 Write Concern 이라고 한다.
      • 단일 노드에서의 동기화 제어 수준과 레플리카 셋의 여러 노드간 동기화 수준을 제어하는 두개 그룹으로 나눠볼 수 있다.
    • 단일 노드(Standalone mongod) 동기화 제어
      • 단일 노드 인스턴스에서는 레플리카셋(Replica Set)의 복제(replication)와 관련된 w 옵션은 무시되며 동기화 제어는 주로 저널링(Journaling)을 통해 이뤄진다.
      • w (쓰기 승인 수준)
        • 1 (또는 기본값): 쓰기 작업이 mongod 인스턴스에 성공적으로 적용(메모리에 기록)되면 승인을 요청, 단일 인스턴스에서는 항상 1
      • j (저널링)
        • true: 쓰기 작업이 온디스크 저널에 기록될 때까지 기다린다. 이를 통해 예기치 않은 종료(예: 전원 손실) 시에도 데이터 유실을 최소화 가능
        • false: 쓰기 작업이 메모리에 적용되면 즉시 승인을 반환 mongod가 종료되면 디스크에 플러시되기 전의 데이터는 손실 가능. j 옵션은 w의 승인 이후에 저널링 대기 시간을 추가하여 내구성을 제어.
      • wtimeout (시간 초과): 단일 노드에서는 w가 항상 1이므로 실질적인 대기 시간 제어는 j: true일 때 저널 플러시를 기다리는 시간과 관련될 수 있지만, wtimeout은 주로 레플리카셋 환경에서 활용됩니다.
    • 레플리카셋(Replica Set) 간 동기화 제어
      • 레플리카셋 환경에서 Write Concern은 Primary 노드에 쓰인 데이터가 Secondary 노드로 복제되어 승인되는 수준을 제어하여 데이터의 내구성(Durability) 및 가용성(Availability) 결정
      • w:1
        • Primary 노드의 쓰기 승인만 기다린다. 가장 빠르지만, Primary 장애 시 롤백(Rollback)될 위험이 있다.
      • w:n (n≥2)
        • Primary 포함 총 n개 노드의 쓰기 승인을 기다린다. 내구성이 향상되지만, Primary를 포함하여 n개 노드가 모두 가용
      • w:”majority”
        • 투표권이 있는 데이터 노드 과반수의 쓰기 승인을 기다린다. (예: 3노드 셋에서는 2개, 5노드 셋에서는 3개) 가장 높은 내구성 및 일관성을 보장합니다. Primary가 Failover되어도 데이터는 롤백되지 않습니다. (대부분의 MongoDB 배포에서 기본값)
      • j:true
        • w 조건(노드 수)을 만족하는 모든 노드가 쓰기 작업을 온디스크 저널에 기록할 때까지 추가로 대기. Primary 장애뿐만 아니라, 노드 자체의 비정상 종료로 인한 데이터 손실 위험까지 최소화하여 최고 수준의 내구성을 제공.
          • w: majority 와 함께 사용 시 최적
      • wtimeout:
        • 쓰기 승인을 기다릴 최대 시간(밀리초)을 설정
        • 지정된 시간 내에 w 수준을 만족하지 못하면 쓰기 오류를 반환하여 애플리케이션 블로킹을 방지.(시간 초과 후에도 쓰기는 노드에 적용될 수 있습니다.)
  • Read Concern
    • 쿼리 작업 시 반환되는 데이터의 일관성(Consistency)과 격리 수준(Isolation Level)을 제어하는 설정으로 “읽기 작업이 어느 정도까지 복제된 데이터의 상태를 반영해야 하는가”를 정의
    • 레플리카셋(Replica Set) 환경에서 중요하며, 쓰기 작업이 Primary에서 Secondary로 복제되는 과정에서 발생할 수 있는 데이터의 불일치(Staleness) 문제 관리.
    • Read Concern은 { readConcern: } 형식으로 지정되며, 주요 레벨은 다음과 같습니다.
    • MongoDB Read Concern 레벨
      • local
        • 쿼리를 수행한 노드(Primary 또는 Secondary)의 가장 최근 데이터(Recent Data)를 반환
        • 가장 빠르지만 Primary에서 쓰기가 발생하고 Secondary로 복제되기 전이라면, Secondary에서 읽을 때 뒤처진(Stale) 데이터를 읽을 수 있다.
        • Primary 장애 시 롤백될 수 있는 데이터를 읽을 위험도 있다.
      • available
        • local과 유사하며, 주로 샤드 클러스터(Sharded Cluster)에서 사용
        • 샤드가 Failover/Rollback 중인 경우에도 최대한 데이터를 제공
        • local과 동일한 일관성 수준. 데이터의 최신 상태를 보장하지 않는다.
      • majority
        • 과반수의 투표 노드에 쓰기가 성공적으로 적용(커밋)되어 영구적으로(Durable) 저장된 데이터만 반환.
        • 가장 높은 일관성. Primary Failover 시 롤백되지 않음이 보장되는 데이터만 읽는다. 이 수준을 사용하면 w:”majority” Write Concern으로 쓰여진 데이터만 읽을 수 있습니다.
        • 지연 시간은 증가할 수 있다
      • linearizable
        • 최고 수준의 일관성을 제공. 읽기 작업은 Primary에서 가장 최근에 완료된 모든 쓰기 작업이 반영된 데이터를 반환하며, 마치 모든 작업이 직렬적으로(순차적으로) 수행된 것처럼 보인다.
        • 매우 높은 지연 시간. 이 수준은 Primary 노드에서만 사용할 수 있으며, 쓰기 작업이 과반수 노드에 w: majority로 커밋된 후 읽기 수행. 네트워크 지연에 매우 민감하여 성능 저하를 일으킬 수 있다.
      • snapshot
        • 멀티 도큐먼트 트랜잭션(Multi-Document Transactions) 내에서만 사용 가능
        • 트랜잭션이 시작된 시점의 스냅샷 일관성을 보장. 트랜잭션 내에서 일관된 데이터 뷰를 제공.
  • Read Preference
    • 클라이언트의 읽기(Read) 작업을 레플리카셋(Replica Set)의 어떤 멤버에게 보낼지 결정하는 설정. 이는 읽기 요청을 라우팅(Routing)하는 메커니즘으로, 주로 확장성과 일관성 사이의 균형을 맞추는 데 사용.
    • Read Preference는 클라이언트 드라이버 수준에서 설정하며, 복제 지연(Replication Lag) 및 노드 부하 분산에 영향
    • 주요 Read Preference 모드
      • primary (기본값)
        • 모든 읽기 작업을 Primary 노드로 라우팅. 가장 높은 일관성을 제공하지만, 확장성은 제한적. Primary 노드에 부하 집중 가능
      • primaryPreferred
        • 가능하면 Primary 노드에서 읽기 작업 수행. Primary가 사용 불가능한 경우(예: Failover 중) Secondary 노드에서 읽기 수행.
        • 일관성과 가용성 간의 균형을 제공
      • secondary
        • 모든 읽기 작업을 Secondary 노드로 라우팅. 읽기 부하를 분산시켜 확장성을 높이지만, 데이터가 최신 상태가 아닐 수 있음(복제 지연 가능성).
      • secondaryPreferred
        • 가능하면 Secondary 노드에서 읽기 작업 수행. 모든 Secondary가 사용 불가능한 경우 Primary 노드에서 읽기 수행.
        • 확장성과 가용성 간의 균형 제공
      • nearest
        • 지연 시간(Latency)이 가장 낮은 노드(Primary 또는 Secondary)로 읽기 작업 라우팅. 지리적으로 분산된 배포 환경에서 유용하며, 최저 지연 시간으로 응답을 제공.
    • Read Preference와 Tag Sets
      • Read Preference는 Tag Sets과 함께 사용되어 읽기 트래픽을 특정 조건(예: 지역, 하드웨어 유형)을 가진 Secondary 노드로 보낼 수 있다.
      • Tag Sets: 레플리카셋 멤버에 사용자 지정 태그(Key-Value 쌍) 할당 (예: datacenter:nyc, disk:ssd).
      • 클라이언트는 Read Preference 설정 시 이 태그를 지정하여, 해당 태그를 가진 Secondary 노드 중에서 읽기 작업 수행
      • 예를 들어, Secondary 노드에 analytics 태그를 지정하고, 분석용 쿼리만 이 태그를 가진 노드로 보내 Primary의 부하 최소화
    • Read Preference와 Read Concern의 관계
      • Read Preference와 Read Concern은 함께 작동하여 읽기 작업의 동작을 정의하지만, 그 역할은 명확히 다르다
      • Read Preference: 어디에서 읽을지(노드 선택).
      • Read Concern: 선택된 노드에서 어떤 수준의 일관성을 가진 데이터를 반환할지(데이터 상태).