MongoDB 쿼리에서 Join 하기

byron1st·2021년 11월 1일
2

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에 저장되지 않음. 배열로 정의함)
	}},
}

Aggregate 파이프라인과 함께 사용

쿼리 시나리오

그럼 다음과 같은 쿼리를 해보도록 하자

  • posts 콜렉션이 있고, users 콜렉션이 있다.
  • posts 콜렉션에서 게시글 리스트를 쿼리하는데, creatorID 값을 가지고 이 게시글의 작성자 정보도 같이 불러오고 싶다.(즉, posts 로부터 users 를 Join 하여 쿼리하고 싶다.)
  • posts 콜렉션의 creatorIDusers 콜렉션의 _id 타입인 ObjectId 의 Hex 문자열이다. (즉 둘 사이에 변환이 필요하다)
  • posts 콜렉션 쿼리 시 skiplimit 을 통해 Pagination 한다.
  • posts 콜렉션 쿼리 시 boardID 를 통해 특정 게시판의 게시글만 불러온다.

위의 쿼리를 Aggregate 기능을 이용하여 쿼리하기 위해서는 우선 Aggregate의 파이프라인을 설계해야 한다. 파이프라인은 순서가 중요하다.

  1. $match 를 통해, boardID 에 해당하는 게시글만 선택한다.
  2. $skip$limit 을 통해 Pagination 에 해당하는 게시글을 선택한다.
  3. $project$toObjectId 를 이용하여, creatorID 를 ObjectId 타입으로 변환한다.
  4. $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 파이프를 만들어주자. 위의 코드를 한줄한줄 읽으면 다음과 같다.

  1. from 컬렉션의 아이템들 중,
  2. 실행되는 컬렉션(posts 컬렉션)의 localField 와,
  3. from 컬렉션의 foreignField 가 같은 아이템을 선택해서,
  4. 실행되는 컬렉션(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 쿼리를 실행하는 방법이었다.

profile
Hyperledger Fabric, React/React Native, Software Architecture

0개의 댓글