MongoDB는 대표적인 NoSQL, 또는 Document DB 이다. DB Engines라는 사이트에서 여러 메트릭들을 기반으로 DB 순위를 분석하는데, MongoDB는 NoSQL, Document DB 카테고리 1위일 뿐 아니라, 상위 5개 중 PostreSQL과 함께 상승률이 돋보이는 DB이다.
여담이지만, 향후 대세로 자리잡을(이미 대세이기도 하지만) 데이터베이스를 공부하고자 한다면, RDBMS에서는 PostgreSQL, NoSQL/Key-Value/Document DB 에서는 MongoDB 와 Redis(6위)를 권하고 싶다. 단순히 순위만 높은게 아니라, 성장률도 대단하다.
여튼 MongoDB 는 대표적인 NoSQL DB 이고, NoSQL의 특징 중 하나는 "관계"가 없기 때문에 Join 이 안된다는 것이다. 개인적으로 "당연히" 그렇다고 생각해서, 유사 기능을 찾아볼 생각도 하지 않았는데, 알고보니 쿼리에 한에서는 Join 과 유사한 기능이 있었다.
바로 Aggregate 기능 중 $lookup
이라는 기능이다.
$lookup
MongoDB 의 Aggregate는 파이프라인 형태로, 여러 명령어들을 조합해서 최종 결과물을 얻어내는 기능으로 단순 CRUD로 어느정도 MongoDB에 익숙해졌다면, 좀 더 복잡한 쿼리 등을 위해 고려해볼 수 있는 기능이다. 가령, Map Reduce 같은 기능도 쓸 수 있다.
$lookup
은 Aggregate 파이프라인에서 쓸 수 있는 기능으로, 특정 컬랙션의 필드값을 가지고, 다른 컬랙션의 필드값과 비교해서 같은 값을 가져오는 기능이다. 예를 들어, User
컬랙션의 id
값을 가지고, Post
컬랙션의 creatorID
와 비교하여, 같을 경우 User
객체의 myPosts: Post[]
필드로 가져올 수 있다. 즉, RDBMS 에서 Join과 같은 기능이다.
lookup := bson.D{
{Key: "$lookup", Value: bson.D{
{Key: "from", Value: "users"}, // 연결할 콜렉션
{Key: "foreignField", Value: "_id"}, // users 콜렉션의 필드
{Key: "localField", Value: "creatorObjID"}, // 이 쿼리를 호출하는 콜렉션의 필드
{Key: "as", Value: "creator"}, // users 콜랙션에서 가져온 객체들을 넣을 필드값(실제 DB에 저장되지 않음. 배열로 정의함)
}},
}
그럼 다음과 같은 쿼리를 해보도록 하자
posts
콜렉션이 있고, users
콜렉션이 있다.posts
콜렉션에서 게시글 리스트를 쿼리하는데, creatorID
값을 가지고 이 게시글의 작성자 정보도 같이 불러오고 싶다.(즉, posts
로부터 users
를 Join 하여 쿼리하고 싶다.)posts
콜렉션의 creatorID
는 users
콜렉션의 _id
타입인 ObjectId 의 Hex 문자열이다. (즉 둘 사이에 변환이 필요하다)posts
콜렉션 쿼리 시 skip
과 limit
을 통해 Pagination 한다.posts
콜렉션 쿼리 시 boardID
를 통해 특정 게시판의 게시글만 불러온다.위의 쿼리를 Aggregate 기능을 이용하여 쿼리하기 위해서는 우선 Aggregate의 파이프라인을 설계해야 한다. 파이프라인은 순서가 중요하다.
$match
를 통해, boardID
에 해당하는 게시글만 선택한다.$skip
과 $limit
을 통해 Pagination 에 해당하는 게시글을 선택한다.$project
와 $toObjectId
를 이용하여, creatorID
를 ObjectId 타입으로 변환한다.$lookup
을 통해, users
컬렉션 값들을 가져와서 creator
라는 필드에 할당한다.Aggregate 파이프라인은 다른 MongoDB 기능들에 비해 상당히 느리기 때문에, $match
와 $skip
, $limit
을 통해, 대상이 되는 아이템들의 갯수를 줄여주는것이 중요하다. 그 후 $lookup
을 수행해야 최대한 성능을 끌어올릴 수 있다.
match
, skip
, limit
파이프들 생성이제 ReadPostsWithCreator
라는 함수를 짜보자.
func ReadPostsWithCreator(boardID string, skip int64, limit int64) ([]*PostWithCreator, error) {
match := bson.D{
{Key: "$match", Value: bson.D{
{Key: "boardID", Value: boardID},
}},
}
skipPipe := bson.D{{Key: "$skip", Value: skip}}
limitPipe := bson.D{{Key: "$limit", Value: limit}}
//...
우선 위와같이 match
, skipPipe
, limitPipe
파이프들을 만들어주자. $match
같은 Aggregate 키워드들은 여러개를 한번에 쓸 수 없어서 별도로 파이프를 만들어주어야 한다.
project
파이프 생성func ReadPostsWithCreator(boardID string, skip int64, limit int64) ([]*PostWithCreator, error) {
// ...
project := bson.D{
{Key: "$project", Value: bson.D{
{Key: "creatorObjID", Value: bson.D{
{Key: "$toObjectId", Value: "$creatorID"},
}},
}},
}
// ...
이제 project
파이프를 만들어주자. project
는 특정 필드를 다른 무언가의 값으로 "Projection" 하는 기능이다. 여기서는 posts
컬랙션의 creatorID
필드를 creatorObjID
값으로 "Projection" 해준다. 이 때, 단순히 값을 그대로 프로젝션 해주는게 아니라, $toObjectId
키워드를 이용해서 기존 Hex 문자열의 creatorID
필드값을 ObjectId 로 바꿔서 프로젝션한다. 즉, 간단히 설명하면 posts
컬렉션의 선택된 아이템들 각각에게 creatorObjID
라는 ObjectId 필드가 임시로 추가된 상태인 것이다.
lookup
파이프 생성func ReadPostsWithCreator(boardID string, skip int64, limit int64) ([]*PostWithCreator, error) {
// ...
lookup := bson.D{
{Key: "$lookup", Value: bson.D{
{Key: "from", Value: "users"},
{Key: "localField", Value: "creatorObjID"},
{Key: "foreignField", Value: "_id"},
{Key: "as", Value: "creator"},
}},
}
// ...
이제 대망의 lookup
파이프를 만들어주자. 위의 코드를 한줄한줄 읽으면 다음과 같다.
from
컬렉션의 아이템들 중,posts
컬렉션)의 localField
와,from
컬렉션의 foreignField
가 같은 아이템을 선택해서,posts
컬렉션)의 as
필드에 할당한다.as
필드에 할당까지 마친 후, 쿼리 결과 리스트의 아이템 타입은 아래와 같다.
// lookup 까지 수행한 결과 객체
type PostWithCreator struct {
Post `bson:",inline"` // 원래 Post 객체
Creator []User `bson:"creator"` // as 필드
}
마지막으로 이 파이프들을 연결하여, 쿼리를 실행하자.
type PostWithCreator struct {
Post `bson:",inline"` // 원래 Post 객체
Creator []User `bson:"creator"` // as 필드
}
func ReadPostsWithCreator(boardID string, skip int64, limit int64) ([]*PostWithCreator, error) {
// ...
// DefaultDBTransactionTimeout 은 임의로 지정해둔 상수값
ctx, cancel := context.WithTimeout(context.Background(), DefaultDBTransactionTimeout)
defer cancel()
// db 는 MongoDB 클라이언트 객체
// mongo.Pipeline{} 은 파이프들이 순서대로 들어올 배열 객체
// 아래 코드에서는 match -> skipPipe -> limitPipe -> project -> lookup 순서대로 파이프를 호출
cursor, err := db.Collection("users").Aggregate(ctx, mongo.Pipeline{match, skipPipe, limitPipe, project, lookup})
if err != nil {
return nil, errors.WithStack(err)
}
var results []*PostWithCreator
if err := cursor.All(ctx, &results); err != nil {
return nil, errors.WithStack(err)
}
return results, nil
}
이상으로 MongoDB에서 Join 쿼리를 실행하는 방법이었다.