
회사 내부 프로젝트로 Flask와 MongoDB를 사용하게 되었는데 기존에는 PyMongo로 MongoDB와 연결하여 데이터를 핸들링했었지만 이번 프로젝트에서는 유지보수의 용이함, 데이터 검증 강화와 더불어 ORM에 익숙하지 않은 다른 팀원들과 같이 공부해나가는 의미에서 MongoEngine을 사용하기로 하였습니다.
MongoEngine은 Python에서 사용할 수 있는 MongoDB용 Object - Document 매퍼이며 설치 방법은 다음과 같습니다.
python -m pip install -U mongoengine
connect() 함수를 통해 MongoDB 인스턴스와 연결할 수 있으며 여러 가지 옵션이 있습니다.
# localhost, 27017 포트에서 실행 중인 mongod의 데이터베이스와 연결하려면
connect('DB명')
# URI를 활용한 연결
connect(host="mongod://${MONGO_URI}:${MONGO_PORT}/${MONGO_DBNAME}")
# 인증 기능을 활용한 연결 (어드민 사용)
connect(host="mongod://${MONGO_USERNAME}:${MONGO_PASSWORD}@${MONGO_HOSTNAME}:${MONGO_PORT}/${MONGO_DBNAME}?authSource=admin")
# 인증 기능을 활용한 연결 (특정 DB 인증 사용)
connect(host="mongod://${MONGO_USERNAME}:${MONGO_PASSWORD}@${MONGO_HOSTNAME}:${MONGO_PORT}/${MONGO_DBNAME}?authSource=${MONGO_CREDENTIALS_DB}")
# 키워드를 사용한 연결
connect('${MONGO_DBNAME}', username='${MONGO_USERNAME}', password='${MONGO_PASSWORD}', authentication_source='${MONGO_CREDENTIALS_DB}')
그 외에 ReplicaSet 사용 시 적용하는 ReadPreference 옵션, 다중 DB 사용, DB 스위칭 옵션도 있으나 여기서는 다루지 않습니다.
저의 경우 Spring Data MongoDB를 먼저 써왔었는데 MongoTemplate이나 MongoOperations을 사용하지 않고 application.yml에서 쉽게 DB 관련 연결 설정을 했었다보니 신기했습니다.
Document 클래스를 상속하는 클래스를 만들고 컬럼과 매칭시킬 변수명과 필드명을 선언합니다.
class Article(Document):
title = StringField(max_length=200, required=True)
description = StringField(max_length=2000, required=True)
author = StringField(required=True)
선언할 수 있는 다양한 필드가 존재하였는데 필드 타입 이름이 명시적으로 되어 있어서 편합니다.
다양한 필드 관련 파라미터
class Article(Document):
id = IntField(primary_key=True) # PK로 지정
title = StringField(max_length=200, required=True, validation=_has_keyword) # 아래의 _has_keyword 함수로 유효성 검사
description = StringField(max_length=2000, required=True)
author = StringField(required=True, db_field='WRITER') # 컬렉션 내의 WRITER 필드와 매핑
hashtag_list = ListField(StringField(), default=list) # String 원소를 가진 리스트 필드이며 기본값은 빈 리스트
category = StringField(max_length=1, choices=CATEGORIES) # CATEGORIES 리스트에서 옵션을 1개 고를 수 있음
CATEGORIES = ('일반', '게임', '기타')
def _has_keyword(val):
if '[이벤트]' not in val:
raise ValidationError('[이벤트]를 제목에 꼭 붙여야 합니다')
내장 도큐먼트
내장될 도큐먼트를 EmbeddedDocument를 상속하는 클래스로 만들고, 내장 도큐먼트를 내장할 도큐먼트 측에서는 EmbeddedDocumentField를 사용합니다.
RDBMS를 사용할 경우 다대일, 일대다 관계를 보통 Join과 연관관계 매핑을 통해 해결하지만 MongoDB에서는 내장 도큐먼트를 사용하는 경우가 많습니다. 이전 프로젝트에서도 내장 도큐먼트 덕분에 객체 그래프 탐색이 훨씬 용이했었습니다.
# 설문
class Survey(Document):
title = StringField()
questions = ListField(EmbeddedDocumentField(Question))
# 설문 내 문항
class Question(EmbeddedDocument):
title = StringField()
딕셔너리 필드
저장하려는 항목의 구조가 유동적인 경우 딕셔너리 필드를 사용할 수 있습니다.
# 설문
class Survey(Document):
title = StringField()
questions = ListField(EmbeddedDocumentField(Question))
# 설문 내 문항
class Question(EmbeddedDocument):
title = StringField()
choices = DictField() # 보기 유형이 유동적이므로 딕셔너리로 선언
레퍼런스 필드
다른 도큐먼트에 대한 참조를 지정할 수 있습니다.
# 설문
class Survey(Document):
title = StringField()
questions = ListField(EmbeddedDocumentField(Question))
author = ReferenceField(User)
# 사용자
class User(Doucment):
name = StringField()
meta = {'collection' : '${컬렉션 이름}'} 옵션을 통해 DB에 존재하는 컬렉션과 도큐먼트를 매핑시킬 수 있습니다.
class Survey(Document):
meta = {'collection' : 'test_survey'} # 연결된 DB의 test_survey 컬렉션과 매핑
title = StringField()
그리고 max_size, max_documents 옵션을 meta 내에 지정하여 해당 도큐먼트의 용량과 저장 한도를 지정할 수도 있습니다. max_size의 기본값은 10MB입니다.
class Survey(Document):
meta = {'collection' : 'test_survey',
'max_documents' : 1000,
'max_size' : 2000000
} # 연결된 DB의 test_survey 컬렉션과 매핑, 해당 컬렉션에는 최대 1000개의 도큐먼트까지 저장, 최대 용량은 2MB
title = StringField()
meta 옵션 내에 인덱스 옵션을 설정할 수 있습니다.
class Survey(Document):
meta = {
'title', # 단일 필드 인덱스
'$title', # 텍스트 인덱스
'#title', # 해시드 인덱스
('title', '-rating'), # 컴파운드 인덱스
{
'fields': ['created'],
'expireAfterSeconds': 3600 # TTL 인덱스
}
}
인덱스 옵션
글로벌 인덱스 기본 옵션
지리 인덱스 (Geospatial Index)
meta 내 ordering 옵션을 활용하여 정렬 순서를 지정할 수 있으며 순서는 QuerySet이 생성될 때 적용되며 후속 호출로 재정의될 수 있습니다.
class Survey(Document):
meta = {'ordering': ['-title']}
title = StringField()
# Survey 조회
surveys = Survey.objects() # title 기준 내림차순
surveys = Survey.objects.order_by("+title") # title 기준 오름차순
도큐먼트를 상속하기 위해선 상위 클래스와 하위 클래스에 meta에 다음과 같이 설정해야 합니다.
allow_inheritance는 기본적으로 False입니다.
# 상위 클래스
class Survey(Document):
meta = {'ordering': ['-title']}
title = StringField()
meta = {'allow_inheritance': True}
# 하위 클래스
class SpecialSurvey(Survey):
data = DateTimeField()
이 때 하위 클래스는 상위 클래스가 사용하는 컬렉션을 사용하며 새로 컬렉션이 만들어지지 않습니다. 상위 클래스를 쿼리할 경우 상위, 하위 클래스 모두 조회되지만 하위 클래스를 쿼리할 경우 하위 클래스만 조회됩니다.
도큐먼트 객체 생성 시 필드에 대한 값을 생성자 키워드 인수로 제공하여 생성할 수 있습니다.
class Article(Document):
id = IntField(primary_key=True) # PK로 지정
title = StringField(max_length=200, required=True, validation=_has_keyword) # 아래의 _has_keyword 함수로 유효성 검사
description = StringField(max_length=2000, required=True)
author = StringField(required=True, db_field='WRITER') # 컬렉션 내의 WRITER 필드와 매핑
# Article 도큐먼트 생성
article = Article(id=1, title='테스트1', description='내용없음', author='mrcocoball')
도큐먼트 저장, 원자적 업데이트(Atomic Update)
save()는 Spring Data 프로젝트의 save()와 동일하게 INSERT / UPDATE를 수행합니다. 그 외에도 update_one(), update(), modify() 도 사용이 가능합니다.
class Article(Document):
id = IntField(primary_key=True) # PK로 지정
title = StringField(max_length=200, required=True, validation=_has_keyword) # 아래의 _has_keyword 함수로 유효성 검사
description = StringField(max_length=2000, required=True)
author = StringField(required=True, db_field='WRITER') # 컬렉션 내의 WRITER 필드와 매핑
# Article 도큐먼트 생성
article = Article(id=1, title='테스트1', description='내용없음', author='mrcocoball')
# Article 도큐먼트 저장
article.save()
# Article 도큐먼트 필드 일부 수정 및 업데이트
article.title = '테스트2'
article.save()
# update_one 사용 예시
Article.objects(title='테스트2').update_one(author='mrscocoball')
도큐먼트 삭제는 delete() 함수를 사용하며, 특정 조건으로 검색된 도큐먼트를 삭제하려면 objects()를 사용해야 합니다.
# title이 '테스트1'인 도큐먼트 중 첫번째 도큐먼트를 삭제
Article.objects(title='테스트1').first().delete()
도큐먼트 조회하기는 도큐먼트 클래스의 objects() 함수를 사용합니다. 조회 결과는 QuerySet 객체로 생성되며 JSON으로 나타내려면 별도의 변환이 필요합니다. objects 내에 쿼리 연산자를 사용할 수 있습니다.
class Article(Document):
id = IntField(primary_key=True) # PK로 지정
title = StringField(max_length=200, required=True, validation=_has_keyword) # 아래의 _has_keyword 함수로 유효성 검사
description = StringField(max_length=2000, required=True)
author = StringField(required=True, db_field='WRITER') # 컬렉션 내의 WRITER 필드와 매핑
view_count = IntField()
# 모든 도큐먼트를 조회
articles = Article.objects()
# 모든 도큐먼트 중 첫번째로 조회되는 도큐먼트 조회
first_article = Article.objects().first()
# title이 '조건'인 도큐먼트 조회 (전체)
# 단건 조회를 해야 하면 .first()를 추가
searched_articles = Article.objects(title='조건')
쿼리 연산자는 적용 필드 + __ + 연산자를 통해 사용할 수 있습니다. 쿼리 연산자로는 기본 연산자와 문자열 관련 연산자, 지리 관련 연산자가 있습니다.
# view_count가 10보다 작거나 같은 도큐먼트들을 조회
low_views_documents = Article.objects(view_count__lte=10)
raw 레벨 쿼리도 가능하며 다음과 같이 적용할 수 있습니다. (PyMongo와 동일한 raw 레벨 쿼리)
# 조회 시 raw 레벨 쿼리 사용
Article.objects(__raw__={'title' : '테스트'})
# 업데이트 시 raw 레벨 쿼리 사용
Article.objects(title='테스트').update(__raw__ = {'$set' : {'title' : '테스트2'}})
결과 정렬은 order_by() 함수로 처리하며 여러 필드에 대한 정렬을 진행할 수 있으며 접두사가 없을 경우 오름차순으로 설정됩니다.
articles = Article.objects().order_by('+title', '-description')
결과 제한은 다른 ORM처럼 limit(), skip()이 존재하지만 공식 문서에서는 가급적이면 배열 분할을 사용할 것을 권장하고 있습니다.
articles = Article.objects[:5]
objects() 함수에 대한 재정의를 통해 기본 쿼리 세팅을 변경할 수 있습니다. (@queryset_manager 데코레이터를 사용)
class Article(Document):
id = IntField(primary_key=True) # PK로 지정
title = StringField(max_length=200, required=True, validation=_has_keyword) # 아래의 _has_keyword 함수로 유효성 검사
description = StringField(max_length=2000, required=True)
author = StringField(required=True, db_field='WRITER') # 컬렉션 내의 WRITER 필드와 매핑
view_count = IntField()
@queryset_manager
def objects(doc_cls, queryset):
# objects에 기본적으로 title에 대한 내림차순을 적용
retrun queryset.order_by('-title')
또는, 별도의 함수를 만들고 @queryset_manager를 사용할 수도 있습니다.
class Article(Document):
id = IntField(primary_key=True) # PK로 지정
title = StringField(max_length=200, required=True, validation=_has_keyword) # 아래의 _has_keyword 함수로 유효성 검사
description = StringField(max_length=2000, required=True)
author = StringField(required=True, db_field='WRITER') # 컬렉션 내의 WRITER 필드와 매핑
view_count = IntField()
@queryset_manager
def order_by_title(doc_cls, queryset):
retrun queryset.order_by('-title')
사용자 지정 쿼리 세트를 만들 수 있으며 도큐먼트에서 사용자 지정 쿼리 세트를 사용하려면 meta에 추가하여야 합니다.
# 사용자 쿼리셋 지정
class DateTimeQuerySet(QuerySet):
def order_by_created_at(self):
return self.order-by('-createdAt')
# 도큐먼트에 적용
class Survey(Document):
meta = {'queryset_class' : DateTimeQuerySet} # 사용자 지정 쿼리 세트 지정
...
createdAt = DateTimeField()
...
# 사용
Survey.objects.order_by_created_at()
count(), sum(), average(), item_frequencies() 등의 함수를 지원하며, Aggregate 파이프라인을 실행해야 할 경우 aggregate() 함수를 사용해야 하며, pipeline을 안에 넣어야 합니다.
pipeline = [
{"$sort": {"createdAt" : -1}},
{"$project": {"_id" : 0, "aggregate_title" : {"$toUpper" : "$title"}}}
]
data = Survey.objects().aggregate(pipeline)
필드 하위 집합 검색 시에는 only() 함수를 사용할 수 있습니다. (반대로, 제외시키려면 exclude())
class Article(Document):
id = IntField(primary_key=True) # PK로 지정
title = StringField(max_length=200, required=True, validation=_has_keyword) # 아래의 _has_keyword 함수로 유효성 검사
description = StringField(max_length=2000, required=True)
author = StringField(required=True, db_field='WRITER') # 컬렉션 내의 WRITER 필드와 매핑
view_count = IntField()
# Article 내의 title만 검색하고 싶다면
titles = Article.objects.only('title')
쿼리 결과의 역참조를 끄고 싶을 경우 no_dereference()를 사용합니다.
Java 진영의 QueryDSL와 같이 Q 클래스를 지원하여 이를 통해 복잡한 쿼리를 작성할 수 있습니다. 이 때, 조건을 결합할 때에는 비트 연산자를 사용해야 합니다. (and, or가 아님)
Article.objects(Q(title='title') | Q(description='title'))
Article.objects(Q(createdAt__lte=datetime.now()) & Q(title='title') | Q(description='title'))