데이터베이스와 컬렉션
MongoDB 서버에서도 다른 RDBMS 와 동일하게 하나의 인스턴스에 여러 개의 데이터베이스를 가질 수 있다.
각 데이터베이스는 다시 여러 개의 테이블을 가질 수 있다.
MongoDB 에서는 NoSQL 을 지향하기에 테이블이라는 이름보다는 컬렉션이라는 이름을 공식적으로 사용한다.
네임스페이스
MongoDB 에서는 데이터베이스 이름과 컬렉션의 이름 조합을 네임스페이스라고 한다.
네임스페이스에서 데이터베이스와 컬렉션 이름 사이는 반드시 . 으로 구분해야 한다.
인덱스의 네임스페이스는 컬렉션의 네임스페이스에 추가로 인덱스의 이름을 붙인 형태이다.
요약하면 '데이터베이스.컬렉션.$인덱스' 와 같이 부여된다.
네임스페이스가 중요한 이유는 MongoDB 내부적으로 데이터베이스 이름이나 컬렉션 이름이 단독으로 사용되지 않고 항상 네임스페이스로 각 객체가 관리 참조되기 때문이다.
MMAPv1 스토리지 엔진에서는 데이터베이스별로 네임스페이스 목록을 저장하는 네임스페이스 파일(*.ns)이 생성되고 이 파일의 최대 크기는 2GB 이다.
WiredTiger 스토리지 엔진에서는 별도의 네임스페이스 파일을 사용하지 않기 때문에 WiredTiger 스토리지 엔진을 사용하는 MongoDB 에서는 MMAPv1 스토리지 엔진과 달리 최대로 생성할 수 있는 컬렉션이나 인덱스의 개수에 제약이 없다.
데이터베이스
데이터베이스는 주로 서비스나 데이터의 그룹을 만들기 위해 사용하는 물리적인 개념이다.
MongoDB 의 데이터베이스는 동시 처리 성능과 연관된다. 다만 2.8 버전 이상에서는 동시처리가 도입되어 데이터베이스를 억지로 분리할 필요가 없다.
아래는 데이터베이스를 짧은 혹은 긴 시간동안 잠금하는 명령어이기 때문에 주의해야 한다.
장시간 | db.collection.createIndex() db.runCommand({ reIndex: "collection" }) db.runCommand({ compact: "collection" }) db.createCollection() -> 대용량의 Capped Collection 생성 시 db.collection.validate() db.repairDatabase() db.copyDatabase() |
단시간 | db.collection.dropIndex() db.getLastError() db.isMaster() rs.status() db.serverStatus() db.auth() db.addUser() |
데이터베이스를 너무 잘게 분리하면 관리의 편의성을 떨어뜨리고, 분리된 데이터베이스 때문에 컬렉션의 개수가 늘어난다면 MongoDB 컨피그 서버가 관리해야 할 메타 정보가 그만큼 늘어나게 된다.
컬렉션
RDBMS 에서 주로 테이블이라 부르는 객체를 MongoDB 는 컬렉션이라 표현한다.
MongoDB 매뉴얼에서 조인을 지원하지 않기 때문에 컬렉션은 가능하면 많은 데이터를 내장할 것을 권장하고 있다.
하지만 이는 모델링적인 측면에서 맞는 이야기일지 모르나 성능적인 측면에서는 그렇지 않을 수 있다.
대표적으로 많은 데이터를 하나의 도큐먼트에 내장할수록 도큐먼트 하나하나의 크기가 커지고, 그로 인해 더 많은 디스크 읽기 오퍼레이션이 필요하며, 같은 쿼리를 위해 더 많은 데이터를 읽었기 때문에 메모리의 캐시 효율이 떨어진다.
더군다나 일반적으로 컬렉션에서 읽은 데이터를 한 번에 가져가는 형태의 프로그램은 네트워크 사용량도 많이 증가한다.
MongoDB 는 내부적으로 이미 샤딩 기능을 가지고 있기 떄문에 컬렉션 단위의 샤딩은 별도로 크게 고려하지 않아도 된다.
하지만 정기적으로 대량의 데이터를 삭제해야 하는 요건이 있다면, 삭제 단위로 컬렉션을 분리하는 방법이 좋은 선택이 된다.
또한 하나의 컬렉션에 저장되는 도큐먼트들의 액세스 패턴이 많이 다를 때에도 컬렉션을 물리적으로 분리하고 자주 읽히는 데이터 위주로 메모리 캐시를 활용하도록 유도하면 성능상 이점을 기대할 수 있다.
WiredTiger 스토리지 엔진을 사용하는 컬렉션에서 청크 이동이 발생하는 경우 원본 청크가 저장된 샤드에서는 도큐먼트 삭제 작업이 진행되는데, 이 때 엔진 특성상 데이터 파일의 용량이 오히려 증가할 수 있다. (왜?)
무엇보다 컬렉션의 설계에서 가장 중요한 것은 샤드 키의 선정이다.
샤드 키를 잘못 선정하면 데이터 분산 효과를 무효로 만들게 된다.
뷰
뷰는 복잡한 형태의 데이터 가공 로직을 캡슐화해서 사용자의 접근 용이성을 향상한다.
테이블의 일부 데이터에 대해서만 접근 권한을 허용하여 보안을 강화한다.
MongoDB 의 뷰는 일단 생성되면 show collections 명령으로 기존 컬렉션과 동일하게 목록을 확인할 수 있고 FIND 나 Aggregation 명령을 사용해서 데이터를 조회할 수 있다.
뷰를 초기 생성할때는 createView 명령을 사용하지만 한번 생성되면 컬렉션과 같이 취급하면 된다.
뷰를 삭제할 때도 컬렉션을 삭제할 때처럼 drop 명령을 사용하면 된다.
뷰에 대한 메타 정보는 system.views 컬렉션에 저장된다.
MongoDB 의 뷰에서 주의해야 할 사항이 있다.
- 뷰 쿼리는 항상 Aggregation 으로 처리되므로 FIND 에서 사용하는 조건에 일치하는 인덱스가 있어야 상대적으로 빠른 결과를 얻을 수 있다.
- 중첩된 뷰는 중첩된 Aggregation 쿼리가 실행되는 것과 동일하기 때문에 외부의 뷰 쿼리는 인덱스가 없는 상태의 컬렉션에 대해 쿼리를 실행하는 것과 같다.
- MongoDB 의 뷰는 구체화된 데이터를 별도의 저장소에 저장하지 않기 때문에 핫아 쿼리가 실행될 때마다 뷰를 생성할 때 사용했던 가공 작업이 실행된다.
뷰를 생성할 때 3가지 인자가 필요한데, 첫 번째 인자는 생성될 뷰의 이름, 두 번째 인자는 생성될 뷰가 참조할 컬렉션의 이름, 세 번째는 Aggregation 명령에서 사용하는 파이프라인을 명시하면 된다. 이 파이프라인으로 원본 컬렉션에서 조회할 도큐먼트를 선별하거나 도큐먼트들이 가져올 필드들을 가공하는 작업을 수행할 수 있다.
샤딩된 클러스터 환경의 MongoDB 서버에서 뷰의 샤딩 여부는 뷰가 참조하는 컬렉션이 샤딩된 컬렉션인지 아닌지에 의존적이다. 즉 뷰가 참조하는 컬렉션이 샤딩된 컬렉션이면 이 컬렉션을 기반으로 하는 뷰도 동일하게 샤딩된 뷰로 처리된다.
BSON 도큐먼트
Binary JSON 의 약자로 JSON 형태의 도큐먼트를 바이너리 포맷으로 인코딩한 도큐먼트를 의미한다.
BSON 은 JSON 과 비교했을 때 다음과 같은 장점이 있다.
유형 | 설명 |
Lightweight | BSON 은 각 필드의 값을 단순히 문자열만으로 저장하는 것이 아니라, 정수와 부동 소수점 날짜 등과 같이 이진데이터 타입을 이용해서 데이터를 저장한다. 따라서 저장공간을 절약하여 가볍고 효율적으로 처리할 수 있다. |
Traversable | BSON 도큐먼트의 각 필드는 항상 필드 값의 데이터 타입과 필드 값의 길이가 먼저 저장돼 있기 때문에 복잡한 파싱 처리 과정 없이 불필요한 필드는 건너뛰고 필요한 필드만 빠르게 찾아갈 수 있다. |
Efficient | BSON 은 기본 데이터 타입으로 C 언어의 원시타입을 사용하고 있기 때문에 어떤 개발 언어에서도 매우 빠르게 인코딩 및 디코딩 처리를 할 수 있다. |
BSON 은 아래 4종류의 기본 데이터 타입을 사용한다.
타입 | 설명 |
BYTE | 일반적인 문자열 데이터를 저장하기 위한 저장공간으로 BSON 에서는 주로 바이트의 배열로 사용한다. |
INT32 | 32비트 부호를 가지는 정수 |
INT64 | 64비트 부호를 가지는 정수 |
DOUBLE | 8바이트 부동 소수점 |
BSON 도큐먼트는 이 4가지 기본 타입을 이용해서 만들어지는 파생된 Boolean 이나 String , Timestamp 등의 데이터 타입을 사용할 수 있도록 지원한다. 그뿐만 아니라 BSON 도큐먼트는 배열이나 서브-도큐먼트와 같은 또 다른 도큐먼트를 중첩해서 가질 수 있다.
BSON 도큐먼트의 구성
- BSON 도큐먼트는 4바이트 정수로 시작하고, 항상 도큐먼트의 마지막은 0x00 으로 끝난다.
- BSON 도큐먼트의 시작 4바이트는 전체 도큐먼트의 크기를 저장하는데, 4바이트 정수이기 때문에 BSON 도큐먼트의 전체 크기에 대한 제한은 2^(32-1) 이다.
- 하지만 MongoDB 에서는 BSON 도큐먼트의 최대 크기를 16MB 로 제한하고 있다. 전체 크기를 제한한 이유는 너무 큰 도큐먼트로 인한 성능 저하를 막기 위해서이다.
- BSON 도큐먼트의 실제 데이터 영역은 각 엘리먼트 단위로 '데이터 타입' 과 '엘리먼트 이름', '값' 순서로 저장된다.
엘리먼트 이름은 NULL(0x00) 으로 끝나는 문자열이며 '값' 은 '데이터 타입' 에 따라서 저장되는 포맷이 조금씩 차이가 있다.
- BSON 도큐먼트는 반드시 필드를 하나씩 읽고 지나가야만 원하는 엘리먼트를 읽을 수 있으며, 또 하나는 EMBED_DOCUMENT(서브 도큐먼트, ARRAY) 는 제일 앞쪽에 길이가 저장되므로 불필요하면 한번에 건너뛸 수 있다.
제한사항
Mongo 셀이나 프로그램 언어를 이용해서 MongoDB 와 통신하는 경우 저장하고자 하는 도큐먼트 또는 검색 조건을 표현하는 도큐먼트는 JSON 으로 표시되는 경우가 많다. 하지만 드라이버 내부적으로 모두 JSON 을 BSON 으로 변환해서 MongoDB 서버와 통신한다.
그래서 MongoDB 에서 사용되는 모든 JSON 은 BSON 포맷과 호환되어야 한다.
대표적인 특성은 아래와 같다.
- 하나의 도큐먼트는 반드시 { 로 시작해서 } 로 끝난다.
- 도큐먼트의 모든 원소는 반드시 키와 값의 쌍으로 구성되어야 한다.
- 중첩된 도큐먼트의 깊이는 100레벨까지 지원한다.
- 도큐먼트의 전체 크기는 16MB 까지만 지원한다.
데이터 타입
MongoDB 는 최종적으로 데이터를 BSON 으로 인코딩해서 디스크에 저장하므로 결국 BSON 이 지원하는 데이터 타입만 사용할 수 있다.
자주 사용되는 데이터 타입에 대해 살펴본다.
데이터 타입 | 설명 |
ObjectId | 12바이트의 Binary Data 타입을 원시타입으로 사용하는 데이터 타입이다. _id 필드의 값으로 자주 사용된다. ObjectId 는 멀티쓰레드나 분산 시스템에서도 고유한 값을 보장해주도록 설계되었기 때문에 일반적으로 샤딩이 적용된 MongoDB 에서 유니크한 값을 생성하는 목적으로 활용한다. |
Integer & Double |
정수형 타입은 저장 공간의 크기에 따라 32비트와 64비트 정수로 나뉜다. MongoDB 는 부호없는 정수 타입을 별도로 지원하지 않는다. MongoDB 에서 지원하는 Numeric 타입으로 Double 과 32비트 64비트 정수가 있는데, 이들을 비교할 때 Double 타입으로 전환된 후에 비교된다. |
Decimal | Decimal 타입을 이용해 고정 소수점 데이터를 저장할 수 있다. |
String | MongoDB 의 문자열은 UTF-8 문자셋을 사용한다. 만약 응용 프로그램이 UTF-8 문자열을 사용하지 않는경우 MongoDB 드라이버가 이를 UTF-8 로 전환한 후 BSON 도큐먼트를 생성한다. |
Timestamp | 8바이트 저장공간으로, 처음 4바이트는 유닉스 타임스탬프이며 뒤 4바이트는 자동으로 증가하는 시퀀스 값을 저장한다. Timestamp 타입은 클라이언트에서 객체가 만들어지는 시점이 아니라 MongoDB 서버에서 저장되는 시점의 시각 정보를 저장한다. 즉 Timesatmp 는 하나의 서버에서 반드시 유일한 값을 보장하게 되는데, 사용되는 대표적인 곳은 oplog 의 ts 필드이다. |
Date | 내부적(BSON)으로 밀리초 단위의 유닉스타임스탬프 값을 64비트 정수로 저장한다. Date 타입은 부호를 가지는 정수인데, 양의정수는 유닉스 타임스탬프 시작 시점이후를 의미하며, 음의 정수는 유닉스 타임스탬프 시작 시점 이전의 시각을 의미한다. |
데이터 타입 비교
필드 값의 데이터 타입을 검색할 수 있는데 이 때 $type 연산자와 함께 데이터 타입의 별명이나 데이터 타입 아이디를 사용하면 된다.
필드 값의 비교 및 정렬
MongoDB 는 한 컬렉션에 있는 각 도큐먼트 필드가 서로 다른 데이터 타입의 값을 가질 수 있다.
그래서 다른 필드를 비교 정렬할 수 있는 기준이 필요하다.
이렇게 타입이 서로 다른 경우 정렬이 큰 의미를 가지지 않지만 세컨드리 인덱스를 지원하는 솔루션에서 아키텍처적으로 필요하다.
MongoDB 서버는 도큐먼트 필드의 데이터 타입이 다른 경우 다음의 데이터 타입 순서로 정렬을 수행한다.
1. MinKey
2. Null
3. Numbers
4. Symbol, String
5. Object
6. Array
7. BinData
8. ObjectId
9. Boolean
10. Date
11. Timestamp
이렇게 타입이 서로 다른 경우 서버는 단순히 필드 값의 타입만으로 정렬을 수행하게 된다.
문자셋과 콜레이션
콜레이션
문자열 비교에서 사용자가 원하는 언어에 의존적이 규칙을 적용할 수 있게 해준다.
MongoDB 에서 컬렉션이나 뷰 그리고 콜레이션을 지원하는 오퍼레이션 단위로 콜레이션 옵션을 명시해서 사용할 수 있다.
콜레이션을 명시하려면 MongoDB 의 지정된 콜레이션 도큐먼트의 포맷을 사용해야 한다.
포맷옵션 | 설명 |
locale (필수, 이하 선택) | 컬렉션이나 인덱스에서 사용할 로케일을 설정 |
strength | 문자열의 비교를 1~5중 어떤 강도로 할건지를 설정. 숫자 값이 낮을수록 느슨한 비교 |
caseLevel | 대소문자와 발음 기호의 비교를 포함할 건지 추가설정 strength 가 1~2일때 가능 |
caseFirst | 정렬에서 대문자와 소문자중 어떤 문자를 앞쪽으로 정렬할지 결정 |
numericOrdering | 숫자 값으로 구성된 문자열의 정렬 규칙을 숫자처럼 비교할 것인지 결정 |
alternate | 공백이나 구두점 문자를 비교 대상에 포함할 것인지 결정 |
maxVariable | 문장 부호만 비교 문자에서 제외할 것인지 공백 문자만 비교문자에서 제외할 것인지 결정 |
backwards | 악센트가 있는 문자의 정렬 규칙을 거꾸로 수행할 것인지 결정 |
normalization | 문자열 비교를 위해 정규화 과정을 거침 |
strength 값 | 설명 |
1 | 기본 문자만 비교 대상으로 포함 a 와 A 는 같음. a 와 à 는 같음 |
2 | 기본 문자와 발음 부호만 비교 대상으로 포함 a 와 A 는 같음. a 와 à 는 다름 |
3 | MongoDB 의 기본값으로 기본문자와 발음 부호 그리고 대소문자까지 비교 대상으로 포함 a 와 A 는 다름 |
4 | 발음 부호 등을 고려한 비교 수행 ab < a-b < aB |
5 | 유니코드의 코드 값을 이용한 비교 수행 |
MongoDB 3.4 이상부터 콜레이션을 컬렉션과 뷰 그리고 인덱스를 생성할때 설정 할 수 있으며 쿼리나 DML 에서도 콜레이션을 지정해 오퍼레이션을 수행할 수 있다.
한글 콜레이션
한글 콜레이션은 3개의 변형을 지원한다.
변형 | 설명 |
search | 범용적인 검색을 위해 지원하는 콜레이션 |
searchji | 한글의 자음 순서를 우선해서 정렬하는 콜레이션 |
unihan | 한중일 언어의 유니코드 통합 콜레이션으로 한자의 획순을 기준으로 정렬을 수행 |
컬렉션의 콜레이션을 별도로 지정하지 않으면 UTF8 문자셋의 인코딩에 기반해 자동으로 정렬이 수행된다.
그런데 이 기본 정렬 방식에서는 한글보다 항상 영문이 우선순위를 가진다.
영문보다 한글을 먼저 정렬하고자 할 때는 다음과 같이 컬렉션을 생성할 때 한글 콜레이션을 사용하면 된다.
MongoDB 확장 JSON (Extended Json)
MongoDB 에서는 JSON 도큐먼트를 STRICT 모드와 Mongo 셸 모드 두 종류의 모드로 구분해서 사용할 수 있다.
STRICT 모드는 MongoDB 도구 뿐 아니라 외부믜 모든 JSON 도구들이 JSON 도큐먼트를 파싱할 수 있다.
하지만 여전히 JSON 이 지원하지 않는 Binary 나 ObjectId 와 같은 데이터 타입은 인식하지 못한다.
그리고 MongoDB 셸 모드는 Mongo 셸이나 mongoimport 와 같은 MongoDB 의 도구들만 인식할 수 있는 포맷이다.
STRICT 모드 | Mongo 셸 모드 | |
바이너리 타입 | { "$binary} : "<bindata>" , "$type" : "<t>" } | BinData ( <t> , <bindata> ) |
날짜 타입 | { "$date" : "<date>" } | new Date ( <date> ) ISODate ( <date> ) |
타임스탬프 타입 | { "$timestamp" : { "t" : <t>, "i" : <i> } } | Timestamp ( <t> , <i> ) |
바이너리 타입의 bindata 는 바이너리 값을 Base64 로 인코딩한 값이며
t 는 한 바이트의 데이터 타입을 의미한다.
mongoimport 와 mongoexport 도구 그리고 REST 인터페이슨는 STRICT 모드의 JSON 을 지워하며
bsondump 와 mongoimport 그리고 Mongo 셸은 Mongo 셸 모드를 지원한다.
mongo 셸 모드를 지원하지 않는 mongodump 나 mongoexport 에서 셸 모드의 JSON 표기법을 사용하면 에러가 발생한다.
모델링 고려 사항
일반적으로 MongoDB 는 주로 NoSQL 의 범주에 포함해서 언급되며 NoSQL 은 데이터모델링이 중요하지 않게 생각되곤 한다. 그러나 MongoDB 역시 RDBMS 만큼이나 모델링이 중요하다.
도큐먼트의 크기
일반적으로 MongoDB 에 저장되는 도큐먼트 데이터는 RDBMS 의 레코드보다 큰 경향이 있다.
많은 사용자가 도큐먼트 데이터베이스라는 생각으로 많은 정보를 하나의 도큐먼트에 모아서 저장하는 경향도 있기 때문이다.
실제 도큐먼트 하나하나의 크기가 커지면 체감이 될 정도의 문제를 유발하고 있을 가능성이 높다.
단일 도큐먼트 크기로 인해 발생할 수 있는 문제점을 간단한 예제로 살펴본다.
예를들어 게시물이라는 컬렉션에 대략 1억건 정도가 저장되어 있고, 게시물 한 건의 평균 크기가 1KB 정도라고 가정한다.
이런 게시판 서비스에서 사용자가 게시물을 조회할 때마다 조회수를 업데이트하는 기능을 만들려고 한다.
조회수를 저장하는 필드를 기존 게시물 컬렉션에 저장하는게 좋을지, 아니면 별도의 분리된 카운터 컬렉션을 생성하는 것이 좋을지 고민한다.
기존 게시물 컬렉션에 저장하면 아래와 같은 장단점이 있다. 별도 분리 컬렉션은 반대로 생각하면 된다.
- 게시물과 조회수를 하나의 쿼리로 동시에 가져올 수 있기 때문에 응용 프로그램의 코드가 간단해지고 그만큼 페이지 조회도 빨라진다.
- 일반적인 온라인 트랜잭션 서비스의 특성상 게시물을 작성하는 것보다 조회하는 경우가 훨씬 많다. 그래서 사용자들이 게시물을 조회할 때마다 게시물 테이블의 도큐먼트가 변경되고 저장되어야 한다. 그런데 카운터 필드 하나만 변경하면 되지만 카운터 필드가 게시물 컬렉션에 저장되어 있으므로 게시물 도큐먼트를 통째로 변경해야 한다.
조회수를 저장하는 컬렉션은 게시물 아이디(8byte)와 조회수(4byte)만 있다고 가정하여 12 byte 가 저장된다고 가정한다.
컬렉션 | 사이즈 |
게시물 컬렉션 | 1KB * 1억 = 95GB |
게시물 + 조회수 컬렉션 | ( 1KB + 4byte ) * 1억 = 95GB |
조회수 컬렉션 | ( 12Byte * 1억 ) + 1.5GB(프라이머리 인덱스) = 2.62GB |
책의 성능그래프를 보면 통합된 컬렉션이 더 많은 쿼리를 처리하는데,
이는 분리된 컬렉션은 조회를 2번해야 하나 통합된 컬렉션은 1번의 조회를 하면 되기 때문이다.
그러나 통합컬렉션은 주기적인 성능저하가 발생한다.
디스크 그래프를 보면 통합된 컬렉션에서는 주기적으로 엄청난 양의 디스크 쓰기가 발생한다.
MongoDB 의 WiredTiger 스토리지 엔진은 MySQL 이나 오라클과 같은 RDBMS 와는 달리 샤프 체크포인트 방식을 사용하는데, 체크포인트가 발생할 때마다 캐시의 변경된 페이지를 일괄적으로 디스크에 동기화해야 한다.
이때마다 WiredTiger 가 엄청난 양의 디스크 쓰기를 유발하면서 읽기 쿼리를 실행하지 못하는 것이다.
분리된 컬렉션의 경우 조회수만 저장하는 컬렉션의 크기가 크지 않아 많은 변경 쿼리가 유입되더라도 실제 WiredTiger 스토리지 엔진의 캐시에 변경이 가해지는 블록의 수가 통합된 컬렉션의 경우보다 훨씬 적어 디스크 부하를 덜 일으킨다.
MongoDB 를 선택하는 이유 중 하나로 도큐먼트 포맷의 자율성도 상당 부분을 차지한다.
이런 이유로 일반적으로 MongoDB 도큐먼트의 하나의 크기가 상당히 비대해지는 경우가 많다.
문제는 응용 프로그램의 각 위치에서 꼭 필요로 하는 필드만 선택해서 가져가도록 개발하지 않는다는 것이다.
이로인해 MongoDB 서버에서 네트워크 전송량 제한이 자주 병목이 되고 한다.
이를 예방하기 위해 3.6 버전부터 MongoDB 와 라우터 간 데이터 전송뿐 아니라 클라이언트 드라이버까지의 전송 데이터도 압축이 가능하도록 개선되었다.
정규화와 역정규화
주로 MongoDB 에서 종속적인 정보를 부모 컬렉션에 포함하도록 설계하는 이유로 크게 2가지를 언급한다.
- 조인을 지원하지 않음
- 트랜잭션을 지원하지 않음
RDBMS 에서 조인으로 여러 테이블을 동시에 읽는 형태의 쿼리가 필요하다면 MongoDB 에서는 여러 컬렉션의 데이터를 하나의 도큐먼트로 생성하라고 가이드한다.
이렇게 서브 도큐먼트로 내장하면 MongoDB 에서는 하나의 Find 오퍼레이션으로 게시물과 그에 딸린 댓글들을 모두 한 번에 가져갈 수 있기 때문이다.
두번째로 MongoDB 에서는 트랜잭션을 지워하지 않고, 하나의 도큐먼트 처리에 대해서만 원자적인 오퍼레이션을 보장한다. 그래서 2개 이상의 도큐먼트를 원자적으로 저장하거나 삭제해야 한다면 MongoDB 에서는 그 도큐먼트를 하나의 도큐먼트로 묶어서 저장하는 방법을 추천한다.
그러나 이런 방법은 잘못된 모델링 습관을 만들 수 있다.
이렇게 종속적인 컬렉션의 데이터를 부모 컬렉션에 내장할 경우에 발생할 수 있는 성능적인 문제점이 있다.
- 도큐먼트의 크기가 계속 증가하는 문제
- 도큐먼트의 일부 정보에 접근하기 위해서 전체 도큐먼트를 읽고 써야 하는 문제
- 도큐먼트에 포함된 필드를 동시에 읽고 쓰는 데 제한되는 문제
WiredTiger 의 내장된 캐시 메모리에서 데이터를 디스크로 기록하기 위해 기존 데이터와 변경된 데이터의 병합 작업이 발생한다. 이 때 하나의 도큐먼트가 내장된 서브 도큐먼트를 많이 가지면 가질수록 병합해야 하는 데이터가 커지고 캐시 메모리 효율이 떨어진다.
하나의 도큐먼트 크기가 커지면 특정 필드를 읽기 위해 수 킬로에서 수십 킬로바이트의 도큐먼트를 매번 읽어야 한다.
따라서 몇 배의 데이터 블록을 읽어야 하므로 디스크 읽고/쓰기가 늘어난다.
동시 변경 요청이 들어오면 둘 중 하나의 요청은 '처리 실패' 가 반환된다. 그리고 자동으로 MongoDB 서버로 재요청한다.
클라이언트는 실패되었다는 응답은 없으나 처리시간은 그만큼 늦어지게 되며 MongoDB 서버는 그만큼 더 많은 요청을 받게 된다.
결론적으로 하나의 컬렉션을 다르 부모 컬렉션의 서브 도큐먼트로 내장할지 말지는 응용 프로그램에서 데이터를 어떻게 읽어 가느냐에 따라 달라진다.
서브 도큐먼트
동일한 데이터를 저장하더라도 다음과 같이 여러 가지 방법으로 BSON 도큐먼트의 포맷을 선택할 수 있다.
일반적으로 필드를 나열하는 방법과 각 필드의 성격별로 그룹을 만들어 서브 도큐먼트를 만들수 있다.
각 필드를 성격별로 묶어 서브 도큐먼트로 생성하는 방법은 가독성과 식별성을 높이며 메모리 적재 시 크기도 줄여준다.
배열
RDBMS 에서 불가한 복잡한 형태의 데이터 모델을 가능하게 해주는 것이 바로 MongoDB 의 배열 타입이다.
컬렉션의 A 필드는 단순한 문자열을 배열 타입으로 저장하고 있으며, 정규화되는 RDBMS 에서는 불가한(배열) 모델이다.
배열을 사용하게 되면 여러 개의 컬렉션으로 분리되어야 할 데이터가 하나의 컬렉션에 모두 저장되므로 한 번의 쿼리로 조회하거나 변경할 수 있고, 컬렉션이 여러 개일 때보다 빠르게 개발할 수 있다.
또한 이러한 배열 타입에 멀티키 인덱스를 만들수 있다.
단일 도큐먼트에 대해 원자 처리를 지워하는 MongoDB 배열 타입은 트랜잭션이 지원되지 않는 단점을 보완해줄수 있는 좋은 해결책이지만, 배열의 데이터가 많아지면 성능적인 문제점을 유발할 수 있으므로 모델링 시 주의해야 한다.
도큐먼트의 크기 증가
배열 타입으로 데이터가 계속 추가되는 경우를 생각한다.
데이터가 늘어나면 도큐먼트의 크기가 증가되는데, WiredTiger 스토리지 엔진은 트랜잭션을 처리할 수 있는 데이터베이스 엔진으로 설계되어 RDBMS 와 같이 트랜잭션을 지원하는데, 이를 위해 WAL 로그뿐 아니라 Undo 로그도 가지고 있다.
배열에 데이터가 계속 변경되면 변경 히스토리를 위한 메모리 공간의 낭비가 심해지고 그만큼 자주 변경 히스토리를 병합하는 작업을 수행해야 한다.
배열 관련 연산자 선택
배열 타입은 아이템을 추가하거나 삭제할 때 다음과 같은 연산자를 사용한다.
addSet 과 pull 은 데이터체크를 위해 모든 아이템과 비교 작업을 수행해야 하므로 선택하지 않는것이 좋다.
타입 | 연산자 | 설명 |
추가 | push | 배열의 마지막에 새로운 아이템을 추가 |
addtoSet | 추가하는 아이템이 기존 배열에 있는지 체크 | |
삭제 | pop | 배열의 첫 번째 아이템을 삭제 |
pull | 삭제해야 하는 아이템이 기존 배열에 있는지 체크 |
배열과 복제
레플리카 셋에서 MongoDB 의 복제로그는 다른 DBMS 와 달리 멱등의 원칙을 지켜야 한다.
이는 복제 로그가 여러 번 수행되더라도 동일한 결과를 보장할 수 있도록 하기 위함이다.
멱등의 원칙을 준수하기 위해 MongoDB 는 복제로그에 사용자로부터 유입된 쿼리를 그대로 기록하지 않고
사용자의 요청과 원본 데이터를 조금 가공해서 복제로그에 기록하는데 어떻게 기록되는지 본다.
복제 로그에 기록된 내용에서 o 필드의 값이 실제 변경되는 내용을 저장하는 곳인데,
UPDATE 명령은 단순히 push 로 배열의 마지막에 추가만 했지만, 복제 로그에는 배열의 위치와 값이 매핑되어 있음을 알 수 있다. 이는 복제로그가 아무리 많이 반복되어도 같은 결과를 보장하도록 MongoDB 가 로그를 변형해서 저장한다.
배열의 앞에 데이터를 추가해도 복제로그에는 멱등성을 위해 모든 배열의 포지션들이 기록된다.
배열 요소가 많지 않고 값들이 크지 않으면 이런 방식이 문제가 없으나, 배열 요소가 많을수록 성능에 상당한 걸림돌로 작용하게 된다.
배열과 관련된 성능 테스트
배열의 크기가 커질수록 디스크에 써야 하는 데이터량도 늘어난다.
따라서 배열 타입은 적절한 개수의 아이템을 저장하고 관리하는 용도로 사용해야 하며 도큐먼트의 변경이 얼마나 빈번한지 그리고 데이터를 읽을 때 배열 전체 아이템이 필요한지에 따라 적절하게 설계해야 한다.
필드 이름
MongoDB 를 포함한 NoSQL DBSM 는 모두 정해진 스키마를 가지지 않는다.
그래서 컬럼의 이름이나 타입이 별도의 메타 정보로 관리되지 않는다.
이로 인해 컬렉션에 새로운 필드를 추가하거나 타입을 변경하는 것이 필요하지 않다.
그러나 이런 장점은 MongoDB 서버에서 필드명=필드값을 모두 저장하기 때문이다.
따라서 컬럼 이름의 길이가 MongoDB 에서는 새로운 튜닝포인트 이다.
MongoDB 는 데이터 파일을 압축할 수 있기 때문에 실제 디스크의 데이터가 차지하는 공간은 크지 않을 것으로 예측할 수 있다.
실제 데이터 블록 단위로 중복된 필드 이름은 압축으로 최소화되어 실제 필드명의 길이가 데이터 파일의 크기에 크게 영향을 미치지 않는다. 그러나 메모리 캐시는 다르다.
WiredTiger 스토리지 엔진에 자체 내장된 1차 캐시(L1)를 먼저 활용한다. 그런데 2차 캐시로 활용하는 운영 체제의 페이지 캐시는 디스크의 데이터 파일을 그대로 복사해서 캐시하므로 압축된 상태를 유지하지만, WioredTiger 스토리지 엔진의 페이지 캐시는 압축되지 않은 상태로 데이터를 풀어서 메모리에 관리한다. 그래서 필드 이름이 길면 빈번하게 활용하는 1차 캐시의 공간에 낭비가 발생하게 된다.
프레그멘테이션과 패딩
MongoDB 서버는 도큐먼트를 저장할 때 데이터 파일에서 저장하고자 하는 도큐먼트의 사이즈와 같거나 조금 큰 빈 공간을 찾는다.
만약 그런 공간을 찾을 수 없으면 서버는 데이터 파일의 마지막에 추가 공간을 생성한 후에 도큐먼트를 저장한다.
만약 처음 컬렉션을 생성하고 계속 INSERT 만 수행하면 저장한 순서대로 데이터 파일에 전혀 공간낭비 없이 차곡차곡 데이터를 저장할 수 있다. 그러나 UPDATE 되어 도큐먼트 크기가 더 커지면 위치한 블록공간이 없을 때 새로운 크기에 맞는 빈 공간으로 옮기게 된다.
이렇게 데이터파일에서 도큐먼트가 이동하면 MongoDB 서버는 인덱스에서 D4 도큐먼트를 가리키고 있던 주소를 새로운 주소로 변경한다. 이렇게 도큐먼트가 이동하는 작업으로 인해 처음에 도큐먼트가 사용하던 공간은 빈 상태가 되었다. 나중에 새로운 도큐먼트가 이 공간을 활용할 수 있지만 100% 활용할 가능성은 낮다. 이런 이동 과정이 반복되면 데이터 파일에서 도큐먼트 사이 빈 공간이 남게되는데 이런 빈 공간을 프레그멘테이션이라 한다.
이렇게 데이터 파일의 프레그멘테이션이 심해지면 자연스럽게 컬렉션을 스캔하는 쿼리의 성능이 떨어진다.
프레그멘테이션의 문제점을 보완하기 위해 패딩이라는 기능이 도입되었다.
패딩은 MongoDB 가 가진 패딩 기능을 활용할 수도 있으며, 사용자가 수동으로 의미없는 데이터를 덧붙여서 패딩할수도 있다. MongoDB 서버는 내부적으로 데이터 파일의 도큐먼트가 변경될 때마다 도큐먼트가 이동할 확률을 계산해서 컬렉션 단위로 paddingFactor 라는 값을 관리한다. 그리고 새로운 도큐먼트가 저장될 때마다 MongoDB 서버가 paddingFactor 값에 준하는 여분의 공간을 미리 데이터팡리에 준비한다. 앞으로 크기가 증가할 것을 대비해 빈 공간을 미리 만들어 저장하는 것이다.
그래서 MongoDB 서버는 기존의 자동 패딩을 대체할 "power of 2 size" 라는 새로훈 형태의 공간할당 전략을 구현한다.
새로운 공간 할당 전략은 무조건 2바이트 단위로 도큐먼트의 저장 공간을 할당한다.
이 전략으로 인해 도큐먼트의 크기가 늘어나더라도 실제 데이터 파일에서 위치를 이동해야 하는 경우는 줄어들고 프레그멘테이션은 최소화할 수 있다.
도큐먼트 유효성 체크
컬렉션에 속한 필드 값에 대해서는 어떠한 제약도 가지지 않는다.
이로인해 MongoDB 에서는 필드를 추가하기 위해 서비스를 멈추고 ALTER 와 같은 DDL 을 실행할 필요가 없다.
하지만 때로는 NoSQL 에서도 RDBMS 에서와 같은 정규화된 제약이 필요할 수 있다.
MongoDB 에서도 저장되는 도큐먼트의 규칙을 설정할 수 있는 기능을 제공한다.
도큐먼트의 유효성 체크 규직은 컬렉션을 생성할 때 뿐만 아니라 이미 사용되고 있는 컬렉션에 대해 새로운 규칙을 추가할 수 있다.
그리고 MongoDB 의 도큐먼트 유효성 체크에는 쿼리 문장에 사용할 수 있는 대부분의 TRUE FALSE 표현식을 사용할 수 있어 아주 다양하고 복잡한 형태의 체크도 수행할 수 있다.
또한 각 필드의 유효성 체크 결과를 AND 나 OR 로 연산해 도큐먼트의 유효성 결과를 판단하게 할수도 있다.
하지만 유효성 체크는 컬렉션의 도큐먼트 단위로 설정되기 때문에 특정 필드의 유효성 체크에 대해 다른 도큐먼트 또는 다른 컬렉션의 도큐먼트에 있는 필드 값을 참조해서 비교할 수는 없다.
도큐먼트 유효성 체크에는 두 개의 추가 옵션이 있다.
옵션 | 설명 |
validationLevel | moderate : INSERT 와 이미 유효성 체크를 만족하는 UPDATE 에 대해서만 유효성을 체크하고, 유효성 체크를 만족하지 못한 도큐먼트의 UPDATE 에 대해서는 규칙에 어긋나더라도 별도의 조치를 취하지 않고 무시한다. strict : validateionLevel 의 기본값이며, INSERT 나 UPDATE 로 변경되는 모든 도큐먼트에 대해 유효성 체크 규칙에 위배되면 validationAction 에 명시된 대로 작동한다. |
validationAction | warn : INSERT 나 UPDATE 시 유효성 체크 규칙에 위배되면 단순히 규칙에 위배된다는 메시지만 로그 파일로 기록하고 사용자의 요청을 성공으로 완료한다. error : valicationAction 의 기본값이며, 새롭게 저장되거나 변경되는 도큐먼트가 유효성 규칙에 위배되면 사용자 요청을 실패 처리한다. |
조인
MongoDB 서버도 조인을 지원하지 않는다. 그래서 조인이 필요하다고 생각되면 하나의 도큐먼트에 조인 대상 데이터를 내장할 것을 권장한다.
하지만 $lookup 이라는 보조적인 조인 기능을 제공하고 있다.
Lookup 기능은 Aggregation 기능의 일부로 제공된다.
$lookup 기능의 제약사항으로는 3가지가 있다.
- INNER JOIN 은 지원하지 않으며 OUTER JOIN 만 지원한다.
- 조인되는 대상 컬렉션은 같은 데이터베이스에 있어야 한다.
- 샤딩되지 않은 컬렉션만 $lookup 오퍼레이션을 사용할 수 있다.
1, 2 제약조건은 크게 문제되지 않을 수 있으나 3 의 제약조건은 MongoDB 를 사용하는 일반적인 이유가 샤딩인 것을 감안하면 치명적일 수 있다.
샤딩된 컬렉션을 $lookup 의 대상 컬렉션으로 사용하지 못하는 이유는 오퍼레이션이 라우터에서 처리되는 게 아니라 샤드 서버 단위로 처리되기 때문이다. 이 제약사항은 같은 샤드 키로 샤딩된 컬렉션이라 하더라도 피할 수 없다. 같은 샤드 키로 샤딩됐다 하더라도 컬렉션이 다르다면 청크가 서로 다르게 분산되기 때문이다.
$lookup 은 샤드에서 해당 DB 의 프라이머리 샤드로 요청되며, 프라이머리 샤드는 우선 드라이빙 컬렉션 검색을 각 샤드로 전송하고 결과를 수집한다. 그리고 수집된 결과와 드리븐 컬렉션을 조인하게 되는데, 이 때 드리븐 컬렉션은 프라이머리 샤드에 저장돼 잇기 때문에 로컬 샤드에서 조인 처리를 수행할 수 있는 것이다.
그래서 $lookup 을 사용한 조인 쿼리가 많아지면 프라이머리 샤드 서버만 많은 처리를 담당하게 되므로 부하의 불균형이 심해질 수 있다.
'Database > MongoDB' 카테고리의 다른 글
[MongoDB] 실행 계획 및 쿼리 최적화(1) (0) | 2024.11.18 |
---|---|
[MongoDB] 쿼리 개발과 튜닝(1) (6) | 2024.10.06 |
[MongoDB] 잠금과 트랜잭션(2) (0) | 2024.09.09 |
[MongoDB] 인덱스(3) (0) | 2024.08.12 |
[MongoDB] 잠금과 트랜잭션(1) (0) | 2024.08.09 |