Post

MongoDB

MongoDB 학습 및 정리

MongoDB

1. 몽고DB 시작


몽고DB는 강력하고 유연하며 확장성 높은 범용 데이터베이스다. 보조 인덱스(secondary index), 범위 쿼리(range query), 정렬(sorting), 집계(aggregation), 공간 정보 인덱스(geospatial index) 등을 확장 기능과 결합했다.


1.1. 손쉬운 사용


몽고DB는 관계형 데이터베이스가 아니라 도큐먼트 지향 데이터베이스다. 관계형 모델을 사용하지 않는 주된 이유는 분산 확장(scale-out)을 쉽게 하기 위함이지만 다른 이점도 있다.

도큐먼트 지향 데이터베이스에서는 도큐먼트를 사용하여 복잡한 계층 관계를 하나의 레코드로 표현할 수 있다. 이는 객체 지향 언어 관점에 매우 적합하다.

또한 몽고DB에서는 도큐먼트의 키와 값을 미리 정의하지 않는다. 따라서 고정된 스키마가 없으므로 쉽게 필드를 추가하거나 제거할 수 있다.


1.2. 확장 가능한 설계


분산 확장은 저장 공간을 늘리거나 처리량을 높이고 서버를 구매해서 클러스터에 추가하는 방법이다. 따라서 경제적이고 확장이 용이하다. 하지만 하나의 장비만 관리할 때보다 관리가 어렵다.

몽고DB는 분산 확장을 염두에 두고 설계됐다. 도큐먼트를 자동으로 재분배하고 사용자 요청을 올바르게 장비에 라우팅함으로써 클러스터 내 데이터 양과 부하를 조절할 수 있다.


1.3. 다양한 기능


인덱싱
일반적인 보조 인덱스를 지원하며 고유(unique), 복합(compound), 공간 정보, 전문(full-text) 인덱싱 기능도 제공한다. 중첩된 도큐먼트(nested document) 및 배열과 같은 계층 구조의 보조 인덱스도 지원한다.
집계
데이터 처리 파이프라인 개념을 기반으로 한 집계 프레임워크를 제공한다.
특수한 컬렉션 유형
최신 데이터를 유지하고자 세션이나 고정 크기 컬렉션과 같이 특정 시간에 만료해야 하는 데이터에 대해 유효 시간(TTL, Time-To-Live) 컬렉션을 지원한다. 또한 기준 필터(criteria filter)와 일치하는 도큐먼트에 한정된 부분 인덱스를 지원함으로써 효율성을 높이고 필요한 저장 공간을 줄인다.
파일 스토리지
큰 파일과 파일 메타데이터를 편리하게 저장하는 프로토콜을 지원한다.


몽고DB는 3.2에 도입된 $lookup 집계 연산자를 사용하면 매우 제한된 방식으로 조인하는 것이 가능하다. 3.6 버전에서는 관련 없는 서브쿼리 뿐만 아니라 여러 조인 조건으로 보다 복잡한 조인도 할 수 있다.


1.4. 고성능


몽고DB에서는 동시성(concurrency)과 처리량을 극대화하기 위해 와이어드타이거(WiredTiger) 스토리지 엔진에 기회적 락(oppertunistic locking) 을 사용했다. 따라서 캐시처럼 제한된 용량의 램으로 쿼리에 알맞은 인덱스를 자동으로 선택 할 수 있다.


1.5. 몽고DB의 철학


몽고DB 프로젝트의 주 관심사는 확장성이 높으며 유연하고 빠른 데이터 스토리지를 만드는 것이다.


2. 몽고DB 기본


  • 몽고DB 데이터의 기본 단위는 도큐먼트 이며, RDB의 행과 유사하다.
  • 컬렉션 은 동적 스키마가 있는 테이블과 같다.
  • 몽고DB의 단일 인스턴스는 자체적인 컬렉션을 갖는 여러 개의 독립적인 데이터베이스 를 호스팅한다.
  • 모든 도큐먼트는 컬렉션 내에서 고유한 특수키인 "_id" 를 갖는다.
  • 몽고DB는 몽고 쉘 이라는 도구와 함께 배포된다. 몽고 셸은 인스턴스를 관리하고 쿼리 언어로 데이터를 조작하기 위한 내장 지원을 제공한다. 또한 사용자가 자신의 스크립트를 만들고 로드할 수 있다.


2.1. 도큐먼트


몽고DB의 핵심은 정렬된 키와 연결된 값의 집합으로 이뤄진 도큐먼트다.


1
{"greeting" : "Hello, world!"}

 대부분의 도큐먼트는 이보다 복잡한 다중 키/값 쌍을 가진다.


도큐먼트의 키는 문자열이다. 다음 예외 몇 가지를 제외하면 어떤 UTF-8 문자든 쓸 수 있다.

  • 키는 \0(null 문자) 을 포함하지 않는다. \0 은 키의 끝을 나타내는 데 사용된다.
  • .$ 문자들은 예약어이며 부적절하게 사용하면 경고가 발생한다.


몽고DB는 데이터형과 대소문자를 구별한다. 또한, 키가 중복될 수 없다.


2.2. 컬렉션


컬렉션은 도큐먼트의 모음이다. RDB의 테이블에 대응된다고 볼 수 있다.


2.2.1. 동적 스키마


컬렉션은 동적 스키마를 가진다. 하나의 컬렉션 내 도큐먼트들이 모두 다른 구조를 가질 수 있다는 의미다. 예를 들어 다음 도큐먼트들을 하나의 컬렉션에 저장할 수 있다.

1
2
{"greeting" : "Hello, world!", "views": 3}
{"signoff" : "Good night, and good luck"}


도큐먼트들의 키, 키의 개수, 데이터형의 값은 모두 다르다. 도큐먼트에 별도의 스키마가 필요 없지만 하나 이상의 컬렉션이 필요한 이유는 다음과 같다.

  • 같은 컬렉션에 다른 종류의 도큐먼트를 저장하면 번거로운 일이 생길 수도 있다.
  • 컬렉션별로 목록을 뽑으면 한 컬렉션 내 특정 데이터형별로 쿼리해서 목록을 뽑을 때보다 훨씬 빠르다.
  • 같은 종류의 데이터를 하나의 컬렉션에 모아두면 데이터 지역성에도 좋다.
  • 인덱스를 만들면 도큐먼트는 특정 구조를 가져야 한다.


애플리케이션 스키마는 기본적으로 필요하지는 않지만 정의하면 좋다. 몽고DB의 도큐먼트 유효성 검사 기능(document validation functionality)과 객체-도큐먼트 매핑 라이브러리(object-document mapping library)를 이용하여 사용 가능하다.


2.2.2. 네이밍


컬렉션은 이름으로 식별된다. 이는 몇 가지 제약 조건이 있다.

  • 빈 문자열(““)은 유효한 이름이 아니다.
  • \0(null 문자) 은 컬렉션명의 끝을 나타내는 문자이므로 사용할 수 없다.
  • system. 으로 시작하는 컬렉션명은 예약어이므로 사용할 수 없다.
  • 사용자가 만든 컬렉션은 이름에 예약어인 $ 를 포함할 수 없다.


서브컬렉션

서브컬렉션의 네임스페이스에 .(마침표) 문자를 사용해 컬렉션을 체계화한다. (ex. blog.postsblog.authors )

서브컬렉션은 특별한 속성은 없지만 여러 몽고DB 툴에서 지원하므로 유용하다.

  • 큰 파일을 저장하는 프로토콜인 GridFS는 콘텐츠 데이터와 별도로 메타데이터를 저장하는 데 서브 컬렉션을 사용한다.
  • 대부분의 드라이버는 특정 컬렉션의 서브컬렉션에 접근하는 몇 가지 편리한 문법을 제공한다.


2.3. 데이터베이스


몽고DB는 컬렉션에 도큐먼트를 그룹화할 뿐 아니라 데이터베이스에 컬렉션을 그룹 지어 놓는다. 몽고DB의 단일 인스턴스는 여러 데이터베이스를 호스팅할 수 있으며, 각 데이터베이스를 완전히 독립적으로 취급할 수 있다.

데이터베이스는 컬렉션과 마찬가지로 이름으로 식별된다. 이는 몇 가지 제약 조건이 있다.

  • 빈 문자열(““)은 유효한 이름이 아니다.
  • / , \ , . , ' ' , * , < , > , : , | , ? , $ , \0 문자들을 포함할 수 없다.
  • 대소문자를 구별한다.
  • 최대 64바이트다.


직접 접근할 수는 있지만 특별한 의미를 갖는 예약된 데이터베이스 이름도 있다.

admin
인증과 권한 부여 역할을 한다.
local
단일 서버에 대한 데이터를 저장한다.
config
각 샤드의 정보를 저장한다.


컬렉션을 저장하는 데이터베이스의 이름을 컬렉션명 앞에 붙이면 네임스페이스를 얻는다. 예를 들어 cms 데이터베이스의 blog.posts 컬렉션을 사용한다면 컬렉션의 네임스페이스는 cms.blog.posts가 된다.


2.4. 몽고DB 시작


docker-compose를 활용하여 몽고DB를 실행하는 과정이다.

  1. data 폴더 생성
    1
    
     $ mkdir data
    
  2. docker-compose.yml 파일 생성
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
     version: "3.8"
     services:
         mongodb:
             image: mongo
             container_name: mongodb
             ports:
                 - 27017:27017
             volumes:
                 - ./data:/data/db
             environment:
                 - MONGO_INITDB_ROOT_USERNAME=root
                 - MONGO_INITDB_ROOT_PASSWORD=root
         mongo-express:
             image: mongo-express
             container_name: mongo-express
             restart: always
             ports:
                 - 8081:8081
             environment:
                 - ME_CONFIG_MONGODB_ADMINUSERNAME=root
                 - ME_CONFIG_MONGODB_ADMINPASSWORD=root
                 - ME_CONFIG_MONGODB_SERVER=mongodb
    
  3. docker-compose 실행
    1
    
     $ docker-compose up -d && docker-compose logs -f
    
  4. localhost:8081 로 접속한 후에 로그인
    • id : admin
    • pw : pass
  5. 셸로 접속
    1
    2
    3
    
     $ docker exec -it mongodb /bin/bash
     # 컨테이너로 접속 후
     $ mongosh
    


2.5. 몽고DB 셸 소개


셸은 관리 기능이나, 실행 중인 인스턴스를 점검하거나 간단한 기능을 시험하는 데 매우 유용하다.


2.5.1. 셸 실행


mongosh을 통해 셸을 시작한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
$ mongosh
Current Mongosh Log ID:	676e45c5d74e662877fc0420
Connecting to:		mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+2.3.4
Using MongoDB:		8.0.4
Using Mongosh:		2.3.4

For mongosh info see: https://www.mongodb.com/docs/mongodb-shell/


To help improve our products, anonymous usage data is collected and sent to MongoDB periodically (https://www.mongodb.com/legal/privacy-policy).
You can opt-out by running the disableTelemetry() command.

test>


셸은 완전한 자바스크립트 해석기이며 임의의 자바스크립트 프로그램을 실행한다.

1
2
3
4
test> x = 200;
200
test> x / 5;
40


또한 표준 자바스크립트 라이브러리의 모든 기능을 활용할 수 있다.

1
2
3
4
5
6
test> Math.sin(Math.PI / 2)
1
test> new Date("2019/1/1");
ISODate('2019-01-01T00:00:00.000Z')
test> "Hello, world!".replace("World", "MongoDB");
Hello, world!


심지어 자바스크립트 함수를 정의하고 호출할 수도 있다.

1
2
3
4
5
6
7
test> function factorial(n) {
... if (n <= 1) return 1;
... return n * factorial(n - 1);
... }
[Function: factorial]
test> factorial(5);
120


2.5.2. 몽고DB 클라이언트


셸은 시작할 때 몽고DB 서버의 test 데이터베이스에 연결하고, 데이터베이스 연결을 전역 변수 db 에 할당한다. 셸에서는 주로 이 변수를 통해 몽고DB에 접근한다.


현재 db에 할당된 데이터베이스를 확인하려면 db 를 입력한다.

1
2
test> db
test


데이터베이스 선택을 살펴보자.

1
2
3
test> use video
switched to db video
video>
  • 이와 같이 db 변수는 video 데이터베이스를 가리킨다.


자바스크립트 셸이므로 변수 이름을 입력하면 표현식으로 평가된다. 그런 다음 값이 출력된다. 다음과 같이 db 변수에서 컬렉션에 접근한다.

1
2
video> db.movies
video.movies
  • 현재 데이터베이스의 movies 컬렉션을 반환한다.


2.5.3. 셸 기본 작업


셸에서 CRUD 작업을 할 수 있다.


인증
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
db> use admin
switched to db admin
admin> db.auth('root','root')
{ ok: 1 }
admin> show users
[
  {
    _id: 'admin.root',
    userId: UUID('9a65918d-12f8-471c-b59b-51815739c9e7'),
    user: 'root',
    db: 'admin',
    roles: [ { role: 'root', db: 'admin' } ],
    mechanisms: [ 'SCRAM-SHA-1', 'SCRAM-SHA-256' ]
  }
]


생성

insertOne 함수는 컬렉션에 도큐먼트를 추가한다. 우선 도큐먼트를 나타내는 자바스크립트 객체인 movie라는 지역 변수를 생성한다.

1
2
3
4
5
6
7
8
video> movie = {"title" : "Star Wars: Episode IV - A New Hope",
... "director" : "George Lucas",
... "year" : 1977}
{
  title: 'Star Wars: Episode IV - A New Hope',
  director: 'George Lucas',
  year: 1977
}


insertOne 함수를 이용해 movies 컬렉션에 저장할 수 있다.

1
2
3
4
5
admin> db.movies.insertOne(movie)
{
  acknowledged: true,
  insertedId: ObjectId('676e4a4046b45663fdfc0422')
}
  • 다음과 같은 인증 에러가 발생하여 인증을 수행한 후에 movie를 저장했다.
    1
    
      MongoServerError[Unauthorized]: Command insert requires authentication
    


컬렉션에 find 를 호출해서 확인하자.

1
2
3
4
5
6
7
8
9
admin> db.movies.find().pretty()
[
  {
    _id: ObjectId('676e4a4046b45663fdfc0422'),
    title: 'Star Wars: Episode IV - A New Hope',
    director: 'George Lucas',
    year: 1977
  }
]
  • _id 키가 추가됐고 다른 키/값 쌍들은 입력한 대로 저장됐다.


읽기

findfindOne 은 컬렉션을 쿼리하는 데 사용한다. 단일 도큐먼트를 읽으려면 findOne 을 사용하면 된다.

1
2
3
4
5
6
7
admin> db.movies.findOne()
{
  _id: ObjectId('676e4a4046b45663fdfc0422'),
  title: 'Star Wars: Episode IV - A New Hope',
  director: 'George Lucas',
  year: 1977
}
  • findfindOne쿼리 도큐먼트 형태로 조건 전달도 가능하다.


갱신

데이터를 갱신하려면 updateOne 을 사용한다. updateOne 의 매개변수는 최소 두 개다. 첫 번째는 수정할 도큐먼트를 찾는 기준이고, 두 번째는 갱신 작업을 설명하는 도큐먼트이다. 갱신하려면 갱신 연산자인 set 을 이용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
admin> db.movies.updateOne({title: "Star Wars: Episode IV - A New Hope"},
... {$set : {reviews: []}})
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.movies.find().pretty()
[
  {
    _id: ObjectId('676e4a4046b45663fdfc0422'),
    title: 'Star Wars: Episode IV - A New Hope',
    director: 'George Lucas',
    year: 1977,
    reviews: []
  }
]
  • review 키가 생긴 것을 확인할 수 있다.


삭제

deleteOnedeleteMany 는 도큐먼트를 데이터베이스에서 영구적으로 삭제한다. 두 함수 모두 필터 도큐먼트로 삭제 조건을 지정한다.

1
2
3
4
admin> db.movies.deleteOne({title : "Star Wars: Episode IV - A New Hope"})
{ acknowledged: true, deletedCount: 1 }
admin> db.movies.find().pretty()

  • 필터와 일치하는 모든 도큐먼트를 삭제하려면 deleteMany 를 사용한다.


2.6. 데이터형


몽고DB는 도큐먼트의 값으로 다양한 데이터형을 지원한다.


2.6.1. 기본 데이터형


몽고DB에서 도큐먼트는 JSON과 닮았다고 생각할 수 있다. 이는 여섯 가지 데이터형만을 열거하다. 데이터형이 null, 불리언, 숫자, 문자열, 배열, 객체만 지원하기 때문에 표현력은 제한적이다.

JSON은 날짜형이 없어 날짜를 다루기가 까다롭다. 부동소수점형과 정수형을 표현하는 방법이 없고, 32비트와 64비트도 구별되지 않는다.

몽고DB는 JSON의 키/값 쌍 성질을 유지하면서 추가적인 데이터형을 지원한다. 가장 일반적인 데이터형은 다음과 같다.


null
null 값과 존재하지 않는 필드를 표현하는 데 사용한다.
불리언
참과 거짓 값에 사용한다.
숫자
셸은 64비트 부동소수점 수를 기본으로 사용한다. 4바이트 혹은 8바이트의 부호 정수는 각각 NumberInt 혹은 NumberLong 클래스를 사용한다.
문자열
어떤 UTF-8 문자열이든 표현할 수 있다.
날짜
몽고DB는 1970년 1월 1일부터의 시간을 1/1000초 단위로 나타내는 64비트 정수로 날짜를 저장한다.
정규표현식
쿼리는 자바스크립트의 정규 표현식 문법을 사용할 수 있다.
배열
값의 셋이나 리스트를 배열로 표현할 수 있다.
내장 도큐먼트
도큐먼트는 부모 도큐먼트의 값으로 내장된 도큐먼트 전체를 포함할 수 있다.
객체 ID
객체 ID는 도큐먼트용 12바이트 ID다. (ex ObjectId())


다음은 상대적으로 덜 사용되는 데이터형 목록이다.

이진 데이터
임의의 바이트 문자열이며 셸에서는 조작이 불가능하다.
코드
쿼리와 도큐먼트는 임의의 자바스크립트 코드를 포함할 수 있다. (ex. function () { /* ... */ })


2.6.2. 날짜


자바스크립트에서 Date 클래스는 몽고DB의 날짜를 표현하는 데 사용한다. 새로운 Date 객체를 생성할 때는 new Date() 를 호출해야 한다. 셸에서는 날짜가 현지 시간대 설정을 이용해 표시되며, 표준 시간대 정보는 없다.


2.6.3. 배열


배열은 정렬 연산(리스트, 스택, 큐)비정렬 연산(셋)에 호환성 있게 사용 가능한 값이다.

1
{"thighs" : ["pie", 3.14]}
  • 서로 다른 데이터형을 값으로 포함할 수 있다.
  • 심지어는 중첩 배열도 될 수 있다.


도큐먼트 내 배열의 장점으로 몽고DB가 배열의 구조를 ‘이해한다’는 점과, 배열의 내용에 작업을 수행하기 위해 내부에 도달하는 방법을 안다는 점이 있다. 따라서 배열에 쿼리하거나 배열의 내용을 이용해 인덱스를 만들 수 있다.


2.6.4. 내장 도큐먼트


도큐먼트는 키에 대한 값이 될 수 있는데 이를 내장 도큐먼트라고 한다.

1
2
3
4
5
6
7
8
{
	"name" : "John Doe",
	"address" : {
		"street" : "123 Park Street",
		"city" : "Anytown",
		"state" : "NY"
	}
}
  • address는 내장 도큐먼트이다.
  • 이러한 특성으로 몽고DB는 데이터 반복이 생길 수 있다는 단점이 있다.


2.6.5. _id와 ObjectId


몽고DB에 저장된 모든 도큐먼트는 _id 키를 가진다. _id 키 값은 어떤 데이터형이어도 상관없지만 ObjectId 가 기본이다. 하나의 컬렉션에서 모든 도큐먼트는 고유한 _id 값을 가지며, 이 값은 컬렉션 내 모든 도큐먼트가 고유하게 식별되게 한다.


ObjectIds

ObjectId_id 의 기본 데이터형이다. ObjectId 클래스는 가벼우면서도, 전역적으로 고유하게 생성하기 쉽게 설계됐다. ObjectId 를 사용하는 이유는 몽고DB의 분산 특성 때문이다.

ObjectId 는 12바이트 스토리지를 사용하며 24자리 16진수 문자열 표현이 가능하다. 바이트당 2자리를 사용한다.ObjectId 가 16진수 문자열로 표현되긴 하지만 실제로 문자열은 저장된 데이터의 두 배만큼 길다는 점을 알아두자.

ObjectId 의 첫 4바이트는 타임스탬프다. 다음 5바이트는 랜덤 값이다. 최종 3바이트는 서로 다른 시스템에서 충돌하는 ObjectId 들을 생성하지 않도록 랜덤 값으로 시작하는 카운터다. ObjectId 는 프로세스당 1초에 256^3(1677만7216)개까지 생성된다.


_id 자동 생성

도큐먼트를 입력할 때 _id 키를 명시하지 않으면 입력된 도큐먼트에 키가 자동으로 추가된다.


2.7. 몽고DB 셸 사용


다른 장비나 포트에 mongod를 연결하려면 셸을 시작할 때 호스트명, 포트, 데이터베이스를 명시해야 한다.

1
$ mongosh some-host:30000/myDB
  • db는 이제 some-host:30000의 myDB 데이터베이스를 참조한다.
  • --nodb 옵션을 사용하면 어디에도 연결되지 않은 채 셸을 시작할 수 있다.
  • 시작한 후 원하는 때에 new Mongo(호스트명) 를 실행함으로써 연결할 수 있다.


2.7.1. 셸 활용 팁


mongo는 자바스크립트 문서를 참조하면 유용하다. 또한 help를 입력하면 셸에 내장된 도움말을 볼 수 있다. 함수의 기능을 알고 싶으면 함수명을 괄호 없이 입력하면 된다.


2.7.2. 셸에서 스크립트 실행하기


다음과 같이 자바스크립트 파일을 셸로 전달해 실행할 수도 있다.

1
$ mongosh test.js


또한 load 함수를 사용해 대화형 셸에서 스크립트를 실행할 수도 있다.

1
2
test> load('test.js')
true


스크립트에 주어진 포트에서 로컬로 실행되는 데이터베이스에 연결하고 해당 연결에 db를 설정하는 connectTo 함수를 정의하여 사용할 수 있다. 자주 하는 작업이나 관리 작업을 자동화하는 데 셸을 사용한다.


2.7.3. .mongorc.js 만들기


자주 로드되는 스크립트를 .mongorc.js 파일에 넣을 수 있다.

1
2
3
4
5
6
// .mongorc.js

var compliment = ["attractive", "intelligent", "like Batman"];
var index = Math.floor(Math.random()*3);

print("Hello, " + compliment[index] + " today!");
  • 그러면 셸을 시작할 때마다 다음과 같은 문구가 뜬다.


.mongorc.js 스크립트로 사용하고 싶은 전역 변수를 설정하고, 긴 별칭을 짧게 만들고, 내장 함수를 재정의한다. .mongorc.js 의 일반적인 용법 중 하나는 위험한 명령어를 제거하는 것이다. dropDatabasedeleteIndexes 같은 함수가 아무것도 수행하지 않게 재정의하거나 모두 선언 해제한다.

1
2
3
4
5
6
7
8
9
10
11
12
var no = function() {
	print("Not on my watch.");
}

// 데이터베이스 삭제 방지
db.dropDatabase = DB.prototype.dropDatabase = no;

// 컬렉션 삭제 방지
DBCollection.prototype.drop = no;

// 인덱스 삭제 방지
DBCollection.prototype.dropIndex = no;


2.7.4. 프롬프트 커스터마이징하기


기본 셸 프롬프트는 문자열이나 함수에 prompt 변수를 설정해 재정의한다.

1
2
3
admin> prompt = function() { return (new Date()) + "> "; };
[Function: prompt]
Fri Dec 27 2024 08:51:16 GMT+0000 (Coordinated Universal Time)>


일반적으로 프롬프트 함수는 getLastError 호출을 포함해야 한다. 그러면 셸의 연결이 끊겼을 때 쓰기에서의 오류를 감지해서 자동으로 다시 연결해준다.


2.7.5. 복잡한 변수 수정하기


셸에서 다중행 지원은 제한적이며 이전 행들을 편집할 수 없다. 따라서 코드나 객체 블록이 크면 편집기에서 편집하면 좋다. 셸에서 EDITOR 변수를 설정하자.


2.7.6. 불편한 컬렉션명


예약어로 되어 있는 컬렉션명을 사용한다면 getCollection 함수를 사용해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
admin> db.version
[Function: version] AsyncFunction {
  apiVersions: [ 0, 0 ],
  returnsPromise: true,
  serverVersions: [ '0.0.0', '999.999.999' ],
  topologies: [ 'ReplSet', 'Sharded', 'LoadBalanced', 'Standalone' ],
  returnType: { type: 'unknown', attributes: {} },
  deprecated: false,
  platforms: [ 'Compass', 'Browser', 'CLI' ],
  isDirectShellCommand: false,
  acceptsRawInput: false,
  shellCommandCompleter: undefined,
  help: [Function (anonymous)] Help
}
admin> db.getCollection("version")
admin.version
  • 또한 유효하지 않은 문자로 컬렉션명을 만들었을 때도 사용된다.


또 다른 방법으로 배열 접근 구문을 사용해 유효하지 않은 속성명을 피할 수 있다. 자바스크립트에서 x.yx['y'] 와 동일하다.

1
2
3
4
5
6
7
8
admin> var collections = ["posts", "comments", "authors"];

admin> for (var i in collections) {
... print(db.blog[collections[i]]);
... }
admin.blog.posts
admin.blog.comments
admin.blog.authors


3. 도큐먼트 생성, 갱신, 삭제


데이터베이스에서의 기본적인 데이터 입출력을 다룬다.


3.1. 도큐먼트 삽입


삽입은 몽고DB에 데이터를 추가하는 기본 방법이다.

1
2
3
4
5
6
7
admin> db.movies.insertOne({"title" : "Stand by Me"})
{
  acknowledged: true,
  insertedId: ObjectId('676e6d44501ea8f45afc0421')
}
admin> db.movies.findOne()
{ _id: ObjectId('676e6d44501ea8f45afc0421'), title: 'Stand by Me'
  • 도큐먼트에 _id 키가 추가되고 도큐먼트가 저장된다.


3.1.1. insertMany


여러 도큐먼트를 컬렉션에 삽입하려면 insertMany 로 도큐먼트 배열을 데이터베이스에 전달한다. 도큐먼트를 대량 삽입하므로 훨씬 더 효율적이다.

1
2
3
4
5
6
7
8
admin> db.movies.insertMany([{"title" : "Ghostbusters"}, {"title": "E.T."}]);
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('676e6de3501ea8f45afc0422'),
    '1': ObjectId('676e6de3501ea8f45afc0423')
  }
}
  • 48MB보다 큰 메시지를 허용하지 않으므로 이보다 클 경우에는 일괄 삽입 여러 개로 분할한다.


insertMany 를 사용해 대량 삽입할 때 배열 중간에서 오류가 발생하는 경우, 정렬 연산을 선택했는지 혹은 비정렬 연산을 선택했는지에 따라 달라진다. insertMany 에 대한 두 번째 매개변수로 옵션 도큐먼트를 지정할 수 있다. 옵션 도큐먼트에 ordered 키에 true 를 지정한다. false 를 지정하면 몽고DB가 성능을 개선하려고 삽입을 재배열할 수 있다. 정렬되지 않은 삽입의 경우 일부 삽입이 오류를 발생시키는지 여부에 관계없이 모든 도큐먼트 삽입을 시도한다.


다음 예에서는 정렬된 삽입이 기본값이므로 처음 두 개의 도큐먼트만 삽입된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
admin> db.movies.insertMany([
... {"_id" : 0, "title" : "test1"},
... {"_id" : 1, "title" : "test2"},
... {"_id" : 1, "title" : "test3"},
... {"_id" : 2, "title" : "test4"}])
Uncaught:
MongoBulkWriteError: E11000 duplicate key error collection: admin.movies index: _id_ dup key: { _id: 1 }
Result: BulkWriteResult {
  insertedCount: 2,
  matchedCount: 0,
  modifiedCount: 0,
  deletedCount: 0,
  upsertedCount: 0,
  upsertedIds: {},
  insertedIds: { '0': 0, '1': 1 }
}
Write Errors: [
  WriteError {
    err: {
      index: 2,
      code: 11000,
      errmsg: 'E11000 duplicate key error collection: admin.movies index: _id_ dup key: { _id: 1 }',
      errInfo: undefined,
      op: { _id: 1, title: 'test3' }
    }
  }
]
admin> db.movies.find()
[
  { _id: ObjectId('676e6d44501ea8f45afc0421'), title: 'Stand by Me' },
  { _id: ObjectId('676e6de3501ea8f45afc0422'), title: 'Ghostbusters' },
  { _id: ObjectId('676e6de3501ea8f45afc0423'), title: 'E.T.' },
  { _id: 0, title: 'test1' },
  { _id: 1, title: 'test2' }


다음과 같이 정렬되지 않은 삽입을 지정하면 배열의 첫 번째, 두 번째, 네 번째 도큐먼트가 삽입된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
admin> db.movies.insertMany([ { "_id": 0, "title": "test1" }, { "_id": 1, "title": "test2" }, { "_id": 1, "title": "test3" }, { "_id": 2, "title": "test4" }], {"ordered" : false})
Uncaught:
MongoBulkWriteError: E11000 duplicate key error collection: admin.movies index: _id_ dup key: { _id: 1 }
Result: BulkWriteResult {
  insertedCount: 3,
  matchedCount: 0,
  modifiedCount: 0,
  deletedCount: 0,
  upsertedCount: 0,
  upsertedIds: {},
  insertedIds: { '0': 0, '1': 1, '3': 2 }
}
Write Errors: [
  WriteError {
    err: {
      index: 2,
      code: 11000,
      errmsg: 'E11000 duplicate key error collection: admin.movies index: _id_ dup key: { _id: 1 }',
      errInfo: undefined,
      op: { _id: 1, title: 'test3' }
    }
  }
]
admin> db.movies.find()
[
  { _id: ObjectId('676e6d44501ea8f45afc0421'), title: 'Stand by Me' },
  { _id: ObjectId('676e6de3501ea8f45afc0422'), title: 'Ghostbusters' },
  { _id: ObjectId('676e6de3501ea8f45afc0423'), title: 'E.T.' },
  { _id: 0, title: 'test1' },
  { _id: 1, title: 'test2' },
  { _id: 2, title: 'test4' }
]


3.1.2. 삽입 유효성 검사


몽고DB는 삽입된 데이터에 최소한의 검사를 수행한다. 도큐먼트의 기본 구조를 검사해 _id 필드가 존재하지 않으면 새로 추가하고, 모든 도큐먼트는 16MB보다 작아야 하므로 크기를 검사한다. doc 라는 도큐먼트의 Binary JSON 크기를 보려면 셸에서 object.bsonsize(doc) 를 실행한다.


3.2. 도큐먼트 삭제


deleteOnedeleteMany 를 통해 데이터를 삭제할 수 있다. 두 메서드 모두 필터 도큐먼트를 첫 번째 매개변수로 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
admin> db.movies.find()
[
  { _id: ObjectId('676e6d44501ea8f45afc0421'), title: 'Stand by Me' },
  { _id: ObjectId('676e6de3501ea8f45afc0422'), title: 'Ghostbusters' },
  { _id: ObjectId('676e6de3501ea8f45afc0423'), title: 'E.T.' },
  { _id: 0, title: 'test1' },
  { _id: 1, title: 'test2' },
  { _id: 2, title: 'test4' }
]
admin> db.movies.deleteOne({"_id" : 0})
{ acknowledged: true, deletedCount: 1 }
admin> db.movies.find()
[
  { _id: ObjectId('676e6d44501ea8f45afc0421'), title: 'Stand by Me' },
  { _id: ObjectId('676e6de3501ea8f45afc0422'), title: 'Ghostbusters' },
  { _id: ObjectId('676e6de3501ea8f45afc0423'), title: 'E.T.' },
  { _id: 1, title: 'test2' },
  { _id: 2, title: 'test4' }
]
  • 여러 도큐먼트와 일치할 경우 첫 번째 도큐먼트를 삭제한다.


필터와 일치하는 모든 도큐먼트를 삭제하려면 deleteMany 를 사용한다.

1
2
3
4
# 조건에 맞는 도큐먼트 목록 제거
admin> db.movies.deleteMany({"year" : 1984})
# 모든 도큐먼트 제거
admin> db.movies.deleteMany({})


3.2.1. drop


전체 컬렉션을 삭제하려면 drop 을 사용하는 편이 더 빠르다.

1
2
admin> db.movies.drop()
true
  • 이는 전부 삭제한 후에 빈 컬렉션에 인덱스를 재생성한다.


3.3. 도큐먼트 갱신


도큐먼트를 데이터베이스에 저장한 후에는 updateOne , updateMany , replaceOne 과 같은 갱신 메서드를 사용해 변경한다. updateOneupdateMany 는 필터 도큐먼트를 첫 번째 매개변수로, 변경 사항을 설명하는 수정자 도큐먼트를 두 번째 매개변수로 사용한다. replaceOne 는 두 번째 매개변수가 필터와 일치하는 도큐먼트를 교체할 도큐먼트로 지정된다. 갱신은 원자적으로 이뤄진다. 갱신 요청 두 개가 동시에 발생하면 먼저 도착한 요청이 먼저 적용된다.


3.3.1. 도큐먼트 치환


replaceOne 은 도큐먼트를 새로운 것으로 완전히 치환한다. 예를 들어 사용자 도큐먼트를 다음과 같이 변경한다고 가정하자.

1
2
3
4
5
6
{
	"_id" : ObjectId("4b93819219asdsab"),
	"name" : "joe",
	"friends" : 32,
	"enemies" : 2
}
  • friendsenemies 필드를 relationships 라는 서브도큐먼트로 옮겨보자.


셸에서 도큐먼트의 구조를 수정한 후 replaceOne 을 사용해 데이터베이스의 버전을 교체한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
admin> var joe = db.users.findOne({"name" : "joe"});

admin> joe.relationships = {"friends" : joe.friends, "enemies" : joe.enemies};
{ friends: 32, enemies: 2 }
admin> joe.username = joe.name;
joe
admin> delete joe.friends;
true
admin> delete joe.friends;
true
admin> delete joe.enemies;
true
admin> delete joe.name;
true
admin> db.users.replaceOne({"name" : "joe"}, joe);
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.users.find()
[
  {
    _id: ObjectId('676e7528501ea8f45afc0424'),
    relationships: { friends: 32, enemies: 2 },
    username: 'joe'
  }
]


갱신할 경우, 고유한 도큐먼트를 갱신 대상으로 지정하는 것이 좋다.

1
admin> db.users.replaceOne({"_id" : ObjectId('676e7528501ea8f45afc0424')}, joe)
  • _id 값이 컬렉션 기본 인덱스의 기초를 형성하므로 필터에 _id 를 사용해도 효율적이다.


3.3.2. 갱신 연산자


부분 갱신에는 원자적 갱신 연산자를 사용한다. 갱신 연산자는 키를 변경, 추가, 제거하고, 심지어 배열과 내장 도큐먼트를 조작하는 복잡한 갱신 연산을 지정하는 데 사용한다.


다음과 같은 예시가 있다고 가정하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
admin> db.pageviews.insertOne({"url" : "www.example.com", "pageviews" : 52})
{
  acknowledged: true,
  insertedId: ObjectId('676e76cf501ea8f45afc0425')
}
admin> db.pageviews.find()
[
  {
    _id: ObjectId('676e76cf501ea8f45afc0425'),
    url: 'www.example.com',
    pageviews: 52
  }
]


누군가가 페이지를 방문할 때마다 URL로 페이지를 찾고 pageviews 키의 값을 증가시키려면 $inc 제한자를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
admin> db.pageviews.updateOne({"url" : "www.example.com"},
... {"$inc" : {"pageviews" : 1}})
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.pageviews.find()
[
  {
    _id: ObjectId('676e76cf501ea8f45afc0425'),
    url: 'www.example.com',
    pageviews: 53
  }
]
  • 연산자를 사용할 때 _id 값은 변경할 수 없다.


$set 제한자 사용하기

$set 은 필드 값을 설정한다. 필드가 존재하지 않으면 새 필드가 생성된다. 이 기능은 스키마를 갱신하거나 사용자 정의 키를 추가할 때 편리하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
admin> db.users.find()
[
  {
    _id: ObjectId('676e7528501ea8f45afc0424'),
    relationships: { friends: 32, enemies: 2 },
    username: 'joe'
  }
]
admin> db.users.updateOne({"username" : "joe"}, {"$set" : {"favorite book" : "War and Peace"}})
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.users.find()
[
  {
    _id: ObjectId('676e7528501ea8f45afc0424'),
    relationships: { friends: 32, enemies: 2 },
    username: 'joe',
    'favorite book': 'War and Peace'
  }
]


$set 은 키의 데이터형도 변경할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
admin> db.users.updateOne({ "username": "joe" }, { "$set": { "favorite book": ["Cat's Cradle", "Foundation Trilogy", "Ender's Game"] } });
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.users.find()
[
  {
    _id: ObjectId('676e7528501ea8f45afc0424'),
    relationships: { friends: 32, enemies: 2 },
    username: 'joe',
    'favorite book': [ "Cat's Cradle", 'Foundation Trilogy', "Ender's Game" ]
  }
]


$unset 으로 키와 값을 모두 제거할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
admin> db.users.updateOne({ "username": "joe" }, { "$unset": { "favorite book": 1 } });
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.users.find()
[
  {
    _id: ObjectId('676e7528501ea8f45afc0424'),
    relationships: { friends: 32, enemies: 2 },
    username: 'joe'
  }


$set 은 내장 도큐먼트 내부의 데이터를 변경할 때도 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
admin> var joe = db.users.findOne({"username" : "joe"});

admin> joe
{
  _id: ObjectId('676e7528501ea8f45afc0424'),
  relationships: { friends: 32, enemies: 2 },
  username: 'joe'
}
admin> joe.author = {"name": "joe"}
{ name: 'joe' }
admin> db.users.replaceOne({"username":"joe"}, joe);
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.users.find()
[
  {
    _id: ObjectId('676e7528501ea8f45afc0424'),
    relationships: { friends: 32, enemies: 2 },
    username: 'joe',
    author: { name: 'joe' }
  }
]
admin> db.users.updateOne({"username" : "joe"}, {"$set" : { "author.name" : "joe2" } });
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.users.find()
[
  {
    _id: ObjectId('676e7528501ea8f45afc0424'),
    relationships: { friends: 32, enemies: 2 },
    username: 'joe',
    author: { name: 'joe2' }
  }
]
  • 키를 추가, 변경, 삭제할 때는 항상 $ 제한자를 사용해야 한다.
  • 갱신 연산자를 포함하지 않고 값을 수정하려 하면 오류가 발생한다.


증가와 감소

$inc 연산자는 이미 존재하는 키의 값을 변경하거나 새 키를 생성하는 데 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
admin> db.games.insertOne({"game" : "pinball", "user" : "joe"})
{
  acknowledged: true,
  insertedId: ObjectId('67722441b151887453fc0421')
}
admin> db.games.updateOne({"game" : "pinball", "user" : "joe"},
... {"$inc" : { "score" : 50 }});
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.games.find()
[
  {
    _id: ObjectId('67722441b151887453fc0421'),
    game: 'pinball',
    user: 'joe',
    score: 50
  }
]
admin> db.games.updateOne({ "game": "pinball", "user": "joe" }, { "$inc": { "score": 10000 } });
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.games.find()
[
  {
    _id: ObjectId('67722441b151887453fc0421'),
    game: 'pinball',
    user: 'joe',
    score: 10050
  }
]
  • $incint , long , double , decinal 타입 값에만 사용할 수 있다.
  • null , 불리언, 문자열과 같은 데이터형의 값에는 사용할 수 없다.


배열 연산자

배열을 다루는 데 갱신 연산자를 사용할 수 있다.


요소 추가하기

$push 는 배열이 이미 존재하면 배열 끝에 요소를 추가하고, 존재하지 않으면 새로운 배열을 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
admin> db.blogs.posts.insertOne({"title" : "A blog post", "content" : "contents"})
{
  acknowledged: true,
  insertedId: ObjectId('677225b0b151887453fc0422')
}
admin> db.blogs.posts.findOne()
{
  _id: ObjectId('677225b0b151887453fc0422'),
  title: 'A blog post',
  content: 'contents'
}
admin> db.blogs.posts.updateOne({"title" : "A blog post"},
... {"$push" : {"comments" : { "name" : "joe", "email" : "joe@example.com", "content" : "nice posts."}}})
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.blogs.posts.findOne()
{
  _id: ObjectId('677225b0b151887453fc0422'),
  title: 'A blog post',
  content: 'contents',
  comments: [ { name: 'joe', email: 'joe@example.com', content: 'nice posts.' } ]
}


$push$each 제한자를 사용하면 작업 한 번으로 값을 여러 개 추가할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
admin> db.blogs.posts.updateOne({"title" : "A blog post"},
... {"$push" : {"reviews" : {"$each" : [ 1.0, 2.0, 3.0] }}});
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.blogs.posts.findOne()
{
  _id: ObjectId('677225b0b151887453fc0422'),
  title: 'A blog post',
  content: 'contents',
  comments: [ { name: 'joe', email: 'joe@example.com', content: 'nice posts.' } ],
  reviews: [ 1, 2, 3 ]
}


배열을 특정 길이로 늘이려면 $slice$push 와 결합해 사용한다. 배열이 특정 크기 이상으로 늘어나지 않게 하고 효과적으로 ‘top N’ 목록을 만들 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
admin> db.blogs.posts.updateOne({ "title": "A blog post" }, { "$push": { "top10": { "$each": [1.0, 2.0, 3.0], "$slice" : - 1 } } });
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.blogs.posts.findOne()
{
  _id: ObjectId('677225b0b151887453fc0422'),
  title: 'A blog post',
  content: 'contents',
  comments: [ { name: 'joe', email: 'joe@example.com', content: 'nice posts.' } ],
  reviews: [ 1, 2, 3 ],
  top10: [ 3 ]
}
  • 요소의 개수를 1개로 제한한다.
  • 따라서 $slice 는 도큐먼트 내에 큐를 생성하는 데 사용할 수 있다.


마지막으로, 트리밍하기 전에 $sort 제한자를 $push 작업에 적용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
admin> db.blogs.posts.updateOne({ "title": "A blog post" },
... {"$push" : {"related" : {"$each" : [{"name" : "post1", "rating" : 2.0},
... {"name" : "post2", "rating" : 1.0},
... {"name" : "post3", "rating" : 3.0}],
... "$slice" : -2,
... "$sort" : {"rating" : -1}}}});
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.blogs.posts.findOne()
{
  _id: ObjectId('677225b0b151887453fc0422'),
  title: 'A blog post',
  content: 'contents',
  comments: [ { name: 'joe', email: 'joe@example.com', content: 'nice posts.' } ],
  reviews: [
    1, 2, 3, 4, 5,
    6, 4, 5, 6, 4,
    5, 6
  ],
  top10: [ 3 ],
  related: [ { name: 'post1', rating: 2 }, { name: 'post2', rating: 1 } ]
}


배열을 집합으로 사용하기

특정 값이 배열에 존재하지 않을 때 해당 값을 추가하면서, 배열을 집합처럼 처리하려면 쿼리 도큐먼트에 $ne 를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
admin> db.blogs.posts.updateOne({"title": {"$ne" : "post2"}}, {"$push": {"reviewContent" : "contents3"}});
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.blogs.posts.find({})
[
  {
    _id: ObjectId('677225b0b151887453fc0422'),
    title: 'A blog post',
    content: 'contents',
    comments: [
      { name: 'joe', email: 'joe@example.com', content: 'nice posts.' }
    ],
    reviews: [
      1, 2, 3, 4, 5,
      6, 4, 5, 6, 4,
      5, 6
    ],
    top10: [ 3 ],
    related: [
      { name: 'post3', rating: 5 },
      { name: 'post3', rating: 5 },
      { name: 'post3', rating: 5 },
      { name: 'post1', rating: 6 },
      { name: 'post2', rating: 7 }
    ],
    reviewContent: [ 'contents3' ]
  },
  {
    _id: ObjectId('677229dcb151887453fc0426'),
    title: 'post2',
    content: 'contents2'
  }
]


$addToSet 을 사용할 수도 있다. $addToSet 을 사용하면 중복을 피할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
admin> db.users.updateOne({"_id" : ObjectId('676e7528501ea8f45afc0424')}, {"$addToSet" : {"emails" : "joe@gmail.com"}})
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.users.findOne()
{
  _id: ObjectId('676e7528501ea8f45afc0424'),
  relationships: { friends: 32, enemies: 2 },
  username: 'joe',
  author: { name: 'joe2' },
  emails: [ 'joe@gmail.com' ]
}
  • 고유한 값을 여러 개 추가하려면 $addToSet$each 를 결합해 사용한다.


중복을 피해서 두 개 이상의 값을 추가하려면 다음과 같이 연산자를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
admin> db.users.find()
[
  {
    _id: ObjectId('676e7528501ea8f45afc0424'),
    relationships: { friends: 32, enemies: 2 },
    username: 'joe',
    author: { name: 'joe2' },
    emails: [ 'joe@gmail.com', 'joe@email.com', 'joe2@email.com' ]
  }
]
admin> db.users.updateOne({ "_id": ObjectId('676e7528501ea8f45afc0424') }, { "$addToSet": { "emails": { "$each": ["joe@email.com", "joe2@email.com"] } } });
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 0,
  upsertedCount: 0
}
admin> db.users.find()
[
  {
    _id: ObjectId('676e7528501ea8f45afc0424'),
    relationships: { friends: 32, enemies: 2 },
    username: 'joe',
    author: { name: 'joe2' },
    emails: [ 'joe@gmail.com', 'joe@email.com', 'joe2@email.com' ]
  }
]


요소 제거하기

배열의 양쪽 끝에서 요소를 제거하는 $pop 을 사용한다. {"$pop" : {"key" : 1}} 은 배열의 마지막부터 요소를 제거하고, {"$pop" : {"key" : -1}} 은 배열의 처음부터 요소를 제거한다. $pull 은 주어진 조건에 맞는 배열 요소를 제거하는 데 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
admin> db.lists.insertOne({"todo" : ["dishes", "laundry", "dry cleaning"]});
{
  acknowledged: true,
  insertedId: ObjectId('677388feb151887453fc0427')
}
admin> db.lists.find()
[
  {
    _id: ObjectId('677388feb151887453fc0427'),
    todo: [ 'dishes', 'laundry', 'dry cleaning' ]
  }
]
admin> db.lists.updateOne({}, {"$pull" : {"todo" : "laundry"}})
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.lists.find()
[
  {
    _id: ObjectId('677388feb151887453fc0427'),
    todo: [ 'dishes', 'dry cleaning' ]
  }
]
  • $pull 은 도큐먼트에서 조건과 일치하는 요소를 모두 제거한다.
  • 배열 연산자는 배열을 값으로 갖는 키에만 사용한다.
  • 스칼라값을 변경하려면 $set 이나 $inc 를 사용한다.


배열의 위치 기반 변경

배열 내 여러 값을 다루려면 위치를 이용하거나 위치 연산자를 사용하면 된다. 배열 인덱스는 기준이 0이며, 배열 요소는 인덱스를 도큐먼트의 키처럼 사용한다.

배열의 첫 번째의 값을 변경하려면 다음과 같이 변경하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
admin> db.blog.posts.findOne()
{
  _id: ObjectId('67738a68b151887453fc0428'),
  content: 'asdasd',
  comments: [
    { comment: 'good post', author: 'John', votes: 0 },
    { comment: 'test1', author: 'tester1', votes: 3 },
    { comment: 'test2', author: 'tester2', votes: -5 }
  ]
}
admin> db.blog.posts.updateOne({ "comments.author": "John" }, { "$set": { "comments.$.author": "Jim" } });
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.blog.posts.findOne()
{
  _id: ObjectId('67738a68b151887453fc0428'),
  content: 'asdasd',
  comments: [
    { comment: 'good post', author: 'Jim', votes: 0 },
    { comment: 'test1', author: 'tester1', votes: 3 },
    { comment: 'test2', author: 'tester2', votes: -5 }
  ]
}
  • 몽고DB에서는 쿼리 도큐먼트와 일치하는 배열 요소 및 요소의 위치를 알아내서 갱신하는 위치 연산자 $ 를 제공한다.
  • 위치 연산자는 첫 번째로 일치하는 요소만 갱신한다.


배열 필터를 이용한 갱신

개별 배열 요소를 갱신하는 배열 필터인 arrayFilters 를 도입해 특정 조건에 맞는 배열 요소를 갱신할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
admin> db.blog.posts.find()
[
  {
    _id: ObjectId('67738a68b151887453fc0428'),
    content: 'asdasd',
    comments: [
      { comment: 'good post', author: 'Jim', votes: 0 },
      { comment: 'test1', author: 'tester1', votes: 3 },
      { comment: 'test2', author: 'tester2', votes: -5 }
    ]
  }
]
admin> db.blog.posts.updateOne( { "_id" : ObjectId('67738a68b151887453fc0428') }, { "$set": { "comments.$[elem].hidden": true } }, { arrayFilters: [{ "elem.votes": { $lte: -5 } }] } )
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
admin> db.blog.posts.find()
[
  {
    _id: ObjectId('67738a68b151887453fc0428'),
    content: 'asdasd',
    comments: [
      { comment: 'good post', author: 'Jim', votes: 0 },
      { comment: 'test1', author: 'tester1', votes: 3 },
      { comment: 'test2', author: 'tester2', votes: -5, hidden: true }
    ]
  }
]
  • comments 배열의 각 일치 요소에 대한 식별자로 elem 을 정의한다.
  • elem 이 식별한 댓글의 투표값이 -5 이하면 hidden 필드를 추가하고 값을 true 로 설정한다.


3.3.3. 갱신 입력


갱신 입력은 특수한 형태를 갖는 갱신이다. 갱신 조건에 맞는 도큐먼트가 존재하지 않을 때 새로운 도큐먼트를 생성한다. 조건에 맞는 도큐먼트가 발견되면 일반적인 갱신을 수행한다.

갱신 입력을 사용하면 코드를 줄이고 경쟁 상태를 피할 수 있다. updateOneupdateMany 의 세 번째 매개변수는 옵션 도큐먼트로, 갱신 입력을 지정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
admin> db.analytics.updateOne({"url" : "/blog"}, {"$inc" : {"pageviews" : 1}}, {"upsert" : true})
{
  acknowledged: true,
  insertedId: ObjectId('6785cc7f2612814ad1e5ee49'),
  matchedCount: 0,
  modifiedCount: 0,
  upsertedCount: 1
}
admin> db.analytics.findOne()
{
  _id: ObjectId('6785cc7f2612814ad1e5ee49'),
  url: '/blog',
  pageviews: 1
}


도큐먼트가 생성될 때 필드가 설정돼야 할 때가 종종 있는데, 이후 갱신에서는 변경되지 않아야 한다. 이때 $setOneInsert 를 사용한다. $setOnInsert 는 도큐먼트가 삽입될 때 필드 값을 설정하는 데만 사용하는 연산자다. 다음과 같이 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
admin> db.users.find()

admin> db.users.updateOne({}, { "$setOnInsert": { "createdAt": new Date() } }, { "upsert": true })
{
  acknowledged: true,
  insertedId: ObjectId('6785cf822612814ad1e5ee4a'),
  matchedCount: 0,
  modifiedCount: 0,
  upsertedCount: 1
}
admin> db.users.find()
[
  {
    _id: ObjectId('6785cf822612814ad1e5ee4a'),
    createdAt: ISODate('2025-01-14T02:44:18.513Z')
  }
]
admin> db.users.updateOne({}, { "$setOnInsert": { "createdAt": new Date() } }, { "upsert": true })
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 0,
  upsertedCount: 0
}
admin> db.users.find()
[
  {
    _id: ObjectId('6785cf822612814ad1e5ee4a'),
    createdAt: ISODate('2025-01-14T02:44:18.513Z')
  }
]
  • 다시 갱신하면 기존 도큐먼트를 찾고, 아무것도 입력되지 않으며, createdAt 필드는 변경되지 않는다.
  • $setOnInsert 는 패딩을 생성하고 카운터를 초기화하는 데 쓰이며, ObjectId 를 사용하지 않는 컬렉션에 유용하다.


저장 셸 보조자

save 는 도큐먼트가 존재하지 않으면 도큐먼트를 삽입하고, 존재하면 도큐먼트를 갱신하게 하는 셸 함수다. 도큐먼트가 _id 키를 포함하면 save 는 갱신 입력을 실행하고, 포함하지 않으면 삽입을 실행한다.


3.3.4. 다중 도큐먼트 갱신


updateOne 은 필터 조건에 맞는 첫 번째 도큐먼트만 갱신한다. 조건에 맞는 도큐먼트를 모두 수정하려면 updateMany 를 사용하자.

updateMany 는 스키마를 변경하거나 특정 사용자에 새로운 정보를 추가할 때 쓰기 좋다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
admin> db.users.insertMany([{birthday: "10/13/1978"}, {birthday: "10/13/1978"}, {birthday: "10/13/1978"}])
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('678625a83d7174bdf6fc0425'),
    '1': ObjectId('678625a83d7174bdf6fc0426'),
    '2': ObjectId('678625a83d7174bdf6fc0427')
  }
}
admin> db.users.find()
[
  { _id: ObjectId('678625a83d7174bdf6fc0425'), birthday: '10/13/1978' },
  { _id: ObjectId('678625a83d7174bdf6fc0426'), birthday: '10/13/1978' },
  { _id: ObjectId('678625a83d7174bdf6fc0427'), birthday: '10/13/1978' }
]
admin> db.users.updateMany({"birthday" : "10/13/1978"},
... {"$set" : {"gift" : "Happy Birthday!"}})
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 3,
  modifiedCount: 3,
  upsertedCount: 0
}
admin> db.users.find()
[
  {
    _id: ObjectId('678625a83d7174bdf6fc0425'),
    birthday: '10/13/1978',
    gift: 'Happy Birthday!'
  },
  {
    _id: ObjectId('678625a83d7174bdf6fc0426'),
    birthday: '10/13/1978',
    gift: 'Happy Birthday!'
  },
  {
    _id: ObjectId('678625a83d7174bdf6fc0427'),
    birthday: '10/13/1978',
    gift: 'Happy Birthday!'
  }
]


3.3.5. 갱신한 도큐먼트 반환


findOneAndDelete , findOneAndReplace , findOneAndUpdate 를 통해 수정된 도큐먼트를 반환받을 수 있다. 도큐먼트의 값을 원자적으로 얻을 수 있으며, 갱신을 위한 집계 파이프라인을 수용하도록 findOneAndUpdate 를 확장했다. 파이프라인은 $addFields($set) , $project($unset) , $replaceRoot($replaceWith) 로 구성될 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
admin> db.process.findOneAndUpdate({ "status": "DONE" }, { "$set": { "status": "RUNNING" } }, { "sort": { "priority": -1 } })
{
  _id: ObjectId('678626e23d7174bdf6fc0429'),
  status: 'DONE',
  priority: 1
}
admin> db.process.find()
[
  {
    _id: ObjectId('678626e23d7174bdf6fc0429'),
    status: 'RUNNING',
    priority: 1
  }
]
  • findOneAndReplace 는 동일한 매개변수를 사용하며, returnNewDocument 의 값에 따라 교체 전이나 후에 필터와 일치하는 도큐먼트를 반환한다.
  • findOneAndDelete 도 유사하지만 갱신 도큐먼트를 매개변수로 사용하지 않으며 다른 두 메서드의 옵션을 부분적으로 가진다.
  • findOneAndDelete 는 삭제된 도큐먼트를 반환한다.


4. 쿼리


이 장에서는 쿼리를 자세히 살펴본다.

  • $ 조건절을 이용해 범위 쿼리, 셋의 포함 관계, 부동 관계 쿼리 등을 수행한다.
  • 쿼리는 필요할 때마다 도큐먼트 배치를 반환하는 데이터베이스 커서를 반환한다.
  • 커서를 이용해 결과를 몇 개 건너뛰거나, 반환하는 결과 수를 제한하거나, 결과를 정렬하는 등 다양한 메타 연산을 수행한다.


4.1. find 소개


find 함수는 쿼리에 사용한다. 쿼리는 컬렉션에서 도큐먼트의 서브셋을 반환한다. 다음 명령은 컬렉션의 모든 도큐먼트와 일치하므로 모든 도큐먼트를 반환한다.

1
admin> db.c.find()


쿼리 도큐먼트에 여러 키/값 쌍을 추가해 검색을 제한할 수 있다.

1
2
3
admin> db.users.find({"age" : 27})
admin> db.users.find({"username" : "joe"})
admin> db.users.find({"username" : "joe", "age" : 27})


4.1.1. 반환받을 키 지정


반환받을 도큐먼트 내 키/값 정보가 모두 필요하지 않을 수 있다. 그럴 때는 find 의 두 번째 매개변수에 원하는 키를 지정하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
admin> db.users.find()
[
  {
    _id: ObjectId('678625a83d7174bdf6fc0425'),
    birthday: '10/13/1978',
    gift: 'Happy Birthday!'
  },
  {
    _id: ObjectId('678625a83d7174bdf6fc0426'),
    birthday: '10/13/1978',
    gift: 'Happy Birthday!'
  },
  {
    _id: ObjectId('678625a83d7174bdf6fc0427'),
    birthday: '10/13/1978',
    gift: 'Happy Birthday!'
  }
]
admin> db.users.find({}, {"birthday":1})
[
  { _id: ObjectId('678625a83d7174bdf6fc0425'), birthday: '10/13/1978' },
  { _id: ObjectId('678625a83d7174bdf6fc0426'), birthday: '10/13/1978' },
  { _id: ObjectId('678625a83d7174bdf6fc0427'), birthday: '10/13/1978' }
]
admin> db.users.find({}, {"birthday":1, "_id": 0})
[
  { birthday: '10/13/1978' },
  { birthday: '10/13/1978' },
  { birthday: '10/13/1978' }
]
  • _id 키는 지정하지 않아도 항상 반환된다.
  • 두 번째 매개변수를 사용해서 특정 키/값 쌍을 제외한 결과를 얻을 수 있다.


4.1.2. 제약 사항


쿼리에는 몇 가지 제약이 있다. 쿼리 도큐먼트 값은 반드시 상수여야 한다. 이는 도큐먼트 내 다른 키의 값을 참조할 수 없음을 의미한다.

1
db.stock.find({"in_stock" : "this.num_sold"}) // 작동하지 않음


4.2. 쿼리 조건


쿼리는 완전 일치 외에도 범위, OR절, 부정 조건 등 더 복잡한 조건으로 검색할 수 있다.


4.2.1. 쿼리 조건절


<, <=, >, >= 에 해당하는 비교 연산자는 각각 $lt , $lte , $gt , $gte 다. 조합해 사용하면 특정 범위 내 값을 쿼리할 수 있다.

1
admin> db.users.find({"age" : {"$gte" : 18, "$lte" : 30}})


범위 쿼리는 날짜 쿼리에 유용하다.

1
2
3
admin> start = new Date("01/01/2007")
ISODate('2007-01-01T00:00:00.000Z')
admin> db.users.find({"registered" : {"$lt" : start}})


키 값이 특정 값과 일치하지 않는 도큐먼트를 찾는 데는 $ne 를 사용한다.

1
admin> db.users.find({"username": {"$ne" : "joe"}})


4.2.2. OR 쿼리


OR 쿼리에는 두 가지 방법이 있다. $in 은 하나의 키를 다양한 값과 비교하는 쿼리에 사용한다. $or 은 더 일반적이며, 여러 키를 주어진 값과 비교하는 쿼리에 사용한다.

1
admin> db.raffle.find({"ticket_no" : {"$in" : [725, 542, 309]}})


$nin$in 과 반대로 배열 내 조건과 일치하지 않는 도큐먼트를 반환한다.

1
admin> db.raffle.find({"ticket_no" : {"$nin" : [725, 542, 390]}})


$or 은 가능한 조건들의 배열을 취한다.

1
2
admin> db.raffle.find({"$or": [{"ticket_no" : 725}, {"winner" : true}]})
admin> db.raffle.find({"$or": [{"ticket_no" : {"$in" : [725, 542, 390]}}, {"winner": true}]})
  • $or 연산자가 항상 작동하는 동안에는 가능한 한 $in 을 사용하자.
  • 쿼리 옵티마이저는 $in 을 더 효율적으로 다룬다.


4.2.3. $not


$not 은 메타 조건절이며 어떤 조건에도 적용할 수 있다.

1
admin> db.users.find({"id_num" : {"$not" : {"$mod" : [5, 1]}}})
  • $not 은 정규 표현식과 함께 사용해 주어진 패턴과 일치하지 않는 도큐먼트를 찾을 때 유용하다.


4.3. 형 특정 쿼리


일부 데이터형은 쿼리 시 형에 특정하게 작동한다.


4.3.1. null


null 은 스스로 일치하는 것을 찾는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
admin> db.c.find()
[
  { _id: ObjectId('678658d953857f4543fc0421'), y: null },
  { _id: ObjectId('678658d953857f4543fc0422'), y: 1 },
  { _id: ObjectId('678658d953857f4543fc0423'), y: 2 }
]
admin> db.c.find({"y": null})
[ { _id: ObjectId('678658d953857f4543fc0421'), y: null } ]
admin> db.c.find({"z": null})
[
  { _id: ObjectId('678658d953857f4543fc0421'), y: null },
  { _id: ObjectId('678658d953857f4543fc0422'), y: 1 },
  { _id: ObjectId('678658d953857f4543fc0423'), y: 2 }
]


값이 null 인 키만 찾고 싶으면 키가 null 인 값을 쿼리하고, $exists 조건절을 사용해 null 존재 여부를 확인하면 된다.

1
admin> db.c.find({"z" : {"$eq" : null, "$exists" : true}})


4.3.2. 정규 표현식


$regex 는 쿼리에서 패턴 일치 문자열을 위한 정규식 기능을 제공한다.

1
admin> db.users.find({"name" : {"$regex" : /joe/i}})
  • 대소문자 구분 없이 일치하는 문자를 찾는다.
  • 정규 표현식 플래그(ex. i)는 사용할 수 있지만 꼭 필요하지는 않다.


정규 표현식 일치에 펄 호환 정규 표현식(PCRE) 라이브러리를 사용하며, PCRE에서 쓸 수 있는 모든 문법은 몽고DB에서 쓸 수 있다.


4.3.3. 배열에 쿼리하기


배열 오소 쿼리는 스칼라 쿼리와 같은 방식으로 동작하도록 설계됐다.

1
2
3
4
5
6
7
8
9
10
11
12
admin> db.food.insertOne({"fruit" : ["apple", "banana", "peach"]})
{
  acknowledged: true,
  insertedId: ObjectId('67865ab353857f4543fc0424')
}
admin> db.food.find({"fruit" : "banana"})
[
  {
    _id: ObjectId('67865ab353857f4543fc0424'),
    fruit: [ 'apple', 'banana', 'peach' ]
  }
]


$all 연산자

2개 이상의 배열 요소가 일치하는 배열을 찾으려면 $all 을 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
admin> db.food.find()
[
  {
    _id: ObjectId('67865ab353857f4543fc0424'),
    fruit: [ 'apple', 'banana', 'peach' ]
  },
  {
    _id: ObjectId('67865afc53857f4543fc0425'),
    fruit: [ 'apple', 'kumquat', 'orange' ]
  },
  {
    _id: ObjectId('67865b0753857f4543fc0426'),
    fruit: [ 'cherry', 'banana', 'orange' ]
  }
]
admin> db.food.find({fruit : {$all: ["apple", "banana"]}})
[
  {
    _id: ObjectId('67865ab353857f4543fc0424'),
    fruit: [ 'apple', 'banana', 'peach' ]
  }
]


배열 내 특정 요소를 쿼리하려면 key.index 구문을 이용해 순서를 지정한다.

1
2
3
4
5
6
7
admin> db.food.find({"fruit.2" : "peach"})
[
  {
    _id: ObjectId('67865ab353857f4543fc0424'),
    fruit: [ 'apple', 'banana', 'peach' ]
  }
]


$size 연산자

$size 는 특정 크기의 배열을 쿼리하는 유용한 조건절이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
admin> db.food.find({"fruit": {"$size" : 3}})
[
  {
    _id: ObjectId('67865ab353857f4543fc0424'),
    fruit: [ 'apple', 'banana', 'peach' ]
  },
  {
    _id: ObjectId('67865afc53857f4543fc0425'),
    fruit: [ 'apple', 'kumquat', 'orange' ]
  },
  {
    _id: ObjectId('67865b0753857f4543fc0426'),
    fruit: [ 'cherry', 'banana', 'orange' ]
  }
]

admin> db.food.updateMany({}, {"$push" : {"fruit": "strawberry2"}, "$inc" : {"size" : 1}})
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 3,
  modifiedCount: 3,
  upsertedCount: 0
}
admin> db.food.find()
[
  {
    _id: ObjectId('67865ab353857f4543fc0424'),
    fruit: [ 'apple', 'banana', 'peach', 'strawberry', 'strawberry2' ],
    size: 2
  },
  {
    _id: ObjectId('67865afc53857f4543fc0425'),
    fruit: [ 'apple', 'kumquat', 'orange', 'strawberry2' ],
    size: 1
  },
  {
    _id: ObjectId('67865b0753857f4543fc0426'),
    fruit: [ 'cherry', 'banana', 'orange', 'strawberry2' ],
    size: 1
  }
]


$slice 연산자

$slice 연산자를 사용해서 배열 요소의 부분집합을 반환받을 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
admin> db.blog.posts.find()
[
  {
    _id: ObjectId('67738a68b151887453fc0428'),
    content: 'asdasd',
    comments: [
      { comment: 'good post', author: 'Jim', votes: 0 },
      { comment: 'test1', author: 'tester1', votes: 3 },
      { comment: 'test2', author: 'tester2', votes: -5, hidden: true }
    ]
  }
]
admin> db.blog.posts.find({}, {"comments" : {"$slice" : 1}})
[
  {
    _id: ObjectId('67738a68b151887453fc0428'),
    content: 'asdasd',
    comments: [ { comment: 'good post', author: 'Jim', votes: 0 } ]
  }
]


$slice 는 또한 오프셋과 요소 개수를 지정해 원하는 범위 안에 있는 결과를 반환할 수 있다.

1
2
3
4
5
6
7
8
admin> db.blog.posts.find({}, {"comments" : {"$slice" : [1, 1]}})
[
  {
    _id: ObjectId('67738a68b151887453fc0428'),
    content: 'asdasd',
    comments: [ { comment: 'test1', author: 'tester1', votes: 3 } ]
  }
]
  • -1 은 마지막의 값을 가져온다.


일치하는 배열 요소의 반환

특정 기준과 일치하는 배열 요소를 원할 수도 있다. $ 연산자를 사용하면 일치하는 요소를 반환받을 수 있다.

1
2
3
4
5
6
7
admin> db.blog.posts.find({"comments.author" : {"$regex" : /^tester/}}, {"comments.$": 1})
[
  {
    _id: ObjectId('67738a68b151887453fc0428'),
    comments: [ { comment: 'test1', author: 'tester1', votes: 3 } ]
  }
]


배열 및 범위 쿼리의 상호작용

도큐먼트 내 스칼라(비배열 요소)는 쿼리 기준의 각 절과 일치해야 한다.

1
2
3
4
5
admin> db.test.find({"x": {"$gt": 10, "$lt": 20}})
[
  { _id: ObjectId('67865ed353857f4543fc0428'), x: 15 },
  { _id: ObjectId('67865ed353857f4543fc042a'), x: [ 5, 25 ] }
]
  • 25는 $gt: 10 에 일치하고, 5는 $lt : 20 에 일치하기 때문에 반환되었다.


원하는 결과를 얻으려면 다음과 같이 몇 가지 방법을 사용해야 한다. 첫 번째 방법으로 $elemMatch 연산자를 사용하면 몽고DB는 두 절을 하나의 배열 요소와 비교한다. 하지만 $elemMatch 연산자는 비배열 요소를 일치시키지 않는다.

1
2
3
4
admin> db.test.find({"x": {"$elemMatch": {"$gt": 10, "$lt": 20}}})

admin> db.test.find({"x": {"$elemMatch": {"$gt": 10, "$lt": 30}}})
[ { _id: ObjectId('67865ed353857f4543fc042a'), x: [ 5, 25 ] } ]


배열을 포함하는 도큐먼트에 범위 쿼리를 할 때 min 함수와 max 함수를 사용하면 좋다. 배열에 대한 $gt, $lt 쿼리의 인덱스 한계는 비효율적이다. 어떤 값이든 허용하므로 범위 내 값뿐 아니라 모든 인덱스 항목을 검색한다.


4.3.4. 내장 도큐먼트에 쿼리하기


내장 도큐먼트 쿼리는 도큐먼트 전체를 대상으로 하는 방식과 도큐먼트 내 키/값 쌍 각각을 대상으로 하는 방식으로 나뉜다.

1
2
3
4
5
6
7
8
admin> db.people.find({"name": {"first": "Joe", "last": "Schmoe"}})
[
  {
    _id: ObjectId('678772b353857f4543fc042b'),
    name: { first: 'Joe', last: 'Schmoe' },
    age: 45
  }
]
  • 이와 같이 내장 도큐먼트를 쿼리할 수 있다.
  • 그러나 서브도큐먼트 전체에 쿼리하려면 서브도큐먼트와 정확히 일치해야 한다.


내장 도큐먼트에 쿼리할 때는 가능하다면 특정 키로 쿼리하는 방법이 좋다. 내장 도큐먼트의 키를 쿼리할 때는 점 표기법을 사용한다.

1
2
3
4
5
6
7
8
admin> db.people.find({"name.first": "Joe", "name.last": "Schmoe"})
[
  {
    _id: ObjectId('678772b353857f4543fc042b'),
    name: { first: 'Joe', last: 'Schmoe' },
    age: 45
  }
]


모든 키를 지정하지 않고도 조건을 정확하게 묶으려면 $elemMatch 를 사용한다. 이 조건절은 조건을 부분적으로 지정해 배열 내에서 하나의 내장 도큐먼트를 찾게 해준다.

1
2
3
4
5
6
7
8
9
10
admin> db.blog.insertOne({"content": "content", "comments": [
... {"author": "joe", "score": 3, "comment": "nice post"},
... {"author": "mary", "score": 6, "comment": "terrible post"}
... ]
... }
... )
{
  acknowledged: true,
  insertedId: ObjectId('6787746453857f4543fc042c')
}
  • $elemMatch 를 사용해 조건을 그룹화할 수 있다. 따라서 내장 도큐먼트에서 2개 이상의 키의 조건 일치 여부를 확인할 때만 필요하다.


4.4. $where 쿼리


$where 절을 사용해 임의의 자바스크립트를 쿼리의 일부분으로 실행하면 거의 모든 쿼리를 표현할 수 있다. 따라서 보안상의 이유로 $where 절 사용을 제한해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
admin> db.foo.insertOne({"apple": 1, "banana": 6, "peach": 3})
{
  acknowledged: true,
  insertedId: ObjectId('67877de153857f4543fc042d')
}
admin> db.foo.insertOne({"apple": 8, "spinach": 4, "watermelon": 4})
{
  acknowledged: true,
  insertedId: ObjectId('67877df453857f4543fc042e')
}
admin> db.foo.find({"$where": function() { for (var current in this) { for (var other in this) { if (current != other && this[current] == this[other]){ return true; } } } return false; }});
[
  {
    _id: ObjectId('67877df453857f4543fc042e'),
    apple: 8,
    spinach: 4,
    watermelon: 4
  }
]
  • $where 쿼리는 일반 쿼리보다 훨씬 느리니 반드시 필요한 경우가 아니면 사용하지 말자. 또한 인덱스를 쓸 수 없다.
  • $where 절은 결과를 세부적으로 조정할 때 사용하자.
  • 집계 표현식을 사용할 수 있도록 $expr 연산자가 추가됐으므로 $where 대신 $expr 을 사용하자.


4.5. 커서


데이터베이스는 커서를 사용해 find의 결과를 반환한다. 결과 개수를 제한하거나, 결과 중 몇 개를 건너뛰거나, 여러 키를 조합한 결과를 어떤 방향으로든 정렬하는 등 다양하게 조작할 수 있다.

1
2
3
4
5
6
7
8
admin> for(i=0; i<100; i++) {
... db.collection.insertOne({x : i});
... }
{
  acknowledged: true,
  insertedId: ObjectId('67877f3953857f4543fc0492')
}
admin> var cursor = db.collection.find();
  • 간단한 컬렉션을 생성해 쿼리한 후 결과를 cursor 변수에 저장했다.


결과를 얻으려면 커서의 next 메서드를 사용하고, 다른 결과가 있는지 확인하려면 hasNext 를 사용한다.

1
2
3
4
5
6
7
8
9
10
admin> while (cursor.hasNext()) {
... obj = cursor.next();
... print(obj);
... }
{ _id: ObjectId('67877f3953857f4543fc042f'), x: 0 }
{ _id: ObjectId('67877f3953857f4543fc0430'), x: 1 }
{ _id: ObjectId('67877f3953857f4543fc0431'), x: 2 }
{ _id: ObjectId('67877f3953857f4543fc0432'), x: 3 }
// 중간 생략
{ _id: ObjectId('67877f3953857f4543fc0492'), x: 99 }


forEach 반목문에 사용할 수 있다.

1
2
3
4
admin> var cursor = db.people.find();

admin> cursor.forEach(function (x) { print(x.name); });
{ first: 'Joe', last: 'Schmoe' }
  • find 를 호출할 때 셸이 데이터베이스를 즉시 쿼리하지는 않으며 결과를 요청하는 쿼리를 보낼 때까지 기다린다.


예를 들어 다음 쿼리들은 모두 동일하게 작동한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
admin> cursor.forEach(function (x) { print(x.name); })
admin> var cursor = db.collection.find().sort({"x": 1}).limit(1).skip(10);

admin> cursor
[ { _id: ObjectId('67877f3953857f4543fc0439'), x: 10 } ]
admin> var cursor = db.collection.find().limit(1).sort({"x": 1}).skip(10);

admin> cursor
[ { _id: ObjectId('67877f3953857f4543fc0439'), x: 10 } ]
admin> var cursor = db.collection.find().skip(10).limit(1).sort({"x": 1});

admin> cursor
[ { _id: ObjectId('67877f3953857f4543fc0439'), x: 10 } ]


4.5.1. 제한, 건너뛰기, 정렬


결과 개수를 제한하려면 find 호출에 limit 함수를 연결한다.

1
2
3
4
5
6
admin> db.collection.find().limit(3)
[
  { _id: ObjectId('67877f3953857f4543fc042f'), x: 0 },
  { _id: ObjectId('67877f3953857f4543fc0430'), x: 1 },
  { _id: ObjectId('67877f3953857f4543fc0431'), x: 2 }
]
  • limit 은 상한만 설정하고 하한은 설정하지 않는다.


skiplimit 와 유사하게 작동한다.

1
2
3
4
5
6
admin> db.collection.find().skip(3)
[
  { _id: ObjectId('67877f3953857f4543fc0432'), x: 3 },
  { _id: ObjectId('67877f3953857f4543fc0433'), x: 4 },
  { _id: ObjectId('67877f3953857f4543fc0434'), x: 5 },
  // 생략


sort 는 객체를 매개변수로 받는다. 매개변수는 키/값 쌍의 셋, 키는 키의 이름, 값은 정렬 방향이다. 정렬 방향은 1(오름차순) 또는 -1(내림차순) 이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
admin> db.collection.find().sort({x:1})
[
  { _id: ObjectId('67877f3953857f4543fc042f'), x: 0 },
  { _id: ObjectId('67877f3953857f4543fc0430'), x: 1 },
  { _id: ObjectId('67877f3953857f4543fc0431'), x: 2 },
  { _id: ObjectId('67877f3953857f4543fc0432'), x: 3 },
  ...

admin> db.collection.find().sort({x:-1})
[
  { _id: ObjectId('67877f3953857f4543fc0492'), x: 99 },
  { _id: ObjectId('67877f3953857f4543fc0491'), x: 98 },
  { _id: ObjectId('67877f3953857f4543fc0490'), x: 97 },
  ...


페이지를 나눌 때 편리하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
admin> db.collection.find().sort({x:1}).limit(5)
[
  { _id: ObjectId('67877f3953857f4543fc042f'), x: 0 },
  { _id: ObjectId('67877f3953857f4543fc0430'), x: 1 },
  { _id: ObjectId('67877f3953857f4543fc0431'), x: 2 },
  { _id: ObjectId('67877f3953857f4543fc0432'), x: 3 },
  { _id: ObjectId('67877f3953857f4543fc0433'), x: 4 }
]
admin> db.collection.find().sort({x:1}).limit(5).skip(5)
[
  { _id: ObjectId('67877f3953857f4543fc0434'), x: 5 },
  { _id: ObjectId('67877f3953857f4543fc0435'), x: 6 },
  { _id: ObjectId('67877f3953857f4543fc0436'), x: 7 },
  { _id: ObjectId('67877f3953857f4543fc0437'), x: 8 },
  { _id: ObjectId('67877f3953857f4543fc0438'), x: 9 }
]


비교 순서

몽고DB에는 데이터형을 비교하는 위계 구조가 있다. 정수형과 불리언형, 문자열형과 null형처럼 때로는 하나의 키에 여러 데이터형 값을 저장할 수 있다. 데이터형이 섞여 있는 키는 미리 정의된 순서에 따라 정렬한다. 데이터형 정렬 순서는 다음과 같다.

  1. 최솟값
  2. null
  3. 숫자(int, long, double, decimal)
  4. 문자열
  5. 객체/도큐먼트
  6. 배열
  7. 이진 데이터
  8. 객체 ID
  9. 불리언
  10. 날짜
  11. 타임스탬프
  12. 정규 표현식
  13. 최댓값


4.5.2. 많은 수의 건너뛰기 피하기


skip 은 생략된 결과물을 모두 찾아 폐기하므로 결과가 많으면 느려진다. 따라서 많은 수의 건너뛰기는 피해야 한다.


skip을 사용하지 않고 페이지 나누기

예를 들어 date 를 내림차순으로 정렬해 도큐먼트를 표시한다고 가정하자. 아래와 같이 첫 페이지를 구한다.

1
admin> var page1 = db.foo.find().sort({"date": -1}).limit(100)


마지막 도큐먼트의 date 값을 사용해 다음 페이지를 가져온다.

1
2
3
4
5
6
7
8
9
10
11
var latest = null;

// 첫 페이지 보여주기
while (page1.hasNext()) {
	latest = page1.next();
	display(latest);
}

// 다음 페이지 가져오기
var page2 = db.foo.find({"date" : {"$lt" : latest.date}});
page2.sort({"date" : -1}).limit(100);
  • 이제 쿼리에 skip 을 쓸 필요가 없다.


랜덤으로 도큐먼트 찾기

도큐먼트를 입력할 때 랜덤 키를 별도로 추가하는 방법이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
admin> db.people.insertOne({"name": "joe", "random": Math.random()})
{
  acknowledged: true,
  insertedId: ObjectId('6787847753857f4543fc04f7')
}
admin> db.people.insertOne({"name": "john", "random": Math.random()})
{
  acknowledged: true,
  insertedId: ObjectId('6787847c53857f4543fc04f8')
}
admin> db.people.insertOne({"name": "jim", "random": Math.random()})
{
  acknowledged: true,
  insertedId: ObjectId('6787848053857f4543fc04f9')
}
admin> var random = Math.random()
admin> db.people.findOne({"random" : {"$gt" : random}})
{
  _id: ObjectId('6787847753857f4543fc04f7'),
  name: 'joe',
  random: 0.5306662954107095
}


4.5.3. 종료되지 않는 커서


서버 측에서 보면 커서는 메모리와 리소스를 점유한다. 커서가 더는 가져올 결과가 없거나 클라이언트로부터 종료 요청을 받으면 데이터베이스는 점유하고 있던 리소스를 해제한다.

서버 커서를 종료하는 몇 가지 조건이 있다. 첫 번째로, 커서는 조건에 일치하는 결과를 모두 살펴본 후에는 스스로 정리한다. 또한 커서가 클라이언트 측에서 유효 영역을 벗어나면 드라이버는 데이터베이스에 메시지를 보내 커서를 종료해도 된다고 알린다.

마지막으로, 사용자가 아직 결과를 다 살펴보지 않았고, 커서가 여전히 유효 영역 내에 있더라도 10분 동안 활동이 없으면 데이터베이스 커서는 자동으로 죽는다.


5. 인덱싱


이 장에서는 다음을 알아본다.

  • 인덱싱의 정의와 사용하는 이유
  • 인덱싱할 필드를 선정하는 방법
  • 인덱스 사용을 평가하고 적용하는 방법
  • 인덱스 생성 및 제거에 대한 관리 정보


5.1. 인덱싱 소개


인덱스를 사용하지 않는 쿼리를 컬렉션 스캔이라 하며, 서버가 쿼리 결과를 찾으려면 ‘전체 내용을 살펴봐야 함’을 의미한다. 큰 컬렉션을 스캔할 때는 컬렉션 스캔이 매우 느려지니 이런 방식은 피하자.


처음에는 인덱스를 사용하지 않고, 다음에는 인덱스를 사용해봄으로써 쿼리 성능 차이를 확인해보자.

1
2
3
4
5
# 1백만 건의 도큐먼트를 갖는 컬렉션 생성
admin> for (i=0; i<1000000; i++) {
... db.users.insertOne(
... { "i": i, "username": "user" + i, "age" : Math.floor(Math.random()*120), "created": new Date() });
... }


explain 함수를 이용해 쿼리가 실행될 때 몽고DB가 무엇을 하는지 확인할 수 있다. explain 커서 메서드는 다양한 CRUD 작업의 실행 정보를 제공한다. 이 메서드는 여러 가지 모드를 실행할 수 있다. executionStats 모드는 인덱스를 이용한 쿼리의 효과를 이해하는 데 도움이 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
admin> db.users.find({"username": "user101"}).explain("executionStats")
{
  explainVersion: '1',
  queryPlanner: {
    namespace: 'admin.users',
    parsedQuery: { username: { '$eq': 'user101' } },
    indexFilterSet: false,
    planCacheShapeHash: '1DCAA255',
    planCacheKey: 'E4474531',
    optimizationTimeMillis: 0,
    maxIndexedOrSolutionsReached: false,
    maxIndexedAndSolutionsReached: false,
    maxScansToExplodeReached: false,
    prunedSimilarIndexes: false,
    winningPlan: {
      isCached: false,
      stage: 'COLLSCAN',
      filter: { username: { '$eq': 'user101' } },
      direction: 'forward'
    },
    rejectedPlans: []
  },
  executionStats: {
    executionSuccess: true,
    nReturned: 1,
    executionTimeMillis: 221,
    totalKeysExamined: 0,
    totalDocsExamined: 1000000,
    executionStages: {
      isCached: false,
      stage: 'COLLSCAN',
      filter: { username: { '$eq': 'user101' } },
      nReturned: 1,
      executionTimeMillisEstimate: 195,
      works: 1000001,
      advanced: 1,
      needTime: 999999,
      needYield: 0,
      saveState: 14,
      restoreState: 14,
      isEOF: 1,
      direction: 'forward',
      docsExamined: 1000000
    }
  },
  command: { find: 'users', filter: { username: 'user101' }, '$db': 'admin' },
  serverInfo: {
    host: '2ae17a4d2a79',
    port: 27017,
    version: '8.0.4',
    gitVersion: 'bc35ab4305d9920d9d0491c1c9ef9b72383d31f9'
  },
  serverParameters: {
    internalQueryFacetBufferSizeBytes: 104857600,
    internalQueryFacetMaxOutputDocSizeBytes: 104857600,
    internalLookupStageIntermediateDocumentMaxSizeBytes: 104857600,
    internalDocumentSourceGroupMaxMemoryBytes: 104857600,
    internalQueryMaxBlockingSortMemoryUsageBytes: 104857600,
    internalQueryProhibitBlockingMergeOnMongoS: 0,
    internalQueryMaxAddToSetBytes: 104857600,
    internalDocumentSourceSetWindowFieldsMaxMemoryBytes: 104857600,
    internalQueryFrameworkControl: 'trySbeRestricted',
    internalQueryPlannerIgnoreIndexWithCollationForRegex: 1
  },
  ok: 1
}
  • totalDocsExamined 는 쿼리를 실행하면서 살펴본 도큐먼트 개수이며, 보다시피 컬렉션에 들어 있는 모든 도큐먼트 개수와 같다.
  • executionTimeMillis 필드는 쿼리하는 데 걸린 시간을 밀리초 단위로 보여준다.
  • nReturned 필드는 반환받은 결과의 개수를 보여준다.


쿼리에 효율적으로 응답하게 하려면 애플리케이션의 모든 쿼리 패턴에 인덱스를 사용해야 한다. 쿼리 패턴이란 애플리케이션이 데이터베이스에 요구하는 다양한 유형의 질문을 의미한다.


5.1.1. 인덱스 생성


인덱스를 만들려면 createIndex 컬렉션 메서드를 사용한다.

1
2
admin> db.users.createIndex({"username": 1})
username_1
  • db.currentOp() 를 실행하거나 mongod 의 로그를 확인해 인덱스 구축의 진행률을 체크할 수 있다.


인덱스 구축이 완료되면 다시 쿼리해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
admin> db.users.find({"username": "user101"}).explain("executionStats")
{
  explainVersion: '1',
  queryPlanner: {
    namespace: 'admin.users',
    parsedQuery: { username: { '$eq': 'user101' } },
    indexFilterSet: false,
    planCacheShapeHash: '1DCAA255',
    planCacheKey: 'AEA547E9',
    optimizationTimeMillis: 0,
    maxIndexedOrSolutionsReached: false,
    maxIndexedAndSolutionsReached: false,
    maxScansToExplodeReached: false,
    prunedSimilarIndexes: false,
    winningPlan: {
      isCached: false,
      stage: 'FETCH',
      inputStage: {
        stage: 'IXSCAN',
        keyPattern: { username: 1 },
        indexName: 'username_1',
        isMultiKey: false,
        multiKeyPaths: { username: [] },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: { username: [ '["user101", "user101"]' ] }
      }
    },
    rejectedPlans: []
  },
  executionStats: {
    executionSuccess: true,
    nReturned: 1,
    executionTimeMillis: 5,
    totalKeysExamined: 1,
    totalDocsExamined: 1,
    executionStages: {
      isCached: false,
      stage: 'FETCH',
      nReturned: 1,
      executionTimeMillisEstimate: 0,
      works: 2,
      advanced: 1,
      needTime: 0,
      needYield: 0,
      saveState: 0,
      restoreState: 0,
      isEOF: 1,
      docsExamined: 1,
      alreadyHasObj: 0,
      inputStage: {
        stage: 'IXSCAN',
        nReturned: 1,
        executionTimeMillisEstimate: 0,
        works: 2,
        advanced: 1,
        needTime: 0,
        needYield: 0,
        saveState: 0,
        restoreState: 0,
        isEOF: 1,
        keyPattern: { username: 1 },
        indexName: 'username_1',
        isMultiKey: false,
        multiKeyPaths: { username: [] },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: { username: [ '["user101", "user101"]' ] },
        keysExamined: 1,
        seeks: 1,
        dupsTested: 0,
        dupsDropped: 0
      }
    }
  },
  command: { find: 'users', filter: { username: 'user101' }, '$db': 'admin' },
  serverInfo: {
    host: '2ae17a4d2a79',
    port: 27017,
    version: '8.0.4',
    gitVersion: 'bc35ab4305d9920d9d0491c1c9ef9b72383d31f9'
  },
  serverParameters: {
    internalQueryFacetBufferSizeBytes: 104857600,
    internalQueryFacetMaxOutputDocSizeBytes: 104857600,
    internalLookupStageIntermediateDocumentMaxSizeBytes: 104857600,
    internalDocumentSourceGroupMaxMemoryBytes: 104857600,
    internalQueryMaxBlockingSortMemoryUsageBytes: 104857600,
    internalQueryProhibitBlockingMergeOnMongoS: 0,
    internalQueryMaxAddToSetBytes: 104857600,
    internalDocumentSourceSetWindowFieldsMaxMemoryBytes: 104857600,
    internalQueryFrameworkControl: 'trySbeRestricted',
    internalQueryPlannerIgnoreIndexWithCollationForRegex: 1
  },
  ok: 1
}
  • totalDocsExamined 값이 1인 것을 볼 수 있다.


인덱싱된 필드를 변경하는 쓰기(삽입, 갱신, 삭제) 작업은 더 오래 걸린다. 데이터가 변경될 때마다 도큐먼트뿐 아니라 모든 인덱스를 갱신해야 하기 때문이다.


5.1.2. 복합 인덱스 소개


상당수의 쿼리 패턴은 두 개 이상의 키를 기반으로 인덱스를 작성해야 한다. 인덱스가 앞부분에 놓일 때만 정렬에 도움이 된다. 즉, 다음 정렬에 큰 도움이 되지 않는다.

1
admin> db.users.find().sort({"age": 1, "username": 1}).explain("executionStats")


위 정렬을 최적화하려면 ageusername 에 인덱스를 만들어야 한다.

1
2
admin> db.users.createIndex({"age": 1, "username": 1})
age_1_username_1
  • 이는 복합 인덱스라 불리며, 쿼리에서 정렬 방향이 여러 개이거나 검색 조건에 여러 개의 키가 있을 때 유용하다.
  • 각 인덱스 항목은 나이와 사용자명을 포함하고 레코드 식별자를 가리킨다.


실행하는 쿼리의 종류에 따라 인덱스를 사용하는 방법이 다르다. 가장 많이 사용하는 세 가지 방법을 알아보자.

  • db.users.find({"age" : 21}).sort({"username" : -1})
    • {"age" : 21} 과 일치하는 마지막 항목부터 순서대로 인덱스를 탐색한다.
  • db.users.find({"age" : {"$gte" : 21, "$lte" : 30}})
    • 인덱스에 있는 첫 번째 키인 age 를 사용해 일치하는 도큐먼트를 반환받는다.
  • db.users.find({"age" : {"$gte" : 21, "$lte" : 30}}).sort({"username" : 1})
    • 이전처럼 몽고DB는 검색 조건에 맞는 인덱스를 사용한다.


복합 인덱스를 구성할 때는 정렬 키를 첫 번째에 놓으면 좋다.


5.1.3. 몽고DB가 인덱스를 선택하는 방법


쿼리가 들어오면 몽고DB는 쿼리 모양을 확인한다. 모양은 검색할 필드와 정렬 여부 등 추가 정보와 관련 있다. 시스템은 이 정보를 기반으로 쿼리를 충족하는 데 사용할 인덱스 후보 집합을 식별한다.

쿼리가 들어오고 인덱스 5개 중 3개가 쿼리 후보로 식별됐다고 가정해보자. 몽고DB는 각 인덱스 후보에 하나씩 총 3개의 쿼리 플랜을 만들고, 각각 다른 인덱스를 사용하는 3개의 병렬 스레드에서 쿼리를 실행한다.

이 과정은 레이스와 같다. 앞으로 동일한 모양을 가진 쿼리에 사용할 인덱스로 선택된다. 플랜은 일정 기간 동안 서로 경쟁하며, 각 레이스의 결과로 전체 승리 플랜을 산출한다.

서버는 쿼리 플랜의 캐시를 유지하는데, 승리한 플랜은 차후 모양이 같은 쿼리에 사용하기 위해 캐시에 저장된다. 컬렉션과 인덱스가 변경되면 쿼리 플랜이 캐시에서 제거되고, 몽고DB는 다시 가능한 쿼리 플랜을 실험해 해당 컬렉션 및 인덱스 집합에 가장 적합한 플랜을 찾는다.


5.1.4. 복합 인덱스 사용


읽기와 쓰기를 가능한한 효율적으로 수행하도록 인덱스를 설계하자. 인덱스를 올바르게 설계하려면 실제 워크로드에서 인덱스를 테스트하고 조정해야 하지만, 몇 가지 모범 사례를 적용해볼 수 있다.

먼저 인덱스의 선택성을 고려한다. 쿼리를 충족하는 데 필요한 모든 작업을 고려해야 하며 때로는 트레이드오프가 필요하다.


먼저 인덱스 두 개로 시작해서 몽고DB가 쿼리를 충족하기 위해 이러한 인덱스를 어떻게 사용하는지 살펴본다. 쿼리를 실행하면 explain 메서드의 출력은 몽고DB가 인덱스를 어떻게 사용해서 쿼리를 충족했는지 알려준다. executionStats 필드는 선정된 쿼리 플랜에 대해 완료된 쿼리 실행을 설명하는 통계를 포함한다.

executionStats 에서 먼저 totalKeysExamined 를 살펴보자. 이는 인덱스 내에서 몇 개의 키를 통과했는지 나타낸다. totalKeysExaminednReturned 와 비교하면 쿼리와 일치하는 도큐먼트를 찾으려고 얼마나 많은 인덱스를 통과했는지 알 수 있다. 이로써 쿼리를 충족하는 데 사용된 인덱스가 선택적이지 않은지를 확인하면 된다.

explain 출력 맨 위에는 쿼리 플랜이 있다(winningPlan 참조). 쿼리 플랜은 쿼리를 충족하는 데 사용한 단계를 설명한다. 이는 서로 다른 쿼리 플랜이 경쟁한 결과이다. 우리는 사용한 인덱스 종류와 인메모리 정렬을 수행해야 하는지 여부에 관심을 둬야 한다.

explain 출력은 쿼리 플랜을 단계 트리로 표시한다. 각 단계에는 하위 단계 개수에 따라 하나 이상의 입력 다녜까 있을 수 있다. 입력 단계는 도큐먼트나 인덱스 키를 상위 단계에 제공한다. 쿼리 플랜에 SORT 단계가 표시된다면, 이는 데이터베이스에서 결과 셋을 정렬할 때 인덱스를 사용할 수 없었으며 대신 인메모리 정렬을 했다는 의미다.


커서 hint 메서드를 사용하면 모양이나 이름을 지정함으로써 사용할 인덱스를 지정할 수 있다. 인덱스 필터는 쿼리, 정렬, 프로젝션 사양의 조합인 쿼리 모양을 사용한다. planCacheSetFilter 함수를 인덱스 필터와 함께 사용하면, 쿼리 옵티마이저가 인덱스 필터에 지정된 인덱스만 고려하도록 제한할 수 있다.

1
2
3
4
5
admin> db.users
			.find({"age" : {"$gte" : 21, "$lte" : 30}})
			.sort({"username" : 1})
			.hint({username:1})
			.explain("executionStats")


모든 데이터셋에 해당되지는 않지만, 일반적으로 동등 필터를 사용할 필드가 다중값 필터를 사용할 필드보다 앞에 오도록 복합 인덱스를 설계해야 한다.

복합 인덱스를 설계할 때는 인덱스를 사용할 공통 쿼리 패턴의 동등 필터, 다중값 필터, 정렬 구성 요소를 처리하는 방법을 알아야 한다. 이러한 세 가지 요소는 모든 복합 인덱스 설계 시 고려해야 하며, 인덱스를 올바르게 설계하면 몽고DB에서 최상의 쿼리 성능을 얻을 수 있다.

인덱스를 더 잘 설계하면 인메모리 정렬을 피할 수 있다. 이를 통해 데이터셋 크기와 시스템 부하와 관련해 보다 쉽게 확장할 수 있다.

하지만 복합 인덱스를 설계할 때는 일반적으로 트레이드오프가 있다.

복합 인덱스에서 자주 발생하는 문제로, 인메모리 정렬을 피하려면 반환하는 도큐먼트 개수보다 더 많은 키를 검사해야 한다. 즉 복합 인덱스 키 사이에 정렬 필드를 포함해야 한다. 정렬 구성 요소는 동등 필터 바로 뒤, 다중값 필터 앞에 포함한다. 이 인덱스는 쿼리에서 고려하는 키 집합을 매우 선택적으로 좁힌다.


요약하면 복합 인덱스를 설계할 때,

  • 동등 필터에 대한 키를 맨 앞에 표시해야 한다.
  • 정렬에 사용되는 키는 다중값 필드 앞에 표시해야 한다.
  • 다중값 필터에 대한 키는 마지막에 표시해야 한다.


키 방향 선택하기

두 개 이상의 검색 조건으로 정렬할 때는 인덱스 키의 방향이 서로 달라야 한다. 복합 정렬을 서로 다른 방향으로 최적화하려면 방향이 맞는 인덱스를 사용해야 한다.

인덱스 방향은 다중 조건에 따라 정렬할 때만 문제가 된다. 단일 키로 정렬하면 몽고DB는 인덱스를 쉽게 역순으로 읽을 수 있다.


커버드 쿼리 사용하기

쿼리가 단지 인덱스에 포함된 필드를 찾는 중이라면 도큐먼트를 가져올 필요가 없다. 인덱스가 쿼리가 요구하는 값을 모두 포함하면, 쿼리가 커버드된다고 한다. 도큐먼트로 되돌아가지 말고 항상 커버드 쿼리를 사용하자.

쿼리가 확실히 인덱스만 사용하게 하려면 _id 필드를 반환받지 않도록 반환받을 키를 지정해야 한다. 쿼리하지 않는 필드에 인덱스를 만들어야 할 수도 있으므로, 쓰기 때문에 늘어날 부하와 쿼리 속도를 잘 조율해야 한다.

커버드 쿼리에 explain 을 실행하면 결과에 FETCH 단계의 하위 단계가 아닌 IXSCAN 단계가 있으며, executionStats 에서는 totalDocsExamined 의 값이 0이 된다.


암시적 인덱스

복합 인덱스는 이중 임무를 수행할 수 있으며 쿼리마다 다른 인덱스처럼 동작할 수 있다. 인덱스가 N개의 키를 가진다면 키들의 앞부분은 공짜 인덱스가 된다. 예를 들어 {"a": 1, "b": 1, "c": 1, ..., "z": 1} 과 같은 인덱스가 있다면 사실상 {"a": 1} , {"a": 1, "b": 1} , {"a": 1, "b": 1, "c": 1} 등으로 인덱스를 가진다.


5.1.5. $ 연산자의 인덱스 사용법


비효율적인 연산자

일반적으로 부정 조건은 비효율적이다. $ne 쿼리는 인덱스를 사용하긴 하지만 잘 활용하지 못한다.

$not 은 종종 인덱스를 사용하는데, 어떻게 사용해야 하는지 모를 때가 많다. $not 은 기초적인 범위와 정규 표현식을 반대로 뒤집을 수 있다. 하지만 대두분은 테이블 스캔을 수행한다. $nin 은 항상 테이블 스캔을 수행한다.


범위

복합 인덱스는 다중 절 쿼리를 효율적으로 실행하도록 돕는다. 다중 필드로 인덱스를 설계할 때는 완전 일치가 사용될 필드를 첫 번째에, 범위가 사용될 필드를 마지막에 놓자. 하나의 쿼리에 두 개의 범위를 사용하면 비효율적인 쿼리 플랜이 된다.


OR 쿼리

쿼리당 하나의 인덱스만 사용할 수 있다. 유일한 예외는 $or 다. $or 는 두 개의 쿼리를 수행하고 결과를 합치므로 $or 은 절마다 하나씩 인덱스를 사용할 수 있다. 일반적으로 두 번 쿼리해서 결과를 병합하면 한 번 쿼리할 때보다 훨씬 비효율적이다. 그러니 가능하면 $or 보다는 $in 을 사용하자.

$or 을 사용해야 한다면 두 쿼리의 결과를 조사하고 중복을 모두 제거해야 함을 명시하자.


5.1.6. 객체 및 배열 인덱싱


도큐먼트 내부에 도달해서 내장 필드와 배열에 인덱스를 생성하도록 허용한다. 내장 객체와 배열 필드는 복합 인덱스에서 최상위 필드와 결합될 수 있으며, 다소 특수한 경우를 제외하면 대부분 일반적인 인덱스 필드와 같은 방식으로 동작한다.


내장 도큐먼트 인덱싱하기

인덱스는 일반적인 키에 생성될 때와 동일한 방식으로 내장 도큐먼트 키에 생성될 수 있다. 서브필드에 인덱스를 만들어 해당 필드를 이용하는 쿼리의 속도를 높일 수 있다.

1
db.users.createIndex({"loc.city" : 1})
  • 서브도큐먼트 전체를 인덱싱하면, 서브도큐먼트 전체에 쿼리할 때만 도움이 된다.


배열 인덱싱하기

배열에도 인덱스를 생성할 수 있다. 인덱스를 사용하면 배열의 특정 요소를 효율적으로 찾을 수 있다.

배열을 인덱싱하면 배열의 각 요소에 인덱스 항목을 생성하므로 도큐먼트별로 인덱스 항목을 가진다. 따라서 입력, 갱신, 제거 작업을 하려면 모든 배열 요소가 갱신돼야 하므로 배열 인덱스를 단일값 인덱스보다 더 부담스럽게 만든다.

배열 요소에 대한 인덱스에는 위치 개념이 없다. 따라서 comments.4 와 같이 특정 배열 요소를 찾는 쿼리에는 인덱스를 사용할 수 없다.


배열의 특정 항목에 인덱스를 생성할 수는 있다.

1
db.blog.createIndex({"comments.10.votes" : 1})
  • 이 인덱스는 정확히 11번째 배열 요소를 쿼리할 때만 유용하다.


인덱스 항목의 한 필드만 배열로부터 가져올 수 있다.

1
2
3
4
5
6
7
8
# 정상
db.multi.insert({"x" : [1, 2, 3], "y" : 1})

# 정상
db.multi.insert({"x" : 1, "y" : [1, 2, 3]})

# 비정상
db.multi.insert({"x" : [1, 2, 3], "y" : [1, 2, 3]})


다중키 인덱스가 미치는 영향

어떤 도큐먼트가 배열 필드를 인덱스 키로 가지면 인덱스는 즉시 다중키 인덱스로 표시된다. explain() 출력을 보면 인덱스가 다중키 인덱스인지 확인할 수 있으며, 다중키 인덱스가 사용됐다면 isMultiKey 필드는 true 다.

다중키 인덱스는 비다중키 인덱스보다 약간 느릴 수 있다. 하나의 도큐먼트를 여러 개의 인덱스 항목이 가리킬 수 있으므로 몽고DB는 결과를 반환하기 전에 중복을 제거해야 한다.


5.1.7. 인덱스 카디널리티


카디널리티는 컬렉션의 한 필드에 대한 고유값이 얼마나 많은지 나타낸다. 일반적으로 필드의 카디널리티가 높을수록 인덱싱이 더욱 도움이 된다.

일반적으로 높은 카디널리티 키를 생성하면 좋다. 적어도 복합 인덱스에서 높은 카디널리티 키를 낮은 카디널리티 키보다 앞에 놓자.


5.2. explain 출력


explain 은 쿼리에 대한 많은 정보를 제공하며, 느린 쿼리를 위한 중요한 진단 도구다. 쿼리의 explain 출력을 보면 어떤 인덱스가 어떻게 사용되는지 알 수 있다.

쿼리가 COLLSCAN 을 사용하면 인덱스를 사용하지 않음을 알 수 있다. totalKeysExamined 는 검색한 인덱스 항목 개수를 보고하며 totalDocsExamined 는 검색한 도큐먼트 개수를 나타낸다. executionTimeMillis 는 서버가 요청을 받고 응답을 보낸 시점까지 쿼리가 얼마나 빨리 실행됐는지 보고한다. 이는 최고로 뽑힌 플랜이 아니라 모든 플랜이 실행되기까지 걸린 시간을 반영한다.


중요한 필드 몇 가지를 자세히 살펴보자.

  • isMultiKey : 다중키 인덱스 사용 여부
  • nReturned : 쿼리에 의해 반환된 도큐먼트 개수
  • totalDocsExamined : 디스크 내 실제 도큐먼트를 가리키는 인덱스 포인터를 따라간 횟수
  • totalKeysExamine : 인덱스가 사용됐다면 살펴본 인덱스 항목 개수, 테이블 스캔을 했다면 조사한 도큐먼트 개수
  • stage : 인덱스를 사용해 쿼리할 수 있었는지 여부. COLSCAN 은 인덱스로 쿼리할 수 없어 컬렉션 스캔을 수행했음을 뜻한다.
  • needYields : 쓰기 요청을 처리하도록 쿼리가 양보(일시 중지)한 횟수. 대기 중인 쓰기가 있다면 쿼리는 일시적으로 락을 해제하고 쓰기가 처리되게 한다.
  • executionTimeMillis : 데이터베이스가 쿼리하는 데 걸린 시간
  • indexBounds : 인덱스가 어떻게 사용됐는지 설명하며 탐색한 인덱스의 범위를 제공한다.


5.3. 인덱스를 생성하지 않는 경우


인덱스는 컬렉션에서 가져와야 하는 부분이 많을수록 비효율적인데, 인덱스를 하나 사용하려면 두 번의 조회를 해야 하기 때문이다.

대체로 쿼리가 컬렉션의 30% 이상을 반환하는 경우 인덱스는 종종 쿼리를 늦춘다.

인덱스가 적합한 경우컬렉션 스캔이 적합한 경우
큰 컬렉션작은 컬렉션
큰 도큐먼트작은 도큐먼트
선택적 쿼리비선택적 쿼리


5.4. 인덱스 종류


인덱스를 구축할 때 인덱스 옵션을 지정해 동작 방식을 바꿀 수 있다.


5.4.1. 고유 인덱스


고유 인덱스는 각 값이 인덱스에 최대 한 번 나타나도록 보장한다. 특정 도큐먼트에 대해서만 partialFilterExpression 으로 고유 인덱스를 만들면 된다.

1
2
admin> db.users.createIndex({"username": 1}, {"unique": true, "partialFilterExpression" : {"username": {$exists: true} } } )
username_1
  • 동일한 값을 입력할 경우 중복 키 예외를 발생시키면 매우 비효율적이므로, 수많은 중복을 필터링하기보다는 가끔씩 발생하는 중복에 고유 제약 조건을 사용한다.


복합 고유 인덱스

복합 고유 인덱스를 만들 수도 있다. 이때 개별 키는 같은 값을 가질 수 있지만 인덱스 항목의 모든 키에 걸친 값의 조합은 인덱스에서 최대 한 번만 나타난다.


중복 제거하기

기존 컬렉션에 고유 인덱스를 구축할 때 중복된 값이 있으면 실패한다.


5.4.2. 부분 인덱스


고유한 필드가 존재하거나 필드가 아예 존재하지 않으면 uniquepartial 을 결합할 수 있다. 부분 인덱스를 만들려면 partialFilterExpression 옵션을 포함시킨다. 부분 인덱스는 생성하려는 필터 표현식을 나타내는 도큐먼트와 함께 희소 인덱스가 제공하는 기능의 슈퍼셋을 나타낸다.

부분 인덱스는 반드시 고유할 필요는 없다. 고유하지 않은 부분 인덱스를 만들려면 unique 옵션을 제외시키기만 하면 된다.


5.5. 인덱스 관리


데이터베이스의 인덱스 정보는 모두 system.indexes 컬렉션에 저장된다. 특정 컬렉션의 모든 인덱스 정보를 확인하려면 db.컬렉션명.getIndexes() 를 실행한다.

key 는 힌트에 사용하거나, 인덱스가 명시돼야 하는 위치에 사용할 수 있다. v 필드는 내부적으로 인덱스 버저닝에 사용된다.


5.5.1. 인덱스 식별


인덱스명은 서버에서 인덱스를 삭제하거나 조작하는 데 사용된다. createIndex 의 옵션으로 원하는 이름을 지정할 수 있다.


5.5.2. 인덱스 변경


dropIndex 명령을 사용해 인덱스를 제거할 수 있다.

몽고DB는 인덱스 구축이 완료될 때까지 데이터베이스의 모든 읽기와 쓰기를 중단한다. 읽기와 쓰기에 어느 정도 응답하게 하려면 인덱스를 구축할 때 background 옵션을 사용해야 한다.


6. 특수 인덱스와 컬렉션 유형


다음 내용을 살펴보자.

  • 큐 같은 데이터를 위한 제한 컬렉션
  • 캐시를 위한 TTL 인덱스
  • 단순 문자열 검색을 위한 전문 인덱스
  • 2D 구현 및 구면 기하학을 위한 공간 정보 인덱스
  • 대용량 파일 저장을 위한 GridFS


6.1. 공간 정보 인덱스


몽고DB는 2dsphere2d 라는 공간 정보 인덱스를 가진다. 2dsphere 인덱스는 WGS84 좌표계를 기반으로 지표면을 모델링하는 구면 기하학으로 작동한다. 2차원 평면의 점에는 2d 인덱스를 사용하자.


6.2. 전문 검색을 위한 인덱스


제목, 설명 등 컬렉션 내에 있는 필드의 텍스트와 일치시키는 키워드 쿼리를 하게 하려면 text 인덱스를 사용하자.

text 인덱스는 텍스트를 빠르게 검색하는 기능을 제공하며, 언어에 적합한 토큰화, 정지 단어, 형태소 분석 등 일반적인 검색 엔진 요구 사항을 지원한다.

text 인덱스를 만들면 시스템 리소스가 많이 소비될 수 있으므로, 가능하면 백그라운드에서 인덱스를 구축해야 한다.

text 인덱스에 대한 쓰기는 일반적으로 단일 필드, 복합 또는 다중키 인덱스에 대한 쓰기보다 더 많은 비용이 발생한다. 또한 샤딩을 하면 데이터 이동 속도가 느려지며, 모든 텍스트는 새 샤드로 마이그레이션 될 때 다시 인덱싱돼야 한다.


6.2.1. 텍스트 인덱스 생성


다음과 같은 createIndex 호출은 title 과 body 필드내 용어를 기반으로 인덱스를 생성한다.

1
2
admin> db.articles.createIndex({"title": "text", "body": "text"})
title_text_body_text


키에 순서가 있는 일반적인 복합 인덱스와 달리, 기본적으로 각 필드는 text 인덱스에서 동등하게 고려된다. 가중치를 지정하면 몽고DB가 각 필드에 지정하는 상대적 중요도를 제어할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
admin> db.articles.createIndex({"title": "text", "body": "text"},
... {"weights": {"title": 3, "body": 2}})
title_text_body_text
admin> db.articles.getIndexes()
[
  { v: 2, key: { _id: 1 }, name: '_id_' },
  {
    v: 2,
    key: { _fts: 'text', _ftsx: 1 },
    name: 'title_text_body_text',
    weights: { body: 2, title: 3 },
    default_language: 'english',
    language_override: 'language',
    textIndexVersion: 3
  }
]


$** 에 인덱스를 만들면 도큐먼트의 모든 문자열 필드에 전문 인덱스를 생성할 수 있다. 모든 최상위 문자열 필드를 인덱싱할뿐 아니라 내장 도큐먼트와 배열에서 문자열 필드를 검색한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
admin> db.articles.createIndex({"$**": "text"})
$**_text
admin> db.articles.getIndexes()
[
  { v: 2, key: { _id: 1 }, name: '_id_' },
  {
    v: 2,
    key: { _fts: 'text', _ftsx: 1 },
    name: '$**_text',
    weights: { '$**': 1 },
    default_language: 'english',
    language_override: 'language',
    textIndexVersion: 3
  }
]


6.2.2. 텍스트 검색


$text 쿼리 연산자를 사용해 text 인덱스가 있는 컬렉션에 텍스트 검색을 수행하자. $text 는 공백과 구두점을 구분 기호로 사용해 검색 문자열을 토큰화하며, 검색 문자열에서 모든 토큰의 논리적 OR를 수행한다.

1
2
admin> db.articles.find({"$text": {"$search": "body"}}, {title: 1})
[ { _id: ObjectId('67b1870059a646db13a00aa1'), title: 'title' } ]


text 인덱스를 효과적으로 사용하려면 몽고DB에서 텍스트 인덱스가 작동하는 방식을 이해해야 한다. 텍스트를 큰따옴표로 묶어서 정확히 일치하는 구문을 검색할 수 있다.

1
admin> db.articles.find({$text: {$search: "\"impact crater\" lunar"}})
  • 해당 쿼리는 “impact crater” 라는 구문이 포함된 도큐먼트를 모두 찾는다.
  • 몽고DB는 이 쿼리를 "impact crater" AND "lunar" 로 처리한다.


관련성 순으로 정렬하려면 sort 호출을 추가해야 하며, $meta 를 사용해 textScore 필드 값을 지정해야 한다.

1
2
3
4
5
6
7
8
admin> db.articles.find({ $text: { $search: "body" } }, { title: 1, score: { $meta: "textScore" } } ).sort({ score: { $meta: "textScore" } })
[
  {
    _id: ObjectId('67b1870059a646db13a00aa1'),
    title: 'title',
    score: 1
  }
]


6.2.3. 전문 검색 최적화


다른 기준으로 검색 결과를 좁힐 수 있다면 복합 인덱스를 생성할 때 다른 기준을 첫 번째로 두고 전문 필드를 그 다음으로 둔다.

1
db.blog.createIndex({"date": 1, "post": "text"})
  • 전문 인덱스를 “date”에 따라 몇 개의 작은 트리 구조로 쪼개며, 이를 파티셔닝이라고 한다.
  • 전문 인덱스를 분할해 특정 날짜에 대한 전문 검색을 훨씬 빨리 할 수 있다.


6.2.4. 다른 언어로 검색하기

언어에 따라 형태소 분석 방법이 다르므로 인덱스나 도큐먼트가 어떤 언어로 쓰였는지 명시해야 한다. text 인덱스에는 default_language 옵션을 지정할 수 있고 기본값은 “english”로 설정되지만 다양한 언어로 설정할 수 있다.


6.3. 제한 컬렉션


몽고DB의 일반적인 컬렉션은 동적으로 생성되고 추가적인 데이터에 맞춰 크기가 자동으로 늘어난다. 몽고DB는 제한 컬렉션이라는 다른 형태의 컬렉션을 지원하는데, 이는 미리 생성돼 크기가 고정된다. 제한 컬렉션은 새로운 도큐먼트가 입력되면 자동으로 가장 오래된 도큐먼트부터 지운다.

몽고DB TTL 인덱스는 와이어드타이거 스토리지 엔진에서 더 나은 성능을 발휘하므로 제한 컬렉션보다 권장된다. TTL 인덱스는 날짜 유형 필드 값과 인덱스의 TTL 값을 기반으로 일반 컬렉션에서 데이터가 만료되고 제거된다.

제한 컬렉션은 유연성이 부족하지만 로깅에는 나름 유용하다.


6.3.1. 제한 컬렉션 생성


제한 컬렉션은 createCollection 을 사용해 생성할 수 있다.

1
2
admin> db.createCollection("my_collection", {"capped": true, "size": 100000, "max": 100});
{ ok: 1 }
  • 10만 바이트 고정 크기로 100개의 도큐먼트만 저장하도록 생성한 제한 컬렉션이다.
  • 제한 컬렉션은 생성되면 변경할 수 없다.


기존의 일반 컬렉션을 제한 컬렉션으로 변환할 수 있다. 이는 convertToCapped 명령어를 사용해 실행할 수 있다.

1
db.runCommand({"convertToCapped" : "articles", "size" : 100000, "max": 1});


6.3.2. 꼬리를 무는 커서


꼬리를 무는 커서는 결과를 모두 꺼낸 후에도 종료되지 않는 특수한 형태의 커서이다. 이는 결과를 다 꺼내도 종료되지 않으므로 컬렉션에 데이터가 추가되면 새로운 결과를 바로 꺼낼 수 있다. 일반 컬렉션에서는 입력 순서가 추적되지 않기 때문에 꼬리를 무는 커서는 제한 컬렉션에만 사용한다.

꼬리를 무는 커서는 아무런 결과가 없으면 10분 후에 종료되므로 이후에 컬렉션에 다시 쿼리하는 로직을 포함시켜야 한다.

커서는 시간이 초과되거나 누군가가 쿼리 작업을 중지할 때까지는 결과를 처리하거나 다른 결과가 더 도착하기를 기다린다.


6.4. TTL 인덱스


TTL 인덱스를 이용해서 각 도큐먼트에 유효 시간을 설정할 수 있다. 도큐먼트는 미리 설정한 시간에 도달하면 지워진다. 이런 인덱스는 세션 스토리지와 같은 문제를 캐싱하는 데 유용하다.

createIndex 의 두 번째 인자에 expireAfterSeconds 옵션을 명시해 TTL 인덱스를 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
admin> db.sessions.find()
[
  {
    _id: ObjectId('67c462d65ab7898c51a00aa1'),
    seesionId: 'test1',
    lastUpdated: ISODate('2025-03-02T13:53:26.321Z')
  },
  {
    _id: ObjectId('67c463115ab7898c51a00aa2'),
    seesionId: 'test2',
    lastUpdated: ISODate('2025-03-02T13:54:25.132Z')
  }
]
admin> db.sessions.createIndex({"lastUpdated": 1}, {"expireAfterSeconds": 10})
lastUpdated_1
admin> db.sessions.find()

  • 서버 시간이 도큐먼트 시간의 expireAfterSeconds 초를 지나면 도큐먼트가 삭제된다.


몽고DB는 TTL 인덱스를 매분마다 청소하므로 초 단위로 신경 쓸 필요없다. collMod 명령어를 이용해 expireAfterSeconds 를 변경할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
admin> db.sessions.getIndexes()
[
  { v: 2, key: { _id: 1 }, name: '_id_' },
  {
    v: 2,
    key: { lastUpdated: 1 },
    name: 'lastUpdated_1',
    expireAfterSeconds: 10
  }
]
admin> db.runCommand({"collMod": "sessions", "index": {"keyPattern": {"lastUpdated": 1}, "expireAfterSeconds": 20}});
{
  expireAfterSeconds_old: Long('10'),
  expireAfterSeconds_new: Long('20'),
  ok: 1
}
admin> db.sessions.getIndexes()
[
  { v: 2, key: { _id: 1 }, name: '_id_' },
  {
    v: 2,
    key: { lastUpdated: 1 },
    name: 'lastUpdated_1',
    expireAfterSeconds: 20
  }
]


6.5. GridFS로 파일 저장하기


GridFS는 몽고DB에 대용량 이진 파일을 저장하는 메커니즘이다. GridFS의 장점은 다음과 같다.

  • 아키텍처 스택을 단순화할 수 있다. 파일 스토리지를 위한 별도의 도구 대신 GridFS를 사용하면 된다.
  • 몽고DB를 위해 설정한 기존의 복제나 자동 샤딩을 이용할 수 있어, 파일 스토리지를 위한 장애 조치와 분산 확장이 쉽다.
  • 사용자가 올린 파일을 저장할 때 특정 파일 시스템이 갖는 문제를 피할 수 있다.


GridFS의 단점은 다음과 같다.

  1. 성능이 느리다. 파일시스템에서 직접 접근할 때만큼 빠르지 않다.
  2. 도큐먼트를 수정하려면 도큐먼트 전체를 삭제하고 다시 지정하는 방법밖에 없다. 몽고DB는 파일을 여러 개의 도큐먼트로 저장하므로 한 파일의 모든 청크에 동시에 락을 걸 수 없다.


6.5.1. GridFS 시작하기: mongofiles


mongofiles 유틸리티로 쉽게 GridFS를 설치하고 운영할 수 있다. 이는 GridFS에서 파일을 올리고, 받고, 목록을 출력하고, 검색하고, 삭제할 때 등에 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ root@bb7fdb51cfc6:/# echo "Hello, world" > foo.txt
$ root@bb7fdb51cfc6:/# mongofiles put foo.txt
2025-03-02T14:18:23.618+0000	connected to: mongodb://localhost:27017/
2025-03-02T14:18:23.618+0000	adding gridFile: foo.txt
2025-03-02T14:18:23.641+0000	added gridFile: foo.txt

$ root@bb7fdb51cfc6:/# mongofiles list
2025-03-02T14:18:36.340+0000	connected to: mongodb://localhost:27017/
foo.txt	13

root@bb7fdb51cfc6:/# rm foo.txt
root@bb7fdb51cfc6:/# mongofiles get foo.txt
2025-03-03T04:06:38.789+0000	connected to: mongodb://localhost:27017/
2025-03-03T04:06:38.790+0000	finished writing to foo.txt

root@bb7fdb51cfc6:/# cat foo.txt
Hello, world
  • put 연산은 파일시스템으로부터 파일을 받아 GridFS에 추가한다.
  • list 는 GridFS에 올린 파일 목록을 보여준다.
  • getput 의 반대 연산으로 GridFS에서 파일을 받아 파일시스템에 저장한다.
  • mongofiles는 파일명으로 검색하는 search 와 파일을 삭제하는 delete 두 가지 연산을 제공한다.


6.5.2. 몽고DB 드라이버로 GridFS 작업하기


특정 드라이버에서 지원하는 GridFS 관련 정보는 해당 드라이버의 문서를 참조한다.


6.5.3. 내부 살펴보기


GridFS에서 대부분의 작업은 클라이언트 쪽의 드라이버 도구가 처리한다.

GridFS의 기본 개념은 대용량 파일을 청크로 나눈 후 각 청크를 도큐먼트로 저장할 수 있다는 것이다. 파일의 청크를 저장하는 작업 외에도, 여러 청크를 묶고 파일의 메타데이터를 포함하는 단일 도큐먼트를 만든다.


GridFS의 청크는 자체 컬렉션에 저장된다. 청크는 기본적으로 fs.chunks 컬렉션에 저장되지만 바꿀 수 있다. 청크 컬렉션의 도큐먼트 구조는 다음과 같다.

1
2
3
4
5
6
7
8
9
admin> db.fs.chunks.find()
[
  {
    _id: ObjectId('67c468affed5825c11b3618c'),
    files_id: ObjectId('67c468affed5825c11b3618b'),
    n: 0,
    data: Binary.createFromBase64('SGVsbG8sIHdvcmxkCg==', 0)
  }
]
  • _id : 청크의 고유 식별자
  • files_id : 청크에 대한 메타데이터를 포함하는 파일 도큐먼트의 _id
  • n : 다른 청크를 기준으로 하는 파일 내 청크의 위치
  • data : 파일 내 청크의 크기 (바이트 단위)


각 파일의 메타데이터는 기본적으로 별도의 컬렉션인 fs.files 에 저장된다. files 컬렉션 내 각 도큐먼트는 GridFS에서 하나의 파일을 나타내며, 해당 파일과 관련된 어떤 메타데이터든 포함할 수 있다. 도큐먼트 구조는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
admin> db.fs.files.find()
[
  {
    _id: ObjectId('67c468affed5825c11b3618b'),
    length: Long('13'),
    chunkSize: 261120,
    uploadDate: ISODate('2025-03-02T14:18:23.641Z'),
    filename: 'foo.txt',
    metadata: {}
  }
]
  • _id : 파일의 고유 ID, 각 청크에서 file_id키의 값으로 저장된다.
  • length : 파일 내용의 총 바이트 수
  • chunkSize : 파일을 구성하는 각 청크의 크기
  • uploadDate : GridFS에 파일이 저장된 시간


7. 집계 프레임워크


7.1. 파이프라인, 단계 및 조정 가능 항목


집계 프레임워크는 몽고DB 내 분석 도구 모음으로, 하나 이상의 컬렉션에 있는 도큐먼트에 대한 분석을 수행하게 해준다.

집계 프레임워크는 파이프라인 개념을 기반으로 한다. 파이프라인의 모든 단계의 입력과 출력은 도큐먼트 또는 도큐먼트 스트림이다.

집계 파이프라인의 개별 단계는 데이터 처리 단위다. 한 번에 입력 도큐먼트 스트림을 하나씩 가져와서, 각 도큐먼트를 하나씩 처리하고, 출력 도큐먼트 스트림을 하나씩 생성한다.

각 단계는 knobs 또는 tunables 셋을 제공한다. 이 항목들을 조정해 각 단계를 매개변수로 지정함으로써 원하는 작업을 수행할 수 있다.

이러한 tunables는 일반적으로 필드를 수정하거나, 산술 연산을 수행하거나, 도큐먼트를 재구성하거나, 일종의 누산 작업 등 여러 작업을 수행하는 연산자의 형태를 취한다.


7.2. 단계 시작하기: 익숙한 작업들


일치(match), 선출(projection), 정렬(sort), 건너뛰기(skip), 제한(limit) 단계를 살펴보자.

첫 번째 예제로 2004년 데이터를 찾는 간단한 필터를 수행해보자.

1
db.companies.aggregate([ { $match: { founded_year: 2004 } }] )
  • 이는 find를 사용하는 다음 작업과 동일하다.
    1
    
    db.companies.find({founded_year: 2004})
    


파이프라인에 선출 단계를 추가해 몇 개의 필드만 나타내도록 출력을 줄여보자.

1
2
3
4
5
6
7
8
9
10
db.companies.aggregate([  
    {$match: {founded_year: 2004}},  
    {  
        $project: {  
            _id: 0,  
            name: 1,  
            founded_year: 1  
        }  
    }  
])
  • 이처럼 집계 파이프라인에서는 aggregate 메서드를 사용한다.
  • aggregate 는 집계 쿼리를 실행할 때 호출하는 메서드다.
  • 파이프라인은 도큐먼트를 요소로 포함하는 배열이다.
  • 각 도큐먼트는 특정 단계 연산자를 규정해야 한다.


결과 셋을 5개로 제한한 후 원하는 필드를 선출해보자.

1
2
3
4
5
6
7
8
9
10
db.companies.aggregate([  
    {$match: {founded_year: 2004}},  
    {$limit: 5},  
    {  
        $project: {  
            _id: 0,  
            name: 1,  
        }  
    }  
]);


항상 집계 파이프라인의 효율성을 고려해야 한다. 파이프라인을 구축할 때 한 단계에서 다른 단계로 전달해야 하는 도큐먼트 수를 반드시 제한하자.

그러나 순서가 중요하다면 제한 단계 전에 정렬을 수행해야 한다.

1
2
3
4
5
6
7
8
9
10
11
db.companies.aggregate([  
    {$match: {founded_year: 2004}},  
    {$sort: {name: 1}},  
    {$limit: 5},  
    {  
        $project: {  
            _id: 0,  
            name: 1,  
        }  
    }  
]);


정렬을 하고 처음 10개 도큐먼트를 건너뛴 후 결과 셋을 5개로 제한해보자.

1
2
3
4
5
6
7
8
9
10
11
12
db.companies.aggregate([  
    {$match: {founded_year: 2004}},  
    {$sort: {name: 1}},  
    {$skip: 10},  
    {$limit: 5},  
    {  
        $project: {  
            _id: 0,  
            name: 1,  
        }  
    }  
]);


7.3. 표현식


집계 프레임워크는 다양한 표현식 클래스를 지원한다.


불리언 표현식
AND, OR NOT을 쓸 수 있다.
집합 표현식
배열을 집합으로 사용할 수 있다.
비교 표현식
다양한 유형의 범위 필터를 표현할 수 있다.
산술 표현식
산술 연산을 수행할 수 있다.
문자열 표현식
연결, 하위 문자열 검색, 대소문자 및 텍스트 검색과 관련된 작업을 수행할 수 있다.
배열 표현식
배열 요소를 필터링하거나, 배열을 분할하거나, 특정 배열에서 값의 범위를 가져오는 작업을 수행할 수 있다.
가변적 표현식
리터럴, 날짜 값 분석을 위한 식, 조건식을 사용한다.
누산기
기술 통계 및 기타 여러 유형의 값을 계산하는 기능을 제공한다.


7.4. $project


중첩 필드를 승격하는 방법을 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
db.companies.aggregate([  
    {$match: {"funding_rounds.investments.financial_org.permalink": "greylock"}},  
    {  
        $project: {  
            _id: 0,  
            name: 1,  
            ipo: "$ipo.pub_year",  
            valuation: "$ipo.valuation_amount",  
            funders: "$funding_rounds.investments.financial_org.permalink"  
        }  
    }  
]).pretty()
  • ipo, valuation, founders는 최상위 필드이며 필드 값은 중첩된 도큐먼트 및 배열에서 승격됐다.
  • $ 문자는 선출 단계에서 필드 경로로 해석되고 각 필드에서 선출할 값을 선택하는 데 사용된다.


7.5. $unwind


집계 파이프라인에서 배열 필드로 작업할 때는 종종 하나 이상의 전개 단계를 포함해야 한다. 이를 통해 지정된 배열 필드의 각 요소에 대해 출력 도큐먼트가 하나씩 있는 출력을 생성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
db.companies.aggregate([  
    {$match: {"funding_rounds.investments.financial_org.permalink": "greylock"}},  
    {$unwind: "$funding_rounds"},  
    {  
        $project: {  
            _id: 0,  
            name: 1,  
            amount: "$funding_rounds.raised_amount",  
            year: "$funding_rounds.funded_year"  
        }  
    }  
])


7.6. 배열 표현식


필터 표현식은 필터 기준에 따라 배열에서 요소의 부분집합을 선택한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
db.companies.aggregate([  
    {$match: {"funding_rounds.investments.financial_org.permalink": "greylock"}},  
    {  
        $project: {  
            _id: 0,  
            name: 1,  
            founded_year: 1,  
            rounds: {  
                $filter: {  
                    input: "$funding_rounds",  
                    as: "round",  
                    cond: {$gte: ["$$round.raised_amount", 100000000]}  
                }  
            }  
        }  
    },  
    {$match: {"rounds.investments.financial_org_permalink": "greylock"}}  
]);


다음으로 배열 요소 연산자를 살펴보자. $arrayElemAt 연산자를 사용하면 배열 내 특정 슬롯에서 요소를 선택할 수 있다. 다음 파이프라인은 $arrayElemAt 사용 예시다.

1
2
3
4
5
6
7
8
9
10
11
12
db.companies.aggregate([  
    {$match: {"founded_year": 2010}},  
    {  
        $project: {  
            _id: 0,  
            name: 1,  
            founded_year: 1,  
            first_round: {$arrayElemAt: ["$funding_rounds", 0]},  
            last_round: {$arrayElemAt: ["$funding_rounds", -1]}  
        }  
    }  
])


$slice 표현식은 $arrayElemAt 과 관련 있다. 표현식을 사용하면 배열의 특정 인덱스에서 시작해 하나뿐 아니라 여러 항목을 순서대로 반환할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
db.companies.aggregate([  
    {$match: {"founded_year": 2010}},  
    {  
        $project: {  
            _id: 0,  
            name: 1,  
            founded_year: 1,  
            early_rounds: {$slice: ["$funding_rounds", 1, 3]}  
        }  
    }  
])


배열의 크기나 길이를 결정하는 $size 연산자를 수행할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
db.companies.aggregate([  
    {$match: {"founded_year": 2010}},  
    {  
        $project: {  
            _id: 0,  
            name: 1,  
            founded_year: 1,  
            total_rounds: {$size: "$funding_rounds"}  
        }  
    }  
])
  • $size 표현식은 선출 단계에서 사용될 때 단순히 배열 요소 개수인 값을 제공한다.


7.7. 누산기


누산기를 사용하면 특정 필드의 모든 값 합산($sum), 평균 계산($avg) 등의 작업을 할 수 있다. 또한 $first$last 도 누산기로 간주하는데 표현식이 사용된 단계를 통과하는 모든 도큐먼트 내 값을 고려하기 때문이다. $max$min 은 도큐먼트 스트림을 고려해 표시되는 값 중 하나만 저장하는 누산기다. $mergeObjects 를 사용하면 여러 도큐먼트를 하나의 도큐먼트로 결합할 수 있다. 또한 배열용 누산기로 도큐먼트가 파이프라인 단계를 통과할 때 배열에 값을 푸시($push) 할 수 있다. 선출 단계에서는 $sum , $avg 와 같은 누산기가 단일 도큐먼트 내 배열에서 작동하는 반면, 그룹 단계에서는 누산기가 여러 도큐먼트 값에 걸쳐 계산을 수행할 수 있다.


7.7.1. 선출 단계에서 누산기 사용


누산기를 사용하는 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
10
db.companies.aggregate([  
    {$match: {"funding_rounds": {$exists: true, $ne: []}}},  
    {  
        $project: {  
            _id: 0,  
            name: 1,  
            largest_round: {$max: "$funding_rounds.raised_amount"}  
        }  
    }  
])
  • funding_rounds 필드를 포함하고 funding_rounds 배열이 비어 있지 않은 도큐먼트를 필터링한다.


또 다른 예제로 $sum 누산기를 사용해 컬렉션에 있는 각 회사의 총 모금액을 계산해보자.

1
2
3
4
5
6
7
8
9
10
db.companies.aggregate([  
    {$match: {"funding_rounds": {$exists: true, $ne: []}}},  
    {  
        $project: {  
            _id: 0,  
            name: 1,  
            total_funding: {$sum: "$funding_rounds.raised_amount"}  
        }  
    }  
])


7.8. 그룹화 소개


그룹 단계에서는 여러 도큐먼트의 값을 함께 집계하고, 집계한 값에 평균 계산과 같은 집계 작업을 수행할 수 있다.

1
2
3
4
5
6
7
8
9
db.companies.aggregate([  
    {  
        $group: {  
            _id: {founded_year: "$founded_year"},  
            average_number_of_employees: {$avg: "$number_of_employees"}  
        },  
    },  
    {$sort: {average_number_of_employees: -1}}  
]);
  • 그룹 단계를 사용해 설립 연도를 기준으로 모든 회사를 합친 다음 연도마다 평균 직원 수를 계산한다.
  • 그룹 단계의 기본은 도큐먼트의 일부로 지정하는 _id 필드다.


다른 예시를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
db.companies.aggregate([  
    {$match: {"relationships.person": {$ne: null}}},  
    {$project: {relationships: 1, _id: 0}},  
    {$unwind: "$relationships"},  
    {  
        $group: {  
            _id: "$relationships.person",  
            count: {$sum: 1}  
        }  
    },  
    {$sort: {count: -1}}  
])
  • personnull 이 아닌 관계를 모두 필터링
  • 도큐먼트의 관계를 모두 선출
  • 관계를 전개하고 그룹 단계를 통과하도록 함
  • person 값을 갖는 모든 도큐먼트는 함께 그룹화
  • $sum 누산기를 사용해 각 사람이 맺은 관계 수를 계산
  • 내림차순 정렬


7.8.1. 그룹 단계의 _id 필드


그룹 단계에서 _id 필드 값을 구성하는 모범 사례를 살펴보자.

1
2
3
4
5
6
7
8
9
10
db.companies.aggregate([  
    {$match: {founded_year: {$gte: 2013}}},  
    {  
        $group: {  
            _id: {founded_year: "$founded_year"},  
            companies: {$push: "$name"}  
        }  
    },  
    {$sort: {"_id.founded_year": 1}}  
])
  • founded_year 와 같이 그룹화할 값에 명시적으로 레이블을 지정하자.


경우에 따라 여러 필드로 구성된 도큐먼트가 _id 값인 방식을 사용해야 할 수도 있다.

1
2
3
4
5
6
7
8
9
10
db.companies.aggregate([  
    {$match: {founded_year: {$gte: 2101}}},  
    {  
        $group: {  
            _id: {founded_year: "$founded_year", category_code: "$category_code"},  
            companies: {$push: "$name"}  
        }  
    },  
    {$sort: {"_id.founded_year": 1}}  
]);


7.8.2. 그룹 vs 선출


다음 집계 쿼리를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
db.companies.aggregate([  
    {$match: {funding_rounds: {$ne: []}}},  
    {$unwind: "$funding_rounds"},  
    {  
        $sort: {  
            "funding_rounds.funded_year": 1,  
            "funding_rounds.founded_month": 1,  
            "funding_rounds.founded_day": 1  
        }  
    },  
    {  
        $group: {  
            _id: {company: "$name"},  
            funding: {  
                $push: {  
                    amount: "$funding_rounds.raised_amount",  
                    year: "$funding_rounds.funded_year"  
                }  
            }  
        }  
    }  
]);
  • founding_rounds 배열이 비어 있지 않은 도큐먼트를 필터링한다.
  • founding_rounds 를 전개한다.
    • 정렬 및 그룹 단계에서는 각 회사의 funding_rounds 배열의 각 요소에 대해 하나의 도큐먼트가 표시된다.
  • 정렬 단계는 연, 월, 일을 기준으로 오름차순으로 정렬을 수행한다.
  • $push 누산기를 사용해 정렬된 펀딩 라운드 배열을 구성한다.


다음 예를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
db.companies.aggregate([  
    {$match: {funding_rounds: {$ne: []}}},  
    {$unwind: "$funding_rounds"},  
    {  
        $sort: {  
            "funding_rounds.funded_year": 1,  
            "funding_rounds.founded_month": 1,  
            "funding_rounds.founded_day": 1  
        }  
    },  
    {  
        $group: {  
            _id: {company: "$name"},  
            first_round: {$first: "$funding_rounds"},  
            last_round: {$last: "$funding_rounds"},  
            num_rounds: {$sum: 1},  
            total_raised: {$sum: "$funding_rounds.raised_amount"}  
        }  
    },  
    {  
        $project: {  
            _id: 0,  
            company: "$_id.company",  
            first_round: {  
                amount: "$first_round.raised_amount",  
            },  
            last_round: {  
                amount: "$last_round.raised_amount"  
            },  
            num_rounds: 1,  
            total_raised: 1,  
        }  
    },  
    {$sort: {total_raised: -1}}  
]);
  • $first$last 라는 누산기를 사용한다.
    • $first 는 입력 스트림의 첫 번째 값을 저장한다.
    • $last 는 그룹 단계를 통과한 마지막 값을 추적한다.
  • $push 와 마찬가지로 선출 단계에서는 $first$last 를 사용할 수 없다.
    • 선출 단계는 해당 단계를 통해 스트리밍되는 여러 도큐먼트를 기반으로 값을 누적하도록 설계되지 않았다.


7.9. 집계 파이프라인 결과를 컬렉션에 쓰기


집계 파이프라인에서 생성된 도큐먼트를 컬렉션에 쓸 수 있는 두 가지 단계로 $out$merge 가 있다. 두 단계 중 하나만 사용할 수 있으며, 이는 집계 파이프라인의 마지막 단계여야 한다.

$merge 는 샤딩 여부에 관계없이 모든 데이터베이스와 컬렉션에 쓸 수 있다. $merge 는 기존 컬렉션으로 작업할 때 결과를 통합할 수 있다. 그러나 $merge 를 사용할 때 진정한 장점은 파이프라인 실행 시 출력 컬렉션의 내용이 점진적으로 갱신되는 주문식의 구체화된 뷰를 생성할 수 있다는 점이다.


8. 트랜잭션


몽고DB는 여러 작업, 컬렉션, 데이터베이스, 도큐먼트 및 샤드에서 ACID 호환 트랜잭션을 지원한다.


8.1. 트랜잭션 소개


트랜잭션은 읽기나 쓰기 작업이 가능한 데이터베이스 작업을 하나 이상 포함하는 데이터베이스의 논리적 처리 단위다. 트랜잭션의 중요한 특징은 작업이 성공하든 실패하든 부분적으로는 완료되지 않는다는 점이다. (4.2 버전 이상만 가능) 몽고DB는 복제 셋 또는 샤드 전체에 ACID 호환 트랜잭션이 있는 분산 데이터베이스다.


8.1.1. ACID의 정의


ACID는 원자성(Atomicity), 일관성(Consistency), 고립성(Isolation), 영속성(Durability)의 약어다.

원자성
트랜잭션 내 모든 작업이 적용되거나 아무 작업도 적용되지 않도록 한다.
일관성
트랜잭션이 성공하면 데이터베이스가 하나의 일관성 있는 상태에서 다음 일관성 있는 상태로 이동하도록 한다.
고립성
여러 트랜잭션이 데이터베이스에서 동시에 실행되도록 허용하는 속성이다.
영속성
트랜잭션이 커밋될 때 시스템 오류가 발생하더라도 모든 데이터가 유지되도록 한다.


8.2. 트랜잭션 사용법


몽고DB는 트랜잭션을 사용하기 위한 두 가지 API를 제공한다. 첫 번째는 코어 API라는 관계형 데이터베이스와 유사한 구문이며, 두 번째는 트랜잭션 사용에 권장되는 접근 방식인 콜백 API다.

코어 API는 대부분 오류에 재시도 로직을 제공하지 않으며 개발자가 작업에 대한 로직, 트랜잭션 커밋함수, 필요한 재시도 및 오류 로직을 모두 작성해야 한다.

콜백 API는 지정된 논리 세션과 관련된 트랜잭션 시작, 콜백 함수로 제공된 함수 실행, 트랜잭션 커밋(또는 중단)을 포함해 코어 API에 비해 많은 기능을 래핑하는 단일 함수를 제공한다. 이 함수는 커밋 오류를 처리하는 재시도 로직도 포함된다.


두 API에서 개발자는 트랜잭션에서 사용할 논리 세션을 시작해야 하며, 트랜잭션의 작업이 특정 논리 세션과 연결돼야 한다. 논리 세션은 몽고DB 배포 컨텍스트에서 작업의 시간과 순서를 추적한다. 순서에 따라 인과관계가 반영된 읽기 및 쓰기 작업의 시퀀스는 클라이언트 세션으로 정의된다. 클라이언트 세션은 애플리케이션에 의해 시작되며 서버 세션과 상호작용하는 데 사용된다.


코어 API보다 콜백 API를 권장하며, API 간 차이점 요약은 다음과 같다.

  • 코어 API
    • 트랜잭션을 시작하고 커밋하려면 명시적인 호출이 필요하다.
    • TransientTransactionErrorUnknownTransactionCommitResult 에 대한 오류 처리 로직을 통합하지 않고, 대신 사용자 지정 오류 처리를 통합하는 유연성을 제공한다.
    • 특정 트랜잭션을 위해 API로 전달되는 명시적 논리 세션이 필요하다.
  • 콜백 API
    • 트랜잭션을 시작하고 지정된 작업을 실행한 후 커밋(또는 중단)한다.
    • TransientTransactionErrorUnknownTransactionCommitResult 에 대한 오류 처리 로직을 자동으로 통합한다.
    • 특정 트랜잭션을 위해 API로 전달되는 명시적 논리 세션이 필요하다.


8.3. 애플리케이션을 위한 트랜잭션 제한 조정


애플리케이션이 트랜잭션을 최적으로 사용하도록 매개변수를 조정하자.


8.3.1 타이밍과 Oplog 크기 제한


몽고DB 트랜잭션에는 두 가지 주요 제한 범주가 있다.

  • 첫 번째는 트랜잭션의 시간 제한, 즉 특정 트랜잭션이 실행될 수 있는 시간, 트랜잭션이 락을 획득하려고 대기하는 시간, 모든 트랜잭션이 실행될 최대 길이를 제어하는 것과 관련 있다.
  • 두 번째는 몽고DB oplog 항목과 개별 항목에 대한 크기 제한과 관련 있다.


시간 제한

트랜잭션의 최대 실행 시간은 기본적으로 1분 이하다. 이 시간이 경과하면 트랜잭션이 만료됐다고 간주하며 중단된다.

트랜잭션에 시간 제한을 명시적으로 설정하려면 commitTransactionmaxTimeMS 를 지정하는 것이 좋다.

트랜잭션의 작업에 필요한 락을 획득하기 위해 트랜잭션이 대기하는 최대 시간은 기본적으로 5밀리세컨드다. 이 시간 내에 락을 획득할 수 없으면 트랜잭션은 중단된다.


Oplog 크기 제한

트랜잭션의 쓰기 작업에 필요한 만큼 oplog 항목을 생성한다. 그러나 각 oplog 항목은 BSON 도큐먼트 크기 제한인 16MB 이하여야 한다.


트랜잭션은 일관성을 보장하기 위해 제공하지만 풍부한 도큐먼트 모델과 함께 사용돼야 한다. 유연성 있는 모델과 스키마 설계 패턴과 같은 모범 사례를 사용하면 대부분의 상황에서 트랜잭션을 사용하지 않아도 된다. 따라서 트랜잭션은 애플리케이션에서 드물게 사용하는 것이 좋은 강력한 기능이다.


9. 애플리케이션 설계


9.1. 스키마 설계 고려 사항


가장 좋은 설계 접근 방식은 애플리케이션에서 원하는 방식으로 데이터를 표현하는 방법이다. 따라서 관계형 데이터베이스와 달리, 스키마를 모델링하기 전에 먼저 쿼리 및 데이터 접근 패턴을 이해해야 한다.


다음은 스키마를 설계할 때 고려할 주요 요소다.

제약 사항
도큐먼트의 최대 크기는 16MB이며, 디스크에서 전체 도큐먼트를 읽고 쓴다.
쿼리 및 쓰기읩 접근 패턴
쿼리 수를 최소화 하고, 함께 쿼리되는 데이터가 동일한 도큐먼트에 저장되도록 설계를 확인해야 한다. 자주 사용하지 않는 데이터도 다른 컬렉션으로 이동하자. 동적(읽기/쓰기) 데이터와 정적(대부분 읽기) 데이터를 분리할 수 있는지도 고려해볼 만하다.
관계 유형
요구 사항과 도큐먼트 간 관계 측면에서 어떤 데이터가 관련돼 있는지 고려해야 한다. 그 후 데이터나 도큐먼트를 내장하거나 참조할 방법을 결정한다. 추가로 쿼리하지 않고 도큐먼트를 참조하는 방법을 파악해야 하며, 관계가 변경될 때 갱신되는 도큐먼트 개수를 알아야 한다.
카디널리티
도큐먼트와 데이터가 어떻게 관련돼 있는지 확인한 후에는 관계의 카디널리티를 고려해야 한다. 모델링에 최선의 형식을 사용하도록 관계의 카디널리티를 설정하는 것이 매우 중요하다.


9.1.1. 스키마 설계 패턴


스키마 설계 패턴은 다음과 같다.


다형성 패턴

컬렉션 내 모든 도큐먼트가 유사하지만 동일하지 않은 구조를 가질 때 적합하다. 여기에는 공통 쿼리를 지원하는 도큐먼트에서 공통 필드를 식별하는 것이 포함된다. 이를 통해 동일하지 않은 도큐먼트로 구성된 단일 컬렉션에서 간단한 쿼리를 사용해 쿼리 성능을 향상시킬 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
  "_id": "1",
  "name": "스마트폰",
  "price": 500,
  "type": "electronics",
  "warranty": "2년",
  "power": "3000mAh"
}
{
  "_id": "2",
  "name": "티셔츠",
  "price": 20,
  "type": "apparel",
  "size": "L",
  "material": "면"
}


속성 패턴

정렬하거나 쿼리하려는 도큐먼트에 필드의 서브셋이 있는 경우, 정렬하려는 필드가 도큐먼트의 서브셋에만 존재하는 경우 또는 두 조건이 모두 해당되는 경우에 적합하다. 여기에는 데이터를 키/값 쌍의 배열로 재구성하고 배열 요소에 인덱스를 만드는 작업이 포함된다. 이 패턴은 도큐먼트당 많은 유사한 필드를 대상으로 지정하기 때문에 필요한 인덱스가 적어지고 쿼리 작성이 더 간단해진다.

1
2
3
4
5
6
7
8
9
{
  "_id": "상품123",
  "name": "상품 A",
  "attributes": [
    { "key": "color", "value": "red" },
    { "key": "size", "value": "M" },
    { "key": "weight", "value": "10kg" }
  ]
}


버킷 패턴

데이터가 일정 기간 동안 스트림으로 유입되는 시계열 데이터에 적합하다. 데이터를 특정 시간 범위의 데이터를 각각 보유하는 도큐먼트 셋으로 ‘버킷화’하면 시간/데이터 포인트의 포인트당 도큐먼트를 만들 때보다 훨씬 효율적이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "_id": "2023-03-01T00:00:00Z",
  "bucketStart": "2023-03-01T00:00:00Z",
  "bucketEnd": "2023-03-01T00:59:59Z",
  "readings": [
    { "timestamp": "2023-03-01T00:01:00Z", "temperature": 22.5, "humidity": 50 },
    { "timestamp": "2023-03-01T00:02:00Z", "temperature": 22.7, "humidity": 49 },
    { "timestamp": "2023-03-01T00:03:00Z", "temperature": 22.6, "humidity": 50 }
  ],
  "count": 60,
  "avgTemperature": 22.6,
  "avgHumidity": 49.8
}


이상치 패턴

드물게 도큐먼트의 쿼리가 애플리케이션의 정상적인 패턴을 벗어날 때 사용한다. 인기도가 중요한 상황을 위해 설계된 고급 스키마 패턴으로, 주요 영향 요인, 도서 판매, 영화 리뷰 등이 있는 소셜 네트워크에서 볼 수 있다.

  • 메인 컬렉션 (users)
    1
    2
    3
    4
    5
    6
    
      {
        "_id": "user123",
        "name": "John Doe",
        "email": "john@example.com",
        "profileRef": "profile_user123"
      }
    
    • 추가 데이터가 저장된 도큐먼트를 참조
  • 이상치 데이터 컬렉션 (userProfiles)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
      {
        "_id": "profile_user123",
        "bio": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ... (대용량 텍스트)",
        "images": [
          "image1.png",
          "image2.png",
          "image3.png"
        ],
        "additionalData": { 
          "preferences": { "theme": "dark", "language": "en" },
          "history": [ ]
        }
      }
    
    • history 컬럼에 대량의 기록을 저장


계산된 패턴

데이터를 자주 계산해야 할 때나 데이터 접근 패턴이 읽기 집약적일 때 사용한다. 이 패턴은 주요 도큐먼트가 주기적으로 갱신되는 백그라운드에서 계산을 수행하도록 권장한다. 이는 개별 쿼리에 대해 필드나 도큐먼트를 지속적으로 생성하지 않고도 계산된 필드 및 도큐먼트의 유효한 근사치를 제공한다. 이는 CPU에 가해지는 부담을 크게 줄일 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "_id": "order123",
  "items": [
    { "productId": "p1", "quantity": 2, "unitPrice": 50 },
    { "productId": "p2", "quantity": 1, "unitPrice": 100 }
  ],
  "discount": 10,
  "computed": {
    "subtotal": 200,
    "total": 190
  },
  "orderDate": "2023-03-01T10:00:00Z"
}
  • computed 컬럼에 미리 계산해서 저장해둔다.


서브셋 패턴

장비의 램 용량을 초과하는 작업 셋이 있을 때 사용한다. 이는 애플리케이션에서 사용하지 않는 정보를 많이 포함하는 대용량 도큐먼트를 위해 사용된다. 특정 컬레션에 가장 최근 데이터를 저장하고, 다른 컬렉션에 그 외 데이터를 저장한다.

  1. 별도 컬렉션 방식
    1. 전체 도큐먼트 (products 컬렉션)
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      
       {
         "_id": "prod123",
         "name": "Awesome Gadget",
         "price": 99.99,
         "description": "Detailed description with specifications and features...",
         "images": ["img1.png", "img2.png"],
         "reviews": [
           { "user": "user1", "comment": "Great product!", "rating": 5 },
           { "user": "user2", "comment": "Satisfactory", "rating": 4 }
         ],
         "createdAt": "2023-01-01T12:00:00Z"
       }
      
    2. 서브셋 도큐먼트 (productSummaries 컬렉션)
      1
      2
      3
      4
      5
      6
      
       {
         "_id": "prod123",
         "name": "Awesome Gadget",
         "price": 99.99,
         "createdAt": "2023-01-01T12:00:00Z"
       }
      
  2. 도큐먼트 내 하위 필드 검색
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
     {
       "_id": "prod456",
       "name": "Cool Widget",
       "price": 49.99,
       "description": "Detailed description with additional specifications...",
       "createdAt": "2023-02-15T08:30:00Z",
       "summary": {
         "name": "Cool Widget",
         "price": 49.99,
         "createdAt": "2023-02-15T08:30:00Z"
       }
     }
    


확장된 참조 패턴

각각 고유한 컬렉션에 있는 여러 논리 엔티티 또는 ‘사물’이 있고, 특정 기능을 위해 엔티티들을 모을 때 사용한다. 데이터를 중복시키는 대신 정보를 조합하는 데 필요한 쿼리 수를 줄인다.

  • 기본 참조 패턴
    1
    2
    3
    4
    5
    6
    
    {
    "_id": "order123",
    "userId": "user456",
    "orderDate": "2023-03-01T10:00:00Z",
    "items": [ ]
    }
    
    • 사용자 도큐먼트를 참조하는 ID만 저장
  • 확장된 참조 패턴
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    {
    "_id": "order123",
    "user": {
      "id": "user456",
      "name": "John Doe",
      "email": "john@example.com"
    },
    "orderDate": "2023-03-01T10:00:00Z",
    "items": [ ]
    }
    
    • 참조하는 도큐먼트의 식별자 외에도, 자주 조회되는 중요한 필드를 함께 저장합니다.


근사 패턴

리소스가 많이 드는(시간, 메모리, CPU) 계산이 필요하지만 높은 정확도가 반드시 필요하지는 않은 상황에 유용하다. 추천이나 조회 수가 1회가 아니라 100회가 될 때마다 카운터를 갱신하면 쓰기 횟수를 크게 줄일 수 있다.

1
2
3
4
5
6
7
8
{
  "_id": "prod789",
  "name": "Cool Gadget",
  "price": 99.99,
  "averageRating": 4.3,
  "reviewCount": 152,
  "reviewsRef": "reviews_prod789"
}
  • 자주 사용되는 중요한 필드를 함께 저장한다.
    • 리뷰 평점, 개수, 컬렉션 식별자 등


트리 패턴

쿼리가 많고 구조적으로 주로 계층적인 데이터가 있을 때 적용한다. 전체 계층 구조를 추적하는 필드와 즉각적인 범주를 포함하는 필드가 있다. 계층 구조의 범주와 관련된 모든 항목을 쉽게 찾을 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
  "_id": "node1",
  "name": "루트 노드",
  "path": "/"
}

{
  "_id": "node2",
  "name": "자식 노드 1",
  "path": "/node1/"
}
{
  "_id": "node3",
  "name": "자식 노드 2",
  "path": "/node1/"
}

{
  "_id": "node4",
  "name": "자식의 자식",
  "path": "/node1/node2/"
}


사전 할당 패턴

MMAP 스토리지 엔진과 함께 사용됐지만 여전히 사용된다. 이 패턴은 빈 구조를 사전 할당한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 중앙 서버가 아니라 각 노드에 ID 범위를 사전에 할당한 경우
class IDGenerator {
  constructor(start, end) {
    this.current = start;
    this.end = end;
  }

  generateID() {
    if (this.current > this.end) {
      throw new Error("ID 범위를 모두 소진했습니다.");
    }
    return this.current++;
  }
}

// 노드 A에 1000~1999번 ID 범위를 할당
const nodeA_IDGenerator = new IDGenerator(1000, 1999);

console.log("생성된 ID:", nodeA_IDGenerator.generateID()); // 1000, 1001, ...


도큐먼트 버전 관리 패턴

도큐먼트의 이전 버전을 유지하는 메커니즘을 제공한다. 메인 컬렉션의 도큐먼트 버전을 추적하려면 각 도큐먼트에 부가 필드를 추가해야 하며 도큐먼트의 모든 수정 사항을 포함하는 추가 컬렉션이 필요하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
  "_id": "doc123",
  "name": "문서 제목",
  "content": "현재 내용",
  "version": 3,
  "history": [
    {
      "version": 1,
      "content": "초기 내용",
      "modifiedAt": "2023-01-01T10:00:00Z"
    },
    {
      "version": 2,
      "content": "두 번째 버전 내용",
      "modifiedAt": "2023-02-01T12:00:00Z"
    }
  ],
  "modifiedAt": "2023-03-01T14:00:00Z"
}
  • 도큐먼트에 version 필드를 추가하여, 업데이트 시마다 숫자를 증가시킨다.


9.2. 정규화 vs 비정규화


정규화는 컬렉션 간의 참조를 이용해 데이터를 여러 컬렉션으로 나누는 작업이다. 몽고DB 집계 프레임워크는 소스 컬렉션에 일치하는 도큐먼트가 있는 결합된 컬렉션에 도큐먼트를 추가해 왼쪽 외부 조인을 수행하는 $lookup 단계와의 조인을 제공한다. 이는 결합된 컬렉션에 있는 각 일치된 도큐먼트에 새 배열 필드를 추가한다.

비정규화는 모든 데이터를 하나의 도큐먼트에 내장하는 것으로, 정규화의 반대다. 여러 도큐먼트가 최종 데이터 사본에 대한 참조를 갖는 대신에 데이터의 사본을 가진다.

정규화는 쓰기를 빠르게 만들고 비정규화는 읽기를 빠르게 만든다.


9.2.1. 데이터 표현 예제


도큐먼트에 대한 참조를 내장함으로써 역참조하는 쿼리 중 하나를 제거할 수 있다. 이는 즉시 접근하거나 변경할 필요가 없는 데이터를 구조화하는 데 사용하는 꽤 보편적인 방법이다.

읽기를 좀 더 최적화하려면 데이터를 완전히 비정규화하고 각 도큐먼트를 필드에 내장 도큐먼트로 저장해 하나의 쿼리로 모든 정보를 가져오게 할 수 있다. 반면 더 많은 공간을 차지하고 동기화하기가 더 어렵다는 단점이 있다.


마지막으로 앞에서 언급한 내장과 참조가 혼합된 확장 참조 패턴을 사용한다. 자주 사용하는 정보로 서브 도큐먼트의 배열을 생성하고, 추가적인 정보는 실제 도큐먼트를 참조하는 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{  
    "_id": ObjectId("5f0f5f1b7f3d3f3b4b3f3d3b"),  
    "name": "scratch_7",  
    "classes": [{  
        "_id": ObjectId("5f0f5f1b7f3d3f3b4b3f3d3c"),  
        "class": "Trigonometry",  
    }, {  
        "_id": ObjectId("5f0f5f1b7f3d3f3b4b3f3d3d"),  
        "class": "Algebra",  
    }, {  
        "_id": ObjectId("5f0f5f1b7f3d3f3b4b3f3d3e"),  
        "class": "Calculus",  
    }]  
}


정보가 정기적으로 갱신돼야 한다면 정규화하는 것이 좋다. 하지만 드물게 변경된다면 애플리케이션이 수행하는 모든 읽기를 희생해 갱신 프로세스를 최적화해도 이득이 거의 없다.

내장 도큐먼트를 사용하기로 결정하고 도큐먼트를 갱신해야 한다면, 모든 도큐먼트가 성공적으로 갱신되도록 보장하기 위해 cron 작업을 설정해야 한다.

멱등이 아닌 연산자의 경우 작업을 두 개로, 즉 개별적으로 멱등이며 재시도해도 안전한 작업으로 분리해야 한다. 첫 번째 작업에 고유한 보류 토큰을 포함하고, 두 번째 작업에서 고유한 키와 고유한 보류 토큰을 모두 사용하게 하면 된다.

내장된 필드의 내용이나 개수가 제한 없이 늘어나야 한다면 일반적으로 그 정보는 내장되지 않고 참조돼야 한다.

마지막으로, 도큐먼트에 쿼리할 때 결과에서 거의 항상 제외되는 필드는 다른 컬렉션에 속해도 된다.


위 지침들을 요약한 표는 다음과 같다.

내장 방식이 좋은 경우참조 방식이 좋은 경우
작은 서브 도큐먼트큰 서브 도큐먼트
주기적으로 변하지 않는 데이터자주 변하는 데이터
결과적인 일관성이 허용될 때즉각적인 일관성이 필요할 때
증가량이 적은 도큐먼트증가량이 많은 도큐먼트
두 번째 쿼리를 수행하는 데 자주 필요한 데이터결과에서 자주 제외되는 데이터
빠른 읽기빠른 쓰기


users 컬렉션이 있다고 가정하자. 다음처럼 내장 여부를 판단한다.

계정 설정
사용자 도큐먼트에만 관련 있다. 사용자 정보와 함께 노출된다. 일반적으로 내장돼야 한다.
최근 활동
크기가 고정된(예를 들면 최근 10개) 필드라면 내장하는 것이 유용하다.
친구
내장하지 않는다.
사용자가 생성한 모든 내용
내장하지 않는다.


9.2.2. 카디널리티


카디널리티는 컬렉션이 다른 컬렉션을 얼마나 참조하는지 나타내는 개념이다. 일반적인 관계는 일대일, 일대다, 혹은 다대다다. ‘다수’라는 개념을 ‘많음’과 ‘적음’이라는 하위 범주로 나누면 개념상 도움이 된다. 많고 적음의 관계를 결정하면 무엇을 내장할지 결정하는 데 도움이 된다.

일반적으로 ‘적음’ 관계는 내장이 더 적합하고 ‘많음’ 관계는 참조가 더 적합하다. 또한 자주 반환되지 않으면서 매우 자주 변하는 필드는 정규화하는데 적합한 필드다.

내장의 서브 도큐먼트와 참조의 수는 제한된다. 이상치 패턴을 사용하고 필요하다면 연속 도큐먼트를 사용해 해결할 수 있다.


9.3. 데이터 조작을 위한 최적화


읽기 최적화는 인덱스를 사용해 하나의 도큐먼트에서 가능한 한 많은 정보를 반환하는 것과 관련 있다. 쓰기 최적화는 인덱스 개수를 최소화하고 갱신을 가능한 한 효율적으로 수행하는 것과 관련 있다.


9.3.1. 오래된 데이터 제거


오래된 데이터를 제거하는 데 세 가지 방법을 사용한다. 제한 컬렉션을 사용하거나, TTL 컬렉션을 사용하거나, 주기마다 컬렉션을 삭제한다.

제한 컬렉션은 크기를 크게 설정하고 오래된 데이터가 끝으로 밀려나게 하면 된다. 이는 급격히 증가하는 트래픽에 취약하다.

TTL 컬렉션을 사용하면 도큐먼트가 제거될 때 미세하게 조절할 수 있다. 그러나 쓰기를 매우 많이 수행하는 컬렉션에 사용하기에는 충분히 빠르지 않다. 이는 TTL 인덱스를 탐색해 도큐먼트를 제거한다.

여러 개의 컬렉션을 사용할 수 있다. 한 달에 하나의 컬렉션을 사용하여, 달이 바뀔 때마다 달 컬렉션을 사용하고 특정 시간보다 오래되면 삭제한다. 어떠한 양의 트래픽에도 대부분 버틸 수 있지만, 동적 컬렉션을 사용하므로 애플리케이션 구축이 복잡하다.


9.4. 데이터베이스와 컬렉션 구상


일반적으로 스키마가 유사한 도큐먼트는 같은 컬렉션에 보관해야 한다. 따라서 함께 쿼리하거나 집계해야 하는 도큐먼트는 하나의 큰 컬렉션에 넣는 것이 좋다(다른 컬렉션 혹은 데이터베이스에 있으면 $merge 를 사용할 수 있다).

컬렉션에서는 락(읽기/쓰기)과 저장을 중요하게 고려해야 한다. 쓰기 워크로드가 높다면 여러 물리적 볼륨을 사용해 입출력 병목 현상을 줄일 수 있다. --directoryperdb 옵션을 사용하면 데이터베이스는 각자의 디렉터리에 있으므로 서로 다른 데이터베이스를 서로 다른 볼륨에 마운트 할 수 있다. 그러므로 데이터베이스 내 모든 항목이 비슷한 ‘품질’, 비슷한 접근 패턴, 비슷한 트래픽 수준을 갖는 것이 좋다.


9.5. 일관성 관리


일관성을 얻는 방법을 이해하려면 몽고DB가 내부에서 무엇을 수행하는지 이해해야 한다. 서버는 각 연결에 대한 요청 큐를 보관한다. 클라이언트가 요청을 보내면 요청은 연결 큐의 가장 마지막에 위치하게 된다. 따라서 각 연결은 데이터베이스에 대해 일관적인 관점을 가진다.

드라이버는 효율성을 위해 서버에 대한 여러 연결(풀)을 열고 요청을 분산한다. 일련의 요청이 하나의 연결에 의해 처리되도록 보장하는 메커니즘을 가진다.

읽기 요청을 복제 셋의 세컨더리로 보낼 때 문제가 될 수 있다. 세컨더리는 초, 분, 심지어 시간 단위 전부터 데이터를 읽으므로 프라이머리에 뒤쳐질 수 있다.

몽고DB는 읽을 데이터의 일관성과 격리 속성을 제어하는 readConcern 옵션을 제공한다. writeConcern 과 결합하면 애플리케이션에 대한 일관성과 가용성 보장을 제어할 수 있다. local , available , majority , linearizable , snapshot 이라는 5개의 수준이 있다.


9.6. 스키마 마이그레이션


애플리케이션이 사용하는 각 스키마를 신중히 기록해야 한다. 이상적으로는 도큐먼트 버전 관리 패턴을 적용할 수 있는지 고려해야 한다.

변화하는 요구 사항을 더 구조화된 방식으로 처리할 수 있다. 각 도큐먼트에 version 필드를 추가해, 애플리케이션이 도큐먼트 구조를 위해 무엇을 받아들일지 판단하는 데 사용한다.

스키마가 변경될 때 모든 데이터를 마이그레이션하는 방법도 있다. 몽고DB는 시스템에 많은 부하를 주는 마이그레이션을 피하기 위해 동적 스키마를 갖도록 허용한다. 몽고DB는 마이그레이션을 지원하는 트랜잭션을 지원한다.


9.7. 스키마 관리


버전 3.2에서 스키마 유효성 검사를 도입해 갱신 및 삽입 중에 유효성 검사를 허용한다. 버전 3.6에는 $jsonSchema 연산자를 사용하는 JSON 스키마 유효성 검사가 추가됐으며, 이제 몽고DB의 모든 스키마 유효성 검사에 권장된다.

기존 컬렉션에 유효성 검사를 추가하려면 validator 옵션과 함께 collMod 명령을 사용한다. 새 컬렉션에 유효성 검사를 추가하려면 db.createCollection() 을 사용할 때 validator 옵션을 지정한다.


9.8. 몽고DB를 사용하지 않는 경우


  • 여러 차원에 걸쳐 조인하는 작업은 관계형 데이터베이스에 더 적합하다.
  • 관계형 데이터베이스는 몽고DB를 지원하지 않는 도구를 사용할 수 있다.


10. 복제 셋 설정


10.1. 복제 소개


복제는 데이터의 동일한 복사본을 여러 서버상에서 보관하는 방법이며 실제 서비스를 배포할 때 권장된다. 복제는 애플리케이션이 정상적으로 동작하게 하고 데이터를 안전하게 보존한다.

몽고DB에서 복제 셋을 생성함으로써 복제를 설정할 수 있다. 복제 셋은 클라이언트 요청을 처리하는 프라이머리 서버 한 대와, 프라이머리 데이터의 복사본을 갖는 세컨더리 서버 여러 대로 이루어진다.


10.2. 복제 셋 설정 - 1장


몽고DB가 고가용성 및 재해 복구를 처리하는 방법을 살펴보자. 운영 환경에서는 항상 복제 셋을 사용하며, 각 멤버에 전용 호스트를 할당해 리소스 경합을 방지하고 서버 오류에 대한 격리를 제공해야 한다. DNS를 사용하면 복제 셋 멤버를 호스팅하는 서버를, 클라이언트를 재구성할 필요 없이 돌아가면서 변경할 수 있다는 장점이 있다.


먼저 다음 명령을 통해 3개의 디렉터리를 만든다.

1
$ mkdir -p /data/rs{1,2,3}
  • 그러면 /data/rs1 ,/data/rs2 , /data/rs3 디렉터리가 생성된다.


다음으로 다음 명령을 실행한다.

1
2
3
$ mongod --replSet mdbDefGuide --dbpath /data/rs1 --port 27017 --oplogSize 200
$ mongod --replSet mdbDefGuide --dbpath /data/rs1 --port 27018 --oplogSize 200
$ mongod --replSet mdbDefGuide --dbpath /data/rs1 --port 27019 --oplogSize 200


10.3. 네트워크 고려 사항


복제 셋의 모든 멤버는 같은 셋 내 다른 멤버와 연결할 수 있어야 한다. 복제 셋의 각 멤버가 다른 멤버와 통신하려면 다른 멤버가 연결할 수 있는 IP 주소에도 바인딩해야 한다. --bind_ip 를 지정하거나 인스턴스 구성 파일에 있는 bind_ip 를 사용한다.

1
$ mongod --bind_ip localhost,192.51.100.1 --replSet mdbDefGuid --dbpath ~/data/rs1 --port 27017 --smallfiles --oplogSize 200


10.4. 보안 고려 사항


IP 주소에 바인딩하기 전 복제 셋을 구성할 때, 권한 제어를 활성화하고 인증 메커니즘을 지정해야 한다. 또한 디스크의 데이터를 암호화하고, 복제 셋 멤버 간 통신 및 셋과 클라이언트 간 통신을 암호화하면 좋다.


10.5. 복제 셋 설정 - 2장


각 멤버를 나열하는 구성을 만들어 mongod 프로세스 중 하나로 보내면 멤버들은 서로의 존재를 알게 된다. mongo 셸에서 구성 도큐먼트를 만들고 rs.initiate() 보조자에 전달해 복제 셋을 시작한다. 그러면 3개의 멤버가 있는 복제 셋이 시작되며 구성을 나머지 mongod들에 전파해 복제 셋이 형성된다.


Reference


한빛미디어: 몽고DB 완벽 가이드

This post is licensed under CC BY 4.0 by the author.