-
Notifications
You must be signed in to change notification settings - Fork 14
Description
샤딩
MongoDB 샤딩 아키텍처
- 샤드 서버(shard server)
- 실제 데이터 저장
- 컨피그 서버(config server)
- 샤드 서버에 저장된 사용자 데이터가 어떻게 스플릿 되어서 분산돼 있는지에 대한 메타 정보를 저장
- 라우터(mongos)
- 데이터 저장 X
- 쿼리 요청을 어떤 샤드로 전달할지 파악하는 역할
- 각 샤드로부터 받은 쿼리 결과 데이터를 병합해서 사용자에게 돌려주는 역할
하나의 샤드 클러스터에 샤드 서버는 레플리카 셋 형태로 1개 이상 존재할 수 있으며, 라우터 서버도 1개 이상 존재할 수 있습니다.. 하지만 컨피그 서버는 하나의 샤드 클러스터에 단 하나의 레플리카 셋만 존재할 수 있습니다. 여기에서 샤드 서버와 컨피그 서버의 레플리카 셋이 몇 개의 멤버를 가질지는 제약이 없습니다. 샤드 서버와 컨피그 서버는 모두 레플리카 셋으로 구축할 것이 권장됩니다.
샤딩 알고리즘
샤딩 알고리즘은 레인지 샤딩, 해시 샤딩, 태그 기반 샤딩 3가지를 지원합니다. 샤딩은 데이터를 일정 기준으로 그룹핑해서 관리하는데, 이 그룹을 청크라고 합니다. 레인지 샤딩과 해시 샤딩은 각 데이터를 어떤 청크에 포함시켜야 할지를 결정하지만 청크를 어느 샤드에서 관리할 지는 사용자가 정할 수 없습니다. 하지만 태그 기반의 경우에는 특정 청크를 특정 샤드에만 저장하고 처리할 수 있게 할 수 있습니다.
청크는 물리적인 의미를 갖지 않는 논리적으로만 존재하는 개념입니다. 즉, 청크 단위로 데이터 파일이 생기거나 데이터가 모여있지는 않습니다. 만약 물리적인 개념이었다면 데이터 파일 개수가 매우 많아지고 검색도 힘들었을 것입니다. 샤드 서버에서 청크에 관계 없이 하나의 컬렉션에 속한 데이터는 하나의 데이터 파일에 서로 섞여서 존재합니다. 청크의 실체는 컨피그 서버의 메타 데이터로만 존재하고, 실제 각 샤드 서버는 청크라는 개념에 대해서 알 필요도 없습니다.
- 레인지 샤딩
- 샤드 키의 값을 기준으로 범위를 나누고 사용자 데이터가 어느 청크에 포함될지 결정
- 샤드 키의 값을 변형하지 않은 상태에서 샤드 키 값의 구간에 따라 청크를 할당
- 해시 샤딩
- 샤드 키의 값의 MD5 해시 함수를 사용하여 해시값을 이용해서 청크를 할당
- 지역 기반 샤딩(zone sharding) = 태그 기반 샤딩
- 관심 대상의 데이터에만 적용할 수 있는 방식
- 독립적으로 사용할 수 있는 방식이 아니고 레인지 샤딩 또는 해시 샤딩과 반드시 함께 사용해만 합니다.
- 레인지 샤딩 또는 해시 샤딩을 적용한 상태에서 데이터를 저장할 샤드를 한 번 더 조정할 수 있는 옵션으로 이해해도 됩니다.
인덱스
클러스터 인덱스는 5.3 버전 이후부터 지원합니다. 이전에 사용하던 인덱스는 모두 논클러스터 인덱스라고 보면 됩니다.
인덱스 레인지 스캔

검색해야 할 인덱스의 범위가 결정된 경우에 사용할 수 있는 방식으로 검색하고자 하는 값의 수나 검색 결과 레코드의 건수와 관계없이 레인지 스캔이라고 표현합니다. 실제 원하는 시작점을 찾기 위해서 루트노드부터 비교를 시작해서 브랜치 노드를 거치며 최종적으로 리프 노드의 시작 지점을 찾습니다. 리프 노드에서 시작해야할 위치를 찾게 되면 그때부터 리프 노드 간의 링크를 이용해서 리프 노드만 스캔하게 됩니다. 최종 스캔을 멈춰야 할 위치에서 사용자에게 결과를 반환하면 처리가 완료됩니다. 인덱스 레인지 스캔이 제일 보편적이나 인덱스를 통해서 읽어야 할 데이터 레코드가 15~20%를 넘으면 인덱스를 통한 읽기보다 컬렉션(데이터 파일) 풀 스캔이 더 효율적으로 처리됩니다.
커버링 인덱스
- RDBMS와 같이 인덱스만으로 쿼리를 처리할 수 있는 경우에는 도큐먼트가 저장된 컬렉션 데이터 파일을 읽지 않고 쿼리를 처리합니다.
인덱스 인터섹션
대부분의 DBMS에서 하나의 쿼리는 일반적으로 하나의 인덱스를 이용해서 처리됩니다. 쿼리가 여러 개의 테이블을 이용하는 경우라면 테이블별로 하나의 인덱스를 활용합니다. 그런데 인덱스 인터섹션 최적화를 사용하는 경우에는 2개 이상의 인덱스를 사용하게 되며, Mongodb도 이를 지원합니다. 인덱스 인터섹션 최적화는 일반적으로 쿼리에 맞게 복합 필드 인덱스로 구성되지 않고, 필드마다 하나의 인덱스가 만들어진 경우에 자주 사용됩니다. 하지만 각 인덱스가 필드 하나씩만 가진 경우에는 대부분 잘못 생성된 인덱스일 가능성이 높습니다. 즉, 인덱스 인터섹션은 인덱스가 최적으로 생성되지 못한 경우에 자주 사용될 수 있는 MongoDB의 최적화 방법 중 하나입니다.
인덱스 풀 스캔
인덱스 리프 노드의 제일 앞 또는 제일 뒤로 이동한 다음 인덱스의 리프 노드를 연결하는 링크드 리스트를 따라서 처음부터 끝까지 스캔하는 방식을 말합니다. 이 방식은 인덱스 레인지 스캔 보다는 느리지만 컬렉션 풀 스캔보다는 빠릅니다. 쿼리가 인덱스에 명시된 컬럼만으로 조건을 처리할 수 있거나 전체 쿼리를 처리할 수 있는 경우에 이 방식이 주로 사용됩니다. 만약 컬렉션의 모든 데이터에 대해 인덱스뿐만 아니라 데이터 레코드까지 읽어야 한다면 이 방식으로 처리되진 않습니다.
컴파운드 인덱스
컨파운드 인덱스는 2개 이상의 필드를 묶어서 만드는 인덱스를 의미합니다. 컴파운드 인덱스는 첫 필드값으로 먼저 구성되고 그 이후의 필드값으로 구성됩니다. 따라서 인덱스 내에서 각 필드의 순서에 따라 성능이 크게 달라질 수 있으므로 순서가 매우 중요합니다.
멀티 키 인덱스
비정규화로 인해 나타는 인덱스 형태로, 멀티 키 인덱스는 하나의 도큐먼트가 여러 개의 인덱스 키를 갖는 형태입니다. 반대로 하면 여러 개의 인덱스 키가 하나의 도큐먼트를 가리키는 구조입니다. 하나의 도큐먼트 안에 서브 도큐먼트가 있거나 배열타입의 필드가 존재할 경우에 사용됩니다.
주의사항
db.survey.insert( { _id: 1, item: "abc", ratings: [2,9]})
db.survey.insert( { _id: 1, item: "def", ratings: [4,3]})
db.survey.createIndex({ratings:1})
db.survey.find({ratings: : { $gte: 3, $lte: 6}})find의 실행 결과는 gte와 lte를 따로 비교하고 두 개의 결과를 병합한 합집합 형태로 도출됩니다. 따라서 두 도큐먼트가 모두 검색됩니다. 일반적으로 알고 있는 between 연산을 위해서는 elemtMatch 연산을 사용해야 합니다.
db.survey.insert( { _id: 1, item: "gg", ratings: [2,4]})
db.survey.find({ratings: : { $elemMatch: {$gte: 3, $lte: 6}}})이 결과 두 조건의 결과에서 교집합의 결과가 도출됩니다. 하지만 여기서도 주의할점은 3 <= x <= 6 을 하나라도 만족하는 엘리먼트가 있다면 결과에 포함되므로 gg와 def 두 도큐먼트가 모두 검색됩니다.
멀티 키 인덱스는 비정규화된 데이터 포맷을 위해 꼭 필요한 인덱스지만 몇 가지 제약사항이 있습니다.
- 멀티 키 인덱스는 샤드 키로 사용될 수 없다.
- 해시 알고리즘을 사용하는 인덱스는 멀티 키 인덱스로 정의될 수 없다.
- 멀티 키 인덱스는 커버링 인덱스 처리가 불가능하다.
전문 검색 인덱스
전문 검색 인덱스는 여러 문서에서 특정의 문자열을 검색할 때 사용합니다.
db.survey.createIndex({title:"text"})
db.survey.createIndex({title:"text", contents: "text"})
db.survey.createIndex({title:"text", contents: "text"}, weights:{title:2, contents:1})전문 인덱스를 만들 때는 text 라는 용어를 사용합니다. 전문 인덱스는 컬렉션 당 하나만 만들 수 있기 때문에 여럿 필드를 등록하고 싶을 때는 위처럼 인덱스 생성 시, 여러 필드를 입력해야 합니다. 중요도(weight)를 설정할 수도 있습니다. 중요도를 입력하지 않으면 기본적으로 1로 설정됩니다.
// 데이터 저장
mongo> db.books.insertMany(
[
{ _id : 1, title : "Java book", contents : "Java with me" }
, { _id : 2, title : "kotlin book", contents : "kotlin with me" }
, { _id : 3, title : "Coffee book", contents : "coffee with me" }
]
);
// 인덱스 생성
mongo> db.books.createIndex( { title : "text" } );
// 전문 검색 쿼리 실행
mongo> db.books.find( { $text : { $search : "java coffee" } } ).sort( { "_id": 1 } );
{ _id : 1, title : "Java book", contents : "Java with me" }
{ _id : 3, title : "Coffee book", contents : "coffee with me" }
mongo> db.books.find( { $text : { $search : "book -coffee" } } ).sort( { "_id": 1 } );검색 시 java와 coffee를 포함하는 조건으로 OR 연산이 실행됩니다. - 기호를 사용하면 분리언 검색이 되므로 포함하지 않는 결과를 검색할 수 있습니다.
제약사항
- 컬렉션 당 단 1개만 생성 가능
- 전문 검색 쿼리($text)가 사용된 쿼리에서는 쿼리 힌트 사용 불가능
- 쿼리 결과의 정렬은 전문 검색 인덱스를 사용 불가
- 전문 검색 인덱스는 멀티 키 인덱스나 공간 검색 인덱스와 함께 컴파운드 인덱스로 생성 불가
- 전문 검색 인덱스의 검색은 접두어 일치를 사용할 수 없으며 항상 전체 일치 검색만 사용 가능
- 전문 인덱스는 주로 크기가 큰 도큐먼트를 파싱하여 인덱싱하므로 인덱스가 매우 커지고 처리시간이 많이 소요됩니다. 따라서 대용량 전문 검색 기능은 피하는 것이 좋습니다.
트랜잭션
WiredTiger 스토리지 엔진을 사용하면 몽고 DB에서도 트랜잭션을 사용할 수 있습니다. 기존의 RDBMS에서는 READ-UNCOMMITTED, READ_COMMITTED, REPEATABLE-READ, SERIALIZABLE 4가지 격리 수준을 제공하지만, WiredTiger 스토리지 엔진은 SERIALIZABLE 격리 수준은 제공하지 않습니다. WiredTiger 스토리지 엔진은 트랜잭션 로그뿐만 아니라 체크포인트로도 영속성이 보장되므로 트랜잭션 로그가 없어도 마지막 체크포인트 시점의 데이터를 복구할 수 있습니다. 마지막으로 트랜잭션 특성 중 중요한 것은 트랜잭션이 커밋되기 전에 트랜잭션 로그를 디스크로 기록하지 않는다는 것인데 이에 따라 하나의 트랜잭션이 변경할 수 있는 데이터의 크기는 WiredTiger 스토리지 엔진의 공유 캐시 크기로 제한됩니다.
쓰기 충돌
RDBMS 서버에서는 두 세션이 하나의 레코드를 변경하고자 할 때, 해당 레코드에 대해 잠금을 먼저 획득한 세션이 데이터를 모두 변경하고 잠금을 해제할 때까지 나머지 세션이 기다리고 해제된 이후에 두번째 세션이 처리합니다. mongoDB는 하나의 데이터(도큐먼트)를 동시에 변경하려고 하는 상황을 쓰기 충돌이라고 하는데 RDBMS와는 조금 다르게 처리합니다. 도큐먼트가 이미 다른 커넥션에 의해 잠금이 걸려있으면 즉시 업데이트 실행을 취소하고 writeConflict Exception을 발생시킵니다. 그러면 업데이트 명령을 실행했던 세션은 같은 업데이트 문장을 재시도합니다. 이 과정은 mongoDB 서버 프로세스 내부에서만 실행되며, 실제 응용 프로그램에서는 이런 재처리 과정을 알아채지 못합니다. 기존 RDBMS도 마찬가지이지만 몽고에서도 하나의 도큐먼트를 많은 스레드가 동시에 변경하려고 하면 update 문장의 실행이 재시도로 인해 폭증하면서 cpu 사용량이 높아지지만 실제 요청을 처리하는 성능이 떨어지는 경우가 생기므로 writeConflict를 모니터링하고 빈번히 발생한다면 모델을 변경할 필요가 있습니다.
write concern
mongodb에서는 rdbms와 달리 트랜잭션의 시작과 종료(commit)을 명시적으로 실행할 방법이 없습니다. 따라서 mongodb 서버에서 도큐먼트를 저장할 때 사용자의 데이터 변경 요청에 응답이 반환되는 시점이 트랜잭션의 커밋으로 간주되며 mongodb 서버는 사용자가 요청한 변경 사항이 어떤 상태까지 완료되면 응답을 내려보낼지 판단해야 합니다. 사용자의 변경 요청에 응답하는 시점을 결정하는 옵션을 mongodb 서버에서는 writeConcern이라고 합니다. 이는 데이터 읽기와는 무관하고 insert, update, delete 오퍼레이션에 대해서만 설정할 수 있습니다.
레플리카 셋에서의 숫자 값은 동기화해야할 멤버의 개수를 설정합니다. 이를 2개로 설정하면, 레플리카 셋 멤버 중 자신(프라이머리)를 포함한 2개의 멤버가 사용자의 변경 요청을 필요한 수준까지 처리했을 때 클라이언트로 성공 또는 실패 메시지를 반환합니다. (ex. w : 2) 이와는 별개로 majority라는 옵션도 제공합니다. 이는 멤버의 과반수가 동기화되면 클라이언트로 데이터 변경 요청 결과를 반환하는 옵션입니다. readConern도 writeconcern과 동일하게 읽기를 실행할 멤버의 개수를 설정할 수 있고 majority도 설정할 수 있습니다.
read concern
mongodb 서버는 레플리카 셋으로 구축되며, 각 레플리카 셋 간의 데이터 동기화 여부는 Write concern 옵션에 따라 다양한 상태를 가질 수 있습니다. 즉, 특정 상태에서는 동기화되지 않은 데이터가 세컨드리 멤버에 존재할 수 있습니다. 결국에는 동기화되겠지만 데이터를 읽어가는 입장에서는 문제가 있습니다. 이런 동기화 과정 중에 데이터 읽기를 일관성ㄷ 있게 유지할 수 있도록 MongoDB에서는 readConcern 옵션을 제공합니다. read concern 옵션은 writeconcern 옵션과 달리 레플리카 셋 간의 동기화 이슈만 제어합니다.
- local
- 디폴트 옵션
- 쿼리가 실행되는 mongodb 서버가 가진 최신의 데이터를 반환하는 방식
- 레플리카 셋의 다른 멤버가 가진 데이터의 상태를 확인하지 않고 최신 데이터를 프라이머리 멤버만 가진 상태
- majority
- 레플리카 셋에서 다수의 멤버들이 최신의 데이터를 가졌을 때에만 읽기 결과가 반환
- linearizable
- 레플리카의 모든 멤버가 가진 변경 사항에 대해서만 쿼리 결과를 반환
read preference
read concern 은 mongodb 레플리카 셋에서 어떤 데이터를 읽어서 클라이언트로 반환할 것인지 결정하는 옵션인 반면, read preference는 클라이언트의 쿼리를 어떤 mongodb 서버로 요청해서 실행할 것인지 결정하는 옵션입니다. read concern은 데이터 읽기의 일관성이 목적이라면 read preference는 데이터 읽기로 인한 부하의 분산이 목적입니다. 5개의 모드를 지원하고 조회하는 쿼리에만 영향을 미치고 insert, update, delete는 프라이머리 멤버로만 실행됩니다.
- primary
- default 옵션
- 프라이머리 멤버로만 요청
- primaryPreferred
- 가능하면 프라이머리 멤버로 전송
- 레플리카 셋에 프라이머리 멤버가 없는 경우에는 세컨드리 멤버로 요청
- secondary
- 쿼리를 레플리카 셋의 세컨드리 멤버로만 전송
- 두 개 이상일 경우에는 적절히 분산하여 전송
- secondaryPreferred
- secondary와 동일하지만 요청할 수 있는 세컨드리 멤버가 없으면 primary로 전송
- nearest
- 레플리카 셋에서 쿼리의 응답이 빠른 멤버로 쿼리 요청
- 프라이머리인지 세컨드리인지는 고려하지 않음.
- 레플리카 셋이 같은 IDC 내부에 있으면 큰 의미가 없고, 레플리카 셋 멤버들이 글로벌하게 분산되어 있는 경우에 적절한 옵션
primary 모드를 사용하지 않을 경우 데이터 읽기가 세컨드리에서 실행될 수 있으며 이로 인해 프라이머리에서 변경된 데이터가 적용되지 않은 이전 상태의 데이터를 읽을 가능성이 있습니다. MongoDB 서버의 세컨드리 멤버는 거의 순간적으로 프라이머리 OpLog을 세컨드리 멤버로 복제합니다. 하지만 세컨드리는 OpLog를 적용할 때 글로벌 잠금을 걸고 멀티 스레드로 OpLog의 내용을 지생행합니다. 이때 레플리에키션 스레드는 세컨드리 mongodb 서버에서 글로벌 잠금을 걸기 때문에 데이터 조회 쿼리를 실행하지 못하게 되므로 데이터 변경이 무거우면 서로 영향을 미칩니다. 즉, 복제 지연은 언제든지 발생할 수 있으므로 세컨드리 읽기는 충분한 검토 후에 사용해야 합니다.
maxStalenessSeconds
maxStalenessSeconds 옵션은 주기적으로 각 세컨드리 멤버의 마지막 쓰기 시점을 이용해 복제 지연을 측정하고, 복제 지연이 maxStalenessSeconds 보다 큰 경우에 해당 멤버로의 연결을 사용하지 못하게 합니다.
모델링
정규화 vs 비정규화
- 정규화
- 컬렉션 간의 참조를 이용해 데이터를 여러 컬렉션으로 나누는 작업
- 비정규화
- 모든 데이터를 하나의 도큐먼트에 내장하는 것
- 여러 도큐먼트가 최종 데이터 사본에 대한 참조를 갖는 대신에 데이터의 사본을 갖는다. => 정보가 변경되면 여러 도큐먼트가 갱신돼야 하지만, 하나의 쿼리로 관련된 모든 데이터를 가져올 수 있다.
일반적으로 정규화는 쓰기를 빠르게 만들고 비정규화는 읽기를 빠르게 만듭니다.
도큐먼트 설계
- 학생(student)
- 클래스 (class)
데이터를 수도 코드로 간단하게 예시를 봅시다.
-- studentClass
{
"_id" : ObjectId("1"),
"studentId" : ObjectId("2"),
"classes" : [
ObjectId("3"),
ObjectId("4"),
ObjectId("5"),
]
}관계형 데이터베이스라면 위와 같은 형태로 조인 테이블을 만들게 됩니다. 과목과 학생이 자주 바뀌거나 데이터를 빠르게 조회해야 할 때가 아니면 몽고DB에서 일반적으로 데이터를 구조화하는 방법은 아닙니다.
-- student
{
"_id" : ObjectId("1"),
"name" : "tony"
"classes" : [
ObjectId("3"),
ObjectId("4"),
ObjectId("5"),
]
}student 도큐먼트에 class에 대한 참조를 내장함으로써 정보 조회를 위한 쿼리 개수를 줄일 수 있습니다. 이는 즉시 접근하거나 변경할 필요가 없는 데이터를 구조화하는 데 보편적으로 사용되는 방법입니다.
-- student
{
"_id" : ObjectId("1"),
"name" : "tony"
"classes" : [
{
"class": "science",
"credits" : 3,
"room": 201
},
{
"class": "math",
"credits" : 4,
"room": 202
}
]
}읽기를 좀 더 최적화하려면 데이터를 완전히 비정규화하고 각 과목을 classes 필드에 내장하는 도큐먼트로 저장해 하나의 쿼리로 모든 정보를 가져오게 할 수 있습니다. 쿼리 하라만 사용해 정보를 얻는다는 장점이 있는 반면 많은 공간을 차지하고 동기화하기 어렵다는 단점이 있습니다. 예를 들어, math가 3학점으로 변경된다면 수강하는 모든 학생의 도큐먼트를 갱신해야 합니다.
-- student
{
"_id" : ObjectId("1"),
"name" : "tony"
"classes" : [
{
"_id" : ObjectId("2"),
"class": "science",
},
{
"_id" : ObjectId("3"),
"class": "math",
}
]
}내장과 참조가 혼합된 확장 참조 패턴입니다. 자주 사용하는 정보로 서브도큐먼트의 배열을 생성하고, 추가적인 정보는 실제 도큐먼트를 참조하는 방식입니다. 시간이 흐르면서 요구 사항의 변경에 따라 내장된 정보의 양이 계속 바뀔 수 있다면 이런 방식도 좋은 선택지 입니다.
설계시 고려 사항
- 설계 시, 정보가 읽히는 빈도에 비해 얼마나 자주 갱신되는지도 중요하게 고려해야 합니다. 정보가 정기적으로 갱신돼야 한다면 정규화하는 것이 좋습니다. 하지만 드물게 갱신된다면 모든 읽기를 희생해 갱신 프로세스를 최적화해도 이득이 거의 없습니다.
- 대표적인 예시로는 사용자와 주소를 별도의 컬렉션에 저장하는 경우입니다. 사람들은 주소를 거의 바꾸지 않으므로 데이터는 드물게 갱신됩니다. 누군가가 이사할 때를 대비해 모든 읽기를 느리게 만들어서는 안되므로 주소는 사용자 도큐먼트에 내장하는 것이 올바른 설계입니다.
- 내장된 필드의 내용이나 개수가 제한 없이 늘어나야 한다면 일반적으로 그 정보는 내장되지 않고 참조돼야 합니다.
- 예를 들어 댓글 트리나 활동 목록 등은 개수가 한 사용자에게 있어 개수가 제한되지 않기 때문에 내장하는 것보다 자체적인 도큐먼트로 저장되고 참조하는 것이 올바른 설계입니다.
- 도큐먼트에 쿼리할 때 결과에서 거의 항상 제외되는 필드는 다른 컬렉션에 속해도 됩니다.
| 내장 방식이 좋은 경우 | 참조 방식이 좋은 경우 |
|---|---|
| 작은 서브 도큐먼트 | 큰 서브 도큐먼트 |
| 주기적으로 변하지 않는 데이터 | 자주 변하는 데이터 |
| 결과적인 일관성이 허용될 때 | 즉각적인 일관성이 필요할 때 |
| 증가량이 적은 도큐먼트 | 증가량이 많은 도큐먼트 |
| 두 번째 쿼리를 수행하는 데 자주 필요한 데이터 | 결과에서 자주 제외되는 데이터 |
| 빠른 읽기 | 빠른 쓰기 |
user 컬렉션으로 예를 들어 봅시다.
- 걔정 설정
- 해당 사용자 도큐먼트에만 관련이 있으며 도큐먼트 내 다른 정보와 함께 노출됩니다. => 내장돼야 합니다.
- 최근 활동
- 최근 활동의 증가량과 변화량에 따라 다릅니다.
- 크기가 고정된(예를 들면 최근 10개) 필드라면 내장하는 것이 유용합니다.
- 크키가 고정되지 않은 경우라면 참조관계를 갖는 것이 유용합니다.
- 친구
- 일반적으로 내장하지 않으며, 내장하더라도 완전히 내장하지 않아야 합니다.
- 생성자가 생성한 모든 내용
- 내장하지 않습니다.
카디널리티
카디널리티는 컬렉션이 다른 컬렉션을 얼마나 참조하는지 나타내는 개념입니다. 일반적인 관계는 일대일, 일대다, 다대다입니다. 몽고 DB를 사용할 때는 다수라는 개념을 다수 안에서도 많음과 적음으로 구분하는 것이 개념상 도움이 됩니다. 예를 들어 각 작성자가 게시물을 조금만 작성하면 작성자와 게시물은 일대소 관걔입니다. 태그보다 게시물이 더 많으면 블로그 게시물과 태그는 다대소 관계입니다. 게시물마다 댓글이 많이 달려 있으면 게시물과 댓글은 일대다 관계입니다. 많고 적음의 관계를 결정하면 무엇을 내장하고 무엇을 참조할지 결정하는 데 도움이 됩니다. 일반적으로 적음 관계는 내장이 적합하고 많음 관계는 참조가 적합합니다.
친구, 팔로워 그리고 불편한 관계
소셜 그래프 데이터에 대한 고려 사항을 다뤄봅시다. 많은 앱에서는 사람, 내용, 팔로워, 친구 등을 연결합니다. 이렇게 긴밀하게 연결된 정보는 내장할지 참조할지 적절히 결정하는 방법을 파악하기 까다로울 수 있습니다. 일반적으로 이런 관계는 한 사용자가 다른 사람의 알림을 구독하는 발행-구독 시스템으로 단순화할 수 있습니다.
구독을 구현하는 전형적인 방법은 세 가지가 있습니다.
{
"_id": ObjectId("1"),
"username": "tony",
"email": "tony@test.com",
"following": [
ObjectId("2"),
ObjectId("3"),
]
}위와 같은 모델링은 사용자가 관심 가질 수 있는 활동(following)을 모두 찾을 수 있습니다. 하지만 새로 게시된 활동에 관심있는 사람을 모두 찾으려면 모든 사용자에 걸쳐 following 필드를 쿼리해야 합니다.
{
"_id": ObjectId("1"),
"username": "tony",
"email": "tony@test.com",
"followers": [
ObjectId("2"),
ObjectId("3"),
]
}그 대신 게시자 도큐먼트에 팔로워를 추가할 수 있습니다. 이 경우 사용자는 뭔가를 할 때마다 알림을 보내야 할 모든 사용자를 바로 알 수 있습니다. 하지만 팔로우 하는 사람을 모두 찾으려면 users 컬렉션에 전체 쿼리를 날려야 한다는 단점이 있습니다.(이전과 반대)
{
"_id": ObjectId("1"), // 팔로우 대상 id
"followers": [
ObjectId("2"),
ObjectId("3"),
]
}두 방법 모두 추가적인 단점이 따르는데 사용자 도큐먼트를 더욱 크고 자주 바뀌도록 만듭니다. following, followers 필드는 심지어 반환될 필요가 없을 때도 많습니다. 따라서 좀 더 정규화하고 구독을 다른 컬렉션에 별도로 저장함으로써 이런 단점을 극복할 수 있습니다. 이렇게까지 정규화하면 지나칠 때가 많지만, 자주 반환되지 않으면서 매우 자주 변하는 필드에 유용합니다.
이러한 예시는 게시글과 댓글 관계에서도 고려해볼 수 있습니다. 일반적으로 게시글과 댓글은 일대다 관계를 가지고 있습니다. 댓글을 게시글의 서브 도큐먼트로 설계할 경우 다음과 같은 문제가 생깁니다.
- 도큐먼트의 크기가 계속 증가
- 도큐먼트의 일부 정보에 접근하기 위해 전체 도큐먼트를 읽고 써야하는 문제
- 도큐먼트에 포함된 게시글과 댓글을 동시에 읽고 쓰는 데 제한되는 문제
따라서 댓글은 게시글에 내장하는 것보다는 별도의 컬렉션으로 분리하는 것이 좋습니다.
유명인 사용자 대처하기
어떤 전략을 사용하든 내장이 작동하는 서브도큐먼트와 참조는 제한됩니다. 유명인 사용자는 팔로워를 저장하는 도큐먼트가 넘칠 수 있습니다. 이는 이상치 패턴을 사용하고 필요하다면 연속 도큐먼트를 사용해 해결할 수 있습니다.
{
"_id": ObjectId("1"),
"username": "tony",
"email": "tony@test.com",
"tbc": [
ObjectId("a"),
ObjectId("b"),
]
"followers": [
ObjectId("2"),
ObjectId("3"),
...
]
}
{
"_id": ObjectId("a"),
"followers": [
ObjectId("4"),
ObjectId("5"),
...
]
}
{
"_id": ObjectId("b"),
"followers": [
ObjectId("6"),
ObjectId("7"),
...
]
}이후 다음 도큐먼트 조회를 돕기 위해 to be continued(tbc) 배열에 애플리케이션 로직을 추가합니다.
cf) 이상치패턴
인기도가 중요한 상황을 위해 설계된 고급 스키마 패턴으로, 도서 판매, 영화 리뷰 등이 있는 소셜 네트워크에서 볼 수 있습니다. 플래그를 사용해 도큐먼트가 이상점임을 나타내며 추가 오버플로를("_id"를 통해 첫 번째 도큐먼트를 다시 참조하는) 하나 이상의 도큐먼트에 저장합니다. 플래그는 애플리케이션 코드에서 오버플로 도큐먼트를 검색하기 위한 추가 쿼리를 만드는 데 사용합니다.
쿼리 최적화
쿼리 실행 계획 해석
mongoDB의 실행 계획은 쿼리 뒤에 .explain() 을 명령으로 확인할 수 있고 다음과 같은 3가지 옵션이 있습니다.
- queryPlanner : 기본 옵션으로 가장 단순한 결과를 보여줍니다.
- 쿼리가 의도했던 인덱스를 제대로 활용했는지
- 정렬 작업을 인덱스를 활용해서 처리했는지, 별도의 정렬 작업을 수행했는지
- 쿼리의 프로젝션이 인덱스를 이용해서 처리되는지(커버링 인덱스 처리 여부)
- executionStats : queryPlanner의 내용 포함 + 선택된 최적 실행 계획을 실행하고 실행된 내역을 상세히 보여주는 모드
- 인덱스의 선택도가 좋은지 나쁜지
- 실행 계획의 각 처리 단계에서 어떤 스테이지가 가장 느린지
- allPlansExecution : 옵티마이저가 최적으로 선택한 실행 계획과 그 실행 계획의 상세 내역을 포함 + 최적 선택 이전의 나머지 후보 실행 계획들에 대한 내용 포함
- 옵티마이저가 어떤 실행 계획들을 검토했는지
- 여러 실행 계획 중 왜 최적 실행 계획이 선택됐는지
실행 계획 스테이지
실행 계획은 여러 스테이지가 트리 형태로 구성됩니다. 따라서 스테이지들이 어떻게 구성되느냐에 따라 성능이 달라지고 각 스테이지가 어떤 역할을 수행하는지 알아야 합니다.
- COLLSCAN : 컬렉션 풀 스캔
- IXSCAN : 인덱스 레인지 스캔
- QUEUED_DATA : 컬렉션 없이 자체적으로 임시 데이터를 만드는 스테이지
- FETCH : IXSCAN으로 부터 입력(RecordID)를 받아서 도큐먼트를 출력하는 스테이지
- AND_SORTED : 인덱스 인터섹션 실행 계획을 사용할 때, 각 인덱스를 통해서 읽은 도큐먼트의 교집합을 찾는 스테이지
- AND_HASH : 인덱스 인터섹션 실행 계획을 사용할 때, 각 인덱스를 통해서 읽은 도큐먼트의 교집합을 찾는 스테이지
- SORT_KEY_GENERATOR : 정렬 작업에서 인덱스를 사용하지 못하면 정렬 기준 필드를 먼저 추출하는 스테이지
- COUNT : 자식 스테이지에서 반환되는 도큐먼트의 건수를 누적하는 스테이지로 실행 계획 상 최상위 스테이지에 존재
- COUNT_SCAN : db.collection.count() 명령이 인덱스를 사용할 수 있을 때 사용되는 스테이지
- DISTINCT_SCAN : db.collection.distinct() 명령을 위해 사용되는 것으로, 인덱스 레인시 스캔의 변형으로 인덱스 키를 순차적으로 읽으면서 유니크한 값이 나타날 때에만 부모 스테이지로 결과를 반환
- ENSURE_SORTED : 자식 스테이지에서 반환된 결과에서 정렬 기준에 어긋나는 도큐먼트가 나타나면 버리는 처리를 수행하는 스테이지
- GROUP : db.collection.group 명령이나 aggregation에서 group 파이프라인을 위해 사용되는 스테이지
- IDHACK : _id 필드를 동등 비교로 검색하는 쿼리를 처리하는 스테이지
- INDEX_ITERATOR : 인덱스 스캔을 이용하는 커서를 이용해서 끝까지 인덱스 키를 읽는 스테이지
- LIMIT : 쿼리에서 limit이 사용된 경우에 지정된 N 도큐먼트만 반환하는 스테이지
- SKIP : 쿼리에서 skip이 사용된 경우 지정된 N 도큐먼트를 버리고 나머지를 반환하는 스테이지
- SORT_MERGE : 두 개 이상의 자식 노드에서 반환된 결과 집합을 병합하는 스테이지
- MULTI_ITERAOR : 병렬 컬렉션 쿼리나 RepairCursor 명령을 위해 사용되는 스테이지
- SHARDING_FILTER : 샤딩이 적용된 mongoDB 클러스터에서는 각 샤드가 반드시 자기가 담당하는 청크 범위의 데이터만 가지고 있는 것이 아니고 고아 도큐먼트를 가질 수 있는데 이런 고아 도큐먼트를 필터링해서 버리는 역할을 처리하는 스테이지
- SORT : 인덱스를 이용하지 못하는 정렬 처리를 위해 쿼리 실행 시점에 도큐먼트를 정렬하는 스테이지
- TEXT, TEXT_MATCH, TEXT_OR : 전문 검색 스테이지
- UPDATE : 도큐먼트 데이터 변경 작업 스테이지
- DELETE : 도큐먼트 삭제 작업 스테이지
튜닝 포인트
- 쿼리가 인덱스를 사용하는가?
- lookup 쿼리가 사용되는 경우가 아니라면 IXSCAN과 COLLSCAN이 동시에 나타나진 않습니다. 따라서 적어도 IXCAN을 사용하는지 여부를 판단하는 것이 기본입니다.
- 도큐먼트 정렬이 인덱스를 사용하는가?
- 인덱스 정렬 순서가 쿼리에서 필요로 하는 정렬 순서와 같다면 인덱스 순서대로 데이터를 반환함으로써 별도의 정렬 처리를 회피할 수 있습니다.
- 실행 계획에 SORT 스테이지가 사용된다면 실행 시점에 정렬 작업을 필요로 한다고 판단되므로 수천 또는 수만 건의 도큐먼트를 정렬해야하는 경우에는 정렬 대상 도큐먼트가 32MB가 넘으면서 메모리 부족으로 실패할 수 있으므로 주의가 필요합니다.
- 필드 프로젝션이 인덱스를 사용(커버링 인덱스)하는가?
- 커버링 인덱스가 사용되는 지는 FETCH 스테이지가 있는지 없는지 유무로 판단할 수 있습니다. 만약 IXSCAN 스테이지의 상위에 FETCH 스테이지가 표시돼 있다면 이는 커버링 인덱스로 처리되지 않음을 의미합니다.
- 인덱스 키 엔트리와 도큐먼트를 얼마나 읽었는가?
- 쿼리 최상단에 totalKeysExamined와 totalDocsExamined 필드 값은 쿼리가 처리되면서 읽은 인덱스 키의 개수와 도큐먼트 개수를 의미합니다.
- 일반 쿼리에서 인덱스 레인지 스캔은 랜덤 액세스가 아니므로 totalKeysExamined 값이 조금 높은 수치더라도 필요한 만큼의 성능을 보일 때가 많지만 인덱스를 통해서 RecordID를 찾고 이를 이용해 도큐먼트를 가져오는 작업은 모두 랜덤 액세스로 처리되므로 totalDocsExamined 값이 높다면 최적화가 필요합니다. 이를 최적화 하기 위해서는 보통 인덱스를 더 최적화된 형태로 만들어야 합니다.
- 인덱스의 선택도는 얼마나 좋은가?
- 실행 계획상에서 사용되는 인덱스가 쿼리에 사용된 조건을 얼마나 커버하는지는 성능상 매우 중요한 요소입니다.
- 쿼리에 title과 category를 조건으로 할 때, title만 인덱스가 걸려있다면 category 조건은 한건 한건씩 도큐먼트를 읽어서 비교해야 합니다. 이때는 category 필드 값의 카디널리티 낮으면 복합 인덱스로 추가하는 것이 좋고 카디널리티가 높으면 복합 인덱스는 효율이 좋지 못합니다.
- 어떤 스테이지가 가장 많은 시간을 소모하는가?
- 각 스테이지가 소요된 시간을 확인해서 가장 오래 걸리는 스테이지를 최적화합니다.
