Featured image of post 3.1. 저장소와 검색: 데이터베이스를 강력하게 만드는 데이터 구조

3.1. 저장소와 검색: 데이터베이스를 강력하게 만드는 데이터 구조

데이터 중심 애플리케이션 설계

가장 기본적인 수준에서 데이터베이스는 데이터 저장, 데이터 제공 두 가지 작업을 수행한다.

애플리케이션 개발자가 처음부터 자신의 저장소 엔진을 구현하기보다는 사용 가능한 여러 저장소 엔진 중에 애플리케이션에 적합한 엔진을 선택하는 작업이 필요하다.

따라서 특정 작업부하(workload) 유형에서 좋은 성능을 내게끔 저장소 엔지을 조정하려면 저장소 엔진이 내부에서 수행되는 작업에 대해 대략적인 개념을 이해할 필요가 있다.

특히 트랜잭션 작업부하에 맞춰 최적화된 저장소 엔진과 분석을 위해 최적화된 엔진 간에는 큰 차이가 있으므로, 우선 관계형 데이터베이스NoSQL이라 불리는 데이터베이스에 사용되는 저장소 엔진에 대해 간략히 살펴보고, 로그 구조(log-structured) 계열 저장소 엔진(B-tree 같은)과 페이지 지향(page-oriented) 계열 저장소 엔진을 검토한다.

로그와 색인

1
2
3
4
5
6
7
8
#!/bin/bash
db_set () {
  echo "$1,$2" >> database
}

db_get () {
  grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
}

위는 매우 간단한 데이터베이스로 키-값 저장소를 함수 두 개로 구현되었으며, 기본적인 저장소 형식은 매 라인마다 쉼표로 구분된 키-값 쌍을 포함한 텍스트 파일이다.

  • db_set을 호출할 때마다 파일의 끝에 추가하므로 키를 여러 번 갱신해도 값의 예전 버전을 덮어 쓰지 않음
  • 최신 값을 찾기 위해 파일에서 키의 가장 마지막 항목 확인 (tail -n 1)

일반적으로 파일 추가 작업은 매우 효율적이기 때문에 db_set 함수는 매우 간단한 작업의 경우 꽤 좋은 성능을 보이는데, 이 때문에 많은 데이터베이스는 내부적으로 추가 전용(append-only) 데이터 파일인 로그(log)를 사용한다.

  • 실제 데이터베이스는 동시성 제어, 디스크 공간 최적화, 요류 처리 등 여러 많은 문제를 다루지만 기본 원리는 같음

로그(log) 란?
일반적인 의미로 연속된 추가 전용 레코드

db_set에 비해 db_get 함수는 데이터베이스에 많은 레코드가 존재하는 경우 성능이 매우 좋지 않다.

  • 키를 찾을 때마다 키가 있는지 찾기 위해 전체 데이터베이스 파일을 처음부터 끝까지 스캔해야함(O(n))

이 때문에 데이터베이스에서 특정 키의 값을 효율적으로 찾기 위해서는 다른 데이터 구조 즉 색인이 필요하다.

색인의 일반적인 개념은 어떤 부가적인 메타데이터를 유지하는 것 이다.

이 메타데이터는 이정표 역할을 해서 원하는 데이터의 위치를 찾는데 도움을 주며, 동일한 데이터를 여러 가지 다양한 방법으로 검색하고 싶다면 데이터의 각 부분에 다양한 색인이 필요하다.

색인은 기본 데이터(primary data)에서 파생된 추가적인 구조로, 많은 데이터베이스는 색인의 추가와 삭제를 허용한다.

  • 데이터베이스의 내용에는 영향을 미치지 않음
  • 질의 성능에만 영향을 줌

이러한 추가적인 구조로 인해 특히 쓰기 과정에서 오버헤드가 발생하며, 위 예시의 로그처럼 단순히 파일에 추가하는 작업이 가장 간단한 쓰기 작업이므로 성능을 앞서기 어렵다.

어떤 종류의 색인이라도 데이터를 쓸 때마다 색인을 갱신하기 때문에 대개 쓰기 속도를 느리게 만든다.

이러한 성능 저하는 저장소 시스템의 중요한 트레이드 오프이며 색인을 잘 선택했다면 읽기 질의 속도가 향상되지만, 쓰기 성능은 저하 된다.

때문에 보통 데이터베이스는 자동으로 색인을 생성하지 않으므로, 개발자나 관리자가 애플리케이션의 질의 패턴에 대한 지식을 통해 수동으로 색인을 선택해야한다.

해시 색인

디스크 상의 데이터를 색인하기 위해 인메모리 데이터 구조를 사용할 수 있다.

단순히 파일에 추가하는 방식으로 데이터 저장소를 구성한다고 가정할때, 가장 간단하게 가능한 색인 전략은 키를 데이터 파일의 바이트 오프셋에 매핑해 인메모리 해시 맵을 유지하는 전략이다.

인메모리 해시맵으로 키-값 쌍의 로그 저장하기

  • 파일에 새로운 키-값 쌍을 추가할 때마다 방금 기록한 데이터의 오프셋을 반영하기 위해 해시 맵도 갱신
  • 값을 조회하려면 해시 맵을 사용해 데이터 파일에서 오프셋을 찾아 해당 위치를 구하고 값을 읽음

이 방식은 매우 단순해 보이지만 실제로 많이 사용하는 접근법이다.

  • 해시 맵을 전부 메모리에 유지하기 때문에 사용 가능한 램에 모든 키가 저장된다는 조건을 전제로 고성능으로 읽기, 쓰기를 보장
  • 값은 한 번의 디스크 탐색으로 디스크에서 적재할 수 있기 때문에 사용 가능한 메모리보다 더 많은 공간을 사용

이러한 저장소 엔진은 각 키의 값이 자주 갱신되는 상황에 매우 적합하다.

  • 쓰기가 많지만 고유 키는 많지 않은 작업부하
  • ex) 키가 특정 영상의 URL, 값이 영상의 재생 횟수 인 경우

하지만 파일에 항상 추가만 할 경우 결국 디스크 공간이 부족해지며, 이를 위해 특정 크기의 세그먼트(segment)로 로그를 나누는 방식이 좋은 해결책으로 사용된다.

  • 특정 크기에 도달하면 세그먼트 파일을 닫고, 새로운 세그먼트 파일에 이후 쓰기를 수행
  • 세그먼트 파일들에 대해 컴팩션(compaction)을 수행할 수 있음
    • 로그에서 중복된 키를 버리고 각 키의 최신 갱신 값만 유지하는 것

키-값 갱신 로그를 컴팩션하여 각 키의 최신 값만 유지

컴팩션은 보통 세그먼트를 더 작게 만들기 때문에 컴팩션을 수행할 때 동시에 여러 세그먼트들을 병합할 수 있다.

  • 이 때 세그먼트가 쓰여진 후에는 절대 변경할 수 없기 때문에 병합할 세그먼트는 새로운 파일로 만듦
  • 고정된 세그먼트의 병합과 컴팩션은 백그라운드 스레드에서 수행할 수 있음
  • 컴팩션을 수행하는 동안 이전 세그먼트 파일을 사용해 읽기와 쓰기 요청의 처리를 정상적으로 계속 수행할 수 있음

병합 과정이 끝난 이후에는 일기 요청은 이전 세그먼트 대신 새로 병합한 세그먼트를 사용하게끔 전환하고, 이전 세그먼트 파일을 삭제한다.

컴팩션과 세그먼트 병합을 동시에 수행

각 세그먼트는 키를 파일 오프셋에 매핑한 자체 인메모리 해시 테이블을 갖게된다.

  • 키의 값을 찾으려면 최신 세그먼트 해시 맵을 먼저 확인
  • 키가 없다면 두 번째 최신 세그먼트 등을 확인

병합 과정을 통해 세그먼트 수를 적게 유지하므로 조회할 때 많은 해시 맵을 확인할 필요가 없다.


이러한 아이디어를 실제로 구현하려면 세부적으로 많은 사항을 고려해야 한다.

  • 파일 형식
    • CSV는 로그에 가장 적합한 형식은 아님
    • 바이트 단위의 문자열 길이를 부호화한 다음 원시 문자열(이스케이핑할 필요 없이)을 부호화하는 바이너리 형식을 사용하는 편이 더 빠르고 간단함
  • 레코드 삭제
    • 키와 관련된 값을 삭제하려면 데이터 파일에 특수한 삭제 레코드(툼스톤(tombstone))를 추가해야함
    • 로그 세그먼트가 병합될 때 툼스톤은 병합 과정에서 삭제된 키의 이전 값을 무시
  • 고장(Crash) 복구
    • 데이터베이스가 재시작되면 인메모리 해시 맵은 손실
    • 전체 세그먼트 파일을 처음부터 끝가지 읽고 각 키에 대한 최신 값의 오프셋을 확인해서 각 세그먼트 해시 맵을 복원 가능
    • 하지만 세그먼트 파일이 크면 해시 맵 복원은 오랜 시간이 걸리 수 있음(서버 재시작을 고통스럽게함)
    • 비트캐스크 같은 경우 각 세그먼트 해시 맵을 메모리로 조금 더 빠르게 로딩할 수 있게 스냅숏을 디스크에 저장해 복수 속도를 높임
  • 부분적 레코드 쓰기
    • 로그에 레코드를 추가하는 도중 데이터베이스가 죽을 수 있음
    • 비트캐스크 파일은 체크섬을 포함하고 있어 로그의 손상된 부분을 탐지해 무시할 수 있음
  • 동시성 제어
    • 쓰기를 엄격하게 순차적으로 로그에 추가할 때 일반적으로 하나의 쓰기 쓰레드만 사용
    • 데이터 파일 세그먼트는 추가 전용이거나 불변(immutable)이므로 다중 스레드로 동시에 읽기 가능

장점

추가 전용 로그는 같은 키의 여러개의 쓰기가 발생하므로 비효율 적으로 보일 수 있다.

예전 값을 새로운 값으로 덮어써 정해진 자리에 파일을 갱신하는 방법도 있지만, 추가 전용 설계는 여러 측면에서 장점이 있다.

  • 추가와 세그먼트 병합은 순차적인 쓰기 작업이므로 보통 무작위 쓰기보다 훨씬 빠름
    • 특히 HDD에서 두드러지며, 일부 확장된 순차 쓰기는 SSD에서도 빠름
  • 세그먼트 파일이 추가 전용이나 불변이면 동시성과 고장 복구는 훨씬 간단
    • 이전 값 부분과 새로운 값 부분을 포함한 파일을 나누어 담으므로, 값을 덮어 쓰는 동안 데이터베이스가 죽는 경우에 대해 걱정할 필요가 없음
  • 오래된 세그먼트 병합은 시간이 지남에 따라 조각화되는 데이터 파일 문제를 피할 수 있음

단점

하지만 해시 테이블 색인 또한 제한 사항이 있다.

  • 해시 테이블은 메모리에 저장해야 하므로 키가 너무 많으면 문제가 발생함
    • 디스크에 해시 맵을 유지할 수 있지만 디스크 상의 해시ㅣ 맵에 좋은 성능을 기대하기 어려움
      • 무작위 접근 I/O가 많이 필요함
      • 디스크가 가득 찼을 때 확장하는 비용이 비쌈
      • 해시 충돌 해소를 위해 추가 로직 필요
  • 점위 질의(Range query)에 적합하지 않음
    • 키 값이 연속적이어도 쉽게 스캔할 수 없어 모든 개별 키를 조회해야함

SS테이블과 LSM 트리

각 로그 구조화 저장소 세그먼트는 키-값 쌍의 연속이다.

이 쌍은 쓰여진 순서대로 나타나므로, 로그에서 같은 키를 갖는 값 중 나중의 값이 이전 값보다 우선 한다는 점만 제외하면 파일에서 키-값 쌍의 순서는 문제가 되지 않는다.

이를 개선하기 위해 키-값 쌍을 키로 정렬하는 방법을 고려할 수 있다.

이처럼 키로 정렬된 형식을 정렬된 문자열 테이블(Sorted String Table), 짧게 SS테이블이라 부른다.

  • 각 키는 각 병합된 세그먼트 파일 내에 한 번만 나타나함
    • 컴팩션 과정은 이미 이를 보장

SS테이블은 해시 색인을 가진 로그 세그먼트보다 몇 가지 큰 장점이 있다.


세그먼트 병합은 파일이 사용 가능한 메모리보다 크더라도 간단하고 효율적

SS 테이블 세그먼트 병합하고 각 키의 최신 값만 유지

  1. 입력 파일을 함께 읽고 각 파일의 첫 번째 키를 확인(정렬된 순서에 따라)
  2. 가장 낮은 키를 출력 파일로 복사한 뒤 이 과정 반복
  • 이 과정에서 새로운 병합 세그먼트 파일이 생성되며, 새로 만든 세그먼트 파일도 역시 키로 정렬
  • 각 세그먼트는 일정 기간 동안 쓰여진 모든 값이 포함되므로, 여러 세그먼트의 같은 값이 포함될 수 있지만, 가장 최근에 만들어진 값이 최신
  • 따라서 다중 세그먼트가 동일한 키를 포함하는 경우 가장 최근 세그먼트의 값을 유지하고 오래된 세그먼트의 값은 버림

파일에서 특정 키를 찾기 위해 메모리에 모든 키의 색인을 유지할 필요가 없음

인메모리 색인을 가진 SS테이블

handiwork 키를 찾으려 하지만 세그먼트 파일에서 키의 정확한 오프셋을 알지 못한다고 가정해도, handbaghandsome 키의 오프셋을 알고 있다면, 이미 정렬되어있다는 특성을 활용하여 두 키 사이에 있다는 사실을 알 수 있다.

  • handbag 오프셋으로 이동해 handiwork가 나올 때까지 스캠하면 됨(키가 존재하지 않는 경우 찾을 수 없음)
  • 일부 키에 대한 오프셋을 알려주는 인메모리 색인은 여전히 필요
  • 하지만 색인 내용이 희소할 수 있는 경우, 수 킬로파이트 정도는 매우 빠르게 스캔할 수 있기 때문에 세그먼트 파일 내 수 킬로바이트당 키 하나로 충분

압축

  • 읽기 요청은 요청 범위 내에서 여러 키-값 쌍을 스캔해야 하기 때문에 해당 레코드들을 블록으로 그룹화하고 디스크에 쓰기 전 압축함
  • 희소 인메모리 색인의 각 항목은 압축된 블록의 시작을 가리키게 됨
  • 디스크 공간을 절약할 수 있고, 이로 인해 I/O 대역폭 사용을 줄일 수 있음

SS테이블 생성과 유지

유입되는 쓰기가 임의 순서로 발생하기 때문에 데이터를 키로 정렬하려면 고민이 필요하다.

디스크 상에 정렬된 구조를 유지하는 일은 가능하지만 메모리에 유지하는 편이 훨씬 쉽다.

  • 레드 블랙 트리, AVL 트리 등

이런 데이터 구조를 이용하면 임의 순서로 키를 삽입하고 정렬된 순서로 해당 키를 다시 읽을 수있다.

  • 쓰기가 들어오면 인메모리 균형 트리 데이터 구조에 추가
    • 이 인메모리 트리는 멤테이블(memtable)이라고도 함
  • 멤테이블이 보통 수 메가바이트 정도의 임곗값보다 커지면 SS테이블 파일로 디스크에 기록
    • 트리가 이미 키로 정렬된 키-값 쌍을 유지하므로 효율적으로 수행 가능
    • 새로운 SS테이블 파일은 데이터베이스의 가장 최신 세그먼트가 됨
    • SS테이블을 기록하는 동안 쓰기는 새로운 멤테이블 인스턴스에 기록
  • 읽기 요청을 제공하려면 먼제 멤테이블에서 키를 찾고 없다면 디스크 상의 가장 최신 세그먼트에서 부터 차례대로 확인
  • 가끔 세그먼트 파일을 합치고 덮어 쓰여지거나 삭제된 값을 버리는 병합과 컴팩션 과정을 수행
    • 이 과정은 백그라운드에서 처리

이 계획은 데이터베이스가 고장나면 아직 디스크로 기록되지 않고 멤테이블에 있는 가장 최신 쓰기는 손실되는 문제가 있다.

이를 대비하기위해 매번 쓰기를 즉시 추가할 수 있게 분리된 로그를 디스크 상 유지해야한다.

  • 이 로그는 멤테이블을 복원할 때만 필요하므로 순서가 정렬되지 않아도 문제되지 않음

멤테이블을 SS테이블로 기록하고 나면 해당 로그는 버릴 수 있다.

SS테이블에서 LSM 트리 만들기

여기에 기술된 알고리즘은 기본적으로 레벨DB(LevelDB)와 록스DB(RocksDB), 그리고 다른 애플리케이션에내장하기 이ㅜ해 설계된 키-값 저장소 엔진 라이브러리에서 사용한다.

구글의 빅테이블(Bigtable)논문에서 영감을 얻은 카산드라와 HBase에서도 유사한 저장소 엔진을 사용한다.

원래 이 색인 구조는 로그 구조화 병합 트리(Log-Structured Merge-Table, LSM 트리)란 이름으로 발표되었으며, 이 색인 구조는 로그 구조화 파일 시스템의 초기 작업의 기반이 됐다.

정렬된 파일 병합과 컴팩션 원리를 기반으로 하는 저장소 엔진LSM 저장소 엔진이라 부른다.

성능 최적화

LSM 트리 알고리즘은 데이터베이스에 존재하지 않는 키를 찾는 경우 느릴 수 있다.

  • 멤테이블을 확인한 다음 키가 존재하지 않는 사실을 확인하기 전 까지 오래된 세그먼트까지 거슬러 올라야함(디스크 I/O 발생 가능)

이런 종류의 접근을 최적화하기 위해 저장소 엔진은 보통 블룸 필터(Bloom filter)를 추가적으로 사용한다.

블룸 필터(Bloom filter)?
집합 내용을 근사한(approximating) 메모리 효율적 데이터 구조

블룸 필터는 키가 데이터베이스에 존재하지 않음을 알려주므로 존재하지 않는 키를 위한 불필요한 디스크 읽기를 많이 절약할 수 있다.


SS테이블을 압축하고 병합하는 순서와 시기를 결정하는 다양한 전략이 있다.

가장 일반적으로 선택하는 전략은 크기 계층 컴팩션(size-tiered compaction)과 레벨 컴팩션(leveled compaction)이다.

  • 크기 게층 컴팩션
    • 상대적으로 새롭고 작은 SS테이블을 상대적으로 오래됐고 큰 SS테이블에 연이어 병합
  • 레벨 컴팩션
    • 키 범위를 더 작은 SS테이블로 나누고 오래된 데이터는 개별 “레벨"로 이동
    • 컴팩션을 점진적으로 진행해 디스크 공간을 덜 사용

여러 중요한 세부 사항이 있지만 LSM 트리의 기본 개념은 간단하고 효과적이다.

  • 백그라운드에서 연쇄적으로 SS테이블을 지속적으로 병합하는 것

이 개념은 데이터셋이 가능한 메모리보다 훨씬 크더라도 여전히 효과적이고, 데이터가 정렬된 순서로 저장돼 있다면 범위 질의를 효율적으로 수행할 수 있다.

  • 최소에서 최대까지 모든 키를 스캔

이 접근법의 디스크 쓰기는 순차적이기 때문에 LSM 트리가 매우 높은 쓰기 처리량을 보장할 수 있다.

B 트리

지금까지 설명한 로그 구조화 색인이 점점 보편화되고 있지만 가장 일반적인 색인 유형은 아니며, 가장 널리 사용되는 색인 구조는 B 트리(B-Tree)로 구조가 로그 구조화 색인과는 상당히 다르다.

B 트리는 SS테이블과 같이 키로 정렬된 키-값 쌍을 유지하기 때문에 키-값 검색과 범위 질의에 효율적이지만, 설계 철학이 매우 다르다.

  • 로그 구조화 색인
    • 데이터베이스를 일반적으로 수 메가바이트 이상의 가변 크기를 가진 세그먼트로 나누고 항상 순차적으로 세그먼트를 기록
  • B 트리
    • 전통적으로 4KB 크기의 고정 크기 블록이나 페이지로 나누고 한 번에 하나의 페이지에 읽기 또는 쓰기 수행
    • 디스크가 고정 크기 블록으로 배열되기 때문에 근본적으로 하드웨어와 조금 더 밀접한 관련이 있음

B 트리의 각 페이지는 주소나 위치를 통해 식별할 수 있으며, 이러한 방식으로 하나의 페이지가 다른 페이지를 참조할 수 있다.

  • 포인터와 비슷하지만 메모리 대신 디스크에 있음

이러한 페이지 참조는 페이지 트리를 구성하는 데 사용할 수 있다.

B 트리 색인을 이용한 키 검색

한 페이지는 B 트리의 루트(root)로 지정되며, 색인에서 키를 찾으려면 루트에서 시작한다.

페이지는 여러 하위 페이지의 참조를 포함되며, 각 하위 페이지는 키가 계속 이어지는 범위를 담당하고 참조 사이의 키는 해당 범위의 경계가 어디인지 나타낸다.

최종적으로는 개별 키(리프 페이지(leaf page))를 포함하는 페이지에 도달하게되며, 이 페이지는 각 키의 값을 포함하거나 값을 찾을 수 있는 페이지의 참조를 포함하게된다.

  • B 트리의 한 페이지에서 하위 페이지를 참조하는 수를 분기 계수(branching factor)라 부름
  • 실제로 분기 계수는 페이지 참조와 범위 경계를 저장할 공간의 양에 의존적(보통 수백개)

페이지 분리로 커진 B 트리

이 알고리즘은 트리가 계속 균형을 유지하는 것을 보장한다.

  • n개의 키를 가진 B 트리의 깊이가 항상 O(log n)

대부분 데이터베이스는 B 트리의 깊이가 3 ~ 4 정도면 충분하므로 검색하려는 페이지를 찾기 위해 많은 페이지 참조를 따라가지 않아도 된다.

  • 분기 계수 500의 4KB 페이지의 4단계 트리는 256TB까지 저장할 수 있음

신뢰할 수 있는 B 트리 만들기

B 트리의 기본적인 쓰기 동작은 새로운 데이터를 디스크 상의 페이지에 덮어쓰며, 덮어쓰기가 페이지 위치를 변경하지 않는다고 가정한다.

  • 페이지를 덮어쓰더라도 페이지를 가르키는 모든 참조는 온전히 남음
  • LSM 트리와 같은 로그 구조화 색인은 파일에 추가 할 뿐(더 이상 쓸모 없는 파일은 삭제) 같은 위치의 파일은 변경하지 않음

디스크의 페이지를 덮어 쓰는 일은 실제 하드웨어가 처리한다.

  • 하드드라이브
    • 디스크 헤드를 적절한 곳으로 옮기고, 회전하는 플래터의 올바른 위치가 돌아올 때까지 기다린 다음 적합한 섹터에 새로운 데이터를 덮어씀
  • SSD
    • SSD가 저장소 칩의 상당한 블록을 한번에 지우고 다시 쓰기를 수행

일부 동작은 여러 다양한 페이지의 덮어쓰기를 필요로 한다.

예를 들어, 삽입 때문에 페이지가 너무 많아져 페이지를 나눠야 하는 경우 분할된 두 페이지를 기록하고 두 하위 페이지의 참조를 갱신하게끔 상위 페이지를 덮어쓰기를 해야한다.

  • 이러한 처리 중 일부 페이지만 기록하고 데이터베이스가 고장난다면 결국 색인이 훼손되므로 위험함

데이터베이스가 고장 상황에서 스스로 복구할 수 있도록 일반적으로 디스크 상에 쓰기 전 로그(write-ahead log, WAL, 재실행 로그, redo log)라는 데이터 구조를 추가해 B 트리를 구현한다.

쓰기 전 로그
트리 페이지에 변경된 내용을 적용하기 전 모든 B 트리의 변경 사항을 기록하는 추가 전용 파일
데이터베이스가 복구될 때 일관성 있는 상태로 B 트리를 복원하는 데 사용됨

같은 자리의 페이지를 갱신하는 작업은 추가적인 골칫거리다.

다중 스레드가 동시에 B 트리에 접근할 때 동시성 제어를 하지 않는다면 스레드가 일관성이 깨진 상태의 트리에 접근할 수 있다.

동시성 제어는 보통 래치(latch, 가벼운 잠금)로 트리의 데이터 구조를 보호한다.

  • 로그 구조화 접근 방식에서는 유입 질의의 간섭 없이 백그라운드에서 모든 병합을 수행하고 가끔 원자적으로 새로운 세그먼트를 이전 세그먼트로 바꾸기 때문에 훨씬 간단함

B 트리 최적화

B 트리는 오랫동안 사용된만큼 많은 최적화 기법이 있다.

  • 페이지 덮어 쓰기와 고장 복구를 위한 WAL 유지 대신, (LMDB 같은)일부 데이터베이스는 쓰기 시 복사 방식(copy-on-write scheme)을 사용함
    • 변경된 페이지는 다른 위치에 기록하고 트리에 상위 페이지의 새로운 버전을 만들어 새로운 위치를 가르키게 함
    • 동시성 제어에도 유용
  • 페이지에 전체 키를 저장하는 것이 아니라 키를 축약해 쓰면 공간을 절약할 수 있음
    • 트리 내부 페이지에서 키가 키 범위 사이의 경계 역할을 하는 데 충분한 정보만 제공하면 됨
    • 페이지 하나에 키를 더 많이 채우면 트리는 더 높은 분기 계수를 얻어 트리 깊이 수준을 낮출 수 있음(B+ 트리라 부르기도 함)
  • 일반적으로 페이지는 디스크 상 어디에나 위치할 수 있기 때문에(키 범위가 가깝다고 가까운 위치애 페이지를 배치하지 않음) 질의가 정렬된 순서로 키 범위의 상당 부분을 스캔해야 한다면 모든 페이지에 대해 디스크 찾기가 필요하기 때문에 페이지 단위 배치는 비효율적임
    • 많은 B 트리 구현에서 리프 페이지를 디스크 상에 연속된 순서로 나타나게끔 트리를 배치하려 시도하지만 트리가 커지면 순서를 유지하기 어려움
    • LSM 트리는 병합하는 과정에서 저장소의 큰 세그먼트를 한 번에 다시 쓰기 때문에 디스크에서 연속된 키를 서로 가깝게 유지하기 더 쉬움
  • 트리에 포인터를 추가
    • 각 리프 페이지가 양쪽 형제 페이지에 대한 참조를 가지면 상위 페이지로 다시 이동하지 않아도 순서대로 키를 스캔할 수 있음
  • 프랙탈 트리(fractal tree) 같은 변형은 디스크 찾기를 줄이기 위해 로그 구조하 개념을 일부 빌렸음
    • 기하학의 프랙탈과는 아무 의미 없음

B 트리와 LSM 트리 비교

일반적으로 B 트리가 LSM 트리보다 구현 성숙도가 더 높지만 LSM 트리도 그 성능 특성 때문에 관심을 받고 있다.

LSM 트리는 보통 쓰기에서 더 빠르고, B 트리는 읽기에서 더 빠르다고 여겨진다.

  • LSM 트리는 각 컴팩션 단계에 있는 여러 가지 데이터 구조와 SS테이블을 확인해야하므로 읽기가 보통 더 느림

하지만 벤치마크는 보통 결정적이지 않고 작업부하의 세부 사항에 민감하므로 정확한 비교를 위해 실제 필요한 작업부하로 시스템을 테스트해야한다.

LSM 트리의 장점

B 트리 색인

  • 모든 데이터 조각을 최소한 두 번 기록해야한다. (쓰기 전 로그, 트리 페이지)
  • 해당 페이지 내 몇 바이트만 바뀌어도 한 번에 전체 페이지를 기록해야 하는 오버헤드
  • 일부 저장소 엔진은 전원에 장애가 발생했을 때 일부만 갱신된 페이지로 끝나지 않게 동일한 페이즈를 두 번 덮어씀

로그 구조화 색인

  • SS테이블의 반복된 컴팩션과 병함으로 인해 여러 번 데이터를 다시 씀
    • 쓰기 증폭(write amplification)
      • 데이터베이스에 쓰기 한 번이 데이터베이스 수명 동안 디스크에 여러 번의 쓰기를 야기하는 효과
      • SSD는 수명 동안 블록 덮어쓰기 횟수가 정해져 있으므로 주의해야함

쓰기가 많은 애플리케이션에서 성능 병목은 데이터베이스가 디스크에 쓰는 속도일 수 있는데, 이 경우 쓰기 증폭은 성능 비용이다.

  • 저장소 엔진이 디스크에 기록할수록 디스크 대역폭 내 처리할 수 있는 초당 쓰기는 점점 줄어듦

LSM 트리가 상대적으로 쓰기 증폭이 더 낮고, 트리에서 여러 페이지를 덮어쓰는 것이 아닌 순차적으로 컴팩션된 SS 테이블을 쓰기 때문에, 보통 B 트리보다 쓰기 처리량을 높게 유지할 수 있다.

  • 특히 하드디스크는 순차 쓰기가 임의 쓰기 보다 훨씬 더 빠르다.

LSM 트리는 압축률이 더 좋다.

  • 보통 B 트리보다 디스크에 더 적은 파일을 생성한다.

B 트리 저장소 엔진은 파편화로 인해 사용하지 않는 디스크 공간 일부가 남는다.

  • 페이지를 나누거나 로우가 기존 페이지에 맞지 않을 때 페이지의 일부 공간은 사용하지 않게됨
  • LSM 트리는 페이지 지향적이지 않고 주기적으로 파편화를 없애기 위해 SS테이블을 다시 기록하기 때문에 저장소 오버헤드가 더 낮다.(레벨 컴팩션 사용시 특히 더)

대다수의 SSD 펌웨어는 내장 저장소 칩에서 임의 쓰기를 순차 쓰기로 전환하기 우해 내부적으로 로그 구조화 알고리즘을 사용한다.

  • 그래서 저장소 엔진의 쓰기 패턴이 SSD에 미치는 영향은 분명하지 않음
  • 하지만 낮은 쓰기 증폭과 파편화 감소는 SSD의 경우 훨씬 유리함

데이터를 더 밀집해 표현하면 가능한 I/O 대역폭 내에서 더 많은 읽기와 쓰기 요청이 가능하다.

LMS 트리의 단점

로그 구조화 저장소의 단점은 컴팩션 과정이 때로는 진행 중인 읽기와 쓰기의 성능에 영향을 준다는 점이다.

저장소 엔진은 컴팩션을 점진적으로 수행하고 동시 접근의 영향이 없게 수행하려 하지만, 디스크가 가진 자원은 한계가 있으므로 디스크에서 비싼 컴팩션 연산이 끝날 때까지 요청이 대기해야하는 상황이 발생하기 쉽다.

처리량과 평균 응답 시간이 성능에 미치는 영향은 대개 작으나 로그 구조화 저장소 엔진의 상위 백분위 질의의 응답 시간은 때때로 길다.

반면 B 트리의 성능은 로그 구조화 저장소 엔진보다 예측하기 쉽다.


또 다른 컴팩션 문제는 높은 쓰기 처리량에서 발생한다.

디스크의 쓰기 대역폭은 유한하기 때문에 초기 쓰기(로깅(logging)과 멤테이블을 디스크로 방출(flushing))와 백그라운드에서 수행되는 컴팩션 스레드가 이 대역폭을 공유해야한다.

빈 데이터베이스에 쓰는 경우 전체 디스크 대역폭을 초기 쓰기만을 위해 사용할 수 있지만, 데이터베이스가 커질수록 컴팩션을 위해 더 많은 디스크 대역폭이 필요하다.

쓰기 처리량이 높음에도 컴팩션 설정을 주의 깊게 하지 않으면 컴팩션이 유입 쓰기 속도를 따라갈 수 없다.

  • 디스크 상에 병합되지 않은 세그먼트 수는 디스크 공간이 부족할 때 까지 증가할 수 있음
  • 더 많은 세그먼트 파일을 확인해야 하기 때문에 읽기 또한 느려짐

보통 SS테이블 기반 저장소 엔진은 컴팩션이 유입 속도를 따라가지 못해도 유입 쓰기의 속도를 조절하지 않으므로 이런 상황을 감지하기 위한 명시적 모니터링이 필요하다.


B 트리의 장점은 각 키가 색인의 한 곳에만 정확하게 존재한다는 점이다. 하지만 로그 구조화 저장소 엔진은 다른 세그먼트에 같은 키의 다중 복사본이 존재할 수 있다.

이런 측면 때문에 강력한 트랜잭션 시맨틱(semantic)를 제공하는 데이터베이스에는 B 트리가 훨씬 매력적이다.

  • 많은 관계형 데이터베이스에서 트랜잭션 격리(transactional isolation)는 키 범위의 잠금을 사용해 구현한 반면 B 트리 색인에서는 트리에 직접 잠금을 포함시킴

B 트리는 데이터베이스 아키텍처에 아주 깊게 뿌리내렸고, 많은 작업부하에 대해 지속적으로 좋은 성능을 제공하므로 금방 사라질 가능성은 거의 없으며, 새로운 데이터 저장소에서는 로그 구조화 색인이 점점 인기를 얻고 있다.

사용 사례에 적합한 저장소 엔진의 유형을 결정하기 위한 빠르고 쉬운 규칙은 없기 때문에 테스트를 통해 경험적으로 결정하는 방법도 나쁘지 않다.

기타 색인 구조

지금까지는 키-값 색인을 살펴봤다.

대표적인 기-값 색인 예시는 관계형 모델의 기본키(primary-key) 색인이다.

  • 기본키로 관계형 테이블에서 하나의 로우를, 문서 데이터베이스에서 하나의 문서를, 그래프 데이터베이스에서 하나의 정점을 고유하게 식별
  • 데이터베이스에서 다른 레코드는 기본키로 로우/문서/정점을 참조할 수 있다.

보조 색인(secondary index) 을 사용하는 방식도 매우 일반적이다.

  • 관계형 데이터베이스에서 CREATE INDEX 명령을 통해 보조 색인을 생성할 수 있음
  • 보조 색인은 보통 효율적인 조인을 수행하는 데 결정적인 역할을 함

보조 색인은 키-값 색인에서 쉽게 생성할 수 있는데, 기본키 색인과의 주요 차이점은 키가 고유하지 않다는 점이다.

  • 같은 키를 가진 많은 로우(문서, 정점)이 있을 수 있음

이 점은 두 가지 방법으로 개선될 수 있다.

  • 색인의 각 값에 일치하는 로우 식별자 목록을 만들기
  • 로우 식별자를 추가해 각 키를 고유하게 만들기

어느 쪽이든 보조 색인으로 B 트리와 로그 구조화 색인 둘 다 사용할 수 있다.

색인 안에 값 저장하기

색인에서 키는 질의가 검색하는 대상이지만, 값은 다음의 두 가지 중 하나에 해당한다.

  • 질문의 실제 로우
  • 다른 곳에 저장된 로우를 가르키는 참조

후자의 경우 로우가 저장된 곳을 힙 파일(heap file)이라 하고 특정 순서 없이 데이터를 저장함

힙 파일 접근은 여러 보조 색인이 존재할 때 데이터 중복을 피할 수 있기 때문에 일반적인 방식이다.

  • 각 색인은 힙 파일에서 위치만 참조하고 실제 데이터는 일정한 곳에 유지함

힙 파일 접근 방식은 키를 변경하지 않고 값을 갱신할 때 꽤 효율적이다.

  • 새로운 값이 이전 값보다 많은 공간을 필요로 하지 않으면 레코드를 제자리에 덮어쓸 수 있음

하지만 새로운 값이 많은 공간을 필요로 한다면 힙에서 충분한 공간이 있는 새로운 곳으로 위치를 이동해야한다.

  • 이러한 경우 모든 색인이 레코드의 새로운 힙 위치를 가르키게끔 갱신하거나 이전 힙 위치에 전방향 포인터를 남겨야함

색인에서 힙 파일로 다시 이동하는 일은 읽기 성능에 불이익이 너무 많기 때문에, 어떤 상황에서는 색인 안에 바로 색인된 로우를 저장하는 편이 바람직 할 수 있다.

이를 클러스터드 색인(clustered index)라고 하며, MySQL의 InnoDB 저장소 엔진에서는 테이블의 기본키가 언제가 클러스터드 색인이고 보조 색인은 (힙 파일의 위치가 아닌) 기본키를 참조한다.

클러스터드 색인과 비 클러스터드 색인 사이의 절충안을 커버링 색인(covering index)이나 포괄열이 있는 색인(index with included column)이라 한다.

  • 색인 안에 테이블의 컬럼 일부를 저장
  • 색인만 사용해 일부 질의에 응답이 가능함(색인이 질의를 커버했다고 말함)

모든 종류의 데이터 복제와 마찬가지로 클러스터드 색인과 커버링 색인은 읽기 성능을 높일 수 있지만 추가적인 저장소가 필요하고 쓰기 과정에 오버헤드가 발생한다.

또한 애플리케이션 단에서 복제로 인한 불일치를 파악할 수 없기 때문에 데이터베이스는 트랜잭션 보장을 강화하기 위해 별도의 노력이 필요하다.

다중 컬럼 색인

지금까지 설명한 색인은 하나의 키만 값에 대응하므로 테이블의 다중 컬럼에 동시에 질의를 해야한다면 충분하지 않다.

다중 컬럼 색인의 가장 일반적인 유형은 결합 색인(concatenated index)이라고 한다.

  • 하나의 컬럼에 다른 컬럼을 추가하는 방식으로 하나의 키에 여러 필드를 단순히 결합
  • 필드가 결합하는 순서는 색인 정의에 명시

이 방법은 (성, 이름)을 키로 전화번호 값으로 하는 색인을 제공하는 전화번호부와 유사하다.

  • 순서가 정렬돼 있으므로 특정 성을 가진 모든 사람, 특정 성 이름 조합을 가진 모든 사람을 찾을 때도 이 색인을 활용할 수 있음
  • 하지만 특정 이름을 가진 모든 사람을 찾을 때는 쓸모 없음

다차원 색인은 한 번에 여러 컬럼에 질의하는 조금 더 일반적인 방법이다.

  • 지리 공간 데이터 등

레스토랑 검색 웹 사이트에 각 레스토랑의 위도와 경도를 포함한 데이터베이스가 있다고 가정하면, 사용자가 지도에서 레스토랑을 찾을 때 웹 사이트는 사용자가 현재 보는 네모단 지도 영역 내 모든 레스토랑을 찾아야 하므로 다음과 같은 이차원 범위 질의가 필요하다.

1
2
3
4
SELECT *
FROM restaurants
WHERE latitude > 51.4946 AND latitude < 51.5079
  AND longitude > -0.1162 AND longitude < -0.1004;

표준 B 트리나 LSM 트리 색인은 이런 유형의 질의에 효율적으로 응답할 수 없다.

  • 위도 범위 안의 모든 레스토랑이나 경도 범위 안의 모든 레스토랑을 줄 수는 있지만 둘을 동시에 주진 못함

한 가지 방법은 이차원 위치를 공간 채움 곡선(space-filling curve)을 이용해 단일 숫자로 변환한 다음 일반 B 트리 색인을 사용하는 것이다.
좀 더 일반적인 방법은 R 트리처럼 전문 공간 색인(specialized spatial index)을 사용하는 것이다.

다차원 색인의 활용은 지리학적인 위치에만 국한되지 않는다.

  • 색상(RGB), 날씨(날짜, 기온)

전문 검색과 퍼지 색인

지금까지 설명한 모든 색인은 정확한 데이터를 대상으로 키의 정확한 값이나 정렬된 키의 값의 범위를 질의할 수 있다고 가정하므로, 철자가 틀린 단어와 같이 유사한 키에 대해서는 검색 할 수 없다.

이처럼 애매모호한(fuzzy) 질의에는 다른 기술이 필요하다.


전문 검색 엔진은 일반적으로 특정 단어를 검색할 때 해당 단어의 동의어로 질의를 확장한다.

그리고 단어의 문법적 활용을 무시하고 동일한 문서에서 서로 인접해 나타난 단어를 검색하거나 언어학적으로 텍스트를 분석해 사용하는 등 다양한 기능을 제공한다.

아파치 루씬은 문서나 질의의 오타에 대처하기 위해 특정 편집 거리(edit distance) 내 단어를 검색할 수 있다.

  • 편집 거리 1은 한 글자가 추가되거나 삭제되거나 교체됐음을 의미

루씬은 용어 사전을 위해 SS테이블 같은 구조를 사용한다. 이 구조는 작은 인메모리 색인이 필요하며, 이 색인은 키를 찾는데 필요한 정렬 파일의 오프셋을 질의에 알려주는 데 사용한다.

레벨DB에서 이 인메로리 색인은 일부 키의 희소 컬렉션이지만, 루씬에서 인메모리 색인은 여러 키 내 문자에 대한 유한 상태 오토마톤(finite state automaton)으로 트라이(trie)와 유사하다.

  • 이 오토마톤은 레벤슈타인 오토마톤(levenshtein automaton)으로 변환할 수 있다.
  • 특정 편집 거리 내에서 효율적인 단어 검색을 제공

그 밖의 퍼지 검색 기술은 문서 분류 및 머신러닝의 방향으로 진행되고 있다.

모든 것을 메모리에 보관

지금까지 설명한 데이터 구조는 모두 디스크 한계에 대한 해결책 이었다.

디스크는 메인 메모리와 비교해 대루기 어렵다.

  • 자기 디스크와 SSD를 사용할 때 읽기와 쓰기에서 좋은 성능을 원한다면 주의해서 데이터를 디스크에 배치해야 함

이런 불편함을 참을 수 있는 이유는 디스크의 장점 때문이다.

  • 지속성: 디스크 내용은 전원이 꺼져도 손실되지 않음
  • 가격: 램보다 훨씬 더 저렴

하지만 과거에 비해 메인 메모리의 가격이 매우 저렴해졌고, 데이터셋의 대부분은 그다지 크지 않기 때문에 메모리에 전체를 보관하는 방식도 고려할 수 있게 되었으며, 이러한 이유로 인메모리 데이터베이스가 개발되었다.


맴캐시드 같은 일부 인베모리 키-값 저장소는 장비가 재시작되면 데이터 손실을 허용하는 캐시 용도로만 활용되었으나, 다른 인메모리 데이터베이스는 지속성을 목표로 한다.

  • 배터리 전원 공급 RAM 같은 특수 하드웨어 사용
  • 디스크에 변경 사항의 로그를 기록
  • 디스크에 주기적인 스냅숏 기록
  • 다른 장비에 인메모리 상태를 복제

인메모리 데이터베이스가 재시작 되는 경우 디스크나 네트워크를 통해 복제본에서 상태를 다시 적재해야 한다.

디스크는 전적으로 지속성을 위한 추가 전용 로그로 사용되고, 읽기는 전적으로 메모리에서 제공되기 때문에 디스크에 기록하더라도 여전히 인메모리 데이터베이스이다.


직관에 어긋나지만 인메모리 데이터베이스의 성능 강점은 디스크에서 읽지 않아도 된다는 사실 때문은 아니다.

  • 디스크 기반 저장소 엔진도 운영체제가 최근에 사용한 디스크 블록을 메모리에 캐시하기 때문에 충분한 메모리를 가진 경우에는 디스크에서 읽을 필요 없음
  • 오히려 인메모리 데이터 구조를 디스크에 기록하기 위한 형태로 부호화하는 오버헤드를 피할 수 있어 더 빠를 수도 있음

성능 외에도 인메모리 데이터베이스는 또 다른 재미있는 영역으로서 디스크 기반 색인으로 구현하기 어려운 데이터 모델을 제공한다.

  • 레디스
    • 우선순위 큐와 셋 같은 다양한 데이터 구조를 데이터베이스 같은 인터페이스로 제공
    • 메모리에 모든 데이터를 유지하기 때문에 고현이 비교적 간단

최근 연구에 따르면 인메모리 데이터베이스 아키텍처가 디스크 중심 아키텍처에서 발생하는 오버헤드 없이 가용한 메모리보다 더 큰 데이터셋을 지원하게끔 확장할 수 있다.

안티 캐싱(anti-caching)
메모리가 충분하지 않을 때 가장 최근에 사용하지 않은 데이터를 메모리에서 디스크로 보내고 나중에 다시 접근할 때 메모리에 적재하는 방식으로 동작

운영체제가 가상 메모리와스왑 파일에서 수행하는 방식과 유사하지만 데이터베이스는 전체 메모리 페이지보다 개별 레코드 단위로 작업할 수 있기 때문에 OS보다 효율적으로 메모리를 관리할 수 있다.

하지만 이 접근 방식은 여전히 전체 색인이 메모리에 있어야 한다.