https://medium.com/@hnasr/mongodb-internal-architecture-9a32f1403d6f 를 읽고 번역 및 추가적으로 정리해본 글입니다.
MongoDB는 RDBMS(Relational Database)와는 다른 document(이하 도큐먼트) 기반의 Non-Relational Database이다. 도큐먼트는 JSON 포맷을 따르고, Mongo는 내부적으로 이 JSON 도큐먼트를 저장 속도와 효율을 위해 BSON(Binary JSON) 형태로 변환해서 저장한다. 그리고 Mongo는 다시 이 도큐먼트를 꺼내올 때 BSON을 JSON으로 변환해서 가져온다.
MongoDB 문서에서 BSON을 설명을 보면 다음과 같다.
BSON은 “Binary JSON”을 의미한다. BSON은 타입과 길이 정보를 인코딩하기 때문에 JSON에 비해서 훨씬 빠르게 탐색할 수 있다. 그리고 BSON은 MongoDB가 중요한 정보를 잃지 않도록 날짜나 이진 데이터같은 몇 가지의 JSON 타입이 아닌 데이터도 추가한다.
{"hello": "world"} → \x16\x00\x00\x00 // total document size \x02 // 0x02 = type String hello\x00 // field name \x06\x00\x00\x00world\x00 // field value \x00 // 0x00 = type EOO ('end of object')
도큐먼트는 사이즈가 클 수 있기 때문에, Mongo는 가끔 사이즈를 줄이기 위해서 도큐먼트를 압축할 수도 있다 (이후에 설명할 WireTiger
엔진은 항상 자동으로 압축해서 저장한다. 또는 사용자가 수동으로 document를 압축할 수도 있다 - compact
명령어).
Collections(이하 컬렉션)는 RDBMS의 테이블과 같은 개념이며, 여러개의 도큐먼트를 가질 수 있다. 다만 MongoDB는 RDBMS처럼 스키마를 정의하지는 않는 데이터베이스이기 때문에, 하나의 컬렉션안에 여러 스키마의 도큐먼트가 존재하는 것도 가능하다. 스키마에 엄격하지 않은 이런 점 때문에 주로 빠르게 개발할 때 많이 쓰인다.
_id
Index (Primary Index)각 도큐먼트는 고유한 _id
필드를 가지며, 이게 기본 키의 역할을 한다. _id
필드는 항상 도큐먼트의 첫번째 필드여야하기 때문에 만약 _id
필드가 첫번째가 아니라면 DB 서버가 알아서 맨처음으로 옮겨놓는다. 그리고 MongoDB는 _id
값이 없는 도큐먼트가 들어오면 ObjectId
값으로 _id
필드를 자동으로 만들어준다.
기본키인_id
타입은 12바이트인 ObjectId
인데, 12바이트로 이렇게 큰 이유는 Mongo는 확장성이 중요한 시스템에 많이 사용하기 때문에 여러 샤드에서도 고유한 값을 가질 수 있어야 해서다. ObjectId
에 대한 자세한 설명은 공식 문서를 참조하자.
그리고 MongoDB에서 컬렉션을 생성하면 도큐먼트의 기본키인 _id
로 B+Tree 주 인덱스를 생성한다.
컬렉션의 다른 필드로 보조 B+Tree 인덱스를 생성할 수도 있다. 보조 인덱스의 크기는 2가지 요인에 의해 결정되는데, 바로 1) 인덱싱 될 필드의 key 사이즈와, 2) 도큐먼트 pointer 사이즈에 의해 결정된다.
MMAPv1
MongoDB가 처음 릴리즈되었을 때, MMAPv1
이라는 스토리지엔진을 사용했다. 이 스토리지 엔진에선 BSON 도큐먼트는 압축되지 않은 상태 그대로 디스크에 저장되었고, _id
기본키 인덱스는 Diskloc이라는 특수한 값을 가리켰는데, 이 Diskloc은 디스크내에서 도큐먼트가 존재하는 (파일 번호, 파일 offset) 쌍을 나타냈다.
그래서 _id
로 도큐먼트를 가져오려고 하면, 인덱스 리프 페이지에서 Diskloc 값을 찾아서 이 값으로 파일을 찾고 Offset만큼 디스크에서 파일을 바로 읽었다.
이 방식에는 한계점이 있었는데, Diskloc 방식은 O(1)만에 도큐먼트를 찾을 수는 있었지만, 도큐먼트가 새로 추가되거나 수정되면 인덱스를 유지하기가 힘들었다. 왜냐하면 어떤 도큐먼트를 수정했을 때, 사이즈가 커진다면 offset 도 변경되어야 하는데, 이 변경된 값에 맞춰서 다른 Diskloc offset들도 업데이트되어야 했기 때문이다.
또 추가적인 한계점은 MMAPv1
은 쓰기 연산에 글로벌 락을 사용해서, 동시에 쓰기 요청이 들어오면 현저하게 느렸다. 이후 Mongo는 MMAPv1
이 글로벌 쓰기 락이 아니라 컬렉션 단위 락(테이블 단위 락)으로 변경했지만, 나중엔 아예 WiredTiger
을 기본 엔진으로 변경하면서 더 이상 MMAPv1
을 사용하지 않는다.
WireTiger
MongoDB는 2014년 부터 WireTiger
를 기본 스토리지엔진으로 선정했다. WireTiger
는 도큐먼트 단위 락이나 압축 같은 여러가지 특징을 가지고 있었기 때문에 MMAPv1
엔진에서는 하지 못했던, 같은 컬렉션 내부의 다른 도큐먼트에 동시에 쓰기 같은 것들도 할 수 있었다.
WireTiger
에서는 BSON 도큐먼트는 압축된 후, 리프 노드가 (recordId, BSON) 쌍인 hidden clustered 인덱스에 저장되었다. 이로 인해 더 많은 BSON 도큐먼트를 더 적은 I/O로 가져올 수 있었고, 전체적인 성능이 향상되었다.
이제 주 인덱스인 _id
와 보조 인덱스들은 Diskloc
대신에 새로운 값인 recordId(64bit)
를 가리키도록 변경되었다. 하지만 이 방식에도 단점이 있는데, 유저가 도큐먼트의 _id
값으로 조회할 때, 이전과는 다르게 BSON 도큐먼트를 찾기 위해 lookup을 한 번 더 해야 한다는 것을 의미한다(이전엔 인덱스에서 _id
로 바로 실제 도큐먼트를 조회했다면, 이제는 _id
로 recordId를 찾은 후, hidden 인덱스에서 recordId
로 다시 BSON 도큐먼트를 찾아야 하는 것이다)
이건 모든 인덱스에 다 적용되는 문제였기 때문에(주 인덱스, 보조 인덱스 할 것 없이 전부 다 해당 인덱스로 recordId
를 찾기 위한 추가 인덱스가 필요하다는 의미) 결국 Discord에서는 인덱스가 RAM에 더 이상 올라가지 않아서 Cassandra로 옮기게 되었다고 한다.
Clustered Collections 은 2022년 6월에 MongoDB에서 새로 도입된 기능이다(MongoDB 5.3부터 적용). Clustered Index 는 조회 시 필요한 값이 리프 페이지에 바로 저장되어 있는 인덱스를 말한다('Index-only Scans'이라고도 한다)
Clustered Index는 주 인덱스인 _id
에 한해서만 적용되었는데, 이 덕분에 이제 _id
로 도큐먼트를 조회할 때 추가적인 lookup이 필요하지 않고 바로 BSON 도큐먼트를 반환할 수 있게 되었다.
다만 보조 인덱스는 Clustered Index를 적용하진 않고, 대신에 더 이상 쓰지 않는 recordId
가 아니라 _id
를 가리키게끔 바뀌었다.
여기서 모든 보조 인덱스가 12bit인 recordId
대신에 12byte(_id
크기)를 저장하게 되었기 때문에 인덱스가 기존에 비해 훨씬 커질 수 있게 되었단 점을 주의해야 한다.
여기까지 살펴보면 보조 인덱스가 primary key를 가리키는 InnoDB구조와 비슷하지만 MySQL은 테이블들이 무조건 Clustered Index를 가지는 반면에(직접 정의하지 않으면, primary key로 InnoDB가 알아서 생성한다), MongoDB는 컬렉션에 Clustered Index를 적용할지 하지 않을 지 선택할 수 있다는 점에 차이가 있다.