diff --git "a/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 2.58.33.png" "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 2.58.33.png" new file mode 100644 index 0000000..c16eb6d Binary files /dev/null and "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 2.58.33.png" differ diff --git "a/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 3.24.17.png" "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 3.24.17.png" new file mode 100644 index 0000000..479f118 Binary files /dev/null and "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 3.24.17.png" differ diff --git "a/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.06.26.png" "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.06.26.png" new file mode 100644 index 0000000..a587d95 Binary files /dev/null and "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.06.26.png" differ diff --git "a/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.06.35.png" "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.06.35.png" new file mode 100644 index 0000000..a587d95 Binary files /dev/null and "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.06.35.png" differ diff --git "a/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.13.56.png" "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.13.56.png" new file mode 100644 index 0000000..7ad84db Binary files /dev/null and "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.13.56.png" differ diff --git "a/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.29.08.png" "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.29.08.png" new file mode 100644 index 0000000..89ab57a Binary files /dev/null and "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.29.08.png" differ diff --git "a/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.31.35.png" "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.31.35.png" new file mode 100644 index 0000000..90f8bde Binary files /dev/null and "b/DB/img/\354\212\244\355\201\254\353\246\260\354\203\267 2026-04-05 \354\230\244\355\233\204 4.31.35.png" differ diff --git "a/DB/\354\240\200\354\236\245\354\206\214\354\231\200 \352\262\200\354\203\211.md" "b/DB/\354\240\200\354\236\245\354\206\214\354\231\200 \352\262\200\354\203\211.md" new file mode 100644 index 0000000..0e24b2b --- /dev/null +++ "b/DB/\354\240\200\354\236\245\354\206\214\354\231\200 \352\262\200\354\203\211.md" @@ -0,0 +1,348 @@ + + +# 데이터 베이스를 강력하게 만드는 저장 구조 + +![[스크린샷 2026-04-05 오후 2.58.33.png]] +DB의 두가지 주요 기능은 데이터 저장과 불러오기다. +위의 이미지는 Key-Value로 값을 저장하고 불러오는 간단한 DB를 만들어 보았다. 파일을 추가하는 작업은 일반적으로 효율적으로 처리 되기 때문에 db_set() 함수 같은 경우는 꽤 좋은 성능을 보여준다. + +반면 db_get 함수는 데이터베이스에 많은 레크드가 있다면 성능이 ==**매우**== 좋지 않다. 매번 키를 찾을 때마다 데이터베이스 파일을 처음부터 끝까지 찾아봐야하기 때문이다. 전체 비용이 O(n) 이 되는 것이다. + +-> 데이터 베이스에서 특정 키의 값을 효율적으로 찾기 위해서는 다른 데이터 구조가 필요하다. + +바로 "Index" 이다. + +색인의 일반적인 개념은 부가적인 메타 데이터를 유지하는 것이다. (데이터에 대한 색인이라기 보다는ㅇㅇ 데이터를 위한 색인) 이 메타데이터는 이정표 역할을 해서 데이터의 위치를 찾는데 도움이 된다. + +즉 색인은 기본적인 데이터에서 파생된 추가적인 구조인다. + +많은 데이터베이스는 색인의 추가와 삭제를 지원하는데 이 작업은 데이터베이스의 내용에는 영향을 미치지 않는다. 단지 **질의 성능에 영향**을 줄 뿐이다. 추가적으로 구조적인 변경이 있을 때 성능 영향이 있을 수 있는데 그것이 바로 쓰기 작업이다. 쓰기 작업이 일어나게 되면 알잘딱갈센 하게 구조와 색인을 맞춰야 하기 때문에 시간이 걸린다. + +여기서 데이터베이스의 트레이드 오프가 발생한다. 쓰기냐 읽기냐 +그렇기 때문에 모든 데이터에 색인을 걸어두지 않는다. 그래야 필요이상으로 오버헤드를 발생시키지 않으면서 애플리케이션에 가장 큰 이익을 가져다 줄 수 있다. + +## 해시 색인 + + +그렇다면 가장 기본이 되는 키-값(Key-Value) 데이터를 어떻게 색인할까? 대부분의 프로그래밍 언어에서 사용하는 사전(dictionary) 타입과 유사하게, 보통 **해시 맵(Hash Map)** 을 사용한다. + +-> 여기서 아이디어가 출발한다. 이미 인메모리(In-memory) 데이터 구조로 해시 맵이 잘 되어 있는데, 이걸 이용해서 디스크 상의 데이터를 색인하는 것은 어떨까? + +앞서 말한 단순히 파일 끝에 추가(append)하는 방식의 저장소라면, 가장 간단한 색인 전략은 다음과 같다. **'키(Key)'를 데이터 파일의 '바이트 오프셋(Byte Offset)'에 매핑**하여 인메모리 해시 맵에 유지하는 것이다. +![[스크린샷 2026-04-05 오후 3.24.17.png]] + +- 쓰기: 파일에 새로운 데이터를 추가할 때마다, 방금 쓴 데이터의 위치(오프셋)를 해시 맵에 갱신한다. + +- 읽기: 해시 맵에서 키를 찾아 오프셋을 구하고, 디스크의 해당 위치로 곧바로 이동해 값을 읽는다. + + +이 방식은 너무 단순해 보이지만, 실제로 비트캐스크(Bitcask) 같은 기본 저장소 엔진이 사용하는 강력한 방식이다. 단, 전제 조건이 있다. **모든 '키(Key)'가 메모리(RAM)에 쏙 들어갈 수 있어야 한다.** (값은 디스크에 있어도 무방하다). -> 이 조건만 충족된다면, 디스크를 딱 한 번만 탐색하면 되므로(OS 캐시에 있으면 그마저도 필요 없음) 읽기와 쓰기 모두 엄청난 고성능을 보장한다. + +이런 해시 색인 구조는 어떤 상황에 찰떡일까? 바로 **"고유한 키의 개수는 적지만, 각 키의 값이 아주 자주 갱신되는 상황"** 이다. (예: 특정 고양이 동영상 URL(키)의 조회수(값)가 계속 올라가는 경우) + +하지만 여기서 또 다른 문제가 발생한다. 계속 파일에 추가(append)만 하다 보면 결국 디스크 공간이 꽉 차버리지 않을까? -> 이를 피하기 위한 좋은 해결책이 바로 로그를 특정 크기의 **'세그먼트(Segment)'** 로 나누는 것이다. (여기서 로그는 데이터의 오프셋 위치이다) + +파일이 특정 크기에 도달하면 닫아버리고 새로운 세그먼트 파일에 쓰기를 시작한다. 그리고 닫힌 오래된 세그먼트 파일들에 대해서는 **'컴팩션(Compaction)'** 이라는 작업을 수행한다. -> 컴팩션이란, 과거 로그들 중에서 중복된 키는 과감히 버리고 '각 키의 가장 최신 갱신 값'만 남겨서 알잘딱갈센하게 디스크 용량을 최적화하는 작업이다. + + +![[스크린샷 2026-04-05 오후 4.06.35.png]] + +이렇게 됨으로써 과거의 위치는 지워지고 현재(가장 최신)의 위치만 남겨지게 되었다. + +컴팩션(최신 값만 남기고 중복 제거)을 거치면 세그먼트 크기가 확 줄어들기 때문에, 여러 개의 세그먼트를 하나로 합치는 **'병합(Merge)'** 작업도 동시에 수행할 수 있다. -> 세그먼트는 한 번 쓰여지면 절대 변경되지 않는 불변(immutable)의 성질을 갖는다.(동시성 관련해서 이점을 가져가기 위해) 그래서 병합할 때는 무조건 '새로운 파일'을 만든다. (새로운 파일이 만들어짐으로 파일간의 데이터를 합치는 것을 병합이라 한다) -> 이 작업은 백그라운드 스레드에서 조용히 처리된다. 병합 중에도 기존 읽기/쓰기 요청은 이전 세그먼트들로 잘 처리되다가, 병합이 딱 끝나면 새로운 세그먼트로 방향을 틀어주고(전환), 옛날 파일들은 쿨하게 삭제해 버리면 된다. +![[스크린샷 2026-04-05 오후 4.13.56.png]] + +이제 각 세그먼트들은 자신만의 인메모리 해시 테이블을 갖게 된다. 그럼 값을 찾을 때는 어떻게 할까? -> 가장 최신 세그먼트의 해시 맵부터 뒤져보고, 없으면 그 이전 세그먼트를 찾아보는 식으로 내려간다. 앞서 말한 '병합' 과정 덕분에 세그먼트 개수가 적게 유지되므로 찾아봐야 할 해시 맵도 적어서 성능이 유지된다. + +이런 단순하고 멋진 아이디어를 실제로 구현하려면 몇 가지 디테일한 문제들을 해결해야 한다. + +- **파일 형식:** 텍스트(CSV)보다는 바이트 단위 길이를 부호화하는 **바이너리 형식**이 빠르고 간단하다. + +- **레코드 삭제:** 덮어쓰기가 안 되니 중간 데이터를 지울 수가 없다. 대신 삭제했다는 징표인 특수 레코드(일명 **'툼스톤(Tombstone)'**)를 끝에 추가한다. 나중에 병합 과정에서 이 툼스톤을 만나면 그 키의 이전 값들을 깔끔하게 무시하고 버린다. + +- **고장(Crash) 복구:** DB가 뻗었다가 재시작되면 메모리의 해시 맵이 다 날아간다. 이걸 처음부터 다시 읽어서 복원하려면 한세월이다. -> 그래서 평소에 해시 맵의 스냅샷을 디스크에 저장해두고 이를 로딩하여 복구 속도를 높인다. + +- **부분 쓰기 오류:** 파일에 기록하다가 중간에 죽어버릴 수도 있다. 이를 위해 체크섬(Checksum)을 포함해 두어 손상된 로그를 탐지하고 무시할 수 있게 한다. + +- **동시성 제어:** 쓰기는 꼬이지 않게 오직 '하나의 스레드'만 순차적으로 진행한다. 하지만 읽기는? 데이터가 불변(immutable)이므로 여러 스레드가 동시에 마구 접근해서 읽어도 아무 문제가 없다. + + +가만 보면, 덮어쓰지 않고 계속 끝에 '추가'만 하는 방식이 공간 낭비처럼 보일 수 있다. 하지만 이 **추가 전용(Append-only) 설계**는 여러모로 훌륭하다. + + 1. **압도적인 속도:** 무작위로 여기저기 쓰는 것보다, 순서대로 쭉 이어서 쓰는(순차적 쓰기) 작업이 하드디스크나 SSD 모두에서 훨씬 빠르다. +1. **간단한 동시성과 복구:** 덮어쓰다가 뻑이 날까 봐 걱정할 필요가 없다. 예전 값과 새 값이 따로 존재하므로 안전하다. +2. **파편화 방지:** 시간이 지나 데이터가 조각나는 문제를 세그먼트 병합 과정에서 자연스럽게 해결해 준다. + + +**하지만, 해시 테이블 색인에도 치명적인 한계가 있다.** -> **메모리(RAM) 용량의 압박:** 모든 '키'를 메모리에 올려야 한다는 전제 조건 때문이다. 키가 너무 많아지면 메모리가 버티질 못한다. 그렇다고 해시 맵을 디스크에 두자니, 디스크 무작위 접근(I/O)이 너무 많이 발생하고 해시 충돌까지 처리하려면 성능이 크게 떨어져서 사실상 기대하기 어렵다. + + +하지만 해시 테이블 색인에는 앞서 말한 '모든 키를 메모리에 올려야 한다'는 압박 외에도 치명적인 단점이 하나 더 있다. 바로 "kitty00000부터 kitty99999까지 다 보여줘" 같은 **범위 질의(Range Query)에 매우 취약하다**는 것이다. 해시 맵 구조상 연속적인 스캔이 안 되기 때문이다. -> 이 한계를 돌파하기 위해 한 단계 더 진화한 색인 구조가 **SSTable(Sorted String Table)** 이다. + +## SS 테이블과 LSM 트리 + +기존 세그먼트 파일 형식에 딱 한 가지 룰을 추가한다. **"모든 키-값 쌍을 '키(Key)' 기준으로 정렬(Sort)해서 저장하자"** 단순히 정렬만 했을 뿐인데 애플리케이션에 엄청난 이익을 가져다준다. + +1. 파일이 이미 정렬되어 있으니 세그먼트 병합이 '병합 정렬(Merge sort)'처럼 매우 쉽고 빨라진다. + ![[스크린샷 2026-04-05 오후 4.29.08.png]] + +2. 굳이 모든 키의 오프셋을 메모리에 들고 있을 필요가 없다. 듬성듬성 이정표만 세워두는 **희소 색인(Sparse Index)** 이 가능해져 메모리 낭비가 싹 사라진다. (예: `handbag`과 `handsome` 위치만 알면 그 사이의 `handiwork`는 대략적인 범위만 스캔해서 찾을 수 있다). + ![[스크린샷 2026-04-05 오후 4.31.35.png]] +3. 어차피 범위를 스캔해야 하니, 레코드들을 블록 단위로 묶어 압축해버리면 디스크 I/O 대역폭까지 크게 절약할 수 있다. + + +하지만 여기서 또 다른 트레이드오프적 딜레마가 발생한다. 사용자가 입력하는 데이터는 제멋대로(무작위 순서로) 들어올 텐데, 디스크에 쓸 때는 어떻게 항상 '키 순서대로 정렬'해서 쓸 수 있을까? + + +**저장소 엔진 동작 구조** + +디스크에서 정렬된 구조를 유지하는 것은 가능하지만 메모리에 유지하는 편이 더 쉽고 빠르다. +SSTable 기반 저장소 엔진이 실제로 어떻게 동작하는지 순서대로 보면: + +1. 쓰기 요청이 오면 인메모리 균형 트리(balanced tree)(예: 레드 블랙 트리)에 먼저 넣는다. 이걸 **멤테이블(memtable)** 이라고 부른다. +2. 멤테이블이 일정 크기(보통 수 메가바이트)를 초과하면 SSTable 파일로 디스크에 기록한다. 트리가 이미 정렬되어 있으니 효율적으로 처리 가능. (메모리에 너무 많은 양을 적제하는 것은 부담되기 때문에 SSTable로 밀어 넣는 것이다.) +3. 읽기 요청이 오면 멤테이블 -> 최신 세그먼트 -> 두 번째 오래된 세그먼트 순으로 키를 탐색한다. +4. 백그라운드에서 세그먼트 파일을 합치고 삭제된 값을 버리는 병합/컴팩션(compaction)을 수행한다. + +근데 여기서 문제가 하나 있다. DB가 고장나면 멤테이블에 있던 최신 쓰기 데이터가 날아간다. -> 이걸 방지하기 위해 쓰기마다 별도 로그를 디스크에 남겨둔다. 멤테이블 복원용으로만 쓰이니까 순서 정렬 없어도 됨. 멤테이블을 SSTable로 기록하고 나면 해당 로그는 그냥 버리면 된다. + +Raw한 데이터를 로그로 디스크에 먼저 남기고 멤테이블에서 작업 -> 디스크에 SS테이블로 저장하고 로그 날리기 + +--- + +**SSTable에서 LSM 트리 만들기** + +이 알고리즘의 이름이 바로 **LSM 트리(Log-Structured Merge-Tree)** 다. 레벨DB(LevelDB), 록스DB(RocksDB) 같은 키-값 저장소 엔진 라이브러리에서 핵심으로 사용된다. 카산드라, HBase도 유사한 구조를 쓴다. + +--- + +**성능 최적화** + +LSM 트리의 약점이 있는데, DB에 없는 키를 찾을 때다. 멤테이블부터 가장 오래된 세그먼트까지 전부 뒤져야 하기 때문에 (디스크 읽기가 발생할 수 있음) 꽤 느릴 수 있다. + +-> 이걸 해결하기 위해 **블룸 필터(Bloom filter)** 를 추가적으로 사용한다. 집합 내용을 근사하는(approximating) 메모리 효율적 데이터 구조로, "이 키 DB에 없음"을 미리 알려줘서 불필요한 디스크 읽기를 줄여준다. + +블룸 필터는 큰 체크 박스와 같다. 특별한 해시 함수 구조로 찾고자 하는 데이터를 넣으면 이 데이터는 몇번, 몇번 부분이 체크돼야한다 라는 값이 나온다. 체크가 안 돼있다면 DB에 없는 것이다. O(1) 로 판단 가능 + +컴팩션 전략도 두 가지가 있다: + +- **크기 계층(size-tiered)**: 새롭고 작은 SSTable을 오래된 큰 SSTable에 연이어 병합 +- **레벨 컴팩션(leveled compaction)**: 키 범위를 작은 SSTable로 나누고 데이터를 개별 "레벨"로 이동 -> 점진적으로 컴팩션을 진행해서 디스크 공간을 덜 쓴다 + +--- +**결론** + +LSM 트리의 핵심 아이디어는 백그라운드에서 연쇄적으로 SSTable을 지속적으로 병합하는 것이다. 디스크 쓰기가 순차적이라 쓰기 처리량이 매우 높다는 게 최대 장점. 데이터가 정렬된 순서로 저장돼 있으면 범위 질의도 효율적으로 실행 가능하다(최솟값에서 최댓값까지 키를 죽 스캔). + + +## B 트리 + +색인하면 B 트리를 빼놓을 수 없다. + +앞에서 살펴보 LSM (로그 구조화 색인)은 데이터 베이스를 일반적으로 수 메가바이트 이상의 가변 크기를 가진 세그먼트로 나누고 항상 순차적으로 세그먼트를 기록한다. 반면 B 트리는 전통적으로 4KB 크기의 고정 블록으로 나누고 한 번에 하나의 페이지에 읽기 또는 쓰기를 한다. 디스크가 고정 크기의 블록으로 배열되기 때문에 이 설계는 근본적으로 하드웨어와 좀 더 밀접한 관계를 가지고 있다. + +**B 트리 기본 특성** + +B 트리는 트리가 계속 균형을 유지하는 게 보장된다. n개의 키를 가진 B 트리의 깊이는 항상 O(log n)이다. 대부분의 DB는 깊이 3~4단계면 충분하고 (분기 계수 500의 4KB 페이지 4단계 트리는 256TB까지 저장 가능), 그 덕분에 페이지를 찾기 위해 참조를 많이 따라가지 않아도 된다. + +--- + +**신뢰할 수 있는 B 트리 만들기** + +B 트리의 기본 쓰기 동작은 디스크 상의 페이지를 직접 덮어쓰는 방식이다. LSM 트리가 파일에 추가만 하는 것과는 완전 반대다. + +근데 여기서 문제가 생긴다. 일부 동작은 여러 페이지를 동시에 덮어써야 한다. 예를 들어 삽입 시 페이지가 넘치면 분할하고, 분할된 두 하위 페이지의 참조를 상위 페이지에 갱신해야 한다. -> 이 과정 중에 DB가 고장나면 색인이 훼손된다. 심하면 **고아 페이지(orphan page)**(부모 관계가 없는 페이지)가 생길 수 있다. + +-> 이걸 방지하기 위해 **쓰기 전 로그(Write-Ahead Log, WAL)**(재실행 로그(redo log)라고도 함)를 디스크에 추가한다. B 트리 변경 내용을 실제 페이지에 적용하기 전에 모두 이 로그에 먼저 기록한다. 고장 후 복구할 때 이 로그로 일관성 있는 상태로 되돌린다. + +동시성 문제도 있다. 다중 스레드가 동시에 B 트리에 접근하면 일관성이 깨질 수 있다. -> 보통 **래치(latch)**(가벼운 잠금(lock))로 트리의 데이터 구조를 보호한다. + +--- + +**B 트리 최적화** + +B 트리가 오래된 만큼 최적화 기법도 많이 쌓여있다. 주요한 것들: + +- **WAL 대신 Copy-on-write 방식**: 변경된 페이지를 다른 위치에 기록하고 상위 페이지의 새 버전을 만든다. 동시성 제어에도 유용. +- **키 축약 저장**: 페이지 전체 키 대신 키를 압축해서 공간 절약. 특히 트리 내부 페이지에서 키는 키 범위 경계 역할만 하면 되니까 더 많은 키를 채울수록 분기 계수가 높아진다 -> 트리 깊이 낮아짐. +- **리프(leaf) 페이지 연속 배치**: 쿼리가 정렬된 순서로 키 범위를 스캔할 때 디스크에서 연속된 순서로 읽어야 효율적이다. 근데 트리가 커지면 순서 유지가 어려워진다. (반면 LSM 트리는 병합 과정에서 큰 세그먼트를 한 번에 다시 쓰니까 연속된 키를 가깝게 유지하기가 더 쉽다.) +- **리프 페이지에 포인터 추가**: 각 리프 페이지가 양쪽 형제 페이지에 대한 참조를 가지면 상위 페이지로 다시 이동하지 않고도 순서대로 키를 스캔 가능. +- **프랙탈 트리(fractal tree)**: 디스크 찾기를 줄이기 위해 로그 구조화 개념을 일부 빌려온 B 트리 변형. + +--- + +**B 트리 vs LSM 트리** + +둘을 비교하면: + +- **B 트리**: 읽기가 더 빠름. 구현 성숙도가 더 높음. +- **LSM 트리**: 쓰기가 더 빠름. 각 컴팩션 단계에서 여러 데이터 구조와 SS테이블을 확인해야 해서 읽기가 보통 더 느리다. + +-> 경험적으로 **LSM 트리는 쓰기**, **B 트리는 읽기**에 유리하다고 알려져 있다. 그렇기 때문에 워크로드 특성에 따라 어떤 구조를 쓸지 선택해야 한다. + + +**LSM 트리의 장점** + +B 트리는 모든 데이터를 최소 두 번 기록해야 한다. 쓰기 전 로그(WAL) 한 번 + 트리 페이지 한 번. 심지어 몇 바이트만 바뀌어도 전체 페이지를 통째로 기록해야 한다. -> 이런 오버헤드를 **쓰기 증폭(write amplification)**이라고 한다. + +LSM 트리도 SS테이블 반복 컴팩션/병합으로 데이터를 여러 번 다시 쓰긴 하지만, 그래도 B 트리보다 쓰기 증폭이 더 낮고 여러 페이지를 덮어쓰는 게 아니라 순차적으로 컴팩션된 SS테이블 파일을 쓰기 때문에 훨씬 효율적이다. 자기 하드드라이브에서는 특히 이 차이가 크다. (순차 쓰기가 임의 쓰기보다 훨씬 빠르니까) + +추가로 LSM 트리는 압축률도 좋아서 B 트리보다 디스크에 더 적은 파일을 생성한다. B 트리는 파편화로 인해 사용하지 않는 디스크 공간이 남는 반면, LSM 트리는 주기적으로 SS테이블을 다시 기록해서 파편화를 없애기 때문에 저장소 오버헤드가 더 낮다. + +대부분의 SSD도 내부적으로 로그 구조화 알고리즘을 사용해서 임의 쓰기를 순차 쓰기로 전환한다. 그래서 낮은 쓰기 증폭과 파편화 감소는 SSD에서 훨씬 유리하다. + +--- + +**LSM 트리의 단점** + +단점도 있다. 컴팩션 과정이 때로는 진행 중인 읽기/쓰기 성능에 영향을 준다. 디스크 자원은 한계가 있어서 컴팩션 연산이 끝날 때까지 요청이 대기해야 하는 상황이 생기기 쉽다. -> 처리량과 평균 응답 시간에 미치는 영향은 대개 작지만, **응답 시간이 가끔 튀는 문제**가 있다. 반면 B 트리 성능은 LSM 트리보다 예측하기 쉽다. + +또 다른 문제는 높은 쓰기 처리량 상황에서 디스크 쓰기 대역폭을 초기 쓰기(로깅, 멤테이블 플러시)와 백그라운드 컴팩션이 같이 나눠 써야 한다는 거다. DB가 커질수록 컴팩션을 위해 더 많은 디스크 대역폭이 필요하다. + +쓰기 처리량이 높은데 컴팩션 설정을 제대로 안 해두면 컴팩션이 유입 쓰기 속도를 따라가지 못한다. -> 병합되지 않은 세그먼트 수가 계속 증가하고, 읽기도 더 느려진다. 보통 SS테이블 기반 저장소 엔진은 이걸 자동으로 조절하지 않아서 **명시적 모니터링이 필요하다.** + +--- + +**그래서 뭘 써야 하나** + +B 트리의 장점은 각 키가 색인의 딱 한 곳에만 존재한다는 거다. 반면 로그 구조화 저장소 엔진은 다른 세그먼트에 같은 키의 복사본이 여러 개 존재할 수 있다. -> 이 때문에 **강력한 트랜잭션 시맨틱(transactional semantic)** 이 필요한 DB에는 B 트리가 훨씬 매력적이다. 트랜잭션 격리(transactional isolation)를 키 범위의 잠금으로 구현하기 쉽고, B 트리 색인에서는 트리에 직접 잠금을 걸 수 있으니까. + + +결론적으로 B 트리는 DB 아키텍처에 아주 깊게 뿌리내려서 사라질 가능성은 거의 없고, 새로운 데이터 저장소에서는 로그 구조화 색인이 점점 인기를 얻고 있다. 어떤 저장소 엔진이 맞는지에 대한 빠르고 쉬운 규칙은 없으니까 + +> [! 그래서 뭐가 있는데 ] +> **1. 레벨DB (LevelDB) & 록스DB (RocksDB)** +> - **특징:** 구글이 만든 LevelDB, 그리고 이를 페이스북(메타)이 가져다 더욱 강력하게 개조한 RocksDB입니다. 이 둘은 그 자체로 거대한 DB 서버라기보다는, 다른 프로그램에 부품처럼 쏙 들어가서 데이터를 저장해 주는 아주 빠르고 가벼운 '저장소 엔진(Library)'입니다. +> - **위상:** LSM 트리의 교과서이자 **현재 IT 업계의 표준**입니다. 수많은 최신 데이터베이스들이 바닥부터 저장 구조를 새로 짜는 대신, 그냥 내부 엔진으로 이 RocksDB를 가져다 조립해서 씁니다. +> +>**2. 아파치 카산드라 (Apache Cassandra) & HBase** +>- **특징:** 넷플릭스, 애플 같은 거대 기업들이 전 세계의 엄청난 데이터를 여러 대의 서버에 분산 저장할 때 쓰는 대표적인 NoSQL 데이터베이스입니다. (구글의 Bigtable 논문에서 영감을 받았습니다.) +>- **채택 이유:** 초당 수십만, 수백만 건의 데이터 저장 요청이 쏟아져도 서버가 뻗지 않고 받아내야 합니다. 디스크 헤드를 이리저리 움직이며 덮어쓰지 않고, 멤테이블에 모았다가 디스크에 순차적으로 촥 부어버리는 LSM 구조가 아니면 이 속도를 감당할 수가 없습니다. +> +> **3. 엘라스틱서치 (Elasticsearch / Lucene 엔진)** +> - **특징:** 쇼핑몰이나 웹사이트 검색창에 단어를 칠 때 결과를 찾아주는 가장 대중적인 전문 검색(Full-text search) 엔진입니다. +> - **채택 이유:** 엘라스틱서치의 심장인 '루씬(Lucene)' 엔진 역시 LSM 트리와 매우 유사한 원리를 사용합니다. 새로운 검색어 데이터를 불변의 세그먼트로 계속 추가하고, 백그라운드에서 끊임없이 이 세그먼트들을 병합(Compaction)하면서 검색 속도와 용량을 최적화합니다. +> +> **4. 인플럭스DB (InfluxDB) 같은 시계열 데이터베이스** +> - **특징:** 주식 거래 틱 데이터, 서버 CPU 온도 모니터링, 스마트워치 심박수 등 '시간의 흐름에 따라 끊임없이 발생하여 쌓이는 데이터'를 전문적으로 저장합니다. +> - **채택 이유:** 과거의 기록을 중간에 수정(Update)할 일은 거의 없고, 그저 새로운 시간의 데이터를 미친 듯이 끝에 계속 추가(Append)만 하는 특성이 있기 때문에 LSM 구조와 그야말로 찰떡궁합입니다. + + +## 기타 색인 구조 + +지금까지는 키-값 색인만 봤는데, 실제 DB는 더 다양한 색인 구조를 쓴다. + +키-값 색인의 대표 예시는 관계형 모델의 **기본키(primary key)** 색인이다. 관계형 테이블에서 하나의 로우를, 문서 DB에서 하나의 문서를, 그래프 DB에서 하나의 정점을 고유하게 식별하는 것. 다른 레코드들은 이 기본키를 통해 참조를 따라간다. + +**보조 색인(secondary index)** 도 매우 일반적이다. 관계형 DB에서 `CREATE INDEX` 명령어로 같은 테이블에 보조 색인을 여러 개 생성할 수 있다. 보조 색인이 기본키 색인과 다른 점은 키가 고유하지 않다는 거다. 즉 같은 키를 가진 로우가 여러 개 존재할 수 있다. -> 이걸 해결하는 방법은 두 가지다. 각 값에 일치하는 로우 식별자 목록을 만들거나, 로우 식별자를 추가해서 키를 고유하게 만들거나. B 트리와 LSM 트리 둘 다 보조 색인으로 사용 가능하다. + +--- + +### 색인 안에 값 저장하기 + +색인에서 키는 검색 대상이고, 값은 두 가지 중 하나다. + +1. 실제 로우 데이터 그 자체 +2. 다른 곳에 저장된 로우를 가리키는 참조 + +2번 방식에서 로우가 실제로 저장된 곳을 **힙 파일(heap file)** 이라고 한다. 특정 순서 없이 데이터를 저장하는 곳이다. 힙 파일 접근 방식은 여러 보조 색인이 존재할 때 데이터 중복을 피할 수 있어서 일반적으로 쓰인다. + +힙 파일 방식은 키를 변경하지 않고 값만 갱신할 때 효율적이다. 새 값이 기존 공간보다 크지 않으면 제자리에 덮어쓸 수 있기 때문이다. -> 근데 새 값이 더 많은 공간을 필요로 한다면? 힙에서 충분한 공간이 있는 새 위치로 이동해야 하고, 이 경우 모든 색인이 새로운 힙 위치를 가리키도록 갱신하거나 이전 힙 위치에 전방향 포인터를 남겨야 한다. + +-> 이 힙 파일 왔다갔다가 읽기 성능에 불이익을 주기 때문에 어떤 상황에서는 색인 안에 직접 로우를 저장하는 게 낫다. 이를 **클러스터드 색인(clustered index)** 이라고 한다. 예를 들어 MySQL InnoDB는 테이블의 기본키가 언제나 클러스터드 색인이고 보조 색인은 기본키를 참조한다. + +클러스터드 색인(색인 안에 모든 로우 저장)과 비클러스터드 색인(참조만 저장) 사이의 절충안이 바로 **커버링 색인(covering index)** 또는 **포괄열이 있는 색인(index with included column)** 이다. 색인 안에 테이블의 칼럼 일부를 저장해두는 것. -> 이렇게 하면 색인만으로 일부 질의에 응답이 가능하다. (이런 경우를 "색인이 질의를 커버했다"고 말한다) + +근데 당연히 트레이드오프가 있다. 클러스터드 색인과 커버링 색인은 읽기 성능은 높이지만 추가적인 저장소가 필요하고 쓰기 과정에 오버헤드가 발생한다. 또한 복제로 인한 불일치 문제 때문에 트랜잭션 보장을 강화하기 위한 별도 노력이 필요하다. + +--- + +### 다중 칼럼 색인 + +지금까지 설명한 색인은 하나의 키만 값에 대응한다. -> 테이블의 다중 칼럼에 동시에 질의해야 한다면 충분하지 않다. + +다중 칼럼 색인의 가장 일반적인 유형은 **결합 색인(concatenated index)** 이다. 하나의 칼럼에 다른 칼럼을 붙이는 방식으로 하나의 키에 여러 필드를 단순히 결합한다. 필드가 연결되는 순서는 색인 정의에 명시한다. + +예를 들어 `(성, 이름)`을 키로 전화번호를 값으로 하는 색인을 보면 이 방식의 특성이 명확해진다. + +- 특정 성을 가진 모든 사람 찾기 -> 가능 +- 특정 성+이름 조합을 가진 사람 찾기 -> 가능 +- 특정 이름을 가진 모든 사람 찾기 -> 쓸모없음 + +순서가 정렬돼 있어서 앞 칼럼 기준 탐색은 되지만, 앞 칼럼을 건너뛴 탐색은 안 된다. + +**다차원 색인**은 한 번에 여러 칼럼에 질의하는 더 일반적인 방법이다. 특히 지리 공간 데이터에 중요하게 쓰인다. 예를 들어 레스토랑 검색 웹 사이트에 위도와 경도를 포함한 DB가 있다고 가정하자. 사용자가 지도에서 레스토랑을 찾는다면 현재 보는 네모난 지도 지역 내 모든 레스토랑을 찾아야 한다. -> 이를 위해 이차원 범위 질의가 필요하다. + +```sql +SELECT * FROM restaurants +WHERE latitude BETWEEN 51.4946 AND 51.5079 +AND longitude BETWEEN -0.1162 AND -0.0974; +``` + +표준 B 트리나 LSM 트리 색인으로는 이 질의를 효율적으로 처리할 수 없다. 위도 범위 내의 레스토랑은 찾아도 경도 조건을 추가로 필터링하기가 어렵기 때문이다. -> 이걸 해결하기 위한 방법이 공간 색인(R 트리 등)이고 이건 다음에 다룰 내용이다. + +### 다차원 색인 + +표준 B 트리나 LSM 트리 색인으로는 위도 범위 안의 모든 레스토랑(모든 경도에서)이나 경도 범위 안의 모든 레스토랑은 줄 수 있지만 둘을 동시에 주진 못한다. + +-> 한 가지 방법은 이차원 위치를 공간 채움 곡선(space-filling curve)을 이용해 단일 숫자로 변환한 다음 일반 B 트리 색인을 사용하는 것이다. 더 일반적인 방법은 **R 트리**처럼 전문 공간 색인(specialized spatial index)을 사용하는 것이다. 예를 들어 PostGIS는 R 트리 같은 지리 공간 색인을 구현했다. + +흥미로운 점은 다차원 색인의 활용이 지리적 위치에만 국한되지 않는다는 거다. + +- 전자상거래 웹 사이트: 특정 색상 범위의 제품 검색 -> `(빨강, 초록, 파랑)` 3차원 색인 +- 날씨 관측 DB: 2013년에 기온이 25도에서 30도 사이인 모든 관측 -> `(날짜, 기온)` 2차원 색인 + +1차원 색인을 쓰면 기온과 상관없이 2013년 모든 레코드를 스캔한 다음 기온으로 필터링하거나, 반대로 기온을 스캔하고 연도로 필터링해야 한다. -> 2차원 색인은 타임스탬프와 기온으로 동시에 범위를 줄일 수 있다. 하이퍼텍스(HyperDex)가 이 기법을 사용한다. + +--- + +### 전문 검색과 퍼지 색인 + +지금까지 설명한 모든 색인은 정확한 데이터를 대상으로 했다. 키의 정확한 값이나 정렬된 키 값의 범위를 질의할 수 있다고 가정한 것. -> 근데 철자가 틀린 단어와 같이 **유사한** 키에 대해서는 검색할 수 없다. 이처럼 **애매모호한(fuzzy)** 질의에는 다른 기술이 필요하다. + +전문 검색 엔진은 일반적으로 특정 단어를 검색할 때 해당 단어의 동의어로 질의를 확장하고, 단어의 문법적 활용을 무시하고, 동일한 문서에서 서로 인접해 나타난 단어를 검색하거나 언어학적으로 텍스트를 분석하는 등 다양한 기능을 제공한다. + +루씬은 특정 편집 거리(edit distance) 내 단어를 검색할 수 있다. (편집 거리 1은 한 글자가 추가/삭제/교체됐음을 의미) + +-> 루씬이 용어 사전을 위해 SSTable과 유사한 구조를 쓰는데, 이 인메모리 색인은 키를 찾는 데 필요한 정렬 파일의 오프셋을 질의에 알려주는 데 사용된다. 레벨DB에서 이 인메모리 색인은 일부 키의 희소 컬렉션이다. 하지만 루씬에서 인메모리 색인은 여러 키 내 문자에 대한 유한 상태 오토마톤(finite state automaton)으로 **트라이(trie)** 와 유사하다. -> 이 오토마톤은 **레벤슈타인 오토마톤(levenshtein automaton)** 으로 변환할 수 있는데, 특정 편집 거리 내에서 효율적인 단어 검색을 제공한다. + +그 밖의 퍼지 검색 기술은 문서 분류와 머신러닝 방향으로 진행되고 있다. + +--- + +### 모든 것을 메모리에 보관 + +이번 장에서 지금까지 설명한 데이터 구조는 모두 디스크 한계에 대한 해결책이었다. 디스크는 메인 메모리와 비교해 다루기 어렵다. -> 디스크에는 주요한 두 가지 장점이 있기 때문에 어쩔 수 없이 써왔다. + +1. **지속성:** 전원이 꺼져도 내용이 손실되지 않는다. +2. **가격:** 기가바이트당 가격이 더 저렴하다. + +근데 램이 점점 저렴해지면서 기가바이트당 가격 논쟁은 약해졌다. 데이터셋 대부분은 그다지 크지 않기 때문에 메모리에 전체를 보관하는 방식도 꽤 현실적이다. 혹은 여러 장비 간 분산해서 보관할 수도 있다. -> 이런 이유로 **인메모리 데이터베이스**가 개발됐다. + +맴캐시드 같은 일부 인메모리 키-값 저장소는 장비가 재시작되면 데이터 손실을 허용하는 캐시 용도로만 쓰인다. 하지만 다른 인메모리 데이터베이스는 지속성을 목표로 한다. -> 이 목표를 달성하는 방법은: + +- 배터리 전원 공급 RAM 같은 특수 하드웨어 사용 +- 디스크에 변경 사항의 로그를 기록 +- 디스크에 주기적인 스냅샷을 기록 +- 다른 장비에 인메모리 상태를 복제 + +인메모리 데이터베이스가 재시작되는 경우 특수 하드웨어를 사용하지 않는다면 디스크나 네트워크를 통해 복제본에서 상태를 다시 적재해야 한다. -> 디스크에 기록하더라도 여전히 인메모리 데이터베이스다. 왜냐하면 디스크는 전적으로 지속성을 위한 추가 전용 로그로 사용되고 읽기는 전적으로 메모리에서 제공되기 때문이다. + + +### 모든 것을 메모리에 보관 + +볼트DB(VoltDB), 맴SQL(MemSQL), 오라클 타임즈텐(Oracle TimesTen) 같은 제품은 관계형 모델의 인메모리 데이터베이스다. 이런 제품 벤더는 디스크 상 데이터 구조 관리와 관련된 오버헤드를 모두 없앴기 때문에 성능을 크게 개선했다고 주장한다. 램클라우드(RAMCloud)는 지속성 있는 오픈소스 인메모리 키-값 저장소로 메모리 데이터뿐 아니라 디스크 데이터도 로그 구조화 접근 방식을 사용한다. 레디스(Redis)와 카우치베이스(Couchbase)는 비동기로 디스크에 기록하기 때문에 약한 지속성을 제공한다. + +근데 여기서 직관에 어긋나는 포인트가 있다. 인메모리 데이터베이스의 성능 장점이 "디스크에서 읽지 않아도 된다"는 사실 때문이 아니라는 거다. -> 심지어 디스크 기반 저장소 엔진도 운영체제가 최근에 사용한 디스크 블록을 메모리에 캐시하기 때문에 충분한 메모리를 가진 경우에는 디스크에서 읽을 필요가 없다. 오히려 인메모리 데이터 구조를 디스크에 기록하기 위한 형태로 부호화하는 오버헤드를 피할 수 있어서 더 빠른 것이다. + +성능 외에도 인메모리 데이터베이스가 재미있는 이유가 하나 더 있다. **디스크 기반 색인으로 구현하기 어려운 데이터 모델을 제공한다는 것이다.** 예를 들어 레디스는 우선순위 큐와 셋(set) 같은 다양한 데이터 구조를 데이터베이스 같은 인터페이스로 제공한다. 메모리에 모든 데이터를 유지하기 때문에 구현이 비교적 간단하다. + +--- + +### 인메모리 DB의 확장 가능성 + +최근 연구에 따르면 인메모리 데이터베이스 아키텍처가 디스크 중심 아키텍처에서 발생하는 오버헤드 없이 가용한 메모리보다 더 큰 데이터셋을 지원하게끔 확장할 수 있다. 소위 **안티 캐싱(anti-caching)** 접근 방식이 바로 그것이다. + +-> 안티 캐싱은 메모리가 충분하지 않을 때 가장 최근에 사용하지 않은 데이터를 메모리에서 디스크로 내보내고, 나중에 다시 접근할 때 메모리에 적재하는 방식이다. 운영체제가 가상 메모리와 스왑 파일에서 수행하는 방식과 유사하지만 차이점이 있다. 데이터베이스는 전체 메모리 페이지보다 **개별 레코드 단위**로 작업할 수 있기 때문에 OS보다 더 효율적으로 메모리를 관리할 수 있다. -> 단, 이 접근 방식도 여전히 전체 색인이 메모리에 있어야 한다는 전제 조건은 그대로다. + +나아가 **비휘발성 메모리(non-volatile memory, NVM)** 기술이 더 널리 채택되면 저장소 엔진 설계의 변경이 필요할 것이다. 현재 비휘발성 메모리 기술은 새로운 연구 분야지만 앞으로 계속 주목할 가치가 있다. \ No newline at end of file